Skip to content

Commit 0fe401f

Browse files
committed
Add NatSpec on state variables
1 parent d57ec72 commit 0fe401f

File tree

2 files changed

+187
-1
lines changed

2 files changed

+187
-1
lines changed

crytic_compile/utils/natspec.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,64 @@
22
Natspec module https://solidity.readthedocs.io/en/latest/natspec-format.html
33
"""
44

5+
class DevStateVariable:
6+
"""
7+
Model the dev state variable
8+
"""
9+
10+
def __init__(self, variable: dict) -> None:
11+
"""Init the object
12+
13+
Args:
14+
method (Dict): Method infos (details, params, returns, custom:*)
15+
"""
16+
self._details: str | None = variable.get("details", None)
17+
# For state variable there can be only 1 return documentation.
18+
# We only keep the value that is the documentation, the key is always _0
19+
self._return: str | None = variable["returns"]["_0"] if "returns" in variable else None
20+
# Extract custom fields (keys starting with "custom:")
21+
self._custom: dict[str, str] = {k: v for k, v in variable.items() if k.startswith("custom:")}
22+
23+
@property
24+
def details(self) -> str | None:
25+
"""Return the state variable details
26+
27+
Returns:
28+
Optional[str]: state variable details
29+
"""
30+
return self._details
31+
32+
@property
33+
def variable_return(self) -> str | None:
34+
"""Return the state variable returns
35+
36+
Returns:
37+
Optional[str]: state variable returns
38+
"""
39+
return self._return
40+
41+
@property
42+
def custom(self) -> dict[str, str]:
43+
"""Return the state variable custom fields
44+
45+
Returns:
46+
Dict[str, str]: custom field name => value (e.g. "custom:security" => "value")
47+
"""
48+
return self._custom
49+
50+
def export(self) -> dict:
51+
"""Export to a python dict
52+
53+
Returns:
54+
Dict: Exported dev state variable
55+
"""
56+
result = {
57+
"details": self.details,
58+
"return": self.variable_return,
59+
"custom": self.custom,
60+
}
61+
return result
62+
563

664
class UserMethod:
765
"""
@@ -179,6 +237,9 @@ def __init__(self, devdoc: dict):
179237
self._methods: dict[str, DevMethod] = {
180238
k: DevMethod(item) for k, item in devdoc.get("methods", {}).items()
181239
}
240+
self._state_variables: dict[str, DevStateVariable] = {
241+
k: DevStateVariable(item) for k, item in devdoc.get("stateVariables", {}).items()
242+
}
182243
self._title: str | None = devdoc.get("title", None)
183244
# Extract contract-level custom fields (keys starting with "custom:")
184245
self._custom: dict[str, str] = {k: v for k, v in devdoc.items() if k.startswith("custom:")}
@@ -210,6 +271,15 @@ def methods(self) -> dict[str, DevMethod]:
210271
"""
211272
return self._methods
212273

274+
@property
275+
def state_variables(self) -> dict[str, DevStateVariable]:
276+
"""Return the dev state variables
277+
278+
Returns:
279+
Dict[str, DevStateVariable]: state_variable_name => DevStateVariable
280+
"""
281+
return self._state_variables
282+
213283
@property
214284
def title(self) -> str | None:
215285
"""Return the dev title
@@ -240,6 +310,7 @@ def export(self) -> dict:
240310
"details": self.details,
241311
"title": self.title,
242312
"custom": self.custom,
313+
"state_variables": self.state_variables
243314
}
244315
return result
245316

