Repository: gakonst/ethers-rs
Pull request #931 (2022-02-18 21:25:49)

feat(solc): add helper functions to compile standalone files
<!--
Thank you for your Pull Request. Please provide a description above and review
the requirements below.

Bug fixes and new features should include tests.

Contributors guide: https://github.com/gakonst/ethers-rs/blob/master/CONTRIBUTING.md

The contributors guide includes instructions for running rustfmt and building the
documentation.
-->

## Motivation
Add helper functions to compile specific files with the project settings that can be individual source files or even files outside the source dir.
<!--
Explain the context and why you're making that change. What is the problem
you're trying to solve? In some cases there is not a problem and this can be
thought of as being the motivation for your change.
-->

## Solution

<!--
Summarize the solution and provide any necessary context needed to understand
the code change.
-->

## PR Checklist

- [x] Added Tests
- [x] Added Documentation
- [ ] Updated the changelog



Patch:
diff --git a/ethers-solc/src/lib.rs b/ethers-solc/src/lib.rs
index a74d455a..afc6821b 100644
--- a/ethers-solc/src/lib.rs
+++ b/ethers-solc/src/lib.rs
@@ -9,7 +9,7 @@ pub mod cache;
 pub mod hh;
 pub use artifact_output::*;
 
-mod resolver;
+pub mod resolver;
 pub use hh::{HardhatArtifact, HardhatArtifacts};
 pub use resolver::Graph;
 
@@ -235,6 +235,49 @@ impl<T: ArtifactOutput> Project<T> {
         project::ProjectCompiler::with_sources(self, sources)?.compile()
     }
 
+    /// Convenience function to compile a single solidity file with the project's settings.
+    /// Same as [`Self::svm_compile()`] but with the given `file` as input.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use ethers_solc::Project;
+    /// # fn demo(project: Project) {
+    /// let project = Project::builder().build().unwrap();
+    /// let output = project.compile_file("example/Greeter.sol").unwrap();
+    /// # }
+    /// ```
+    #[cfg(all(feature = "svm", feature = "async"))]
+    pub fn compile_file(&self, file: impl Into<PathBuf>) -> Result<ProjectCompileOutput<T>> {
+        let file = file.into();
+        let source = Source::read(&file)?;
+        project::ProjectCompiler::with_sources(self, Sources::from([(file, source)]))?.compile()
+    }
+
+    /// Convenience function to compile a series of solidity files with the project's settings.
+    /// Same as [`Self::svm_compile()`] but with the given `files` as input.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use ethers_solc::Project;
+    /// # fn demo(project: Project) {
+    /// let project = Project::builder().build().unwrap();
+    /// let output = project
+    ///     .compile_files(
+    ///         vec!["examples/Foo.sol", "examples/Bar.sol"]
+    ///     ).unwrap();
+    /// # }
+    /// ```
+    #[cfg(all(feature = "svm", feature = "async"))]
+    pub fn compile_files<P, I>(&self, files: I) -> Result<ProjectCompileOutput<T>>
+    where
+        I: IntoIterator<Item = P>,
+        P: Into<PathBuf>,
+    {
+        project::ProjectCompiler::with_sources(self, Source::read_all(files)?)?.compile()
+    }
+
     /// Compiles the given source files with the exact `Solc` executable
     ///
     /// First all libraries for the sources are resolved by scanning all their imports.
diff --git a/ethers-solc/src/project_util.rs b/ethers-solc/src/project_util.rs
index e5b36f5f..8683f264 100644
--- a/ethers-solc/src/project_util.rs
+++ b/ethers-solc/src/project_util.rs
@@ -154,12 +154,20 @@ impl<T: ArtifactOutput> TempProject<T> {
         create_contract_file(lib, content)
     }
 
-    /// Adds a new source file
+    /// Adds a new source file inside the project's source dir
     pub fn add_source(&self, name: impl AsRef<str>, content: impl AsRef<str>) -> Result<PathBuf> {
         let name = contract_file_name(name);
         let source = self.paths().sources.join(name);
         create_contract_file(source, content)
     }
