use super::schema::TaxonomySchema;
use crate::{
CalculationArc, ConceptId, DefinitionArc, ExpandedName, Label, PresentationArc, Reference,
RoleUri, SchemaRefUrl,
error::{Result, XbrlError},
taxonomy::{
RoleType,
linkbases::{
parser::{LinkbaseParser, RawLinkbases},
resolver::{self, Linkbases},
},
schema::Concept,
},
};
use indexmap::{IndexMap, IndexSet};
use std::{
collections::{HashMap, HashSet, VecDeque},
fs, io,
path::{Path, PathBuf},
};
#[derive(Debug, Default)]
pub struct TaxonomySet {
entry_point: PathBuf,
schema_refs: IndexMap<SchemaRefUrl, PathBuf>,
schemas: IndexMap<PathBuf, TaxonomySchema>,
linkbase_paths: Vec<PathBuf>,
linkbases: Linkbases,
role_source_schema: HashMap<RoleUri, PathBuf>,
date: Option<String>,
}
impl TaxonomySet {
pub fn discover(schema_refs: Vec<String>, entry_point: PathBuf) -> Result<Self> {
let date = schema_refs.first().and_then(|url| extract_date(url));
if schema_refs.len() > 1
&& let Some(ref expected) = date
{
for url in schema_refs.iter().skip(1) {
if let Some(found) = extract_date(url)
&& &found != expected
{
return Err(XbrlError::VersionMismatch {
expected: expected.clone(),
found,
schema_ref: url.clone(),
});
}
}
}
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut queue: VecDeque<PathBuf> = VecDeque::new();
let mut schemas: IndexMap<PathBuf, TaxonomySchema> = IndexMap::new();
let mut linkbase_set: IndexSet<PathBuf> = IndexSet::new();
let canonical_entry_point =
fs::canonicalize(&entry_point).map_err(|err| XbrlError::FileRead {
path: entry_point.clone(),
source: err,
})?;
let mut schema_refs_map: IndexMap<SchemaRefUrl, PathBuf> = IndexMap::new();
for url in &schema_refs {
let canonical = canonical_entry_point.join(strip_prefix(url));
schema_refs_map.insert(url.clone().into(), canonical.clone());
if visited.insert(canonical.clone()) {
queue.push_back(canonical);
}
}
while let Some(path) = queue.pop_front() {
let schema = TaxonomySchema::from_file(&path)?;
let schema_dir = path.parent().unwrap_or(Path::new("."));
for lbref in &schema.linkbase_refs {
if let Some(resolved) = resolve_local_path(schema_dir, &lbref.href) {
if !resolved.exists() {
return Err(XbrlError::FileRead {
path: resolved,
source: io::Error::new(
io::ErrorKind::NotFound,
"referenced linkbase file does not exist",
),
});
}
let canonical =
fs::canonicalize(&resolved).map_err(|err| XbrlError::FileRead {
path: resolved.clone(),
source: err,
})?;
linkbase_set.insert(canonical);
}
}
for import in &schema.imports {
if let Some(ref location) = import.schema_location
&& let Some(resolved) = resolve_local_path(schema_dir, location)
&& resolved.exists()
&& let Ok(canonical) = std::fs::canonicalize(&resolved)
&& visited.insert(canonical.clone())
{
queue.push_back(canonical);
}
}
for include in &schema.includes {
if let Some(resolved) = resolve_local_path(schema_dir, &include.schema_location)
&& resolved.exists()
&& let Ok(canonical) = fs::canonicalize(&resolved)
&& visited.insert(canonical.clone())
{
queue.push_back(canonical);
}
}
schemas.insert(path, schema);
}
let linkbase_paths: Vec<PathBuf> = linkbase_set.into_iter().collect();
let mut linkbases = RawLinkbases::default();
for path in &linkbase_paths {
let mut parser = LinkbaseParser::from_file(path)?;
parser.parse(&mut linkbases)?;
}
let concepts_by_id = schemas
.values()
.flat_map(|schema| &schema.concepts)
.filter_map(|concept| concept.id.clone().map(|id| (ConceptId::from(id), concept)))
.collect::<HashMap<_, _>>();
let linkbases = resolver::resolve_linkbases(linkbases, &concepts_by_id)?;
let mut role_source_schema: HashMap<RoleUri, PathBuf> = HashMap::new();
for (path, schema) in &schemas {
for role_type in &schema.role_types {
role_source_schema
.entry(role_type.role_uri.clone())
.or_insert_with(|| path.clone());
}
}
let taxonomy = TaxonomySet {
entry_point,
schema_refs: schema_refs_map,
schemas,
linkbase_paths,
linkbases,
role_source_schema,
date,
};
Ok(taxonomy)
}
pub fn entry_point(&self) -> &Path {
&self.entry_point
}
pub fn date(&self) -> Option<&str> {
self.date.as_deref()
}
pub fn schema_refs(&self) -> &IndexMap<SchemaRefUrl, PathBuf> {
&self.schema_refs
}
pub fn role_source_path(&self, role: &str) -> Option<&Path> {
self.role_source_schema.get(role).map(PathBuf::as_path)
}
pub fn schemas(&self) -> &IndexMap<PathBuf, TaxonomySchema> {
&self.schemas
}
pub fn linkbase_paths(&self) -> &[PathBuf] {
&self.linkbase_paths
}
pub fn role_types(&self) -> Vec<&RoleType> {
self.schemas.values().flat_map(|s| &s.role_types).collect()
}
pub fn concepts(&self) -> impl Iterator<Item = &Concept> {
self.schemas
.values()
.flat_map(|schema| schema.concepts.iter())
}
pub fn find_concept(&self, name: &ExpandedName) -> Option<&Concept> {
self.schemas
.values()
.flat_map(|schema| &schema.concepts)
.find(|concept| &concept.name == name)
}
pub fn find_concept_by_id(&self, id: &str) -> Option<&Concept> {
self.schemas
.values()
.flat_map(|schema| &schema.concepts)
.find(|concept| concept.id.as_deref() == Some(id))
}
pub fn find_parent_tuple(&self, concept_id: &str) -> Option<&Concept> {
let element = self.find_concept_by_id(concept_id)?;
self.schemas
.values()
.flat_map(|schema| &schema.concepts)
.find(|concept| {
concept.is_tuple()
&& concept
.content_model
.as_ref()
.map(|model| model.allows_local_name(&element.name.local_name))
.unwrap_or(false)
})
}
pub fn tuple_ancestor_ids(&self, concept_id: &str) -> Vec<String> {
let mut ancestors = Vec::new();
let mut current = concept_id.to_string();
let mut seen = HashSet::new();
while seen.insert(current.clone()) {
let Some(parent_tuple) = self.find_parent_tuple(¤t) else {
break;
};
ancestors.push(parent_tuple.id.clone().unwrap_or_default().to_string());
current = parent_tuple.id.clone().unwrap_or_default().to_string();
}
ancestors.reverse();
ancestors
}
pub fn qualified_name(&self, element_id: &str) -> Option<ExpandedName> {
for schema in self.schemas.values() {
if let Some(concept) = schema
.concepts
.iter()
.find(|concept| concept.id.as_deref() == Some(element_id))
{
return Some(concept.name.clone());
}
}
None
}
pub fn labels_map(&self) -> &HashMap<ExpandedName, Vec<Label>> {
&self.linkbases.labels
}
pub fn labels(&self, concept_name: &ExpandedName) -> Option<&[Label]> {
self.linkbases
.labels
.get(concept_name)
.map(|labels| labels.as_slice())
}
pub fn presentations(&self) -> &IndexMap<RoleUri, Vec<PresentationArc>> {
&self.linkbases.presentations
}
pub fn presentation_arcs(&self, role: &str) -> Option<&[PresentationArc]> {
self.linkbases
.presentations
.get(role)
.map(|arcs| arcs.as_slice())
}
pub fn calculations(&self) -> &HashMap<RoleUri, Vec<CalculationArc>> {
&self.linkbases.calculations
}
pub fn calculation_arcs(&self, role: &str) -> Option<&[CalculationArc]> {
self.linkbases
.calculations
.get(role)
.map(|arcs| arcs.as_slice())
}
pub fn definitions(&self) -> &HashMap<RoleUri, Vec<DefinitionArc>> {
&self.linkbases.definitions
}
pub fn definition_arcs(&self, role: &str) -> Option<&[DefinitionArc]> {
self.linkbases
.definitions
.get(role)
.map(|arcs| arcs.as_slice())
}
pub fn references(&self) -> &HashMap<ConceptId, Vec<Reference>> {
&self.linkbases.references
}
pub fn references_for(&self, concept_id: &str) -> Option<&[Reference]> {
self.linkbases
.references
.get(concept_id)
.map(|references| references.as_slice())
}
pub fn schema_by_namespace(&self, namespace: &str) -> Option<&TaxonomySchema> {
self.schemas
.values()
.find(|s| s.target_namespace.as_deref() == Some(namespace))
}
}
#[cfg(test)]
impl TaxonomySet {
pub fn add_presentation_arc(&mut self, role: String, arc: PresentationArc) {
self.linkbases
.presentations
.entry(role.into())
.or_default()
.push(arc);
}
pub fn add_label(&mut self, concept_name: ExpandedName, label: Label) {
self.linkbases
.labels
.entry(concept_name)
.or_default()
.push(label);
}
}
fn resolve_local_path(base_dir: &Path, reference: &str) -> Option<PathBuf> {
if reference.contains("://") {
return None;
}
Some(base_dir.join(reference))
}
fn extract_date(url: &str) -> Option<String> {
let stripped = strip_prefix(url);
if let Some(segment) = stripped.split('/').next() {
let parts: Vec<&str> = segment.split('-').collect();
if parts.len() >= 3 {
let tail = &parts[parts.len() - 3..];
if tail[0].len() == 4
&& tail[1].len() == 2
&& tail[2].len() == 2
&& tail.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
{
return Some(tail.join("-"));
}
}
}
for segment in stripped.split('/') {
if segment.len() == 4 && segment.chars().all(|c| c.is_ascii_digit()) {
return Some(segment.to_string());
}
}
None
}
pub fn strip_prefix(href: &str) -> &str {
let path = href
.find("://")
.and_then(|i| href[i + 3..].find('/'))
.map(|i| &href[href.find("://").unwrap() + 3 + i..])
.unwrap_or(href);
path.strip_prefix("/taxonomies/")
.or_else(|| path.strip_prefix("/"))
.unwrap_or(path)
}