///| Artifact extraction.
///
/// Extraction turns compiler output text into a raw `storageLayout` plus the
/// provenance its wrapper records. It locates and type-checks the layout; it
/// does not decide compatibility, and it performs no file or network access,
/// so any caller that already holds the text gets the same behavior.
///
/// Recognized shapes, each documented by the tool that writes it:
///
/// - the `storageLayout` object itself, `{ "storage": [...], "types": {...} }`;
/// - a flat artifact with `storageLayout` at the top level, as Foundry writes
/// ();
/// - a solc Standard JSON output document, `contracts..`
/// ();
/// - a build info document, `output.contracts..`, as
/// Hardhat writes it.
///
/// A Hardhat per-contract artifact has `contractName`, `abi`, and bytecode
/// but no storage layout at all, so it reports that instead of a layout.
///|
/// Which wrapper shape a storage layout was found in.
pub(all) enum ArtifactKind {
/// The `storageLayout` object itself.
Layout
/// A flat artifact with `storageLayout` at the top level, in the shape
/// Foundry writes.
FlatArtifact
/// A solc Standard JSON output document with `contracts`.
StandardJson
/// A build info document with `output.contracts`, in the shape Hardhat
/// writes.
BuildInfo
} derive(Eq, @debug.Debug)
///|
/// A located storage layout and the provenance its wrapper records.
pub(all) struct Artifact {
/// Which wrapper shape the layout was found in.
kind : ArtifactKind
/// Source file the wrapper records, when it records one.
source : String?
/// Contract name the wrapper records, when it records one.
contract : String?
/// The raw `storageLayout` object, ready for `normalize_layout`.
layout : RawStorageLayout
/// The normalized ABI, when the artifact carries one. `None` means the
/// artifact has no ABI to compare, which is the normal case for a layout
/// extracted on its own.
abi : Abi?
/// The raw `transientStorageLayout` object, when the artifact carries one.
/// `None` means the artifact reports no transient layout, which Foundry does
/// by omitting an empty one.
transient : RawStorageLayout?
} derive(Eq, @debug.Debug)
///|
/// Reads a storage layout from artifact `text`.
///
/// `select` chooses a contract in a wrapper that can hold several layouts,
/// either by contract name or as `source:contract`. A wrapper with several
/// layouts and no selector produces an ambiguity diagnostic instead of a
/// guess; a wrapper with a single layout ignores `select`.
///
/// Returns the artifact and the diagnostics raised while locating it.
/// Diagnostics are sorted. A `None` artifact always comes with at least one
/// diagnostic.
pub fn extract_artifact(
text : String,
select? : String,
) -> (Artifact?, Array[Diagnostic]) {
let diagnostics : Array[Diagnostic] = []
let parsed = @json.parse(text) catch {
err => {
diagnostics.push(
Diagnostic::new(
ArtifactJsonInvalid,
artifact_root(),
"artifact is not valid JSON: \{err}",
),
)
diagnostics.sort()
return (None, diagnostics)
}
}
// Namespace members are absent from `storageLayout`. Only a struct's AST
// annotation establishes an ERC-7201 namespace; unrelated text does not.
if artifact_has_erc7201_annotation(parsed) {
diagnostics.push(
Diagnostic::new(
ArtifactNamespacedStorageUnsupported,
artifact_root(),
"artifact mentions ERC-7201 namespaced storage, which this version cannot locate; the compiler storageLayout does not list namespace members, so their compatibility is unknown",
),
)
}
// Numbers must be validated on their lexical form. The parser keeps only a
// correctly rounded Double, so a fraction such as 3.0000000000000000000001
// is indistinguishable from 3 by the time a field is read, and a comparison
// built on rounded values could pass silently. Every producer this tool
// supports writes integer numbers only, so a number token that is not an
// integer literal is unusable schema data and refuses the artifact.
let non_integer = scan_non_integer_numbers(text)
if non_integer.length() > 0 {
for violation in non_integer {
let (offset, lexeme) = violation
diagnostics.push(
Diagnostic::new(
ArtifactFieldInvalid,
artifact_root(),
"number literal \"\{lexeme}\" at offset \{offset} is not an integer; artifact numbers must be integer literals",
),
)
}
diagnostics.sort()
return (None, diagnostics)
}
let artifact = extract_from_root(parsed, select, diagnostics)
diagnostics.sort()
(artifact, diagnostics)
}
///|
/// Finds ERC-7201 struct annotations in source ASTs carried by supported
/// artifact wrappers. Without an AST, namespace storage remains invisible.
fn artifact_has_erc7201_annotation(json : Json) -> Bool {
guard json is Object(fields) else { return false }
let flat_ast = match fields.get("ast") {
Some(ast) => ast_has_erc7201_annotation(ast)
None => false
}
if flat_ast {
return true
}
let source_asts = match fields.get("sources") {
Some(sources) => sources_have_erc7201_annotation(sources)
None => false
}
if source_asts {
return true
}
match fields.get("output") {
Some(Object(output)) =>
match output.get("sources") {
Some(sources) => sources_have_erc7201_annotation(sources)
None => false
}
_ => false
}
}
///|
fn sources_have_erc7201_annotation(json : Json) -> Bool {
guard json is Object(sources) else { return false }
for source in sources.keys() {
match sources.get(source) {
Some(Object(fields)) =>
match fields.get("ast") {
Some(ast) => if ast_has_erc7201_annotation(ast) { return true }
None => ()
}
_ => ()
}
}
false
}
///|
fn ast_has_erc7201_annotation(json : Json) -> Bool {
guard json is Object(ast) else { return false }
guard ast.get("nodes") is Some(Array(nodes)) else { return false }
for node in nodes {
guard node is Object(contract) else { continue }
if get_string(contract, "nodeType") != Some("ContractDefinition") {
continue
}
guard contract.get("nodes") is Some(Array(members)) else { continue }
for node in members {
guard node is Object(fields) else { continue }
if get_string(fields, "nodeType") != Some("StructDefinition") {
continue
}
let documentation = match fields.get("documentation") {
Some(String(value)) => Some(value)
Some(Object(doc)) => get_string(doc, "text")
_ => None
}
match documentation {
Some(value) =>
if value.contains("@custom:storage-location erc7201:") {
return true
}
None => ()
}
}
}
false
}
///|
/// Collects every JSON number token that is not an integer literal.
///
/// Runs on text that has already parsed as JSON, so every number token starts
/// at a `-` or a digit outside a string, and its characters are exactly
/// digits, sign, fraction point, and exponent markers. Tokens inside strings
/// are skipped, including escaped quotes. The result holds the offset of each
/// violation in the text and its literal, in document order.
fn scan_non_integer_numbers(text : String) -> Array[(Int, String)] {
let violations : Array[(Int, String)] = []
let length = text.length()
let mut in_string = false
let mut escaped = false
let mut i = 0
while i < length {
let c = text[i]
if in_string {
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '"' {
in_string = false
}
i += 1
continue
}
if c == '"' {
in_string = true
i += 1
continue
}
if c == '-' || (c >= '0' && c <= '9') {
let start = i
while i < length {
let d = text[i]
if d == '.' ||
d == 'e' ||
d == 'E' ||
d == '+' ||
d == '-' ||
(d >= '0' && d <= '9') {
i += 1
} else {
break
}
}
if !is_integer_literal(text[start:i]) {
violations.push((start, text[start:i].to_owned()))
}
continue
}
i += 1
}
violations
}
///|
/// Whether a scanned number token is a plain integer literal: an optional
/// sign followed by digits only, with no fraction point or exponent. JSON
/// forbids leading zeros, and the text has already parsed, so shape is the
/// only thing left to check.
fn is_integer_literal(token : StringView) -> Bool {
let body = if token.length() > 0 && token[0] == '-' {
token[1:]
} else {
token
}
body.length() > 0 && body.iter().all(fn(c) { c >= '0' && c <= '9' })
}
///|
/// Location of the artifact document itself.
fn artifact_root() -> Location {
{ contract: None, path: "$", }
}
///|
/// Renders one key of an artifact path, such as `["src/Vault.sol"]`.
fn quoted(key : String) -> String {
"[\"\{key}\"]"
}
///|
/// Builds a diagnostic for a malformed artifact field.
fn invalid_field(path : String, message : String) -> Diagnostic {
Diagnostic::new(ArtifactFieldInvalid, { contract: None, path, }, message)
}
///|
/// Dispatches on the wrapper shape of the artifact root.
fn extract_from_root(
json : Json,
select : String?,
diagnostics : Array[Diagnostic],
) -> Artifact? {
guard json is Object(fields) else {
diagnostics.push(
Diagnostic::new(
ArtifactShapeUnsupported,
artifact_root(),
"artifact root must be a JSON object",
),
)
return None
}
let has_layout = fields.contains("storage") || fields.contains("types")
let has_flat = match fields.get("storageLayout") {
None | Some(Null) => false
Some(_) => true
}
let has_contracts = fields.contains("contracts")
let has_build_info = match fields.get("output") {
Some(Object(output)) => output.contains("contracts")
_ => false
}
let shapes : Array[String] = []
if has_layout {
shapes.push("a storage layout object")
}
if has_flat {
shapes.push("a flat artifact")
}
if has_contracts {
shapes.push("Standard JSON output")
}
if has_build_info {
shapes.push("a build info document")
}
if shapes.length() > 1 {
diagnostics.push(
Diagnostic::new(
ArtifactShapeAmbiguous,
artifact_root(),
"artifact matches more than one known shape (\{shapes.join(", ")}); extract one artifact shape at a time",
),
)
return None
}
if has_layout {
guard decode_layout(json, "$", diagnostics) is Some(layout) else {
return None
}
return Some({
kind: Layout,
source: None,
contract: None,
layout,
abi: None,
transient: None,
})
}
if has_flat {
guard fields.get("storageLayout") is Some(layout_json) else { return None }
return extract_flat(fields, layout_json, diagnostics)
}
if has_contracts {
guard fields.get("contracts") is Some(contracts) else { return None }
return extract_contracts(
contracts,
"$.contracts",
select,
StandardJson,
diagnostics,
)
}
if has_build_info {
guard fields.get("output") is Some(Object(output)) else { return None }
guard output.get("contracts") is Some(contracts) else { return None }
return extract_contracts(
contracts,
"$.output.contracts",
select,
BuildInfo,
diagnostics,
)
}
if looks_like_contract_artifact(fields) {
diagnostics.push(layout_missing_diagnostic(fields))
return None
}
if fields.contains("language") && fields.contains("sources") {
diagnostics.push(
Diagnostic::new(
ArtifactShapeUnsupported,
artifact_root(),
"artifact is compiler input, which carries no storage layout; pass compiler output or a compile artifact",
),
)
return None
}
diagnostics.push(
Diagnostic::new(
ArtifactShapeUnsupported,
artifact_root(),
"artifact is not a recognized shape; expected a storage layout object, a flat artifact with storageLayout, or compiler output with contracts",
),
)
None
}
///|
/// Whether an unrecognized object still looks like a compiled contract, which
/// makes "no storage layout" a more useful report than "unknown shape".
fn looks_like_contract_artifact(fields : Map[String, Json]) -> Bool {
fields.contains("_format") ||
fields.contains("contractName") ||
fields.contains("sourceName") ||
fields.contains("abi") ||
fields.contains("bytecode") ||
fields.contains("deployedBytecode")
}
///|
/// Reports that a contract artifact carries no storage layout.
fn layout_missing_diagnostic(fields : Map[String, Json]) -> Diagnostic {
let format = match fields.get("_format") {
Some(String(value)) => " of format \"\{value}\""
_ => ""
}
Diagnostic::new(
ArtifactLayoutMissing,
artifact_root(),
"artifact\{format} does not contain a storage layout; compile with the storageLayout output selected",
)
}
///|
/// Extracts the layout of a flat artifact, the shape Foundry writes.
fn extract_flat(
fields : Map[String, Json],
layout_json : Json,
diagnostics : Array[Diagnostic],
) -> Artifact? {
guard layout_json is Object(_) else {
diagnostics.push(invalid_field("$.storageLayout", "expected a JSON object"))
return None
}
guard decode_layout(layout_json, "$.storageLayout", diagnostics)
is Some(layout) else {
return None
}
Some({
kind: FlatArtifact,
source: get_string(fields, "sourceName"),
contract: get_string(fields, "contractName"),
layout,
abi: decode_abi_field(fields, "$.abi", diagnostics),
transient: decode_transient_field(
fields, "$.transientStorageLayout", diagnostics,
),
})
}
///|
/// A contract object inside a wrapper that carries a storage layout.
priv struct LayoutCandidate {
source : String
contract : String
layout : Json
fields : Map[String, Json]
}
///|
/// Extracts a layout from a `contracts` object, which both Standard JSON
/// output and build info documents use.
fn extract_contracts(
contracts : Json,
path : String,
select : String?,
kind : ArtifactKind,
diagnostics : Array[Diagnostic],
) -> Artifact? {
guard contracts is Object(sources) else {
diagnostics.push(invalid_field(path, "expected a JSON object"))
return None
}
let candidates = collect_candidates(sources, path, diagnostics)
if candidates.is_empty() {
diagnostics.push(
Diagnostic::new(
ArtifactLayoutMissing,
{ contract: None, path, },
"compiler output contains no storage layout; compile with the storageLayout output selected",
),
)
return None
}
guard select_candidate(candidates, select, path, diagnostics)
is Some(candidate) else {
return None
}
let layout_path = path +
quoted(candidate.source) +
quoted(candidate.contract) +
".storageLayout"
guard decode_layout(candidate.layout, layout_path, diagnostics)
is Some(layout) else {
return None
}
let contract_path = path +
quoted(candidate.source) +
quoted(candidate.contract)
Some({
kind,
source: Some(candidate.source),
contract: Some(candidate.contract),
layout,
abi: decode_abi_field(candidate.fields, contract_path + ".abi", diagnostics),
transient: decode_transient_field(
candidate.fields,
contract_path + ".transientStorageLayout",
diagnostics,
),
})
}
///|
/// Decodes the `abi` field of a contract object, when it has one.
fn decode_abi_field(
fields : Map[String, Json],
path : String,
diagnostics : Array[Diagnostic],
) -> Abi? {
match fields.get("abi") {
None | Some(Null) => None
Some(value) => decode_abi(value, path, diagnostics)
}
}
///|
/// Collects every contract in a `contracts` object that has a storage layout.
///
/// Sources and contract names are visited in sorted order, so diagnostics and
/// ambiguity reports are the same for every run.
fn collect_candidates(
sources : Map[String, Json],
path : String,
diagnostics : Array[Diagnostic],
) -> Array[LayoutCandidate] {
let candidates : Array[LayoutCandidate] = []
let source_names = sources.keys().collect()
source_names.sort()
for source in source_names {
let source_json = match sources.get(source) {
Some(value) => value
None => continue
}
guard source_json is Object(contracts) else {
diagnostics.push(
invalid_field(path + quoted(source), "expected a JSON object"),
)
continue
}
let contract_names = contracts.keys().collect()
contract_names.sort()
for contract in contract_names {
let contract_json = match contracts.get(contract) {
Some(value) => value
None => continue
}
guard contract_json is Object(fields) else {
diagnostics.push(
invalid_field(
path + quoted(source) + quoted(contract),
"expected a JSON object",
),
)
continue
}
match fields.get("storageLayout") {
Some(Object(_) as layout) =>
candidates.push({ source, contract, layout, fields, })
None | Some(Null) => ()
Some(_) =>
diagnostics.push(
invalid_field(
path + quoted(source) + quoted(contract) + ".storageLayout",
"expected a JSON object",
),
)
}
}
}
candidates
}
///|
/// Picks the one candidate a selector names, or reports why it cannot.
fn select_candidate(
candidates : Array[LayoutCandidate],
select : String?,
path : String,
diagnostics : Array[Diagnostic],
) -> LayoutCandidate? {
match select {
None =>
if candidates.length() == 1 {
Some(candidates[0])
} else {
diagnostics.push(
Diagnostic::new(
ArtifactContractAmbiguous,
{ contract: None, path, },
"artifact holds \{candidates.length()} storage layouts; select one of \{candidate_list(candidates, path)}",
),
)
None
}
Some(selector) => {
let (want_source, want_contract) = split_qualified(selector)
let matching : Array[LayoutCandidate] = []
for candidate in candidates {
let source_matches = match want_source {
Some(source) => source == candidate.source
None => true
}
if source_matches && candidate.contract == want_contract {
matching.push(candidate)
}
}
if matching.length() == 1 {
Some(matching[0])
} else if matching.is_empty() {
diagnostics.push(
Diagnostic::new(
ArtifactContractMissing,
{ contract: None, path, },
"no storage layout matches \"\{selector}\"; available: \{candidate_list(candidates, path)}",
),
)
None
} else {
diagnostics.push(
Diagnostic::new(
ArtifactContractAmbiguous,
{ contract: None, path, },
"\"\{selector}\" matches more than one storage layout; use source:contract, one of \{candidate_list(matching, path)}",
),
)
None
}
}
}
}
///|
/// Splits `source:contract` into its two parts, at the last colon.
fn split_qualified(selector : String) -> (String?, String) {
match selector.rev_split_once(":") {
Some((source, contract)) => (Some(source.to_owned()), contract.to_owned())
None => (None, selector)
}
}
///|
/// Renders the artifact paths of `candidates` as a comma-separated list.
fn candidate_list(candidates : Array[LayoutCandidate], path : String) -> String {
let labels : Array[String] = []
for candidate in candidates {
labels.push(path + quoted(candidate.source) + quoted(candidate.contract))
}
labels.join(", ")
}
///|
/// Decodes the `transientStorageLayout` field of a contract object, when it has
/// one.
///
/// An artifact that omits the field reports no transient layout: Foundry omits
/// an empty one, and a compiler run that was not asked for
/// `transientStorageLayout` reports nothing either.
fn decode_transient_field(
fields : Map[String, Json],
path : String,
diagnostics : Array[Diagnostic],
) -> RawStorageLayout? {
match fields.get("transientStorageLayout") {
None | Some(Null) => None
Some(layout_json) => decode_layout(layout_json, path, diagnostics)
}
}
///|
/// Decodes a `storageLayout` object into the raw storage model.
///
/// Every field is type-checked and any mismatch fails the whole layout, so a
/// half-decoded layout never reaches normalization. `astId` and unknown fields
/// are ignored because they cannot affect compatibility. `types` may be absent
/// or `null`, which the compiler emits and upstream tooling accepts; a
/// variable that then references a type fails normalization explicitly.
fn decode_layout(
json : Json,
path : String,
diagnostics : Array[Diagnostic],
) -> RawStorageLayout? {
guard json is Object(fields) else {
diagnostics.push(invalid_field(path, "expected a JSON object"))
return None
}
let storage_json = match fields.get("storage") {
Some(value) => value
None => {
diagnostics.push(
invalid_field(path + ".storage", "required field is missing"),
)
return None
}
}
guard storage_json is Array(entries) else {
diagnostics.push(invalid_field(path + ".storage", "expected a JSON array"))
return None
}
let storage : Array[RawStorageEntry] = []
for i in 0.. ()
Some(Object(type_table)) => {
let ids = type_table.keys().collect()
ids.sort()
for id in ids {
let type_json = match type_table.get(id) {
Some(value) => value
None => continue
}
guard decode_type(
id,
type_json,
"\{path}.types[\"\{id}\"]",
diagnostics,
)
is Some(entry) else {
return None
}
types.push(entry)
}
}
Some(_) => {
diagnostics.push(invalid_field(path + ".types", "expected a JSON object"))
return None
}
}
Some({ storage, types, })
}
///|
/// Decodes one storage variable, whether it is a top-level variable or a
/// struct member.
fn decode_entry(
json : Json,
path : String,
diagnostics : Array[Diagnostic],
) -> RawStorageEntry? {
guard json is Object(fields) else {
diagnostics.push(invalid_field(path, "expected a JSON object"))
return None
}
guard check_string(fields, "label", path, diagnostics) else { return None }
guard check_string(fields, "contract", path, diagnostics) else { return None }
guard check_string(fields, "slot", path, diagnostics) else { return None }
guard check_string(fields, "type", path, diagnostics) else { return None }
guard check_integer(fields, "offset", path, diagnostics) else { return None }
Some({
label: get_string(fields, "label"),
contract: get_string(fields, "contract"),
slot: get_string(fields, "slot"),
offset: get_integer(fields, "offset"),
type_id: get_string(fields, "type"),
})
}
///|
/// Decodes one entry of the compiler type table.
fn decode_type(
id : String,
json : Json,
path : String,
diagnostics : Array[Diagnostic],
) -> RawStorageType? {
guard json is Object(fields) else {
diagnostics.push(invalid_field(path, "expected a JSON object"))
return None
}
guard check_string(fields, "encoding", path, diagnostics) else { return None }
guard check_string(fields, "label", path, diagnostics) else { return None }
guard check_string(fields, "numberOfBytes", path, diagnostics) else {
return None
}
guard check_string(fields, "base", path, diagnostics) else { return None }
guard check_string(fields, "key", path, diagnostics) else { return None }
guard check_string(fields, "value", path, diagnostics) else { return None }
let members = match fields.get("members") {
None | Some(Null) => None
Some(Array(items)) => {
let decoded : Array[RawStorageEntry] = []
for i in 0.. {
diagnostics.push(
invalid_field(path + ".members", "expected a JSON array"),
)
return None
}
}
Some({
id: Some(id),
encoding: get_string(fields, "encoding"),
label: get_string(fields, "label"),
number_of_bytes: get_string(fields, "numberOfBytes"),
base: get_string(fields, "base"),
key: get_string(fields, "key"),
value: get_string(fields, "value"),
members,
})
}
///|
/// Checks that a field is absent, `null`, or a JSON string.
fn check_string(
fields : Map[String, Json],
name : String,
path : String,
diagnostics : Array[Diagnostic],
) -> Bool {
match fields.get(name) {
None | Some(Null) => true
Some(value) => {
guard value is String(_) else {
diagnostics.push(
invalid_field(path + "." + name, "expected a JSON string"),
)
return false
}
true
}
}
}
///|
/// Checks that a field is absent, `null`, or an integer-valued JSON number.
///
/// The artifact-level scan has already established that every number token in
/// the text is an integer literal, so the `Double` the parser hands over is
/// exact for any machine-sized value; this check only rejects literals whose
/// value exceeds the range every supported target can hold.
fn check_integer(
fields : Map[String, Json],
name : String,
path : String,
diagnostics : Array[Diagnostic],
) -> Bool {
match fields.get(name) {
None | Some(Null) => true
Some(Number(value, ..)) =>
if json_integer(value) is Some(_) {
true
} else {
diagnostics.push(
invalid_field(
path + "." + name,
"expected an integer that fits in a machine integer",
),
)
false
}
Some(_) => {
diagnostics.push(
invalid_field(path + "." + name, "expected a JSON number"),
)
false
}
}
}
///|
/// Reads an optional string field, after `check_string` has accepted it.
fn get_string(fields : Map[String, Json], name : String) -> String? {
match fields.get(name) {
Some(String(value)) => Some(value)
_ => None
}
}
///|
/// Reads an optional integer field, after `check_integer` has accepted it.
fn get_integer(fields : Map[String, Json], name : String) -> Int? {
match fields.get(name) {
Some(Number(value, ..)) => json_integer(value)
_ => None
}
}
///|
/// Converts an integral JSON number to an `Int`, or `None` when it is outside
/// the range every supported target can hold.
///
/// Callers reach this only after the artifact-level scan has proven the token
/// was an integer literal. A literal within ±(2^53 - 1) converts through
/// `Double` without loss; anything larger rounds to a value beyond the machine
/// range and is rejected here, so a fractional literal can never slip through
/// as a rounded integer.
fn json_integer(value : Double) -> Int? {
if value < -2147483648.0 || value > 2147483647.0 {
None
} else {
let converted = value.to_int()
if converted.to_double() == value {
Some(converted)
} else {
None
}
}
}
///|
pub extend ArtifactKind with Eq::{not_equal, equal}
///|
pub extend ArtifactKind with @debug.Debug::{to_repr}
///|
pub extend Artifact with Eq::{not_equal, equal}
///|
pub extend Artifact with @debug.Debug::{to_repr}