tests/test_natspec.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22
Test NatSpec parsing, including custom fields (@custom:*)
33
"""
44

5-
from crytic_compile.utils.natspec import DevDoc, DevMethod, Natspec, UserDoc, UserMethod
5+
from crytic_compile.utils.natspec import (
6+
DevDoc,
7+
DevMethod,
8+
DevStateVariable,
9+
Natspec,
10+
UserDoc,
11+
UserMethod,
12+
)
613

714

815
class TestUserMethod:
@@ -94,6 +101,73 @@ def test_devmethod_empty_method(self) -> None:
94101
assert method.custom == {}
95102

96103

104+
class TestDevStateVariable:
105+
"""Tests for DevStateVariable class"""
106+
107+
def test_devstatevariable_basic_fields(self) -> None:
108+
"""Test DevStateVariable parses basic fields"""
109+
var_data = {
110+
"details": "The total supply of tokens",
111+
"returns": {"_0": "The total token supply"},
112+
}
113+
var = DevStateVariable(var_data)
114+
assert var.details == "The total supply of tokens"
115+
assert var.variable_return == "The total token supply"
116+
117+
def test_devstatevariable_custom_fields(self) -> None:
118+
"""Test DevStateVariable extracts custom fields"""
119+
var_data = {
120+
"details": "Owner address",
121+
"returns": {"_0": "The contract owner"},
122+
"custom:security": "critical",
123+
"custom:access": "public",
124+
}
125+
var = DevStateVariable(var_data)
126+
assert var.custom == {
127+
"custom:security": "critical",
128+
"custom:access": "public",
129+
}
130+
131+
def test_devstatevariable_no_custom_fields(self) -> None:
132+
"""Test DevStateVariable returns empty dict when no custom fields"""
133+
var_data = {
134+
"details": "Balance mapping",
135+
"returns": {"_0": "User balance"},
136+
}
137+
var = DevStateVariable(var_data)
138+
assert var.custom == {}
139+
140+
def test_devstatevariable_no_returns(self) -> None:
141+
"""Test DevStateVariable with no returns field"""
142+
var_data = {
143+
"details": "Some variable",
144+
}
145+
var = DevStateVariable(var_data)
146+
assert var.details == "Some variable"
147+
assert var.variable_return is None
148+
149+
def test_devstatevariable_export(self) -> None:
150+
"""Test DevStateVariable export"""
151+
var_data = {
152+
"details": "Token name",
153+
"returns": {"_0": "The token name string"},
154+
"custom:immutable": "true",
155+
}
156+
var = DevStateVariable(var_data)
157+
exported = var.export()
158+
159+
assert exported["details"] == "Token name"
160+
assert exported["return"] == "The token name string"
161+
assert exported["custom"]["custom:immutable"] == "true"
162+
163+
def test_devstatevariable_empty(self) -> None:
164+
"""Test DevStateVariable with empty dict"""
165+
var = DevStateVariable({})
166+
assert var.details is None
167+
assert var.variable_return is None
168+
assert var.custom == {}
169+
170+
97171
class TestUserDoc:
98172
"""Tests for UserDoc class"""
99173

@@ -230,6 +304,47 @@ def test_devdoc_export_with_methods_custom(self) -> None:
230304
assert exported["methods"]["foo()"]["custom"]["custom:audit"] == "verified"
231305
assert exported["methods"]["foo()"]["details"] == "foo details"
232306

307+
def test_devdoc_state_variables(self) -> None:
308+
"""Test DevDoc parses stateVariables"""
309+
devdoc_data = {
310+
"title": "Test Contract",
311+
"methods": {},
312+
"stateVariables": {
313+
"totalSupply": {
314+
"details": "Total token supply",
315+
"returns": {"_0": "The total supply"},
316+
},
317+
"owner": {
318+
"details": "Contract owner",
319+
"returns": {"_0": "Owner address"},
320+
"custom:security": "critical",
321+
},
322+
},
323+
}
324+
devdoc = DevDoc(devdoc_data)
325+
326+
assert "totalSupply" in devdoc.state_variables
327+
assert "owner" in devdoc.state_variables
328+
329+
total_supply = devdoc.state_variables["totalSupply"]
330+
assert total_supply.details == "Total token supply"
331+
assert total_supply.variable_return == "The total supply"
332+
assert total_supply.custom == {}
333+
334+
owner = devdoc.state_variables["owner"]
335+
assert owner.details == "Contract owner"
336+
assert owner.variable_return == "Owner address"
337+
assert owner.custom == {"custom:security": "critical"}
338+
339+
def test_devdoc_no_state_variables(self) -> None:
340+
"""Test DevDoc with no stateVariables"""
341+
devdoc_data = {
342+
"title": "Test Contract",
343+
"methods": {},
344+
}
345+
devdoc = DevDoc(devdoc_data)
346+
assert devdoc.state_variables == {}
347+
233348

234349
class TestNatspec:
235350
"""Tests for Natspec class"""

0 commit comments

Comments
 (0)