use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::apidoc::ApidocDict;
pub(crate) fn is_apidoc_debug_enabled() -> bool {
std::env::var("LIBPERL_MACROGEN_DEBUG_APIDOC")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
pub(crate) fn cargo_warning(msg: &str) {
println!("cargo:warning={}", msg);
eprintln!("{}", msg);
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ApidocPatchFile {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub comment: Option<String>,
#[serde(default)]
pub patches: Vec<ApidocPatch>,
}
fn default_schema_version() -> u32 { 1 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApidocPatch {
pub name: String,
pub kind: PatchKind,
#[serde(default)]
pub value: Option<String>,
#[serde(default)]
pub arg_index: Option<usize>,
#[serde(default)]
pub source_loc: Option<String>,
pub reason: String,
#[serde(default)]
pub upstream_status: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PatchKind {
#[serde(rename = "return_type_override")]
ReturnTypeOverride,
#[serde(rename = "arg_type_override")]
ArgTypeOverride,
#[serde(rename = "skip_codegen")]
SkipCodegen,
#[serde(rename = "remove")]
Remove,
}
#[derive(Debug, Default)]
pub struct ApidocPatchSet {
pub return_overrides: HashMap<String, (String, String)>,
pub arg_overrides: HashMap<String, Vec<(usize, String, String)>>,
pub skip_codegen: HashMap<String, String>,
pub removals: HashSet<String>,
pub source_paths: Vec<PathBuf>,
}
impl ApidocPatchSet {
pub fn empty() -> Self { Self::default() }
pub fn load_json<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let path_ref = path.as_ref();
let content = std::fs::read_to_string(path_ref)?;
let file: ApidocPatchFile = serde_json::from_str(&content).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData,
format!("apidoc patches JSON parse error: {}", e))
})?;
if file.schema_version != 1 {
return Err(io::Error::new(io::ErrorKind::InvalidData,
format!("unsupported apidoc patches schema_version: {}", file.schema_version)));
}
let mut set = Self::default();
set.source_paths.push(path_ref.to_path_buf());
for p in file.patches {
match p.kind {
PatchKind::ReturnTypeOverride => {
let v = p.value.clone().ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
format!("patch for {}: return_type_override requires `value`", p.name)))?;
set.return_overrides.insert(p.name, (v, p.reason));
}
PatchKind::ArgTypeOverride => {
let v = p.value.clone().ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
format!("patch for {}: arg_type_override requires `value`", p.name)))?;
let idx = p.arg_index.ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
format!("patch for {}: arg_type_override requires `arg_index`", p.name)))?;
set.arg_overrides.entry(p.name).or_default().push((idx, v, p.reason));
}
PatchKind::SkipCodegen => {
set.skip_codegen.insert(p.name, p.reason);
}
PatchKind::Remove => {
set.removals.insert(p.name);
}
}
}
Ok(set)
}
pub fn load_for_perl_version<P: AsRef<Path>>(
apidoc_dir: P, major: u32, minor: u32,
) -> io::Result<Self> {
let filename = format!("v{}.{}.patches.json", major, minor);
let path = apidoc_dir.as_ref().join(&filename);
if !path.exists() {
return Ok(Self::empty());
}
Self::load_json(&path)
}
pub fn load_for_apidoc_path<P: AsRef<Path>>(apidoc_path: P) -> io::Result<Self> {
let path_ref = apidoc_path.as_ref();
let dir = path_ref.parent().unwrap_or_else(|| Path::new("."));
let debug = is_apidoc_debug_enabled();
let mut set = Self::default();
if debug {
cargo_warning(&format!(
"[apidoc-patches] load_for_apidoc_path: apidoc_path={}, dir={}",
path_ref.display(), dir.display()
));
}
let common_path = dir.join("common.patches.json");
if common_path.exists() {
let common = Self::load_json(&common_path)?;
if debug {
cargo_warning(&format!(
"[apidoc-patches] loaded common.patches.json: \
{} return_overrides, {} arg_overrides, {} skip_codegen, {} removals",
common.return_overrides.len(),
common.arg_overrides.len(),
common.skip_codegen.len(),
common.removals.len(),
));
}
set.merge_overlay(common);
} else if debug {
cargo_warning(&format!(
"[apidoc-patches] common.patches.json NOT FOUND at {}",
common_path.display()
));
}
let version_path = {
let stem = path_ref.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(String::new);
path_ref.with_file_name(format!("{}.patches.json", stem))
};
if version_path.exists() {
let version = Self::load_json(&version_path)?;
if debug {
cargo_warning(&format!(
"[apidoc-patches] loaded {}: \
{} return_overrides, {} skip_codegen, {} removals",
version_path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
version.return_overrides.len(),
version.skip_codegen.len(),
version.removals.len(),
));
}
for name in &version.removals {
set.return_overrides.remove(name);
set.arg_overrides.remove(name);
set.skip_codegen.remove(name);
}
set.merge_overlay(version);
} else if debug {
cargo_warning(&format!(
"[apidoc-patches] {} NOT FOUND",
version_path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
));
}
Ok(set)
}
fn merge_overlay(&mut self, other: ApidocPatchSet) {
for (k, v) in other.return_overrides {
self.return_overrides.insert(k, v);
}
for (k, v) in other.arg_overrides {
self.arg_overrides.insert(k, v);
}
for (k, v) in other.skip_codegen {
self.skip_codegen.insert(k, v);
}
for name in other.removals {
self.removals.insert(name);
}
self.source_paths.extend(other.source_paths);
}
pub fn merge_skip_list<P: AsRef<Path>>(&mut self, path: P) -> io::Result<usize> {
let path_ref = path.as_ref();
let content = std::fs::read_to_string(path_ref)?;
let display_name = path_ref.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path_ref.display().to_string());
let reason = format!("skip-list: {}", display_name);
let mut added = 0usize;
for raw_line in content.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() { continue; }
if !self.skip_codegen.contains_key(line) {
self.skip_codegen.insert(line.to_string(), reason.clone());
added += 1;
}
}
Ok(added)
}
pub fn is_empty(&self) -> bool {
self.return_overrides.is_empty()
&& self.arg_overrides.is_empty()
&& self.skip_codegen.is_empty()
}
pub fn count(&self) -> usize {
self.return_overrides.len()
+ self.arg_overrides.iter().map(|(_, v)| v.len()).sum::<usize>()
+ self.skip_codegen.len()
}
pub fn apply_to_apidoc(&self, dict: &mut ApidocDict) -> Vec<String> {
let debug = is_apidoc_debug_enabled();
let mut applied: Vec<String> = Vec::new();
if debug {
cargo_warning(&format!(
"[apidoc-patches] apply_to_apidoc: dict has {} entries; \
patches: {} return_overrides, {} arg_overrides, {} skip_codegen",
dict.len(),
self.return_overrides.len(),
self.arg_overrides.len(),
self.skip_codegen.len(),
));
}
for (name, (new_ty, _reason)) in &self.return_overrides {
if let Some(entry) = dict.get_mut(name) {
let old = entry.return_type.clone();
entry.return_type = Some(new_ty.clone());
applied.push(name.clone());
if debug {
cargo_warning(&format!(
"[apidoc-patches] return_type_override APPLIED `{}`: {} -> {}",
name,
old.as_deref().unwrap_or("(none)"),
new_ty,
));
}
} else {
cargo_warning(&format!(
"[apidoc-patches] return_type_override MISS `{}`: \
target not found in apidoc dict (dict has {} entries) — \
codegen falls back to whatever else is inferred",
name, dict.len()
));
}
}
for (name, list) in &self.arg_overrides {
if let Some(entry) = dict.get_mut(name) {
for (idx, new_ty, _reason) in list {
if let Some(arg) = entry.args.get_mut(*idx) {
arg.ty = new_ty.clone();
} else {
cargo_warning(&format!(
"[apidoc-patches] arg_type_override `{}` arg_index {} \
out of range (entry has {} args)",
name, idx, entry.args.len()
));
}
}
applied.push(name.clone());
} else {
cargo_warning(&format!(
"[apidoc-patches] arg_type_override MISS `{}`: \
target not found in apidoc dict",
name
));
}
}
if debug {
let interest_prefixes: Vec<&str> = self.return_overrides.keys()
.chain(self.skip_codegen.keys())
.map(|s| s.as_str())
.collect();
let mut prefixes_set: std::collections::HashSet<&str> = std::collections::HashSet::new();
for n in &interest_prefixes {
if let Some(idx) = n.find('_') {
prefixes_set.insert(&n[..idx + 1]);
}
}
for prefix in prefixes_set {
let matches: Vec<String> = dict.iter()
.filter(|(name, _)| name.starts_with(prefix))
.map(|(name, e)| format!("{}->{}", name, e.return_type.as_deref().unwrap_or("?")))
.collect();
cargo_warning(&format!(
"[apidoc-patches] dict entries with prefix `{}` ({} entries): {:?}",
prefix, matches.len(), matches
));
}
}
applied
}
pub fn skip_reason(&self, name: &str) -> Option<&str> {
self.skip_codegen.get(name).map(|s| s.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::TempDir;
fn write_json(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
let mut f = fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
path
}
const COMMON_PATCH: &str = r#"{
"schema_version": 1,
"patches": [
{ "name": "RCPV_LEN", "kind": "return_type_override",
"value": "STRLEN", "reason": "common: wrong apidoc" },
{ "name": "Perl_custom_op_xop", "kind": "skip_codegen",
"reason": "common: macro lacks aTHX_" }
]
}"#;
#[test]
fn test_load_common_only() {
let tmp = TempDir::new().unwrap();
write_json(tmp.path(), "common.patches.json", COMMON_PATCH);
let apidoc_path = tmp.path().join("v5.40.json");
let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
assert_eq!(set.return_overrides.len(), 1);
assert_eq!(set.return_overrides["RCPV_LEN"].0, "STRLEN");
assert_eq!(set.skip_codegen.len(), 1);
assert!(set.skip_codegen.contains_key("Perl_custom_op_xop"));
assert_eq!(set.source_paths.len(), 1);
}
#[test]
fn test_version_overrides_common() {
let tmp = TempDir::new().unwrap();
write_json(tmp.path(), "common.patches.json", COMMON_PATCH);
let version_json = r#"{
"schema_version": 1,
"patches": [
{ "name": "RCPV_LEN", "kind": "return_type_override",
"value": "Size_t", "reason": "v5.42: tweaked" }
]
}"#;
write_json(tmp.path(), "v5.42.patches.json", version_json);
let apidoc_path = tmp.path().join("v5.42.json");
let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
assert_eq!(set.return_overrides["RCPV_LEN"].0, "Size_t");
assert!(set.skip_codegen.contains_key("Perl_custom_op_xop"));
assert_eq!(set.source_paths.len(), 2);
}
#[test]
fn test_remove_kind_drops_common_entry() {
let tmp = TempDir::new().unwrap();
write_json(tmp.path(), "common.patches.json", COMMON_PATCH);
let version_json = r#"{
"schema_version": 1,
"patches": [
{ "name": "Perl_custom_op_xop", "kind": "remove",
"reason": "fixed upstream in 5.42" }
]
}"#;
write_json(tmp.path(), "v5.42.patches.json", version_json);
let apidoc_path = tmp.path().join("v5.42.json");
let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
assert!(!set.skip_codegen.contains_key("Perl_custom_op_xop"));
assert!(set.return_overrides.contains_key("RCPV_LEN"));
assert!(set.removals.contains("Perl_custom_op_xop"));
}
#[test]
fn test_no_patches_files_returns_empty() {
let tmp = TempDir::new().unwrap();
let apidoc_path = tmp.path().join("v5.40.json");
let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
assert!(set.is_empty());
assert_eq!(set.source_paths.len(), 0);
}
#[test]
fn test_remove_only_in_singlefile_load_does_not_panic() {
let tmp = TempDir::new().unwrap();
let json = r#"{
"schema_version": 1,
"patches": [
{ "name": "FOO", "kind": "remove", "reason": "test" }
]
}"#;
let path = write_json(tmp.path(), "v5.42.patches.json", json);
let set = ApidocPatchSet::load_json(&path).unwrap();
assert!(set.return_overrides.is_empty());
assert!(set.skip_codegen.is_empty());
assert!(set.removals.contains("FOO"));
}
}