Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions crytic_compile/crytic_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from crytic_compile.platform import all_platforms
from crytic_compile.platform.abstract_platform import AbstractPlatform
from crytic_compile.platform.all_export import PLATFORMS_EXPORT
from crytic_compile.platform.exceptions import InvalidCompilation
from crytic_compile.platform.solc import Solc
from crytic_compile.platform.solc_standard_json import SolcStandardJson
from crytic_compile.platform.standard import export_to_standard
Expand All @@ -41,6 +42,39 @@
logging.basicConfig()


_PLATFORM_CLEAN_HINTS: dict[str, str] = {
"Brownie": "brownie compile --all",
"Buidler": "npx buidler clean",
"Dapp": "dapp clean",
"Embark": "embark reset",
"Etherlime": "etherlime compile --runs 200 --solcVersion 0.5.16 --buildDirectory ./build",
"Foundry": "forge clean",
"Hardhat": "npx hardhat clean",
"Truffle": "npx truffle compile --all",
"Waffle": "rm -rf build",
}


def _stale_cache_hint(file: "Filename", platform: AbstractPlatform | None) -> str:
"""Build a `clean and rebuild` hint for stale build artifacts.

Args:
file (Filename): source file with a mismatched cache.
platform (Optional[AbstractPlatform]): underlying build platform, if any.

Returns:
str: human-readable suggestion mentioning the platform-specific command.
"""
base = (
f"Source map for '{file.absolute}' falls outside the cached source. "
"Build artifacts are likely stale relative to the source on disk."
)
command = _PLATFORM_CLEAN_HINTS.get(platform.NAME) if platform is not None else None
if command:
return f"{base} Try `{command}` and recompile."
return f"{base} Try removing the build directory and recompiling."