+
+    /// Adds a solidity contract in the project's root dir.
+    /// This will also create all intermediary dirs.
+    pub fn add_contract(&self, name: impl AsRef<str>, content: impl AsRef<str>) -> Result<PathBuf> {
+        let name = contract_file_name(name);
+        let source = self.root().join(name);
+        create_contract_file(source, content)
+    }
 }
 
 impl<T: ArtifactOutput + Default> TempProject<T> {
diff --git a/ethers-solc/src/resolver.rs b/ethers-solc/src/resolver/mod.rs
similarity index 92%
rename from ethers-solc/src/resolver.rs
rename to ethers-solc/src/resolver/mod.rs
index 16bfbc34..c8e1ca60 100644
--- a/ethers-solc/src/resolver.rs
+++ b/ethers-solc/src/resolver/mod.rs
@@ -48,6 +48,7 @@
 
 use std::{
     collections::{HashMap, HashSet, VecDeque},
+    fmt, io,
     path::{Path, PathBuf},
 };
 
@@ -58,6 +59,9 @@ use solang_parser::pt::{Import, Loc, SourceUnitPart};
 
 use crate::{error::Result, utils, ProjectPathsConfig, Solc, SolcError, Source, Sources};
 
+mod tree;
+pub use tree::{print, Charset, TreeOptions};
+
 /// The underlying edges of the graph which only contains the raw relationship data.
 ///
 /// This is kept separate from the `Graph` as the `Node`s get consumed when the `Solc` to `Sources`
@@ -129,11 +133,28 @@ pub struct Graph {
 }
 
 impl Graph {
+    /// Print the graph to `StdOut`
+    pub fn print(&self) {
+        self.print_with_options(Default::default())
+    }
+
+    /// Print the graph to `StdOut` using the provided `TreeOptions`
+    pub fn print_with_options(&self, opts: TreeOptions) {
+        let stdout = io::stdout();
+        let mut out = stdout.lock();
+        tree::print(self, &opts, &mut out).expect("failed to write to stdout.")
+    }
+
     /// Returns a list of nodes the given node index points to for the given kind.
     pub fn imported_nodes(&self, from: usize) -> &[usize] {
         self.edges.imported_nodes(from)
     }
 
+    /// Returns `true` if the given node has any outgoing edges.
+    pub(crate) fn has_outgoing_edges(&self, index: usize) -> bool {
+        !self.edges.edges[index].is_empty()
+    }
+
     /// Returns all the resolved files and their index in the graph
     pub fn files(&self) -> &HashMap<PathBuf, usize> {
         &self.edges.indices
@@ -148,6 +169,9 @@ impl Graph {
         &self.nodes[index]
     }
 
+    pub(crate) fn display_node(&self, index: usize) -> DisplayNode {
+        DisplayNode { node: self.node(index), root: &self.root }
+    }
     /// Returns an iterator that yields all nodes of the dependency tree that the given node id
     /// spans, starting with the node itself.
     ///
@@ -646,6 +670,23 @@ impl Node {
     }
 }
 
+/// Helper type for formatting a node
+pub(crate) struct DisplayNode<'a> {
+    node: &'a Node,
+    root: &'a PathBuf,
+}
+
+impl<'a> fmt::Display for DisplayNode<'a> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let path = utils::source_name(&self.node.path, self.root);
+        write!(f, "{}", path.display())?;
+        if let Some(ref v) = self.node.data.version {
+            write!(f, " {}", v.data())?;
+        }
+        Ok(())
+    }
+}
+
 #[derive(Debug, Clone)]
 #[allow(unused)]
 struct SolData {
@@ -850,4 +891,47 @@ mod tests {
         );
         assert_eq!(graph.imported_nodes(1).to_vec(), vec![2, 0]);
     }
+
+    #[test]
+    #[cfg(not(target_os = "windows"))]
+    fn can_print_dapp_sample_graph() {
+        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test-data/dapp-sample");
+        let paths = ProjectPathsConfig::dapptools(root).unwrap();
+        let graph = Graph::resolve(&paths).unwrap();
+        let mut out = Vec::<u8>::new();
+        tree::print(&graph, &Default::default(), &mut out).unwrap();
+
+        assert_eq!(
+            "
+src/Dapp.sol >=0.6.6
+src/Dapp.t.sol >=0.6.6
+├── lib/ds-test/src/test.sol >=0.4.23
+└── src/Dapp.sol >=0.6.6
+"
+            .trim_start()
+            .as_bytes()
+            .to_vec(),
+            out
+        );
+    }
+
+    #[test]
+    #[cfg(not(target_os = "windows"))]
+    fn can_print_hardhat_sample_graph() {
+        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test-data/hardhat-sample");
+        let paths = ProjectPathsConfig::hardhat(root).unwrap();
+        let graph = Graph::resolve(&paths).unwrap();
+        let mut out = Vec::<u8>::new();
+        tree::print(&graph, &Default::default(), &mut out).unwrap();
+        assert_eq!(
+            "
+contracts/Greeter.sol >=0.6.0
+└── node_modules/hardhat/console.sol >= 0.4.22 <0.9.0
+"
+            .trim_start()
+            .as_bytes()
+            .to_vec(),
+            out
+        );
+    }
 }
