Skip to content

Commit f434015

Browse files
authored
Merge pull request #78 from jjohannes/generic_imports_resolver
Generalize implementation of ImportsResolver to allow imports from arbitrary npm packages
2 parents 6c23c2e + 5186e4b commit f434015

File tree

8 files changed

+209
-103
lines changed

8 files changed

+209
-103
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
1111

1212
### Features
1313

14-
*
14+
* allow Solidity imports from arbitrary npm packages [#78](https://github.com/hyperledger-web3j/web3j-solidity-gradle-plugin/pull/78)
1515

1616
### BREAKING CHANGES
1717

src/main/groovy/org/web3j/solidity/gradle/plugin/ImportsResolver.groovy

+51-14
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,31 @@
1212
*/
1313
package org.web3j.solidity.gradle.plugin
1414

15-
import groovy.transform.Memoized
15+
import groovy.io.FileType
16+
17+
import java.util.regex.Pattern
1618

1719
/**
1820
* Helper class to resolve the external imports from a Solidity file.
1921
*
20-
* Supported providers are:
22+
* The import resolving is done in three steps:
2123
* <ul>
22-
* <li><a href="https://www.npmjs.com/package/@openzeppelin/contracts">Open Zeppelin</a></li>
23-
* <li><a href="https://www.npmjs.com/package/@uniswap/lib">Uniswap</a></li>
24+
* <li>
25+
* First, all packages needed for direct imports are extracted from sol files to generate a package.json.
26+
* This is done in a separate Gradle task, so that the next steps are only performed when direct imports change.
27+
* </li>
28+
* <li>
29+
* Second, required packages are downloaded by npm.
30+
* </li>
31+
* <li>
32+
* Third, sol files that were downloaded are analyzed as well and all packages required are collected.
33+
* This information is used in compileSolidity for the allowed paths and the path remappings.
34+
* </li>
2435
* </ul>
2536
*/
26-
@Singleton
2737
class ImportsResolver {
2838

29-
private Set<String> PROVIDERS = ["@openzeppelin/contracts", "@uniswap/lib"]
39+
private static final IMPORT_PROVIDER_PATTERN = Pattern.compile(".*import.*['\"](@[^/]+/[^/]+).*");
3040

3141
/**
3242
* Looks for external imports in Solidity files, eg:
@@ -41,17 +51,44 @@ class ImportsResolver {
4151
* @param nodeProjectDir the Node.js project directory
4252
* @return
4353
*/
44-
@Memoized
45-
Map<String, String> resolveImports(final File solFile, final File nodeProjectDir) {
46-
final Map<String, String> imports = [:]
47-
PROVIDERS.forEach { String provider ->
48-
def importFound = !solFile.readLines().findAll {
49-
it.contains(provider)
50-
}.isEmpty()
54+
static Set<String> extractImports(final File solFile) {
55+
final Set<String> imports = new TreeSet<>()
56+
57+
solFile.readLines().each { String line ->
58+
final importProviderMatcher = IMPORT_PROVIDER_PATTERN.matcher(line)
59+
final importFound = importProviderMatcher.matches()
5160
if (importFound) {
52-
imports.put(provider, "$nodeProjectDir.path/node_modules/$provider")
61+
final provider = importProviderMatcher.group(1)
62+
imports.add(provider)
5363
}
5464
}
65+
5566
return imports
5667
}
68+
69+
static Set<String> resolveTransitive(Set<String> directImports, File nodeModulesDir) {
70+
final Set<String> allImports = new TreeSet<>()
71+
if (directImports.isEmpty()) {
72+
return allImports
73+
}
74+
75+
def transitiveResolved = 0
76+
allImports.addAll(directImports)
77+
78+
while (transitiveResolved != allImports.size()) {
79+
transitiveResolved = allImports.size()
80+
allImports.collect().each { nodeModule ->
81+
final packageFolder = new File(nodeModulesDir, nodeModule)
82+
if (packageFolder.exists()) { // this may be a dev dependency from a test that we do not need
83+
packageFolder.eachFileRecurse(FileType.FILES) { dependencyFile ->
84+
if (dependencyFile.name.endsWith('.sol')) {
85+
allImports.addAll(extractImports(dependencyFile))
86+
}
87+
}
88+
}
89+
}
90+
}
91+
92+
return allImports
93+
}
5794
}

src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityCompile.groovy

+27-9
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313
package org.web3j.solidity.gradle.plugin
1414

15+
import org.gradle.api.file.RegularFileProperty
1516
import org.gradle.api.tasks.*
1617
import org.web3j.sokt.SolcInstance
1718
import org.web3j.sokt.SolidityFile
@@ -20,7 +21,7 @@ import org.web3j.sokt.VersionResolver
2021
import java.nio.file.Paths
2122

2223
@CacheableTask
23-
class SolidityCompile extends SourceTask {
24+
abstract class SolidityCompile extends SourceTask {
2425

2526
@Input
2627
@Optional
@@ -70,8 +71,26 @@ class SolidityCompile extends SourceTask {
7071
@Optional
7172
private CombinedOutputComponent[] combinedOutputComponents
7273

74+
@InputFile
75+
@PathSensitive(PathSensitivity.NONE)
76+
abstract RegularFileProperty getResolvedImports()
77+
78+
SolidityCompile() {
79+
resolvedImports.convention(project.provider {
80+
// Optional file input workaround: https://github.com/gradle/gradle/issues/2016
81+
// This is a provider that is only triggered when not overwritten (solidity.resolvePackages = false).
82+
def emptyImportsFile = project.layout.buildDirectory.file("sol-imports-empty.txt").get()
83+
emptyImportsFile.asFile.parentFile.mkdirs()
84+
emptyImportsFile.asFile.createNewFile()
85+
return emptyImportsFile
86+
})
87+
}
88+
7389
@TaskAction
7490
void compileSolidity() {
91+
final imports = resolvedImports.get().asFile.readLines().findAll { !it.isEmpty() }
92+
final File nodeModulesDir = project.node.nodeProjectDir.dir("node_modules").get().asFile
93+
7594
for (def contract in source) {
7695
def options = []
7796

@@ -105,18 +124,17 @@ class SolidityCompile extends SourceTask {
105124
options.add('--ignore-missing')
106125
}
107126

108-
if (!allowPaths.isEmpty()) {
127+
if (!allowPaths.isEmpty() || !imports.isEmpty()) {
109128
options.add("--allow-paths")
110-
options.add(allowPaths.join(','))
129+
options.add((allowPaths + imports.collect { new File(nodeModulesDir,it).absolutePath }).join(','))
111130
}
112131

113-
final File nodeProjectDir = project.node.nodeProjectDir.asFile.get()
114-
def allPathRemappings = pathRemappings + ImportsResolver.instance.resolveImports(contract, nodeProjectDir)
132+
pathRemappings.each { key, value ->
133+
options.add("$key=$value")
134+
}
115135

116-
if (!allPathRemappings.isEmpty()) {
117-
allPathRemappings.forEach { key, value ->
118-
options.add("$key=$value")
119-
}
136+
imports.each { provider ->
137+
options.add("$provider=$nodeModulesDir/$provider")
120138
}
121139

122140
options.add('--output-dir')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2024 Web3 Labs Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
5+
* the License. You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
10+
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
11+
* specific language governing permissions and limitations under the License.
12+
*/
13+
package org.web3j.solidity.gradle.plugin
14+
15+
import groovy.json.JsonBuilder
16+
import groovy.transform.CompileStatic
17+
import org.gradle.api.DefaultTask
18+
import org.gradle.api.file.ConfigurableFileCollection
19+
import org.gradle.api.file.RegularFileProperty
20+
import org.gradle.api.provider.Property
21+
import org.gradle.api.tasks.*
22+
23+
@CacheableTask
24+
@CompileStatic
25+
abstract class SolidityExtractImports extends DefaultTask {
26+
27+
@Input
28+
abstract Property<String> getProjectName()
29+
30+
@InputFiles
31+
@PathSensitive(value = PathSensitivity.RELATIVE)
32+
@SkipWhenEmpty
33+
abstract ConfigurableFileCollection getSources()
34+
35+
@OutputFile
36+
abstract RegularFileProperty getPackageJson()
37+
38+
SolidityExtractImports() {
39+
projectName.convention(project.name)
40+
}
41+
42+
@TaskAction
43+
void resolveSolidity() {
44+
final Set<String> packages = new TreeSet<>()
45+
46+
sources.each { contract ->
47+
packages.addAll(ImportsResolver.extractImports(contract))
48+
}
49+
50+
final jsonMap = [
51+
"name" : projectName.get(),
52+
"description" : "",
53+
"repository" : "",
54+
"license" : "UNLICENSED",
55+
"dependencies": packages.collectEntries {
56+
[(it): "latest"]
57+
}
58+
]
59+
60+
packageJson.get().asFile.text = new JsonBuilder(jsonMap).toPrettyString()
61+
}
62+
}

src/main/groovy/org/web3j/solidity/gradle/plugin/SolidityPlugin.groovy

+29-17
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ package org.web3j.solidity.gradle.plugin
1414

1515
import com.github.gradle.node.NodeExtension
1616
import com.github.gradle.node.NodePlugin
17+
import com.github.gradle.node.npm.task.NpmInstallTask
1718
import org.gradle.api.Plugin
1819
import org.gradle.api.Project
1920
import org.gradle.api.file.DirectoryProperty
@@ -102,8 +103,7 @@ class SolidityPlugin implements Plugin<Project> {
102103
*/
103104
private static void configureSolidityCompile(final Project project, final SourceSet sourceSet) {
104105

105-
def srcSetName = sourceSet.name == 'main' ? '' : capitalize((CharSequence) sourceSet.name)
106-
def compileTask = project.tasks.create("compile${srcSetName}Solidity", SolidityCompile)
106+
def compileTask = project.tasks.create(sourceSet.getTaskName("compile", "Solidity"), SolidityCompile)
107107
def soliditySourceSet = sourceSet.convention.plugins[NAME] as SoliditySourceSet
108108

109109
if (!requiresBundledExecutable(project)) {
@@ -146,27 +146,39 @@ class SolidityPlugin implements Plugin<Project> {
146146
compileTask.outputs.dir(soliditySourceSet.solidity.destinationDirectory)
147147
compileTask.description = "Compiles $sourceSet.name Solidity source."
148148

149-
if (project.solidity.resolvePackages) {
150-
project.getTasks().named('npmInstall').configure {
151-
it.dependsOn(project.getTasks().named("resolveSolidity"))
152-
}
153-
compileTask.dependsOn(project.getTasks().named("npmInstall"))
154-
}
155-
156149
project.getTasks().named('build').configure {
157150
it.dependsOn(compileTask)
158151
}
159152
}
160153

161154
private void configureSolidityResolve(Project target, DirectoryProperty nodeProjectDir) {
162-
def resolveSolidity = target.tasks.create("resolveSolidity", SolidityResolve)
163-
resolveSolidity.sources = resolvedSolidity.solidity
164-
resolveSolidity.description = "Resolve external Solidity contract modules."
165-
resolveSolidity.allowPaths = target.solidity.allowPaths
166-
resolveSolidity.onlyIf { target.solidity.resolvePackages }
167-
168-
def packageJson = new File(nodeProjectDir.asFile.get(), "package.json")
169-
resolveSolidity.packageJson = packageJson
155+
156+
if (target.solidity.resolvePackages) {
157+
def extractSolidityImports = target.tasks.register("extractSolidityImports", SolidityExtractImports) {
158+
it.description = "Extracts imports of external Solidity contract modules."
159+
it.sources.from(resolvedSolidity.solidity)
160+
it.packageJson.set(nodeProjectDir.file("package.json"))
161+
}
162+
def npmInstall = target.tasks.named(NpmInstallTask.NAME) {
163+
it.dependsOn(extractSolidityImports)
164+
}
165+
def resolveSolidity = target.tasks.register("resolveSolidity", SolidityResolve) {
166+
it.description = "Resolve external Solidity contract modules."
167+
168+
it.dependsOn(npmInstall)
169+
it.packageJson.set(nodeProjectDir.file("package.json"))
170+
it.nodeModules.set(nodeProjectDir.dir("node_modules"))
171+
172+
it.allImports.set(target.layout.buildDirectory.file("sol-imports-all.txt"))
173+
}
174+
175+
final SourceSetContainer sourceSets = target.extensions.getByType(SourceSetContainer.class)
176+
sourceSets.all { SourceSet sourceSet ->
177+
target.tasks.named(sourceSet.getTaskName("compile", "Solidity"), SolidityCompile) {
178+
it.resolvedImports.set(resolveSolidity.flatMap { it.allImports })
179+
}
180+
}
181+
}
170182
}
171183

172184
/**

0 commit comments

Comments
 (0)