Skip to content
This repository was archived by the owner on Apr 20, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ rayon = "1.11"
regex = "1.11"
semver = { version = "1.0", features = ["serde"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1.0"
serde_json = { version = "1.0", features = ["preserve_order"] }
Copy link
Copy Markdown
Member

@zerosnacks zerosnacks Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces some level of overhead but should be relatively small. I am unsure however if this causes any unexpected side effects.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without preserve_order, serde_json::to_value() captures into a BTreeMap-backed Map that re-sorts alphabetically, losing the ordering from the custom serializer

similar-asserts = "1"
solar = { package = "solar-compiler", version = "=0.1.8", default-features = false }
svm = { package = "svm-rs", version = "0.5", default-features = false }
Expand Down
2 changes: 1 addition & 1 deletion crates/artifacts/solc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1474,7 +1474,7 @@ pub struct DocLibraries {
pub struct CompilerOutput {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<Error>,
#[serde(default)]
#[serde(default, with = "serde_helpers::sources_by_id")]
pub sources: BTreeMap<PathBuf, SourceFile>,
#[serde(default)]
pub contracts: Contracts,
Expand Down
59 changes: 59 additions & 0 deletions crates/artifacts/solc/src/serde_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,65 @@ pub mod display_from_str {
}
}

/// Serialize sources map ordered by source unit ID instead of by path.
///
/// This ensures build info JSON maintains the same ordering as solc output,
/// where sources appear in order of their source unit ID.
pub mod sources_by_id {
use crate::SourceFile;
use serde::{
de::{MapAccess, Visitor},
ser::SerializeMap,
Deserializer, Serializer,
};
use std::{collections::BTreeMap, fmt, path::PathBuf};

pub fn serialize<S>(
sources: &BTreeMap<PathBuf, SourceFile>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut entries: Vec<_> = sources.iter().collect();
entries.sort_by_key(|(_, source)| source.id);

let mut map = serializer.serialize_map(Some(entries.len()))?;
for (path, source) in entries {
map.serialize_entry(path, source)?;
}
map.end()
}

pub fn deserialize<'de, D>(deserializer: D) -> Result<BTreeMap<PathBuf, SourceFile>, D::Error>
where
D: Deserializer<'de>,
{
struct SourcesVisitor;

impl<'de> Visitor<'de> for SourcesVisitor {
type Value = BTreeMap<PathBuf, SourceFile>;

fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a map of source files")
}

fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut map = BTreeMap::new();
while let Some((key, value)) = access.next_entry::<PathBuf, SourceFile>()? {
map.insert(key, value);
}
Ok(map)
}
}

deserializer.deserialize_map(SourcesVisitor)
}
}

/// (De)serialize vec of tuples as map
pub mod tuple_vec_map {
use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize, Serializer};
Expand Down
177 changes: 176 additions & 1 deletion crates/compilers/src/buildinfo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ impl<L: Language> RawBuildInfo<L> {
mod tests {
use super::*;
use crate::compilers::solc::SolcVersionedInput;
use foundry_compilers_artifacts::{sources::Source, Contract, Error, SolcLanguage, Sources};
use foundry_compilers_artifacts::{
sources::Source, Contract, Error, SolcLanguage, SourceFile, Sources,
};
use std::path::PathBuf;

#[test]
Expand All @@ -138,4 +140,177 @@ mod tests {
let _info: BuildInfo<SolcVersionedInput, CompilerOutput<Error, Contract>> =
serde_json::from_str(&serde_json::to_string(&raw_info).unwrap()).unwrap();
}

#[test]
fn sources_serialized_by_source_id() {
let v: Version = "0.8.4+commit.c7e474f2".parse().unwrap();
let input = SolcVersionedInput::build(
Sources::from([
(PathBuf::from("z_last.sol"), Source::new("")),
(PathBuf::from("a_first.sol"), Source::new("")),
(PathBuf::from("m_middle.sol"), Source::new("")),
]),
Default::default(),
SolcLanguage::Solidity,
v,
);

let mut output = CompilerOutput::<Error, Contract>::default();
output.sources.insert(PathBuf::from("z_last.sol"), SourceFile { id: 0, ast: None });
output.sources.insert(PathBuf::from("a_first.sol"), SourceFile { id: 2, ast: None });
output.sources.insert(PathBuf::from("m_middle.sol"), SourceFile { id: 1, ast: None });

let raw_info = RawBuildInfo::new(&input, &output, true).unwrap();
let json_str = serde_json::to_string(&raw_info).unwrap();

let output_start = json_str.find(r#""output":"#).unwrap();
let output_section = &json_str[output_start..];

let z_pos = output_section.find("z_last.sol").unwrap();
let m_pos = output_section.find("m_middle.sol").unwrap();
let a_pos = output_section.find("a_first.sol").unwrap();

assert!(
z_pos < m_pos,
"z_last.sol (id=0) should appear before m_middle.sol (id=1) in output.sources"
);
assert!(
m_pos < a_pos,
"m_middle.sol (id=1) should appear before a_first.sol (id=2) in output.sources"
);
}

#[test]
fn sources_ordering_empty() {
let v: Version = "0.8.4+commit.c7e474f2".parse().unwrap();
let input = SolcVersionedInput::build(
Sources::new(),
Default::default(),
SolcLanguage::Solidity,
v,
);

let output = CompilerOutput::<Error, Contract>::default();
let raw_info = RawBuildInfo::new(&input, &output, true).unwrap();
let json_str = serde_json::to_string(&raw_info).unwrap();

assert!(json_str.contains(r#""sources":{}"#));
}

#[test]
fn sources_ordering_single_source() {
let v: Version = "0.8.4+commit.c7e474f2".parse().unwrap();
let input = SolcVersionedInput::build(
Sources::from([(PathBuf::from("only.sol"), Source::new(""))]),
Default::default(),
SolcLanguage::Solidity,
v,
);

let mut output = CompilerOutput::<Error, Contract>::default();
output.sources.insert(PathBuf::from("only.sol"), SourceFile { id: 42, ast: None });

let raw_info = RawBuildInfo::new(&input, &output, true).unwrap();
let json_str = serde_json::to_string(&raw_info).unwrap();

assert!(json_str.contains(r#""only.sol":{"id":42"#));
}

#[test]
fn sources_ordering_with_gaps_in_ids() {
let v: Version = "0.8.4+commit.c7e474f2".parse().unwrap();
let input = SolcVersionedInput::build(
Sources::from([
(PathBuf::from("a.sol"), Source::new("")),
(PathBuf::from("b.sol"), Source::new("")),
(PathBuf::from("c.sol"), Source::new("")),
]),
Default::default(),
SolcLanguage::Solidity,
v,
);

let mut output = CompilerOutput::<Error, Contract>::default();
output.sources.insert(PathBuf::from("a.sol"), SourceFile { id: 100, ast: None });
output.sources.insert(PathBuf::from("b.sol"), SourceFile { id: 5, ast: None });
output.sources.insert(PathBuf::from("c.sol"), SourceFile { id: 50, ast: None });

let raw_info = RawBuildInfo::new(&input, &output, true).unwrap();
let json_str = serde_json::to_string(&raw_info).unwrap();

let output_start = json_str.find(r#""output":"#).unwrap();
let output_section = &json_str[output_start..];

let b_pos = output_section.find("b.sol").unwrap();
let c_pos = output_section.find("c.sol").unwrap();
let a_pos = output_section.find("a.sol").unwrap();

assert!(b_pos < c_pos, "b.sol (id=5) should appear before c.sol (id=50)");
assert!(c_pos < a_pos, "c.sol (id=50) should appear before a.sol (id=100)");
}

#[test]
fn sources_ordering_roundtrip() {
let v: Version = "0.8.4+commit.c7e474f2".parse().unwrap();
let input = SolcVersionedInput::build(
Sources::from([
(PathBuf::from("z.sol"), Source::new("")),
(PathBuf::from("a.sol"), Source::new("")),
]),
Default::default(),
SolcLanguage::Solidity,
v,
);

let mut output = CompilerOutput::<Error, Contract>::default();
output.sources.insert(PathBuf::from("z.sol"), SourceFile { id: 0, ast: None });
output.sources.insert(PathBuf::from("a.sol"), SourceFile { id: 1, ast: None });

let raw_info = RawBuildInfo::new(&input, &output, true).unwrap();
let json_str = serde_json::to_string(&raw_info).unwrap();

let parsed: BuildInfo<SolcVersionedInput, CompilerOutput<Error, Contract>> =
serde_json::from_str(&json_str).unwrap();

assert_eq!(parsed.output.sources.len(), 2);
assert_eq!(parsed.output.sources.get(&PathBuf::from("z.sol")).unwrap().id, 0);
assert_eq!(parsed.output.sources.get(&PathBuf::from("a.sol")).unwrap().id, 1);
}

#[test]
fn sources_ordering_many_sources() {
let v: Version = "0.8.4+commit.c7e474f2".parse().unwrap();

let sources: Sources = (0..50)
.map(|i| (PathBuf::from(format!("contract_{:02}.sol", 49 - i)), Source::new("")))
.collect();

let input =
SolcVersionedInput::build(sources, Default::default(), SolcLanguage::Solidity, v);

let mut output = CompilerOutput::<Error, Contract>::default();
for i in 0..50u32 {
output.sources.insert(
PathBuf::from(format!("contract_{:02}.sol", 49 - i)),
SourceFile { id: i, ast: None },
);
}

let raw_info = RawBuildInfo::new(&input, &output, true).unwrap();
let json_str = serde_json::to_string(&raw_info).unwrap();

let output_start = json_str.find(r#""output":"#).unwrap();
let output_section = &json_str[output_start..];

let mut last_pos = 0;
for i in 0..50 {
let filename = format!("contract_{:02}.sol", 49 - i);
let pos = output_section.find(&filename).unwrap();
assert!(
pos > last_pos || i == 0,
"Sources should be ordered by ID: {filename} (id={i}) at wrong position"
);
last_pos = pos;
}
}
}
3 changes: 2 additions & 1 deletion crates/compilers/src/compilers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use foundry_compilers_artifacts::{
error::SourceLocation,
output_selection::OutputSelection,
remappings::Remapping,
serde_helpers::sources_by_id,
sources::{Source, Sources},
BytecodeObject, CompactContractRef, Contract, FileToContractsMap, Severity, SourceFile,
};
Expand Down Expand Up @@ -244,7 +245,7 @@ pub struct CompilerOutput<E, C> {
pub errors: Vec<E>,
#[serde(default = "BTreeMap::new")]
pub contracts: FileToContractsMap<C>,
#[serde(default)]
#[serde(default, with = "sources_by_id")]
pub sources: BTreeMap<PathBuf, SourceFile>,
#[serde(default, skip_serializing_if = "::std::collections::BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
Expand Down
87 changes: 87 additions & 0 deletions crates/compilers/tests/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,93 @@ contract B { }
assert_eq!(build_info_count, 1);
}

#[test]
fn build_info_sources_ordered_by_id() {
let mut project = TempProject::<MultiCompiler>::dapptools().unwrap();
project.project_mut().build_info = true;

project
.add_source(
"A_Main",
r#"
pragma solidity ^0.8.10;
import "./Z_Imported.sol";
import "./M_AlsoImported.sol";
contract AMain is ZImported, MAlsoImported { }
"#,
)
.unwrap();

project
.add_source(
"Z_Imported",
r"
pragma solidity ^0.8.10;
contract ZImported { }
",
)
.unwrap();

project
.add_source(
"M_AlsoImported",
r"
pragma solidity ^0.8.10;
contract MAlsoImported { }
",
)
.unwrap();

let compiled = project.compile().unwrap();
compiled.assert_success();

let info_dir = project.project().build_info_path();
assert!(info_dir.exists());

for entry in fs::read_dir(info_dir).unwrap() {
let path = entry.unwrap().path();
let json_content = fs::read_to_string(&path).unwrap();

let info: BuildInfo<SolcInput, CompilerOutput<Error, Contract>> =
serde_json::from_str(&json_content).unwrap();

assert!(
info.output.sources.len() >= 3,
"Expected at least 3 sources, got {}",
info.output.sources.len()
);

let mut sources_by_id: Vec<_> = info
.output
.sources
.iter()
.map(|(path, source)| {
(path.file_name().unwrap().to_string_lossy().to_string(), source.id)
})
.collect();
sources_by_id.sort_by_key(|(_, id)| *id);

let reserialized = serde_json::to_string(&info.output).unwrap();
let sources_start = reserialized.find(r#""sources""#).unwrap();
let sources_section = &reserialized[sources_start..];

let mut last_pos = 0;
for (filename, id) in &sources_by_id {
let pattern = format!(r#"{filename}":{{"id":"#);
let pos = sources_section
.find(&pattern)
.unwrap_or_else(|| panic!("Could not find pattern '{pattern}' in sources section"));
assert!(
pos > last_pos || *id == sources_by_id[0].1,
"After round-trip (deserialize then reserialize), sources must be ordered by ID. \
{filename} (id={id}) appeared at wrong position. \
Expected order: {sources_by_id:?}"
);
last_pos = pos;
}
}
}

#[test]
fn can_clean_build_info() {
let mut project = TempProject::<MultiCompiler>::dapptools().unwrap();
Expand Down
Loading