diff --git a/ethers-solc/src/resolver/tree.rs b/ethers-solc/src/resolver/tree.rs
new file mode 100644
index 00000000..230036b6
--- /dev/null
+++ b/ethers-solc/src/resolver/tree.rs
@@ -0,0 +1,183 @@
+use crate::Graph;
+use std::{collections::HashSet, io, io::Write, str::FromStr};
+
+#[derive(Debug, Clone, Copy, Eq, PartialEq)]
+pub enum Charset {
+    Utf8,
+    Ascii,
+}
+
+impl Default for Charset {
+    fn default() -> Self {
+        // when operating in a console on windows non-UTF-8 byte sequences are not supported on
+        // stdout, See also [`StdoutLock`]
+        #[cfg(target_os = "windows")]
+        {
+            Charset::Ascii
+        }
+        #[cfg(not(target_os = "windows"))]
+        Charset::Utf8
+    }
+}
+
+impl FromStr for Charset {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "utf8" => Ok(Charset::Utf8),
+            "ascii" => Ok(Charset::Ascii),
+            s => Err(format!("invalid charset: {}", s)),
+        }
+    }
+}
+
+/// Options to configure formatting
+#[derive(Debug, Clone, Default)]
+pub struct TreeOptions {
+    /// The style of characters to use.
+    pub charset: Charset,
+    /// If `true`, duplicate imports will be repeated.
+    /// If `false`, duplicates are suffixed with `(*)`, and their imports
+    /// won't be shown.
+    pub no_dedupe: bool,
+}
+
+/// Internal helper type for symbols
+struct Symbols {
+    down: &'static str,
+    tee: &'static str,
+    ell: &'static str,
+    right: &'static str,
+}
+
+static UTF8_SYMBOLS: Symbols = Symbols { down: "│", tee: "├", ell: "└", right: "─" };
+
+static ASCII_SYMBOLS: Symbols = Symbols { down: "|", tee: "|", ell: "`", right: "-" };
+
+pub fn print(graph: &Graph, opts: &TreeOptions, out: &mut dyn Write) -> io::Result<()> {
+    let symbols = match opts.charset {
+        Charset::Utf8 => &UTF8_SYMBOLS,
+        Charset::Ascii => &ASCII_SYMBOLS,
+    };
+
+    // used to determine whether to display `(*)`
+    let mut visited_imports = HashSet::new();
+
+    // A stack of bools used to determine where | symbols should appear
+    // when printing a line.
+    let mut levels_continue = Vec::new();
+    // used to detect dependency cycles when --no-dedupe is used.
+    // contains a `Node` for each level.
+    let mut write_stack = Vec::new();
+
+    for (node_index, _) in graph.input_nodes().enumerate() {
+        print_node(
+            graph,
+            node_index,
+            symbols,
+            opts.no_dedupe,
+            &mut visited_imports,
+            &mut levels_continue,
+            &mut write_stack,
+            out,
+        )?;
+    }
+
+    Ok(())
+}
+
+#[allow(clippy::too_many_arguments)]
+fn print_node(
+    graph: &Graph,
+    node_index: usize,
+    symbols: &Symbols,
+    no_dedupe: bool,
+    visited_imports: &mut HashSet<usize>,
+    levels_continue: &mut Vec<bool>,
+    write_stack: &mut Vec<usize>,
+    out: &mut dyn Write,
+) -> io::Result<()> {
+    let new_node = no_dedupe || visited_imports.insert(node_index);
+
+    if let Some((last_continues, rest)) = levels_continue.split_last() {
+        for continues in rest {
+            let c = if *continues { symbols.down } else { " " };
+            write!(out, "{}   ", c)?;
+        }
+
+        let c = if *last_continues { symbols.tee } else { symbols.ell };
+        write!(out, "{0}{1}{1} ", c, symbols.right)?;
+    }
+
+    let in_cycle = write_stack.contains(&node_index);
+    // if this node does not have any outgoing edges, don't include the (*)
+    // since there isn't really anything "deduplicated", and it generally just
+    // adds noise.
+    let has_deps = graph.has_outgoing_edges(node_index);
+    let star = if (new_node && !in_cycle) || !has_deps { "" } else { " (*)" };
+
+    writeln!(out, "{}{}", graph.display_node(node_index), star)?;
+
+    if !new_node || in_cycle {
+        return Ok(())
+    }
+    write_stack.push(node_index);
+
+    print_imports(
+        graph,
+        node_index,
+        symbols,
+        no_dedupe,
+        visited_imports,
+        levels_continue,
+        write_stack,
+        out,
+    )?;
+
+    write_stack.pop();
+
+    Ok(())
+}
+
+/// Prints all the imports of a node
+#[allow(clippy::too_many_arguments, clippy::ptr_arg)]
+fn print_imports(
+    graph: &Graph,
+    node_index: usize,
+    symbols: &Symbols,
+    no_dedupe: bool,
+    visited_imports: &mut HashSet<usize>,
+    levels_continue: &mut Vec<bool>,
+    write_stack: &mut Vec<usize>,
+    out: &mut dyn Write,
+) -> io::Result<()> {
+    let imports = graph.imported_nodes(node_index);
+    if imports.is_empty() {
+        return Ok(())
+    }
+
+    for continues in &**levels_continue {
+        let c = if *continues { symbols.down } else { " " };
+        write!(out, "{}   ", c)?;
+    }
+
+    let mut iter = imports.iter().peekable();
+
+    while let Some(import) = iter.next() {
+        levels_continue.push(iter.peek().is_some());
+        print_node(
+            graph,
+            *import,
+            symbols,
+            no_dedupe,
+            visited_imports,
+            levels_continue,
+            write_stack,
+            out,
+        )?;
+        levels_continue.pop();
+    }
+
+    Ok(())
+}
diff --git a/examples/ethers-wasm/yarn.lock b/examples/ethers-wasm/yarn.lock
index 918850e5..6b711d2f 100644
--- a/examples/ethers-wasm/yarn.lock
+++ b/examples/ethers-wasm/yarn.lock
@@ -4076,9 +4076,9 @@ urix@^0.1.0:
   integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
 
 url-parse@^1.4.3, url-parse@^1.5.1:
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862"
-  integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==
+  version "1.5.7"
+  resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.7.tgz#00780f60dbdae90181f51ed85fb24109422c932a"
+  integrity sha512-HxWkieX+STA38EDk7CE9MEryFeHCKzgagxlGvsdS7WBImq9Mk+PGwiT56w82WI3aicwJA8REp42Cxo98c8FZMA==
   dependencies:
     querystringify "^2.1.1"
     requires-port "^1.0.0"


