diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5396ea..359029a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
### Features
-*
+* allow Solidity imports from arbitrary npm packages [#78](https://github.com/hyperledger-web3j/web3j-solidity-gradle-plugin/pull/78)
### BREAKING CHANGES
diff --git a/src/main/groovy/org/web3j/solidity/gradle/plugin/ImportsResolver.groovy b/src/main/groovy/org/web3j/solidity/gradle/plugin/ImportsResolver.groovy
index 33f7c71..d529ffa 100644
--- a/src/main/groovy/org/web3j/solidity/gradle/plugin/ImportsResolver.groovy
+++ b/src/main/groovy/org/web3j/solidity/gradle/plugin/ImportsResolver.groovy
@@ -12,21 +12,31 @@
*/
package org.web3j.solidity.gradle.plugin
-import groovy.transform.Memoized
+import groovy.io.FileType
+
+import java.util.regex.Pattern
/**
* Helper class to resolve the external imports from a Solidity file.
*
- * Supported providers are:
+ * The import resolving is done in three steps:
*
- * - Open Zeppelin
- * - Uniswap
+ * -
+ * First, all packages needed for direct imports are extracted from sol files to generate a package.json.
+ * This is done in a separate Gradle task, so that the next steps are only performed when direct imports change.
+ *
+ * -
+ * Second, required packages are downloaded by npm.
+ *
+ * -
+ * Third, sol files that were downloaded are analyzed as well and all packages required are collected.
+ * This information is used in compileSolidity for the allowed paths and the path remappings.
+ *
*
*/
-@Singleton
class ImportsResolver {
- private Set PROVIDERS = ["@openzeppelin/contracts", "@uniswap/lib"]
+ private static final IMPORT_PROVIDER_PATTERN = Pattern.compile(".*import.*['\"](@[^/]+/[^/]+).*");
/**
* Looks for external imports in Solidity files, eg:
@@ -41,17 +51,44 @@ class ImportsResolver {
* @param nodeProjectDir the Node.js project directory
* @return
*/
- @Memoized
- Map resolveImports(final File solFile, final File nodeProjectDir) {
- final Map imports = [:]
- PROVIDERS.forEach { String provider ->
- def importFound = !solFile.readLines().findAll {
- it.contains(provider)
- }.isEmpty()
+ static Set extractImports(final File solFile) {
+ final Set imports = new TreeSet<>()
+
+ solFile.readLines().each { String line ->
+ final importProviderMatcher = IMPORT_PROVIDER_PATTERN.matcher(line)
+ final importFound = importProviderMatcher.matches()
if (importFound) {
- imports.put(provider, "$nodeProjectDir.path/node_modules/$provider")
+ final provider = importProviderMatcher.group(1)
+ imports.add(provider)
}
}
+
return imports
}
+
+ static Set resolveTransitive(Set directImports, File nodeModulesDir) {
+ final Set allImports = new TreeSet<>()
+ if (directImports.isEmpty()) {
+ return allImports
+ }
+
+ def transitiveResolved = 0
+ allImports.addAll(directImports)
+
+ while (transitiveResolved != allImports.size()) {
+ transitiveResolved = allImports.size()
+ allImports.collect().each { nodeModule ->
+ final packageFolder = new File(nodeModulesDir, nodeModule)
+ if (packageFolder.exists()) { // this may be a dev dependency from a test that we do not need
+ packageFolder.eachFileRecurse(FileType.FILES) { dependencyFile ->
+ if (dependencyFile.name.endsWith('.sol')) {
+ allImports.addAll(extractImports(dependencyFile))
+ }
+ }
+ }
+ }
+ }
+
+ return allImports
+ }
}
diff --git a/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityCompile.groovy b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityCompile.groovy
index 5f6ab6a..843b38d 100644
--- a/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityCompile.groovy
+++ b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityCompile.groovy
@@ -12,6 +12,7 @@
*/
package org.web3j.solidity.gradle.plugin
+import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.*
import org.web3j.sokt.SolcInstance
import org.web3j.sokt.SolidityFile
@@ -20,7 +21,7 @@ import org.web3j.sokt.VersionResolver
import java.nio.file.Paths
@CacheableTask
-class SolidityCompile extends SourceTask {
+abstract class SolidityCompile extends SourceTask {
@Input
@Optional
@@ -70,8 +71,26 @@ class SolidityCompile extends SourceTask {
@Optional
private CombinedOutputComponent[] combinedOutputComponents
+ @InputFile
+ @PathSensitive(PathSensitivity.NONE)
+ abstract RegularFileProperty getResolvedImports()
+
+ SolidityCompile() {
+ resolvedImports.convention(project.provider {
+ // Optional file input workaround: https://github.com/gradle/gradle/issues/2016
+ // This is a provider that is only triggered when not overwritten (solidity.resolvePackages = false).
+ def emptyImportsFile = project.layout.buildDirectory.file("sol-imports-empty.txt").get()
+ emptyImportsFile.asFile.parentFile.mkdirs()
+ emptyImportsFile.asFile.createNewFile()
+ return emptyImportsFile
+ })
+ }
+
@TaskAction
void compileSolidity() {
+ final imports = resolvedImports.get().asFile.readLines().findAll { !it.isEmpty() }
+ final File nodeModulesDir = project.node.nodeProjectDir.dir("node_modules").get().asFile
+
for (def contract in source) {
def options = []
@@ -105,18 +124,17 @@ class SolidityCompile extends SourceTask {
options.add('--ignore-missing')
}
- if (!allowPaths.isEmpty()) {
+ if (!allowPaths.isEmpty() || !imports.isEmpty()) {
options.add("--allow-paths")
- options.add(allowPaths.join(','))
+ options.add((allowPaths + imports.collect { new File(nodeModulesDir,it).absolutePath }).join(','))
}
- final File nodeProjectDir = project.node.nodeProjectDir.asFile.get()
- def allPathRemappings = pathRemappings + ImportsResolver.instance.resolveImports(contract, nodeProjectDir)
+ pathRemappings.each { key, value ->
+ options.add("$key=$value")
+ }
- if (!allPathRemappings.isEmpty()) {
- allPathRemappings.forEach { key, value ->
- options.add("$key=$value")
- }
+ imports.each { provider ->
+ options.add("$provider=$nodeModulesDir/$provider")
}
options.add('--output-dir')
diff --git a/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityExtractImports.groovy b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityExtractImports.groovy
new file mode 100644
index 0000000..7d9dd8b
--- /dev/null
+++ b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityExtractImports.groovy
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 Web3 Labs Ltd.
+ *
+ * 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.
+ */
+package org.web3j.solidity.gradle.plugin
+
+import groovy.json.JsonBuilder
+import groovy.transform.CompileStatic
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.*
+
+@CacheableTask
+@CompileStatic
+abstract class SolidityExtractImports extends DefaultTask {
+
+ @Input
+ abstract Property getProjectName()
+
+ @InputFiles
+ @PathSensitive(value = PathSensitivity.RELATIVE)
+ @SkipWhenEmpty
+ abstract ConfigurableFileCollection getSources()
+
+ @OutputFile
+ abstract RegularFileProperty getPackageJson()
+
+ SolidityExtractImports() {
+ projectName.convention(project.name)
+ }
+
+ @TaskAction
+ void resolveSolidity() {
+ final Set packages = new TreeSet<>()
+
+ sources.each { contract ->
+ packages.addAll(ImportsResolver.extractImports(contract))
+ }
+
+ final jsonMap = [
+ "name" : projectName.get(),
+ "description" : "",
+ "repository" : "",
+ "license" : "UNLICENSED",
+ "dependencies": packages.collectEntries {
+ [(it): "latest"]
+ }
+ ]
+
+ packageJson.get().asFile.text = new JsonBuilder(jsonMap).toPrettyString()
+ }
+}
diff --git a/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityPlugin.groovy b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityPlugin.groovy
index 23a05fb..90af4d3 100644
--- a/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityPlugin.groovy
+++ b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityPlugin.groovy
@@ -14,6 +14,7 @@ package org.web3j.solidity.gradle.plugin
import com.github.gradle.node.NodeExtension
import com.github.gradle.node.NodePlugin
+import com.github.gradle.node.npm.task.NpmInstallTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
@@ -102,8 +103,7 @@ class SolidityPlugin implements Plugin {
*/
private static void configureSolidityCompile(final Project project, final SourceSet sourceSet) {
- def srcSetName = sourceSet.name == 'main' ? '' : capitalize((CharSequence) sourceSet.name)
- def compileTask = project.tasks.create("compile${srcSetName}Solidity", SolidityCompile)
+ def compileTask = project.tasks.create(sourceSet.getTaskName("compile", "Solidity"), SolidityCompile)
def soliditySourceSet = sourceSet.convention.plugins[NAME] as SoliditySourceSet
if (!requiresBundledExecutable(project)) {
@@ -146,27 +146,39 @@ class SolidityPlugin implements Plugin {
compileTask.outputs.dir(soliditySourceSet.solidity.destinationDirectory)
compileTask.description = "Compiles $sourceSet.name Solidity source."
- if (project.solidity.resolvePackages) {
- project.getTasks().named('npmInstall').configure {
- it.dependsOn(project.getTasks().named("resolveSolidity"))
- }
- compileTask.dependsOn(project.getTasks().named("npmInstall"))
- }
-
project.getTasks().named('build').configure {
it.dependsOn(compileTask)
}
}
private void configureSolidityResolve(Project target, DirectoryProperty nodeProjectDir) {
- def resolveSolidity = target.tasks.create("resolveSolidity", SolidityResolve)
- resolveSolidity.sources = resolvedSolidity.solidity
- resolveSolidity.description = "Resolve external Solidity contract modules."
- resolveSolidity.allowPaths = target.solidity.allowPaths
- resolveSolidity.onlyIf { target.solidity.resolvePackages }
-
- def packageJson = new File(nodeProjectDir.asFile.get(), "package.json")
- resolveSolidity.packageJson = packageJson
+
+ if (target.solidity.resolvePackages) {
+ def extractSolidityImports = target.tasks.register("extractSolidityImports", SolidityExtractImports) {
+ it.description = "Extracts imports of external Solidity contract modules."
+ it.sources.from(resolvedSolidity.solidity)
+ it.packageJson.set(nodeProjectDir.file("package.json"))
+ }
+ def npmInstall = target.tasks.named(NpmInstallTask.NAME) {
+ it.dependsOn(extractSolidityImports)
+ }
+ def resolveSolidity = target.tasks.register("resolveSolidity", SolidityResolve) {
+ it.description = "Resolve external Solidity contract modules."
+
+ it.dependsOn(npmInstall)
+ it.packageJson.set(nodeProjectDir.file("package.json"))
+ it.nodeModules.set(nodeProjectDir.dir("node_modules"))
+
+ it.allImports.set(target.layout.buildDirectory.file("sol-imports-all.txt"))
+ }
+
+ final SourceSetContainer sourceSets = target.extensions.getByType(SourceSetContainer.class)
+ sourceSets.all { SourceSet sourceSet ->
+ target.tasks.named(sourceSet.getTaskName("compile", "Solidity"), SolidityCompile) {
+ it.resolvedImports.set(resolveSolidity.flatMap { it.allImports })
+ }
+ }
+ }
}
/**
diff --git a/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityResolve.groovy b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityResolve.groovy
index ab2e7e6..9e3d25f 100644
--- a/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityResolve.groovy
+++ b/src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityResolve.groovy
@@ -12,80 +12,41 @@
*/
package org.web3j.solidity.gradle.plugin
-import groovy.json.JsonBuilder
-import groovy.json.JsonOutput
+import com.github.gradle.node.npm.task.NpmSetupTask
+import com.github.gradle.node.npm.task.NpmTask
+import groovy.json.JsonSlurper
+import groovy.transform.CompileStatic
import org.gradle.api.DefaultTask
-import org.gradle.api.file.FileTree
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.internal.file.FileOperations
+import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
+import javax.inject.Inject
+
+@CompileStatic
@CacheableTask
-class SolidityResolve extends DefaultTask {
+abstract class SolidityResolve extends DefaultTask {
- private FileTree sources
+ @InputFile
+ @PathSensitive(value = PathSensitivity.NAME_ONLY)
+ abstract RegularFileProperty getPackageJson()
- private Set allowPaths
+ @InputDirectory
+ @PathSensitive(value = PathSensitivity.RELATIVE)
+ abstract DirectoryProperty getNodeModules()
- private File packageJson
+ @OutputFile
+ abstract RegularFileProperty getAllImports()
@TaskAction
void resolveSolidity() {
- final Set libraries = []
- final File nodeProjectDir = project.node.nodeProjectDir.asFile.get()
-
- for (def contract in sources) {
- def imports = ImportsResolver.instance.resolveImports(contract, nodeProjectDir)
- for (provider in imports.keySet()) {
- libraries.add(provider)
- allowPaths.add("$nodeProjectDir.path/node_modules/$provider")
- }
- }
-
- def jsonMap = [
- "name" : project.name,
- "description" : project.description == null ? " " : project.description,
- "repository" : " ",
- "license" : "UNLICENSED",
- "dependencies": libraries.collectEntries {
- [(it): "latest"]
- }
- ]
+ final imports = new JsonSlurper().parse(getPackageJson().get().asFile)['dependencies'] as Map
- if (!packageJson.exists()) {
- packageJson.parentFile.mkdirs()
- packageJson.createNewFile()
- def jsonBuilder = new JsonBuilder()
- jsonBuilder jsonMap
- packageJson.append(JsonOutput.prettyPrint(jsonBuilder.toString()) + "\n")
- }
- }
-
- @InputFiles
- @PathSensitive(value = PathSensitivity.RELATIVE)
- @SkipWhenEmpty
- FileTree getSources() {
- return sources
- }
+ final all = ImportsResolver.resolveTransitive(imports.keySet(), getNodeModules().get().asFile)
- void setSources(FileTree sources) {
- this.sources = sources
+ allImports.get().asFile.text = all.join('\n')
}
- @OutputFile
- File getPackageJson() {
- return packageJson
- }
-
- void setPackageJson(File packageJson) {
- this.packageJson = packageJson
- }
-
- @Input
- @Optional
- Set getAllowPaths() {
- return allowPaths
- }
-
- void setAllowPaths(Set allowPaths) {
- this.allowPaths = allowPaths
- }
}
diff --git a/src/test/groovy/org/web3j/solidity/gradle/plugin/SolidityPluginTest.groovy b/src/test/groovy/org/web3j/solidity/gradle/plugin/SolidityPluginTest.groovy
index 5c4a252..2f4c846 100644
--- a/src/test/groovy/org/web3j/solidity/gradle/plugin/SolidityPluginTest.groovy
+++ b/src/test/groovy/org/web3j/solidity/gradle/plugin/SolidityPluginTest.groovy
@@ -138,6 +138,8 @@ class SolidityPluginTest {
assertTrue(Files.exists(compiledSolDir.resolve("MyCollectible.abi")))
assertTrue(Files.exists(compiledSolDir.resolve("MyCollectible.bin")))
assertTrue(Files.exists(compiledSolDir.resolve("ERC721.abi")))
+ assertTrue(Files.exists(compiledSolDir.resolve("MyOFT.abi")))
+ assertTrue(Files.exists(compiledSolDir.resolve("MyOFT.bin")))
def upToDate = build()
assertEquals(UP_TO_DATE, upToDate.task(":compileSolidity").getOutcome())
diff --git a/src/test/resources/solidity/openzeppelin/MyOFT.sol b/src/test/resources/solidity/openzeppelin/MyOFT.sol
new file mode 100644
index 0000000..014032f
--- /dev/null
+++ b/src/test/resources/solidity/openzeppelin/MyOFT.sol
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: Apache-2.0
+pragma solidity ^0.8.22;
+
+import { OFT } from "@layerzerolabs/oft-evm/contracts/OFT.sol";
+import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
+
+contract MyOFT is OFT {
+ constructor(
+ string memory _name,
+ string memory _symbol,
+ address _lzEndpoint,
+ address _delegate
+ ) OFT(_name, _symbol, _lzEndpoint, _delegate) Ownable(_delegate) {}
+}