def get_platforms() -> list[type[AbstractPlatform]]:
"""Return the available platforms classes in order of preference

Expand Down Expand Up @@ -361,6 +395,10 @@ def get_line_from_offset(self, filename: Filename | str, offset: int) -> tuple[i

Returns:
Tuple[int, int]: (line, line offset)

Raises:
InvalidCompilation: if the offset is outside the cached source range,
which usually means the build artifacts are stale.
"""
if isinstance(filename, str):
file = self.filename_lookup(filename)
Expand All @@ -370,7 +408,10 @@ def get_line_from_offset(self, filename: Filename | str, offset: int) -> tuple[i
self._get_cached_offset_to_line(file)

lines_delimiters = self._cached_offset_to_line[file]
return lines_delimiters[offset]
try:
return lines_delimiters[offset]
except KeyError as exc:
raise InvalidCompilation(_stale_cache_hint(file, self._platform)) from exc

def get_global_offset_from_line(self, filename: Filename | str, line: int) -> int:
"""Return the global offset from a given line
Expand All @@ -381,6 +422,10 @@ def get_global_offset_from_line(self, filename: Filename | str, line: int) -> in

Returns:
int: global offset

Raises:
InvalidCompilation: if the line is outside the cached source range,
which usually means the build artifacts are stale.
"""
if isinstance(filename, str):
file = self.filename_lookup(filename)
Expand All @@ -389,7 +434,10 @@ def get_global_offset_from_line(self, filename: Filename | str, line: int) -> in
if file not in self._cached_line_to_offset:
self._get_cached_offset_to_line(file)

return self._cached_line_to_offset[file][line]
try:
return self._cached_line_to_offset[file][line]
except KeyError as exc:
raise InvalidCompilation(_stale_cache_hint(file, self._platform)) from exc

def _get_cached_line_to_code(self, file: Filename) -> None:
"""Compute the cached lines
Expand Down
115 changes: 115 additions & 0 deletions tests/test_stale_cache_hint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Tests for stale-cache hints in `get_line_from_offset` / `get_global_offset_from_line`."""

from pathlib import Path

import pytest

from crytic_compile import CryticCompile
from crytic_compile.crytic_compile import _PLATFORM_CLEAN_HINTS, _stale_cache_hint
from crytic_compile.platform import Type
from crytic_compile.platform.abstract_platform import AbstractPlatform
from crytic_compile.platform.exceptions import InvalidCompilation
from crytic_compile.utils.naming import Filename


class _StubPlatform(AbstractPlatform):
"""Minimal `AbstractPlatform` that performs no compilation."""

NAME = "Hardhat"
PROJECT_URL = "https://example.invalid"
TYPE = Type.HARDHAT

def compile(self, crytic_compile: "CryticCompile", **kwargs: str) -> None:
return

def clean(self, **kwargs: str) -> None:
return

@staticmethod
def is_supported(target: str, **kwargs: str) -> bool:
return False

def is_dependency(self, path: str) -> bool:
return False

def _guessed_tests(self) -> list[str]:
return []


def _make_filename(tmp_path: Path) -> Filename:
"""Build a `Filename` pointing at an existing on-disk file."""
src = tmp_path / "Foo.sol"
src.write_text("contract C{}\n", encoding="utf-8")
return Filename(
absolute=str(src),
used=str(src),
relative=str(src),
short=src.name,
)


def _make_crytic_compile(tmp_path: Path) -> tuple[CryticCompile, Filename]:
"""Build a `CryticCompile` backed by a stub platform and a single source file."""
filename = _make_filename(tmp_path)
crytic = CryticCompile(_StubPlatform(str(tmp_path)))
crytic.src_content = {filename.absolute: Path(filename.absolute).read_text(encoding="utf-8")}
return crytic, filename


def test_stale_cache_hint_uses_known_platform_command(tmp_path: Path) -> None:
"""Hint surfaces the platform-specific clean command when the platform is recognized."""

class _Platform:
NAME = "Hardhat"

file = _make_filename(tmp_path)
msg = _stale_cache_hint(file, _Platform())
assert "stale" in msg
assert str(file.absolute) in msg
assert _PLATFORM_CLEAN_HINTS["Hardhat"] in msg


def test_stale_cache_hint_falls_back_for_unknown_platform(tmp_path: Path) -> None:
"""Unknown platform names fall back to a generic recommendation."""

class _Platform:
NAME = "MyCustomPlatform"

file = _make_filename(tmp_path)
msg = _stale_cache_hint(file, _Platform())
assert "build directory" in msg
for command in _PLATFORM_CLEAN_HINTS.values():
assert command not in msg


def test_stale_cache_hint_handles_missing_platform(tmp_path: Path) -> None:
"""`None` platform still yields a useful message."""
file = _make_filename(tmp_path)
msg = _stale_cache_hint(file, None)
assert "stale" in msg
assert "build directory" in msg


def test_get_line_from_offset_raises_invalid_compilation(tmp_path: Path) -> None:
"""Out-of-range offset raises `InvalidCompilation` instead of bare `KeyError`."""
crytic, filename = _make_crytic_compile(tmp_path)

line, _ = crytic.get_line_from_offset(filename, 0)
assert line == 1

with pytest.raises(InvalidCompilation) as excinfo:
crytic.get_line_from_offset(filename, 10**9)
assert "stale" in str(excinfo.value)
assert _PLATFORM_CLEAN_HINTS["Hardhat"] in str(excinfo.value)


def test_get_global_offset_from_line_raises_invalid_compilation(tmp_path: Path) -> None:
"""Out-of-range line raises `InvalidCompilation` with a clean-command hint."""
crytic, filename = _make_crytic_compile(tmp_path)

assert crytic.get_global_offset_from_line(filename, 1) == 0

with pytest.raises(InvalidCompilation) as excinfo:
crytic.get_global_offset_from_line(filename, 10**9)
assert "stale" in str(excinfo.value)
assert _PLATFORM_CLEAN_HINTS["Hardhat"] in str(excinfo.value)