use super::constants::Constant;
use super::manual_errors::ManualErrors;
use anyhow::{Context, Result};
use heck::ToLowerCamelCase;
use serde_json::Value;
use std::path::Path;
use syn::{Item, Token};
const CODAMA_DERIVES: &[&str] = &[
"CodamaAccount",
"CodamaAccounts",
"CodamaErrors",
"CodamaEvent",
"CodamaEvents",
"CodamaInstruction",
"CodamaInstructions",
"CodamaPda",
"CodamaType",
];
pub const BUNDLED_CODAMA: &str = "0.13.2";
const CODAMA_CRATES: &[&str] = &["codama", "codama-macros"];
pub struct CodamaUsage {
pub dependency: Option<String>,
pub derives: bool,
}
impl CodamaUsage {
pub fn detected(&self) -> bool {
self.dependency.is_some() && self.derives
}
pub fn undetected_reason(&self) -> &'static str {
if self.derives && self.dependency.is_none() {
"Codama derives found, but neither `codama` nor `codama-macros` is a dependency"
} else {
"no Codama macros detected"
}
}
}
pub fn codama_usage(crate_root: &Path, src_dir: &Path) -> Result<CodamaUsage> {
let cargo_toml = crate_root.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml)
.with_context(|| format!("Failed to read {}", cargo_toml.display()))?;
let manifest: toml::Value = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", cargo_toml.display()))?;
Ok(CodamaUsage {
dependency: codama_dependency(&manifest).map(|(name, _)| name),
derives: scan_for_codama_derives(src_dir)?,
})
}
pub fn codama_macros_detected(crate_root: &Path, src_dir: &Path) -> Result<bool> {
Ok(codama_usage(crate_root, src_dir)?.detected())
}
fn codama_dependency(manifest: &toml::Value) -> Option<(String, Option<String>)> {
let targets = manifest
.get("target")
.and_then(|t| t.as_table())
.into_iter()
.flat_map(|t| t.values());
std::iter::once(manifest)
.chain(targets)
.filter_map(|section| section.get("dependencies")?.as_table())
.flatten()
.find_map(|(key, spec)| {
let name = spec.get("package").and_then(|p| p.as_str()).unwrap_or(key);
if !CODAMA_CRATES.contains(&name) {
return None;
}
let version = spec.as_str().or_else(|| spec.get("version")?.as_str());
Some((name.to_string(), version.map(str::to_string)))
})
}
pub fn version_mismatch(crate_root: &Path) -> Option<String> {
let version = program_codama_version(crate_root)?;
let (program, bundled) = (minor_of(&version)?, minor_of(BUNDLED_CODAMA)?);
if program > bundled {
Some(format!(
"this program uses codama-macros {version}, newer than the codama {BUNDLED_CODAMA} pinoc extracts with. A `#[codama(..)]` directive added after {BUNDLED_CODAMA} stops the extraction"
))
} else if program < bundled {
Some(format!(
"this program uses codama-macros {version}, older than the codama {BUNDLED_CODAMA} pinoc extracts with. A `#[codama(..)]` directive added after {version} extracts here but does not compile; bump codama-macros to use one"
))
} else {
None
}
}
fn program_codama_version(crate_root: &Path) -> Option<String> {
let locked = crate_root.ancestors().take(4).find_map(|dir| {
let lock: toml::Value =
toml::from_str(&std::fs::read_to_string(dir.join("Cargo.lock")).ok()?).ok()?;
let versions: Vec<String> = lock
.get("package")?
.as_array()?
.iter()
.filter(|p| p.get("name").and_then(|n| n.as_str()) == Some("codama-macros"))
.filter_map(|p| Some(p.get("version")?.as_str()?.to_string()))
.collect();
versions
.iter()
.find(|v| minor_of(v) != minor_of(BUNDLED_CODAMA))
.or(versions.first())
.cloned()
});
locked.or_else(|| {
let manifest: toml::Value =
toml::from_str(&std::fs::read_to_string(crate_root.join("Cargo.toml")).ok()?).ok()?;
let requirement = codama_dependency(&manifest)?.1?;
Some(
requirement
.trim_start_matches(|c: char| !c.is_ascii_digit())
.to_string(),
)
})
}
fn minor_of(version: &str) -> Option<(u64, u64)> {
let mut parts = version.split('.');
let major = parts.next()?.trim().parse().ok()?;
let minor = parts
.next()
.and_then(|m| m.trim().parse().ok())
.unwrap_or(0);
Some((major, minor))
}
fn scan_for_codama_derives(dir: &Path) -> Result<bool> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
if scan_for_codama_derives(&path)? {
return Ok(true);
}
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(file) = syn::parse_file(&src) else {
continue;
};
for item in &file.items {
if item_has_codama_derive(item) {
return Ok(true);
}
}
}
Ok(false)
}
fn item_has_codama_derive(item: &Item) -> bool {
let attrs = match item {
Item::Struct(s) => &s.attrs,
Item::Enum(e) => &e.attrs,
_ => return false,
};
derives(attrs, CODAMA_DERIVES)
}
fn derives(attrs: &[syn::Attribute], names: &[&str]) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("derive") {
return false;
}
let Ok(paths) = attr
.parse_args_with(syn::punctuated::Punctuated::<syn::Path, Token![,]>::parse_terminated)
else {
return false;
};
paths.iter().any(|p| {
p.segments
.last()
.is_some_and(|seg| names.iter().any(|name| seg.ident == name))
})
})
}
pub fn extract_native_codama_idl(
crate_root: &Path,
src_dir: &Path,
resolved_address: Option<&str>,
fallback_errors: &[Value],
manual: Option<&ManualErrors>,
constants: &[Constant],
) -> Result<String> {
let json = codama::Codama::load(crate_root)
.and_then(|codama| codama.get_json_idl())
.map_err(|e| extraction_error(e, crate_root, src_dir))?;
let mut value: Value = serde_json::from_str(&json)?;
restore_empty_lists(&mut value);
if let Some(list) = value["program"]["constants"].as_array_mut() {
for constant in constants {
let node = constant.to_codama();
if !list.iter().any(|existing| existing["name"] == node["name"]) {
list.push(node);
}
}
}
if let Some(address) = resolved_address {
value["program"]["publicKey"] = Value::String(address.to_string());
}
let has_native_errors = value["program"]["errors"]
.as_array()
.is_some_and(|a| !a.is_empty());
let fills_errors = !has_native_errors && !fallback_errors.is_empty();
if program_is_empty(&value) {
if fills_errors {
println!("⚠️ Native Codama extraction found no instructions or accounts (does this program use Codama's derive macros?). Its {} error(s) in .codama.json come from the shank IDL.", fallback_errors.len());
} else {
println!("⚠️ Native Codama extraction found no instructions, accounts, or errors. Does this program actually use Codama's derive macros?");
}
} else if fills_errors {
println!(
"ℹ️ No `CodamaErrors` found; added {} error(s) from the shank IDL to .codama.json",
fallback_errors.len()
);
}
if has_native_errors {
let directives = error_directives(src_dir)?;
let renames = pair_error_names(&mut value["program"]["errors"], &directives.names)?;
backfill_messages(&mut value["program"]["errors"], fallback_errors);
if let Some(manual) = manual.filter(|m| m.changes_codes()) {
convert_discriminant_codes(&mut value["program"]["errors"], manual);
}
if !renames.is_empty() {
for (index, name) in &renames {
value["program"]["errors"][*index]["name"] = Value::from(name.as_str());
}
println!(
"ℹ️ {} error name(s) in .codama.json taken from `#[codama(name = \"..\")]`",
renames.len()
);
}
if !directives.skipped.is_empty() {
println!(
"ℹ️ {} error variant(s) left out of .codama.json by `#[codama(skip)]`: {}",
directives.skipped.len(),
directives.skipped.join(", ")
);
}
}
if fills_errors {
value["program"]["errors"] = Value::Array(fallback_errors.iter().map(error_node).collect());
}
Ok(serde_json::to_string_pretty(&value)?)
}
const LIST_FIELDS: &[(&str, &[&str])] = &[
("rootNode", &["additionalPrograms"]),
(
"programNode",
&[
"accounts",
"constants",
"definedTypes",
"errors",
"events",
"instructions",
"pdas",
],
),
("instructionNode", &["accounts", "arguments"]),
("pdaNode", &["seeds"]),
("pdaValueNode", &["seeds"]),
("structTypeNode", &["fields"]),
("enumTypeNode", &["variants"]),
("tupleTypeNode", &["items"]),
("hiddenPrefixTypeNode", &["prefix"]),
("hiddenSuffixTypeNode", &["suffix"]),
("structValueNode", &["fields"]),
("arrayValueNode", &["items"]),
("setValueNode", &["items"]),
("tupleValueNode", &["items"]),
("mapValueNode", &["entries"]),
];
fn restore_empty_lists(value: &mut Value) {
match value {
Value::Object(map) => {
let kind = map.get("kind").and_then(|k| k.as_str()).unwrap_or_default();
if let Some((_, fields)) = LIST_FIELDS.iter().find(|(k, _)| *k == kind) {
for field in *fields {
map.entry(*field)
.or_insert_with(|| Value::Array(Vec::new()));
}
}
map.values_mut().for_each(restore_empty_lists);
}
Value::Array(items) => items.iter_mut().for_each(restore_empty_lists),
_ => {}
}
}
fn error_directives(src_dir: &Path) -> Result<ErrorDirectives> {
fn collect(items: &[Item], out: &mut ErrorDirectives) -> Result<()> {
for item in items {
match item {
Item::Mod(m) => {
if let Some((_, inner)) = &m.content {
collect(inner, out)?;
}
}
Item::Enum(e) if derives(&e.attrs, &["CodamaErrors"]) => {
let (skipped, kept): (Vec<_>, Vec<_>) = e
.variants
.iter()
.partition(|v| codama_directive(&v.attrs).skip);
out.skipped
.extend(skipped.iter().map(|v| v.ident.to_string()));
let variants: Vec<(String, Option<String>)> = kept
.iter()
.map(|v| (v.ident.to_string(), codama_directive(&v.attrs).name))
.collect();
for (index, (variant, name)) in variants.iter().enumerate() {
let Some(name) = name else {
continue;
};
let collision = variants.iter().enumerate().find(|(i, (ident, other))| {
*i != index
&& name_key(other.as_ref().unwrap_or(ident)) == name_key(name)
});
if let Some((_, (other, _))) = collision {
anyhow::bail!(
"`#[codama(name = \"{name}\")]` on error variant `{variant}` collides with variant `{other}`; Codama would keep only one of the two errors"
);
}
out.names.push((variant.clone(), name.clone()));
}
}
_ => {}
}
}
Ok(())
}
let mut files = Vec::new();
let _ = collect_sources(src_dir, &mut files);
let mut out = ErrorDirectives::default();
for (_, src) in files {
if let Ok(file) = syn::parse_file(&src) {
collect(&file.items, &mut out)?;
}
}
Ok(out)
}
#[derive(Default)]
struct ErrorDirectives {
names: Vec<(String, String)>,
skipped: Vec<String>,
}
#[derive(Default)]
struct CodamaDirective {
name: Option<String>,
skip: bool,
}
fn codama_directive(attrs: &[syn::Attribute]) -> CodamaDirective {
let mut out = CodamaDirective::default();
for attr in attrs.iter().filter(|a| a.path().is_ident("codama")) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
out.name = Some(meta.value()?.parse::<syn::LitStr>()?.value());
} else if meta.path.is_ident("skip") {
out.skip = true;
} else if meta.input.peek(Token![=]) {
meta.value()?.parse::<syn::Expr>()?;
} else if meta.input.peek(syn::token::Paren) {
let content;
syn::parenthesized!(content in meta.input);
content.parse::<proc_macro2::TokenStream>()?;
}
Ok(())
});
}
out
}
fn pair_error_names(
errors: &mut Value,
overrides: &[(String, String)],
) -> Result<Vec<(usize, String)>> {
let Some(nodes) = errors.as_array_mut() else {
return Ok(Vec::new());
};
let mut renames = Vec::new();
for (variant, name) in overrides {
let camel_case = name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& name.chars().all(|c| c.is_ascii_alphanumeric());
if !camel_case {
anyhow::bail!(
"`#[codama(name = \"{name}\")]` on error variant `{variant}` is not a camelCase name (a lowercase letter, then letters and digits)"
);
}
let key = name_key(name);
let mut matching = nodes
.iter()
.enumerate()
.filter(|(_, node)| name_key(node["name"].as_str().unwrap_or_default()) == key)
.map(|(index, _)| index);
if let (Some(index), None) = (matching.next(), matching.next()) {
nodes[index]["name"] = Value::from(variant.as_str());
renames.push((index, name.clone()));
}
}
Ok(renames)
}
fn name_key(name: &str) -> String {
name.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn backfill_messages(errors: &mut Value, fallback_errors: &[Value]) {
let Some(nodes) = errors.as_array_mut() else {
return;
};
for node in nodes {
if !node["message"].as_str().unwrap_or_default().is_empty() {
continue;
}
let key = name_key(node["name"].as_str().unwrap_or_default());
let fallback = fallback_errors
.iter()
.find(|e| name_key(e["name"].as_str().unwrap_or_default()) == key)
.and_then(|e| e["msg"].as_str());
if let Some(message) = fallback.filter(|m| !m.is_empty()) {
node["message"] = Value::from(message);
}
}
}
fn convert_discriminant_codes(errors: &mut Value, manual: &ManualErrors) {
let Some(nodes) = errors.as_array_mut() else {
return;
};
let mut converted = 0;
let mut unmatched = Vec::new();
let mut explicit = Vec::new();
for node in nodes.iter_mut() {
let name = node["name"].as_str().unwrap_or_default().to_string();
let key = name_key(&name);
let mut matches = manual
.discriminants
.iter()
.enumerate()
.filter(|(_, (variant, _))| name_key(variant) == key);
let (Some((index, (_, discriminant))), None) = (matches.next(), matches.next()) else {
unmatched.push(format!("`{name}`"));
continue;
};
let program_code = manual.errors[index].code;
let native_code = node["code"].as_u64();
if native_code == Some(u64::from(program_code)) {
continue;
}
if native_code == Some(u64::from(*discriminant)) {
node["code"] = Value::from(program_code);
converted += 1;
} else {
explicit.push(format!(
"`{name}` (Codama {}, program {program_code})",
node["code"]
));
}
}
let total = nodes.len();
if converted == total {
println!(
"ℹ️ .codama.json error codes converted the same way (Codama reads raw discriminants)"
);
} else if converted > 0 {
println!("ℹ️ .codama.json: {converted} of {total} error codes converted the same way (Codama reads raw discriminants)");
}
if !unmatched.is_empty() {
println!(
"⚠️ .codama.json: {} error code(s) left as raw discriminants, because no variant of the enum behind `impl From<_> for ProgramError` matches: {}. They will not match the codes the program returns.",
unmatched.len(),
listed(&unmatched)
);
}
if !explicit.is_empty() {
println!(
"⚠️ .codama.json: {} error(s) keep a Codama code that is neither the discriminant nor what `impl From<_> for ProgramError` returns: {}.",
explicit.len(),
listed(&explicit)
);
}
}
fn listed(names: &[String]) -> String {
const SHOWN: usize = 5;
let mut out = names
.iter()
.take(SHOWN)
.cloned()
.collect::<Vec<_>>()
.join(", ");
if names.len() > SHOWN {
out.push_str(&format!(", and {} more", names.len() - SHOWN));
}
out
}
fn error_node(error: &Value) -> Value {
let name = error["name"].as_str().unwrap_or_default();
serde_json::json!({
"kind": "errorNode",
"name": name.to_lower_camel_case(),
"code": error["code"],
"message": error["msg"].as_str().unwrap_or_default(),
})
}
fn extraction_error(
error: codama::CodamaError,
crate_root: &Path,
src_dir: &Path,
) -> anyhow::Error {
let codama::CodamaError::Compilation(syn_error) = error else {
return anyhow::anyhow!("{error}");
};
let mut files = Vec::new();
let _ = collect_sources(src_dir, &mut files);
for (path, _) in &mut files {
if let Ok(relative) = path.strip_prefix(crate_root) {
*path = relative.to_path_buf();
}
}
let problems: Vec<String> = syn_error
.into_iter()
.map(|e| {
let start = e.span().start();
let token = e.span().source_text().unwrap_or_default();
let skipped = token
.lines()
.take_while(|line| {
let line = line.trim_start();
line.starts_with("//") || line.starts_with("#[")
})
.count();
let item = token.lines().nth(skipped).unwrap_or_default().trim();
let item_note = if item.is_empty() {
String::new()
} else {
format!(" `{item}`")
};
let line_number = start.line + skipped;
let first_line = token.lines().next().unwrap_or_default();
let matches: Vec<&(std::path::PathBuf, String)> = files
.iter()
.filter(|(_, src)| {
!first_line.is_empty()
&& src
.lines()
.nth(start.line.saturating_sub(1))
.and_then(|line| line.get(char_to_byte(line, start.column)..))
.is_some_and(|rest| rest.starts_with(first_line))
})
.collect();
let column = |src: &str| {
let line = src.lines().nth(line_number - 1).unwrap_or_default();
if skipped == 0 {
start.column + 1
} else {
line.len() - line.trim_start().len() + 1
}
};
match matches.as_slice() {
[(path, src)] => format!(
"{e}{item_note}\n --> {}:{line_number}:{}\n | {}",
path.display(),
column(src),
src.lines().nth(line_number - 1).unwrap_or_default().trim()
),
[] => format!("{e}{item_note} (line {line_number})"),
several => format!(
"{e}{item_note} at line {line_number} of one of: {}",
several
.iter()
.map(|(path, _)| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
}
})
.collect();
anyhow::anyhow!(problems.join("\n"))
}
fn char_to_byte(line: &str, column: usize) -> usize {
line.char_indices()
.nth(column)
.map(|(i, _)| i)
.unwrap_or(line.len())
}
fn collect_sources(dir: &Path, out: &mut Vec<(std::path::PathBuf, String)>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_sources(&path, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
if let Ok(src) = std::fs::read_to_string(&path) {
out.push((path, src));
}
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(())
}
fn program_is_empty(root: &Value) -> bool {
let is_empty_array = |key: &str| {
root["program"][key]
.as_array()
.map(|a| a.is_empty())
.unwrap_or(true)
};
is_empty_array("instructions") && is_empty_array("accounts") && is_empty_array("errors")
}