diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
new file mode 100644
index 0000000..d980477
--- /dev/null
+++ b/.github/workflows/CI.yml
@@ -0,0 +1,44 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ format:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v3
+ - name: Update Rust
+ run: rustup update nightly && rustup default nightly
+ - name: Install rustfmt
+ run: rustup component add rustfmt
+ - run: cargo fmt -- --check
+
+ lint:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v3
+ - name: Update Rust
+ run: rustup update nightly && rustup default nightly
+ - name: Install clippy
+ run: rustup component add clippy
+ - run: cargo clippy --all-features -- --deny warnings
+
+ test:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v3
+ - name: Update Rust
+ run: rustup update nightly && rustup default nightly
+ - run: cargo test --all-features
+
+ docs:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@v3
+ - name: Update Rust
+ run: rustup update nightly && rustup default nightly
+ - run: cargo doc
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..afe9a4b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+.DS_Store
+
+target/
+.vscode/
+**/Cargo.lock
+
+rustc-ice*
\ No newline at end of file
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..4d1391b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "sprocket"
+version = "0.1.0"
+description = "A package manager for the Workflow Description Language files"
+edition = "2021"
+
+[dependencies]
+anyhow = "1.0.75"
+clap = { version = "4.4.7", features = ["derive", "string"] }
+codespan-reporting = "0.11.1"
+git-testament = "0.2.5"
+nonempty = "0.9.0"
+pest = { version = "2.7.5", features = ["pretty-print"] }
+tracing = "0.1.40"
+tracing-subscriber = "0.3.18"
+wdl = { version = "0.2.0", features = ["ast", "core", "grammar"] }
+walkdir = "2.4.0"
diff --git a/LICENSE-APACHE b/LICENSE-APACHE
new file mode 100644
index 0000000..009ccce
--- /dev/null
+++ b/LICENSE-APACHE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2023-Present St. Jude Children's Research Hospital
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/LICENSE-MIT b/LICENSE-MIT
new file mode 100644
index 0000000..e0e9487
--- /dev/null
+++ b/LICENSE-MIT
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-Present St. Jude Children's Research Hospital
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..93fd9f3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,106 @@
+
+
+ sprocket
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A package manager for Workflow Description Language files.
+
+
+ Request Feature
+ ·
+ Report Bug
+ ·
+ ⭐ Consider starring the repo! ⭐
+
+
+
+
+## 🎨 Features
+
+* **`sprocket lint`.** Lint Workflow Description Language files.
+
+## Guiding Principles
+
+* **Modern, reliable foundation for everyday bioinformatics analysis—written in Rust.** `sprocket` aims to package together a fairly comprehensive set of tools and for developing bioinformatics tasks and workflows using the [Workflow Description Language](http://openwdl.org/). It is built with modern, multi-core systems in mind and written in Rust.
+* **WDL specification focused.** We aim to implement the various versions of the [OpenWDL specification](https://github.com/openwdl/wdl) to the letter. In other words, `sprocket` aims to be workflow engine independent. In the future, we plan to make `sprocket` extendable for workflow engine teams.
+
+## 📚 Getting Started
+
+### Installation
+
+Before you can install `sprocket`, you'll need to install
+[Rust](https://www.rust-lang.org/). We recommend using
+[rustup](https://rustup.rs/) to accomplish this.
+
+Once Rust is installed, you can install the latest version of `sprocket` (as
+released on the `main` branch) by running the following command.
+
+```bash
+cargo install --locked --git https://github.com/stjude-rust-labs/sprocket.git
+```
+
+## 🖥️ Development
+
+To bootstrap a development environment, please use the following commands.
+
+```bash
+# Clone the repository
+git clone git@github.com:stjude-rust-labs/sprocket.git
+cd sprocket
+
+# Build the crate in release mode
+cargo build --release
+
+# Run the `sprocket` command line tool
+cargo run --release
+```
+
+## 🚧️ Tests
+
+Before submitting any pull requests, please make sure the code passes the
+following checks (from the root directory).
+
+```bash
+# Run the project's tests.
+cargo test --all-features
+
+# Run the tests for the examples.
+cargo test --examples --all-features
+
+# Ensure the project doesn't have any linting warnings.
+cargo clippy --all-features
+
+# Ensure the project passes `cargo fmt`.
+cargo fmt --check
+
+# Ensure the docs build.
+cargo doc
+```
+
+## 🤝 Contributing
+
+Contributions, issues and feature requests are welcome! Feel free to check
+[issues page](https://github.com/stjude-rust-labs/sprocket/issues).
+
+## 📝 License
+
+This project is licensed as either [Apache 2.0][license-apache] or
+[MIT][license-mit] at your discretion.
+
+Copyright © 2023-Present [St. Jude Children's Research Hospital](https://github.com/stjude).
+
+[license-apache]: https://github.com/stjude-rust-labs/sprocket/blob/main/LICENSE-APACHE
+[license-mit]: https://github.com/stjude-rust-labs/sprocket/blob/main/LICENSE-MIT
diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000..0ef9035
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,25 @@
+# Release
+
+ * [ ] Update version in `Cargo.toml`.
+ * [ ] Update `CHANGELOG.md` with version and publication date.
+ * To get the changes to the crate since the last release, you can use a
+ command like the following:
+ ```bash
+ git log sprocket-v0.1.0..HEAD --oneline
+ ```
+ * [ ] Run tests: `cargo test --all-features`.
+ * [ ] Run linting: `cargo clippy --all-features`.
+ * [ ] Run fmt: `cargo fmt --check`.
+ * [ ] Run doc: `cargo doc`.
+ * [ ] Stage changes: `git add Cargo.toml CHANGELOG.md`.
+ * [ ] Create git commit:
+ ```
+ git commit -m "release: bumps `sprocket` version to v0.1.0"
+ ```
+ * [ ] Create git tag:
+ ```
+ git tag sprocket-v0.1.0
+ ```
+ * [ ] Push release: `git push && git push --tags`.
+ * [ ] Go to the Releases page in Github, create a Release for this tag, and
+ copy the notes from the `CHANGELOG.md` file.
\ No newline at end of file
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..7bae9b6
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,15 @@
+# Unstable settings
+condense_wildcard_suffixes = true
+format_code_in_doc_comments = true
+format_macro_matchers = true
+format_strings = true
+group_imports = "StdExternalCrate"
+hex_literal_case = "Upper"
+imports_granularity = "Item"
+newline_style = "Unix"
+normalize_comments = true
+normalize_doc_attributes = true
+reorder_impl_items = true
+use_field_init_shorthand = true
+wrap_comments = true
+version = "Two"
\ No newline at end of file
diff --git a/src/commands.rs b/src/commands.rs
new file mode 100644
index 0000000..e7034df
--- /dev/null
+++ b/src/commands.rs
@@ -0,0 +1 @@
+pub mod lint;
diff --git a/src/commands/lint.rs b/src/commands/lint.rs
new file mode 100644
index 0000000..d4990ab
--- /dev/null
+++ b/src/commands/lint.rs
@@ -0,0 +1,82 @@
+use std::path::PathBuf;
+
+use clap::Parser;
+use clap::ValueEnum;
+use codespan_reporting::term::termcolor::ColorChoice;
+use codespan_reporting::term::termcolor::StandardStream;
+use codespan_reporting::term::Config;
+use codespan_reporting::term::DisplayStyle;
+
+#[derive(Clone, Debug, Default, ValueEnum)]
+pub enum Mode {
+ /// Prints concerns as multiple lines.
+ #[default]
+ Full,
+
+ /// Prints concerns as one line.
+ OneLine,
+}
+
+impl std::fmt::Display for Mode {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Mode::Full => write!(f, "full"),
+ Mode::OneLine => write!(f, "one-line"),
+ }
+ }
+}
+
+/// Arguments for the `lint` subcommand.
+#[derive(Parser, Debug)]
+#[command(author, version, about)]
+pub struct Args {
+ /// The files or directories to lint.
+ #[arg(required = true)]
+ paths: Vec,
+
+ /// The extensions to collect when expanding a directory.
+ #[arg(short, long, default_value = "wdl")]
+ extensions: Vec,
+
+ /// Disables color output.
+ #[arg(long)]
+ no_color: bool,
+
+ /// The report mode.
+ #[arg(short = 'm', long, default_value_t, value_name = "MODE")]
+ report_mode: Mode,
+
+ /// The specification version.
+ #[arg(short, long, default_value_t, value_enum, value_name = "VERSION")]
+ specification_version: wdl::core::Version,
+}
+
+pub fn lint(args: Args) -> anyhow::Result<()> {
+ let (config, writer) = get_display_config(&args);
+ Ok(
+ sprocket::file::Repository::try_new(args.paths, args.extensions)?
+ .report_concerns(config, writer)?,
+ )
+}
+
+fn get_display_config(args: &Args) -> (Config, StandardStream) {
+ let display_style = match args.report_mode {
+ Mode::Full => DisplayStyle::Rich,
+ Mode::OneLine => DisplayStyle::Short,
+ };
+
+ let config = Config {
+ display_style,
+ ..Default::default()
+ };
+
+ let color_choice = if args.no_color {
+ ColorChoice::Never
+ } else {
+ ColorChoice::Always
+ };
+
+ let writer = StandardStream::stderr(color_choice);
+
+ (config, writer)
+}
diff --git a/src/file.rs b/src/file.rs
new file mode 100644
index 0000000..6364439
--- /dev/null
+++ b/src/file.rs
@@ -0,0 +1,299 @@
+//! Filesystems.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use codespan_reporting::files::SimpleFiles;
+use codespan_reporting::term::termcolor::StandardStream;
+use codespan_reporting::term::Config;
+
+use crate::report::Reporter;
+
+/// A filesystem error.
+#[derive(Debug)]
+pub enum Error {
+ /// A WDL 1.x abstract syntax tree error.
+ AstV1(wdl::ast::v1::Error),
+
+ /// A WDL 1.x grammar error.
+ GrammarV1(wdl::grammar::v1::Error),
+
+ /// An invalid file name was provided.
+ InvalidFileName(PathBuf),
+
+ /// An input/output error.
+ Io(std::io::Error),
+
+ /// Attempted to parse an entry that does not exist in the [`Repository`].
+ MissingEntry(String),
+
+ /// The item located at a path was missing.
+ MissingPath(PathBuf),
+
+ /// The item located at a path was not a file.
+ NonFile(PathBuf),
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Error::AstV1(err) => write!(f, "ast error: {err}"),
+ Error::GrammarV1(err) => write!(f, "grammar error: {err}"),
+ Error::InvalidFileName(path) => write!(f, "invalid file name: {}", path.display()),
+ Error::Io(err) => write!(f, "i/o error: {}", err),
+ Error::MissingPath(path) => write!(f, "missing path: {}", path.display()),
+ Error::NonFile(path) => write!(f, "not a file: {}", path.display()),
+ Error::MissingEntry(entry) => write!(f, "missing entry: {entry}"),
+ }
+ }
+}
+
+impl std::error::Error for Error {}
+
+/// A [`Result`](std::result::Result) with an [`Error`].
+type Result = std::result::Result;
+
+/// A repository of files and associated source code.
+#[derive(Debug)]
+pub struct Repository {
+ /// The mapping of entries in the source code map to file handles.
+ handles: HashMap,
+
+ /// The inner source code map.
+ sources: SimpleFiles,
+}
+
+impl Default for Repository {
+ fn default() -> Self {
+ Self {
+ sources: SimpleFiles::new(),
+ handles: Default::default(),
+ }
+ }
+}
+
+impl Repository {
+ /// Creates a new [`Repository`].
+ ///
+ /// # Examples
+ ///
+ /// ```no_run
+ /// use std::path::PathBuf;
+ ///
+ /// use sprocket::fs::Repository;
+ ///
+ /// let mut repository = Repository::try_new(vec![PathBuf::from(".")], vec![String::from("wdl")]);
+ /// ```
+ pub fn try_new(paths: Vec, extensions: Vec) -> Result {
+ let mut repository = Self::default();
+
+ for path in expand_paths(paths, extensions)? {
+ repository.load(path)?;
+ }
+
+ Ok(repository)
+ }
+
+ /// Inserts a new entry into the [`Repository`].
+ ///
+ /// **Note:** typically, you won't want to do this directly except in
+ /// special cases. Instead, prefer using the [`load()`](Repository::load())
+ /// method.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use sprocket::fs::Repository;
+ ///
+ /// let mut repository = Repository::default();
+ /// repository.insert("foo.txt", "bar");
+ /// ```
+ pub fn insert(&mut self, path: impl Into, content: impl Into) {
+ let path = path.into().to_string_lossy().to_string();
+ let content = content.into();
+
+ let handle = self.sources.add(path.clone(), content);
+ self.handles.insert(path, handle);
+ }
+
+ /// Attempts to load a new file and its contents into the [`Repository`].
+ ///
+ /// An error is thrown if any issues are encountered when reading the file.
+ ///
+ /// # Examples
+ ///
+ /// ```no_run
+ /// use sprocket::fs::Repository;
+ ///
+ /// let mut repository = Repository::default();
+ /// repository.load("test.wdl")?;
+ ///
+ /// # Ok::<(), Box>(())
+ /// ```
+ pub fn load(&mut self, path: impl Into) -> Result<()> {
+ let path = path.into();
+
+ if !path.exists() {
+ return Err(Error::MissingPath(path));
+ }
+
+ if !path.is_file() {
+ return Err(Error::NonFile(path));
+ }
+
+ let content = std::fs::read_to_string(&path).map_err(Error::Io)?;
+ self.insert(path, content);
+
+ Ok(())
+ }
+
+ /// Attempts to parse an existing entry into a WDL v1.x abstract syntax
+ /// tree.
+ ///
+ /// # Examples
+ ///
+ /// ```no_run
+ /// use sprocket::fs::Repository;
+ ///
+ /// let mut repository = Repository::default();
+ /// repository.load("test.wdl")?;
+ /// let ast = repository.parse("test.wdl")?;
+ ///
+ /// assert!(matches!(ast.tree(), Some(_)));
+ ///
+ /// # Ok::<(), Box>(())
+ /// ```
+ pub fn parse(&self, entry: impl AsRef) -> Result {
+ let entry = entry.as_ref();
+ let handle = *self
+ .handles
+ .get(entry)
+ .ok_or(Error::MissingEntry(entry.to_owned()))?;
+
+ let file = match self.sources.get(handle) {
+ Ok(result) => result,
+ // SAFETY: this entry will _always_ exist in the inner
+ // [`SimpleFiles`], as we just ensured it existed in the mapping
+ // between entry names and handles.
+ Err(_) => unreachable!(),
+ };
+
+ let mut all_concerns = wdl::core::concern::concerns::Builder::default();
+
+ let (pt, concerns) = wdl::grammar::v1::parse(file.source())
+ .map_err(Error::GrammarV1)?
+ .into_parts();
+
+ if let Some(concerns) = concerns {
+ for concern in concerns.into_inner() {
+ all_concerns = all_concerns.push(concern);
+ }
+ }
+
+ let pt = match pt {
+ Some(pt) => pt,
+ None => {
+ // SAFETY: because `grammar::v1::parse` returns a
+ // `grammar::v1::Result`, we know that either the concerns or the
+ // parse tree must be [`Some`] (else, this would have failed at
+ // `grammar::v1::Result` creation time). That said, we just checked
+ // that `pt` is [`None`]. In this case, it must follow that the
+ // concerns are not empty. As such, this will always unwrap.
+ return Ok(wdl::ast::v1::Result::try_new(None, all_concerns.build()).unwrap());
+ }
+ };
+
+ let (ast, concerns) = wdl::ast::v1::parse(pt).map_err(Error::AstV1)?.into_parts();
+
+ if let Some(concerns) = concerns {
+ for concern in concerns.into_inner() {
+ all_concerns = all_concerns.push(concern);
+ }
+ }
+
+ match ast {
+ Some(ast) => {
+ // SAFETY: the ast is [`Some`], so this will always unwrap.
+ Ok(wdl::ast::v1::Result::try_new(Some(ast), all_concerns.build()).unwrap())
+ }
+ None => {
+ // SAFETY: because `ast::v1::parse` returns a
+ // `ast::v1::Result`, we know that either the concerns or the
+ // parse tree must be [`Some`] (else, this would have failed at
+ // `ast::v1::Result` creation time). That said, we just checked
+ // that `ast` is [`None`]. In this case, it must follow that the
+ // concerns are not empty. As such, this will always unwrap.
+ Ok(wdl::ast::v1::Result::try_new(None, all_concerns.build()).unwrap())
+ }
+ }
+ }
+
+ /// Reports all concerns for all documents in the [`Repository`].
+ ///
+ /// # Examples
+ ///
+ /// ```no_run
+ /// use codespan_reporting::term::termcolor::ColorChoice;
+ /// use codespan_reporting::term::termcolor::StandardStream;
+ /// use codespan_reporting::term::Config;
+ /// use sprocket::fs::Repository;
+ ///
+ /// let mut repository = Repository::default();
+ /// repository.load("test.wdl")?;
+ ///
+ /// let config = Config::default();
+ /// let writer = StandardStream::stderr(ColorChoice::Always);
+ /// repository.report_concerns(config, writer);
+ ///
+ /// # Ok::<(), Box>(())
+ /// ```
+ pub fn report_concerns(&self, config: Config, writer: StandardStream) -> Result<()> {
+ let mut reporter = Reporter::new(config, writer, &self.sources);
+
+ for (file_name, handle) in self.handles.iter() {
+ let document = self.parse(file_name)?;
+
+ if let Some(concerns) = document.into_concerns() {
+ for concern in concerns.into_inner() {
+ reporter.report_concern(concern, *handle);
+ }
+ }
+ }
+
+ Ok(())
+ }
+}
+
+/// Expands a set of [`PathBuf`]s.
+///
+/// This means that, for each [`PathBuf`],
+///
+/// * if the path exists and is a file, the file is added to the result.
+/// * if the path exists and is a directory, all files underneath that directory
+/// (including recursively traversed directories) that have an extension in
+/// the `extensions` list are added to the result.
+/// * if the path does not exist, an error is thrown.
+pub fn expand_paths(paths: Vec, extensions: Vec) -> Result> {
+ paths.into_iter().try_fold(Vec::new(), |mut acc, path| {
+ if !path.exists() {
+ return Err(Error::MissingPath(path));
+ }
+
+ if path.is_file() {
+ acc.push(path);
+ } else if path.is_dir() {
+ let dir_files = walkdir::WalkDir::new(path)
+ .into_iter()
+ .filter_map(std::result::Result::ok)
+ .filter(|entry| {
+ extensions
+ .iter()
+ .any(|ext| entry.path().extension() == Some(ext.as_ref()))
+ })
+ .map(|entry| entry.path().to_path_buf());
+ acc.extend(dir_files)
+ }
+
+ Ok(acc)
+ })
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..0a2cb75
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,11 @@
+//! Package manager for Workflow Description Language (WDL) files.
+
+#![warn(missing_docs)]
+#![warn(rust_2018_idioms)]
+#![warn(rust_2021_compatibility)]
+#![warn(missing_debug_implementations)]
+#![warn(clippy::missing_docs_in_private_items)]
+#![warn(rustdoc::broken_intra_doc_links)]
+
+pub mod file;
+pub mod report;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..f5ca9db
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,61 @@
+//! The Sprocket command line tool.
+
+use clap::Parser;
+use clap::Subcommand;
+use git_testament::git_testament;
+use git_testament::render_testament;
+
+pub mod commands;
+
+git_testament!(TESTAMENT);
+
+#[derive(Subcommand)]
+#[allow(clippy::large_enum_variant)]
+enum Commands {
+ /// Lints Workflow Description Language files.
+ Lint(commands::lint::Args),
+}
+
+#[derive(Parser)]
+#[command(author, version = render_testament!(TESTAMENT), propagate_version = true, about, long_about = None)]
+struct Cli {
+ #[command(subcommand)]
+ pub command: Commands,
+
+ /// Only errors are printed to the stderr stream.
+ #[arg(short, long, global = true)]
+ pub quiet: bool,
+
+ /// All available information, including debug information, is printed to
+ /// stderr.
+ #[arg(short, long, global = true)]
+ pub verbose: bool,
+}
+
+pub fn inner() -> anyhow::Result<()> {
+ let cli = Cli::parse();
+
+ let level = if cli.verbose {
+ tracing::Level::DEBUG
+ } else if cli.quiet {
+ tracing::Level::ERROR
+ } else {
+ tracing::Level::INFO
+ };
+
+ let subscriber = tracing_subscriber::fmt::Subscriber::builder()
+ .with_max_level(level)
+ .with_writer(std::io::stderr)
+ .finish();
+ tracing::subscriber::set_global_default(subscriber)?;
+
+ match cli.command {
+ Commands::Lint(args) => commands::lint::lint(args),
+ }
+}
+
+pub fn main() {
+ if let Err(err) = inner() {
+ eprintln!("error: {}", err);
+ }
+}
diff --git a/src/report.rs b/src/report.rs
new file mode 100644
index 0000000..09f2cc7
--- /dev/null
+++ b/src/report.rs
@@ -0,0 +1,123 @@
+//! Reporting.
+
+use codespan_reporting::diagnostic::Diagnostic;
+use codespan_reporting::diagnostic::Label;
+use codespan_reporting::files::SimpleFiles;
+use codespan_reporting::term;
+use codespan_reporting::term::termcolor::StandardStream;
+use codespan_reporting::term::Config;
+use codespan_reporting::term::DisplayStyle;
+use wdl::core::concern::Concern;
+
+/// A reporter for Sprocket.
+#[derive(Debug)]
+pub(crate) struct Reporter<'a> {
+ /// The configuration.
+ config: Config,
+
+ /// The stream to write to.
+ stream: StandardStream,
+
+ /// The file repository.
+ files: &'a SimpleFiles,
+}
+
+impl<'a> Reporter<'a> {
+ /// Creates a new [`Reporter`].
+ pub(crate) fn new(
+ config: Config,
+ stream: StandardStream,
+ files: &'a SimpleFiles,
+ ) -> Self {
+ Self {
+ config,
+ stream,
+ files,
+ }
+ }
+
+ /// Reports a concern to the terminal.
+ pub(crate) fn report_concern(&mut self, concern: Concern, handle: usize) {
+ let diagnostic = match concern {
+ Concern::LintWarning(warning) => {
+ let mut diagnostic = Diagnostic::warning()
+ .with_code(format!(
+ "{}::{}/{:?}",
+ warning.code(),
+ warning.group(),
+ warning.level()
+ ))
+ .with_message(warning.subject());
+
+ for location in warning.locations() {
+ // SAFETY: if `report` is called, then the location **must**
+ // fall within the provided file's contents. As such, it will
+ // never be [`Location::Unplaced`], and this will always unwrap.
+ let byte_range = location.byte_range().unwrap();
+
+ diagnostic = diagnostic.with_labels(vec![
+ Label::primary(handle, byte_range).with_message(warning.body()),
+ ]);
+ }
+
+ if let Some(fix) = warning.fix() {
+ diagnostic = diagnostic.with_notes(vec![format!("fix: {}", fix)]);
+ }
+
+ diagnostic
+ }
+ Concern::ParseError(error) => {
+ // SAFETY: if `report` is called, then the location **must**
+ // fall within the provided file's contents. As such, it will
+ // never be [`Location::Unplaced`], and this will always unwrap.
+ let byte_range = error.byte_range().unwrap();
+
+ let diagnostic = match &self.config.display_style {
+ DisplayStyle::Rich => Diagnostic::error()
+ .with_message("parse error")
+ .with_labels(vec![
+ Label::primary(handle, byte_range).with_message(error.message()),
+ ]),
+ _ => Diagnostic::error()
+ .with_message(error.message().to_lowercase())
+ .with_labels(vec![
+ Label::primary(handle, byte_range).with_message(error.message()),
+ ]),
+ };
+
+ diagnostic
+ }
+ Concern::ValidationFailure(failure) => {
+ let mut diagnostic = Diagnostic::error()
+ .with_code(failure.code().to_string())
+ .with_message(failure.subject());
+
+ for location in failure.locations() {
+ // SAFETY: if `report` is called, then the location **must**
+ // fall within the provided file's contents. As such, it will
+ // never be [`Location::Unplaced`], and this will always unwrap.
+ let byte_range = location.byte_range().unwrap();
+
+ diagnostic = diagnostic.with_labels(vec![
+ Label::primary(handle, byte_range).with_message(failure.body()),
+ ]);
+ }
+
+ if let Some(fix) = failure.fix() {
+ diagnostic = diagnostic.with_notes(vec![format!("fix: {}", fix)]);
+ }
+
+ diagnostic
+ }
+ };
+
+ // SAFETY: for use on the command line, this should always succeed.
+ term::emit(
+ &mut self.stream.lock(),
+ &self.config,
+ self.files,
+ &diagnostic,
+ )
+ .expect("writing diagnostic to stream failed")
+ }
+}