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 +

+ +

+ + CI: Status + + + License: Apache 2.0 + + + License: MIT + +

+ +

+ 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") + } +}