diff --git a/ape_solidity/compiler.py b/ape_solidity/compiler.py index 68184c6..56aaa7e 100644 --- a/ape_solidity/compiler.py +++ b/ape_solidity/compiler.py @@ -54,9 +54,15 @@ # Define a regex pattern that matches import statements # Both single and multi-line imports will be matched IMPORTS_PATTERN = re.compile( - r"import\s+((.*?)(?=;)|[\s\S]*?from\s+(.*?)(?=;));\s", flags=re.MULTILINE + r"import\s+(([\s\S]*?)(?=;)|[\s\S]*?from\s+([^\s;]+));\s*", flags=re.MULTILINE ) LICENSES_PATTERN = re.compile(r"(// SPDX-License-Identifier:\s*([^\n]*)\s)") + +# Comment patterns +SINGLE_LINE_COMMENT_PATTERN = re.compile(r"^\s*//") +MULTI_LINE_COMMENT_START_PATTERN = re.compile(r"/\*") +MULTI_LINE_COMMENT_END_PATTERN = re.compile(r"\*/") + VERSION_PRAGMA_PATTERN = re.compile(r"pragma solidity[^;]*;") DEFAULT_OPTIMIZATION_RUNS = 200 @@ -142,7 +148,7 @@ class SolidityConfig(PluginConfig): def _get_flattened_source(path: Path, name: Optional[str] = None) -> str: name = name or path.name result = f"// File: {name}\n" - result += path.read_text() + "\n" + result += f"{path.read_text().rstrip()}\n" return result @@ -471,19 +477,6 @@ def get_standard_input_json( version_map, remapping, project=pm, import_map=import_map, **overrides ) - def get_standard_input_json_from( - self, - version_map: dict[Version, set[Path]], - import_remappings: dict[str, str], - project: Optional[ProjectManager] = None, - **overrides, - ): - pm = project or self.local_project - settings = self._get_settings_from_version_map( - version_map, import_remappings, project=pm, **overrides - ) - return self.get_standard_input_json_from_settings(settings, version_map, project=pm) - def get_standard_input_json_from_version_map( self, version_map: dict[Version, set[Path]], @@ -1112,14 +1105,17 @@ def enrich_error(self, err: ContractLogicError) -> ContractLogicError: def _flatten_source( self, - path: Path, + path: Union[Path, str], project: Optional[ProjectManager] = None, raw_import_name: Optional[str] = None, handled: Optional[set[str]] = None, ) -> str: pm = project or self.local_project handled = handled or set() - source_id = f"{get_relative_path(path, pm.path)}" + + path = Path(path) + source_id = f"{get_relative_path(path, pm.path)}" if path.is_absolute() else f"{path}" + handled.add(source_id) remapping = self.get_import_remapping(project=project) imports = self._get_imports((path,), remapping, pm, tracked=set(), include_raw=True) @@ -1132,26 +1128,36 @@ def _flatten_source( continue sub_import_name = import_str.replace("import ", "").strip(" \n\t;\"'") - final_source += self._flatten_source( + sub_source = self._flatten_source( pm.path / source_id, project=pm, raw_import_name=sub_import_name, handled=handled, ) + final_source += sub_source + + flattened_src = _get_flattened_source(path, name=raw_import_name) + if flattened_src and final_source.rstrip(): + final_source = f"{final_source.rstrip()}\n\n{flattened_src}" + elif flattened_src: + final_source = flattened_src - final_source += _get_flattened_source(path, name=raw_import_name) return final_source def flatten_contract( self, path: Path, project: Optional[ProjectManager] = None, **kwargs ) -> Content: - # try compiling in order to validate it works res = self._flatten_source(path, project=project) res = remove_imports(res) res = process_licenses(res) res = remove_version_pragmas(res) pragma = get_first_version_pragma(path.read_text()) res = "\n".join([pragma, res]) + + # Simple auto-format. + while "\n\n\n" in res: + res = res.replace("\n\n\n", "\n\n") + lines = res.splitlines() line_dict = {i + 1: line for i, line in enumerate(lines)} return Content(root=line_dict) @@ -1260,11 +1266,37 @@ def _import_str_to_source_id( return f"{get_relative_path(path.absolute(), pm.path)}" -def remove_imports(flattened_contract: str) -> str: - # Use regex.sub() to remove matched import statements - no_imports_contract = IMPORTS_PATTERN.sub("", flattened_contract) +def remove_imports(source_code: str) -> str: + in_multi_line_comment = False + result_lines = [] + + lines = source_code.splitlines() + for line in lines: + # Check if we're entering a multi-line comment + if MULTI_LINE_COMMENT_START_PATTERN.search(line): + in_multi_line_comment = True + + # If inside a multi-line comment, just add the line to the result + if in_multi_line_comment: + result_lines.append(line) + # Check if this line ends the multi-line comment + if MULTI_LINE_COMMENT_END_PATTERN.search(line): + in_multi_line_comment = False + continue + + # Skip single-line comments + if SINGLE_LINE_COMMENT_PATTERN.match(line): + result_lines.append(line) + continue + + # Skip import statements in non-comment lines + if IMPORTS_PATTERN.search(line): + continue + + # Add the line to the result if it's not an import statement + result_lines.append(line) - return no_imports_contract + return "\n".join(result_lines) def remove_version_pragmas(flattened_contract: str) -> str: @@ -1301,9 +1333,7 @@ def process_licenses(contract: str) -> str: license_line, root_license = extracted_licenses[-1] # Get the unique license identifiers. All licenses in a contract _should_ be the same. - unique_license_identifiers = { - license_identifier for _, license_identifier in extracted_licenses - } + unique_license_identifiers = {lid for _, lid in extracted_licenses} # If we have more than one unique license identifier, warn the user and use the root. if len(unique_license_identifiers) > 1: diff --git a/tests/data/ImportingLessConstrainedVersionFlat.sol b/tests/data/ImportingLessConstrainedVersionFlat.sol index ddf7012..61a4644 100644 --- a/tests/data/ImportingLessConstrainedVersionFlat.sol +++ b/tests/data/ImportingLessConstrainedVersionFlat.sol @@ -3,8 +3,6 @@ pragma solidity =0.8.12; // File: ./SpecificVersionRange.sol - - contract SpecificVersionRange { function foo() pure public returns(bool) { return true; @@ -13,8 +11,6 @@ contract SpecificVersionRange { // File: ImportingLessConstrainedVersion.sol - - // The file we are importing specific range '>=0.8.12 <0.8.15'; // This means on its own, the plugin would use 0.8.14 if its installed. // However - it should use 0.8.12 because of this file's requirements. diff --git a/tests/data/ImportsFlattened.sol.txt b/tests/data/ImportsFlattened.sol similarity index 57% rename from tests/data/ImportsFlattened.sol.txt rename to tests/data/ImportsFlattened.sol index 32b1f59..9cfa235 100644 --- a/tests/data/ImportsFlattened.sol.txt +++ b/tests/data/ImportsFlattened.sol @@ -1,96 +1,85 @@ +pragma solidity ^0.8.4; // SPDX-License-Identifier: MIT -// File: @remapping_2_brownie/BrownieContract.sol - -pragma solidity ^0.8.4; +// File: @dependencyofdependency/contracts/DependencyOfDependency.sol -contract BrownieContract { +contract DependencyOfDependency { function foo() pure public returns(bool) { return true; } } -// File: @styleofbrownie/BrownieStyleDependency.sol +// File: * as Depend from "@dependency/contracts/Dependency.sol -pragma solidity ^0.8.4; +struct DependencyStruct { + string name; + uint value; +} -contract BrownieStyleDependency { +contract Dependency { function foo() pure public returns(bool) { return true; } } +// File: { Struct0, Struct1, Struct2, Struct3, Struct4, Struct5 } from "./NumerousDefinitions.sol -// File: @dependency_remapping/DependencyOfDependency.sol - -pragma solidity ^0.8.4; +struct Struct0 { + string name; + uint value; +} -contract DependencyOfDependency { - function foo() pure public returns(bool) { - return true; - } +struct Struct1 { + string name; + uint value; } -// File: @remapping/contracts/Dependency.sol +struct Struct2 { + string name; + uint value; +} -pragma solidity ^0.8.4; +struct Struct3 { + string name; + uint value; +} +struct Struct4 { + string name; + uint value; +} -struct DependencyStruct { +struct Struct5 { string name; uint value; } -contract Dependency { +contract NumerousDefinitions { function foo() pure public returns(bool) { return true; } } +// File: @noncompilingdependency/CompilingContract.sol -// File: @dependency_remapping/DependencyOfDependency.sol - -pragma solidity ^0.8.4; - -contract DependencyOfDependency { +contract BrownieStyleDependency { function foo() pure public returns(bool) { return true; } } +// File: @browniedependency/contracts/BrownieContract.sol -// File: @remapping_2/Dependency.sol - -pragma solidity ^0.8.4; - - -struct DependencyStruct { - string name; - uint value; -} - -contract Dependency { +contract CompilingContract { function foo() pure public returns(bool) { return true; } } +// File: ./subfolder/Relativecontract.sol -// File: CompilesOnce.sol - -pragma solidity >=0.8.0; - -struct MyStruct { - string name; - uint value; -} - -contract CompilesOnce { - // This contract tests the scenario when we have a contract with - // a similar compiler version to more than one other contract's. - // This ensures we don't compile the same contract more than once. +contract Relativecontract { function foo() pure public returns(bool) { return true; } } - // File: ./././././././././././././././././././././././././././././././././././MissingPragma.sol contract MissingPragma { @@ -98,53 +87,41 @@ contract MissingPragma { return true; } } +// File: @safe/contracts/common/Enum.sol -// File: ./NumerousDefinitions.sol - -pragma solidity >=0.8.0; - -struct Struct0 { - string name; - uint value; -} - -struct Struct1 { - string name; - uint value; -} - -struct Struct2 { - string name; - uint value; +/// @title Enum - Collection of enums +/// @author Richard Meissner - +contract Enum { + enum Operation {Call, DelegateCall} } +// File: ./Source.extra.ext.sol -struct Struct3 { - string name; - uint value; +// Showing sources with extra extensions are by default excluded, +// unless used as an import somewhere in a non-excluded source. +contract SourceExtraExt { + function foo() pure public returns(bool) { + return true; + } } +// File: { MyStruct } from "contracts/CompilesOnce.sol -struct Struct4 { +struct MyStruct { string name; uint value; } -struct Struct5 { - string name; - uint value; -} +contract CompilesOnce { + // This contract tests the scenario when we have a contract with + // a similar compiler version to more than one other contract's. + // This ensures we don't compile the same contract more than once. -contract NumerousDefinitions { function foo() pure public returns(bool) { return true; } } +// File: @noncompilingdependency/subdir/SubCompilingContract.sol -// File: ./subfolder/Relativecontract.sol - -pragma solidity >=0.8.0; - -contract Relativecontract { - +contract SubCompilingContract { function foo() pure public returns(bool) { return true; } @@ -152,8 +129,22 @@ contract Relativecontract { // File: Imports.sol -pragma solidity ^0.8.4; - +import + "./././././././././././././././././././././././././././././././././././MissingPragma.sol"; +import { + Struct0, + Struct1, + Struct2, + Struct3, + Struct4, + Struct5 +} from "./NumerousDefinitions.sol"; +// Purposely repeat an import to test how the plugin handles that. + +// Purposely exclude the contracts folder to test older Ape-style project imports. + +// Showing sources with extra extensions are by default excluded, +// unless used as an import somewhere in a non-excluded source. contract Imports { function foo() pure public returns(bool) { diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 61b15dc..bf79a76 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -687,27 +687,32 @@ def test_enrich_error_when_builtin(project, owner, connection): def test_flatten(mocker, project, compiler): - path = project.sources.lookup("contracts/Imports.sol") + path = project.contracts_folder / "Imports.sol" + base_expected = Path(__file__).parent / "data" # NOTE: caplog for some reason is inconsistent and causes flakey tests. # Thus, we are using our own "logger_spy". logger_spy = mocker.patch("ape_solidity.compiler.logger") res = compiler.flatten_contract(path, project=project) - actual_logs = logger_spy.warning.call_args[0] + call_args = logger_spy.warning.call_args + actual_logs = call_args[0] if call_args else () assert actual_logs, f"Missing warning logs from dup-licenses, res: {res}" actual = actual_logs[-1] + # NOTE: MIT coming from Imports.sol and LGPL-3.0-only coming from + # @safe/contracts/common/Enum.sol. expected = ( "Conflicting licenses found: 'LGPL-3.0-only, MIT'. Using the root file's license 'MIT'." ) assert actual == expected - path = project.sources.lookup("contracts/ImportingLessConstrainedVersion.sol") + path = project.contracts_folder / "ImportingLessConstrainedVersion.sol" flattened_source = compiler.flatten_contract(path, project=project) - flattened_source_path = ( - Path(__file__).parent / "data" / "ImportingLessConstrainedVersionFlat.sol" - ) - assert str(flattened_source) == str(flattened_source_path.read_text()) + flattened_source_path = base_expected / "ImportingLessConstrainedVersionFlat.sol" + + actual = str(flattened_source) + expected = str(flattened_source_path.read_text()) + assert actual == expected def test_compile_code(project, compiler):