Test patch:
diff --git a/ethers-solc/tests/project.rs b/ethers-solc/tests/project.rs
index c1afeb4f..2a32d16f 100644
--- a/ethers-solc/tests/project.rs
+++ b/ethers-solc/tests/project.rs
@@ -454,3 +454,39 @@ fn can_detect_type_error() {
     let compiled = project.compile().unwrap();
     assert!(compiled.has_compiler_errors());
 }
+
+#[test]
+fn can_compile_single_files() {
+    let tmp = TempProject::dapptools().unwrap();
+
+    let foo = tmp
+        .add_contract(
+            "examples/Foo",
+            r#"
+    pragma solidity ^0.8.10;
+
+    contract Foo {}
+   "#,
+        )
+        .unwrap();
+
+    let compiled = tmp.project().compile_file(foo.clone()).unwrap();
+    assert!(!compiled.has_compiler_errors());
+    assert!(compiled.find("Foo").is_some());
+
+    let bar = tmp
+        .add_contract(
+            "examples/Bar",
+            r#"
+    pragma solidity ^0.8.10;
+
+    contract Bar {}
+   "#,
+        )
+        .unwrap();
+
+    let compiled = tmp.project().compile_files(vec![foo, bar]).unwrap();
+    assert!(!compiled.has_compiler_errors());
+    assert!(compiled.find("Foo").is_some());
+    assert!(compiled.find("Bar").is_some());
+}