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
56 changes: 55 additions & 1 deletion crytic_compile/utils/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,65 @@ def _verify_filename_existence(filename: Path, cwd: Path) -> Path:
break

if not filename.exists():
raise InvalidCompilation(f"Unknown file: {filename}")
raise InvalidCompilation(_unknown_file_message(filename, cwd))

return filename


def _unknown_file_message(filename: Path, cwd: Path) -> str:
"""Build an actionable error message for a path that could not be resolved.

Includes hints to verify the path exists and, when the import resembles an
npm/yarn dependency (e.g. ``@openzeppelin/...``), to install project
dependencies.

Args:
filename (Path): the unresolved filename
cwd (Path): the working directory used during resolution

Returns:
str: a multi-line error message
"""
lines = [
f"Unknown file: {filename}",
f" - Verify the path exists (e.g. `ls {filename}`).",
]
if _looks_like_npm_import(filename):
lines.append(
f" - If this is an npm/yarn dependency, install project "
f"dependencies first (e.g. `npm install` or `yarn install` in `{cwd}`)."
)
return "\n".join(lines)


def _looks_like_npm_import(filename: Path) -> bool:
"""Heuristic: is ``filename`` likely an npm/yarn dependency import?

Treats scoped (``@org/pkg/...``) and bare-name (``pkg/...``) paths with
multiple segments as package imports. Excludes absolute paths, paths that
start with ``.`` or ``..``, and the common Solidity project source roots
(``contracts``, ``src``, ``lib``, ``test``, ``script``).

Args:
filename (Path): filename to classify

Returns:
bool: True if the path resembles a package import
"""
if filename.is_absolute():
return False
parts = filename.parts
if len(parts) < 2:
return False
first = parts[0]
if first.startswith("."):
return False
if first in {"contracts", "src", "lib", "test", "tests", "script"}:
return False
# @scope/package or bare package name (no extension/dot in first segment)
return first.startswith("@") or "." not in first


def convert_filename(
used_filename: str | Path,
relative_to_short: Callable[[Path], Path],
Expand Down
73 changes: 73 additions & 0 deletions tests/test_naming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Tests for filename resolution error messages."""

from pathlib import Path

import pytest

from crytic_compile.platform.exceptions import InvalidCompilation
from crytic_compile.utils.naming import (
_looks_like_npm_import,
_unknown_file_message,
_verify_filename_existence,
)


def test_unknown_file_error_includes_path_and_ls_hint(tmp_path: Path) -> None:
"""Error includes the requested filename and an `ls` hint."""
missing = Path("nonexistent/Foo.sol")
with pytest.raises(InvalidCompilation) as excinfo:
_verify_filename_existence(missing, tmp_path)
msg = str(excinfo.value)
assert "Unknown file" in msg
assert str(missing) in msg
assert "ls " in msg


def test_unknown_file_error_npm_scoped_hint(tmp_path: Path) -> None:
"""Scoped package imports surface the npm/yarn install hint."""
missing = Path("@openzeppelin/contracts/token/ERC20/ERC20.sol")
with pytest.raises(InvalidCompilation) as excinfo:
_verify_filename_existence(missing, tmp_path)
msg = str(excinfo.value)
assert "npm install" in msg
assert "yarn install" in msg
assert str(tmp_path) in msg


def test_unknown_file_error_bare_package_hint(tmp_path: Path) -> None:
"""Bare-name imports (e.g. `solmate/...`) surface the install hint."""
missing = Path("solmate/src/tokens/ERC20.sol")
msg = _unknown_file_message(missing, tmp_path)
assert "npm install" in msg


def test_unknown_file_error_project_dir_no_hint(tmp_path: Path) -> None:
"""Local project source paths (`contracts/...`) do not surface npm hint."""
msg = _unknown_file_message(Path("contracts/Foo.sol"), tmp_path)
assert "npm install" not in msg
assert "Unknown file" in msg


def test_unknown_file_error_relative_no_hint(tmp_path: Path) -> None:
"""Explicit relative paths (`./Foo.sol`) do not surface npm hint."""
msg = _unknown_file_message(Path("./Foo.sol"), tmp_path)
assert "npm install" not in msg


@pytest.mark.parametrize(
("path", "expected"),
[
("@openzeppelin/contracts/token/ERC20.sol", True),
("solmate/src/tokens/ERC20.sol", True),
("contracts/Foo.sol", False),
("src/Foo.sol", False),
("lib/forge-std/src/Test.sol", False),
("./Foo.sol", False),
("../Foo.sol", False),
("Foo.sol", False),
("/abs/path/Foo.sol", False),
],
)
def test_looks_like_npm_import(path: str, expected: bool) -> None:
"""Classifier covers scoped/bare/project/relative/absolute cases."""
assert _looks_like_npm_import(Path(path)) is expected