Skip to main content

xbrl_rs/taxonomy/linkbases/
resolver.rs

1use crate::{
2    ConceptId, ExpandedName, RoleUri, XbrlError,
3    taxonomy::{
4        Concept,
5        linkbases::parser::{LabelResource, RawLinkbases, ReferenceResource},
6    },
7    xml::ArcroleUri,
8};
9use indexmap::IndexMap;
10use rust_decimal::Decimal;
11use std::collections::HashMap;
12
13/// A regulatory/legal reference for a taxonomy concept.
14#[derive(Debug, Clone, PartialEq)]
15pub struct Reference {
16    /// The reference role URI.
17    pub role: String,
18}
19
20/// A single key-value part within a reference.
21#[derive(Debug, Clone, PartialEq)]
22pub struct ReferencePart {
23    /// The part name (local element name, e.g., "Name", "Paragraph").
24    pub name: String,
25    /// The part value (text content).
26    pub value: String,
27}
28
29/// A human-readable label for a taxonomy concept.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Label {
32    /// The label role URI (e.g., `http://www.xbrl.org/2003/role/label`).
33    pub role: String,
34    /// The language code (e.g., "de", "en").
35    pub lang: String,
36    /// The label text.
37    pub text: String,
38}
39
40/// A resolved presentation arc between two concepts.
41#[derive(Debug, Clone, PartialEq)]
42pub struct PresentationArc {
43    /// Parent concept of the relationship.
44    pub from: ExpandedName,
45    /// Child concept of the relationship.
46    pub to: ExpandedName,
47    /// Display order among siblings.
48    pub order: Option<Decimal>,
49    /// Preferred label role URI if present.
50    pub preferred_label: Option<RoleUri>,
51    /// Arcrole URI (normally parent-child for presentation).
52    pub arcrole: ArcroleUri,
53}
54
55/// A resolved calculation arc between two concepts.
56#[derive(Debug, Clone, PartialEq)]
57pub struct CalculationArc {
58    /// Source concept of the relationship.
59    pub from: ExpandedName,
60    /// Target concept of the relationship.
61    pub to: ExpandedName,
62    /// Display order among siblings.
63    pub order: Option<Decimal>,
64    /// Weight of the relationship (e.g., 1 or -1).
65    pub weight: Decimal,
66    /// Arcrole URI (normally summation-item for calculation).
67    pub arcrole: ArcroleUri,
68}
69
70/// A resolved definition arc between two concepts.
71#[derive(Debug, Clone, PartialEq)]
72pub struct DefinitionArc {
73    /// Source concept of the relationship.
74    pub from: ExpandedName,
75    /// Target concept of the relationship.
76    pub to: ExpandedName,
77    /// Display order among siblings.
78    pub order: Option<Decimal>,
79    /// Arcrole URI (normally parent-child for definition).
80    pub arcrole: ArcroleUri,
81}
82
83// TODO: key labels and reference by concept name
84/// Resolved linkbase data suitable for use in `TaxonomySet`.
85///
86/// Labels and references are keyed by concept ID to provide metadata for
87/// concepts during validation. Presentation, calculation, and definition arcs
88/// are keyed by role URI to preserve the grouping from the linkbase files.
89///
90/// Linkbases are resolved as follows (e.g., for presentation arcs):
91///    1. RawPresentationArc::from ("de-gaap-ci_bs.ass.fixAss")
92///    2. Lookup in locators (xlink:label → xlink:href)
93///    3. Extract fragment (#de-gaap-ci_bs.ass.fixAss)
94///    4. Find schema element (xs:element/@id or @name), i.e. the resolved
95///       Concept::name
96///    5. PresentationArc::from
97///       ("{http://www.xbrl.de/taxonomies/de-gaap-ci/role/balanceSheet}bs.ass.fixAss")
98#[derive(Debug, Default)]
99pub struct Linkbases {
100    /// Presentation arcs grouped by role URI, in the order roles were first
101    /// encountered during schema discovery.
102    pub presentations: IndexMap<RoleUri, Vec<PresentationArc>>,
103    /// Calculation arcs grouped by role URI.
104    pub calculations: HashMap<RoleUri, Vec<CalculationArc>>,
105    /// Definition arcs grouped by role URI.
106    pub definitions: HashMap<RoleUri, Vec<DefinitionArc>>,
107    /// Concept labels parsed from label linkbase files.
108    /// Keyed by resolved concept name.
109    pub labels: HashMap<ExpandedName, Vec<Label>>,
110    /// Concept references parsed from reference linkbase files.
111    /// Keyed by concept element ID.
112    pub references: HashMap<ConceptId, Vec<Reference>>,
113}
114
115/// Resolve locator references from a linkbase and merge them into the provided
116/// accumulator maps.
117pub fn resolve_linkbases(
118    linkbases: RawLinkbases,
119    concepts_by_id: &HashMap<ConceptId, &Concept>,
120) -> Result<Linkbases, XbrlError> {
121    let mut labels: HashMap<ExpandedName, Vec<Label>> = HashMap::new();
122    let mut presentations: IndexMap<RoleUri, Vec<PresentationArc>> = IndexMap::new();
123    let mut calculations: HashMap<RoleUri, Vec<CalculationArc>> = HashMap::new();
124    let mut definitions: HashMap<RoleUri, Vec<DefinitionArc>> = HashMap::new();
125    let mut references: HashMap<ConceptId, Vec<Reference>> = HashMap::new();
126
127    for link in linkbases.presentation_links {
128        // Map from locator label to href fragment (concept ID)
129        let locator_map: HashMap<&str, &str> = link
130            .locators
131            .iter()
132            .filter_map(|locator| {
133                href_fragment(&locator.href).map(|fragment| (locator.label.as_str(), fragment))
134            })
135            .collect();
136        let arcs: Vec<PresentationArc> = link
137            .arcs
138            .into_iter()
139            .filter_map(|arc| {
140                let from_fragment = locator_map.get(arc.from.as_str())?;
141                let from_concept = concepts_by_id.get(&ConceptId::from(*from_fragment))?;
142                let to_fragment = locator_map.get(arc.to.as_str())?;
143                let to_concept = concepts_by_id.get(&ConceptId::from(*to_fragment))?;
144
145                Some(PresentationArc {
146                    from: from_concept.name.clone(),
147                    to: to_concept.name.clone(),
148                    order: arc.order,
149                    preferred_label: arc.preferred_label.clone(),
150                    arcrole: arc.arcrole.clone(),
151                })
152            })
153            .collect();
154
155        if !arcs.is_empty() {
156            presentations
157                .entry(link.role.into())
158                .or_default()
159                .extend(arcs);
160        }
161    }
162
163    for link in linkbases.calculation_links {
164        // Map from locator label to href fragment (concept ID)
165        let locator_map: HashMap<&str, &str> = link
166            .locators
167            .iter()
168            .filter_map(|locator| {
169                href_fragment(&locator.href).map(|fragment| (locator.label.as_str(), fragment))
170            })
171            .collect();
172        let arcs: Vec<CalculationArc> = link
173            .arcs
174            .into_iter()
175            .filter_map(|arc| {
176                let from_fragment = locator_map.get(arc.from.as_str())?;
177                let from_concept = concepts_by_id.get(&ConceptId::from(*from_fragment))?;
178                let to_fragment = locator_map.get(arc.to.as_str())?;
179                let to_concept = concepts_by_id.get(&ConceptId::from(*to_fragment))?;
180
181                Some(CalculationArc {
182                    from: from_concept.name.clone(),
183                    to: to_concept.name.clone(),
184                    order: arc.order,
185                    weight: arc.weight,
186                    arcrole: arc.arcrole.clone(),
187                })
188            })
189            .collect();
190
191        if !arcs.is_empty() {
192            calculations
193                .entry(link.role.into())
194                .or_default()
195                .extend(arcs);
196        }
197    }
198
199    for link in linkbases.definition_links {
200        // Map from locator label to href fragment (concept ID)
201        let locator_map: HashMap<&str, &str> = link
202            .locators
203            .iter()
204            .filter_map(|locator| {
205                href_fragment(&locator.href).map(|fragment| (locator.label.as_str(), fragment))
206            })
207            .collect();
208        let arcs: Vec<DefinitionArc> = link
209            .arcs
210            .into_iter()
211            .filter_map(|arc| {
212                let from_fragment = locator_map.get(arc.from.as_str())?;
213                let from_concept = concepts_by_id.get(&ConceptId::from(*from_fragment))?;
214                let to_fragment = locator_map.get(arc.to.as_str())?;
215                let to_concept = concepts_by_id.get(&ConceptId::from(*to_fragment))?;
216
217                Some(DefinitionArc {
218                    from: from_concept.name.clone(),
219                    to: to_concept.name.clone(),
220                    order: arc.order,
221                    arcrole: arc.arcrole.clone(),
222                })
223            })
224            .collect();
225
226        if !arcs.is_empty() {
227            definitions
228                .entry(link.role.into())
229                .or_default()
230                .extend(arcs);
231        }
232    }
233
234    for link in linkbases.label_links {
235        let locator_map: HashMap<&str, &str> = link
236            .locators
237            .iter()
238            .filter_map(|locator| {
239                href_fragment(&locator.href).map(|fragment| (locator.label.as_str(), fragment))
240            })
241            .collect();
242        let resource_map: HashMap<&str, &LabelResource> = link
243            .labels
244            .iter()
245            .map(|resource| (resource.label.as_str(), resource))
246            .collect();
247
248        for arc in &link.arcs {
249            if let (Some(&concept_id), Some(&resource)) = (
250                locator_map.get(arc.from.as_str()),
251                resource_map.get(arc.to.as_str()),
252            ) {
253                if let Some(concept) = concepts_by_id.get(&ConceptId::from(concept_id)) {
254                    labels.entry(concept.name.clone()).or_default().push(Label {
255                        role: resource.role.clone().unwrap_or_default(),
256                        lang: resource.lang.clone(),
257                        text: resource.text.clone(),
258                    });
259                } else {
260                    return Err(XbrlError::InvalidLinkbaseResolution {
261                        reason: format!(
262                            "Label linkbase refers to unknown concept ID '{}'",
263                            concept_id
264                        ),
265                    });
266                }
267            }
268        }
269    }
270
271    for link in linkbases.reference_links {
272        // Map from locator label to href fragment (concept ID)
273        let locator_map: HashMap<&str, &str> = link
274            .locators
275            .iter()
276            .filter_map(|locator| {
277                href_fragment(&locator.href).map(|fragment| (locator.label.as_str(), fragment))
278            })
279            .collect();
280        let resource_map: HashMap<&str, &ReferenceResource> = link
281            .references
282            .iter()
283            .map(|resource| (resource.label.as_str(), resource))
284            .collect();
285
286        for arc in &link.arcs {
287            if let (Some(&concept_id), Some(&resource)) = (
288                locator_map.get(arc.from.as_str()),
289                resource_map.get(arc.to.as_str()),
290            ) {
291                references
292                    .entry(concept_id.into())
293                    .or_default()
294                    .push(Reference {
295                        role: resource.role.clone().unwrap_or_default(),
296                    });
297            }
298        }
299    }
300
301    Ok(Linkbases {
302        presentations,
303        calculations,
304        definitions,
305        labels,
306        references,
307    })
308}
309
310/// Extract the fragment (after `#`) from an xlink:href.
311fn href_fragment(href: &str) -> Option<&str> {
312    href.split_once('#').map(|(_, frag)| frag)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::taxonomy::{
319        linkbases::parser::{
320            CalculationLink, DefinitionLink, LabelLink, Locator, PresentationLink,
321            RawCalculationArc, RawDefinitionArc, RawLabelArc, RawPresentationArc, RawReferenceArc,
322            ReferenceLink,
323        },
324        schema::{BaseSubstitutionGroup, PeriodType, SubstitutionGroup, XbrlType},
325    };
326
327    fn create_concepts() -> Vec<Concept> {
328        vec![
329            Concept {
330                name: ExpandedName::new("http://example.com".into(), "concept1".to_string()),
331                id: Some("concept1".to_string()),
332                data_type: XbrlType::Monetary,
333                substitution_group: SubstitutionGroup {
334                    base: BaseSubstitutionGroup::Item,
335                    original: ExpandedName::new(
336                        "http://www.xbrl.org/2003/instance".into(),
337                        "item".to_string(),
338                    ),
339                },
340                period_type: Some(PeriodType::Instant),
341                balance: None,
342                nillable: false,
343                is_abstract: false,
344                tuple_children: Vec::new(),
345                compositor: None,
346            },
347            Concept {
348                name: ExpandedName::new("http://example.com".into(), "concept2".to_string()),
349                id: Some("concept2".to_string()),
350                data_type: XbrlType::Monetary,
351                substitution_group: SubstitutionGroup {
352                    base: BaseSubstitutionGroup::Item,
353                    original: ExpandedName::new(
354                        "http://www.xbrl.org/2003/instance".into(),
355                        "item".to_string(),
356                    ),
357                },
358                period_type: Some(PeriodType::Instant),
359                balance: None,
360                nillable: false,
361                is_abstract: false,
362                tuple_children: Vec::new(),
363                compositor: None,
364            },
365        ]
366    }
367
368    #[test]
369    fn test_resolve_linkbases() {
370        let concepts = create_concepts();
371        let concepts_by_id = concepts
372            .iter()
373            .map(|concept| (ConceptId::from(concept.id.clone().unwrap()), concept))
374            .collect::<HashMap<_, _>>();
375        let raw_presentation = RawLinkbases {
376            presentation_links: vec![PresentationLink {
377                role: "http://example.com/role/presentation".into(),
378                locators: vec![
379                    Locator {
380                        label: "loc1".into(),
381                        href: "schema.xsd#concept1".into(),
382                    },
383                    Locator {
384                        label: "loc2".into(),
385                        href: "schema.xsd#concept2".into(),
386                    },
387                ],
388                arcs: vec![RawPresentationArc {
389                    from: "loc1".into(),
390                    to: "loc2".into(),
391                    order: Some(Decimal::new(1, 0)),
392                    preferred_label: None,
393                    arcrole: "http://www.xbrl.org/2003/arcrole/parent-child".into(),
394                }],
395            }],
396            calculation_links: vec![CalculationLink {
397                role: "http://example.com/role/calculation".into(),
398                locators: vec![
399                    Locator {
400                        label: "loc1".into(),
401                        href: "schema.xsd#concept1".into(),
402                    },
403                    Locator {
404                        label: "loc2".into(),
405                        href: "schema.xsd#concept2".into(),
406                    },
407                ],
408                arcs: vec![RawCalculationArc {
409                    from: "loc1".into(),
410                    to: "loc2".into(),
411                    order: Some(Decimal::new(1, 0)),
412                    weight: Decimal::new(1, 0),
413                    arcrole: "http://www.xbrl.org/2003/arcrole/summation-item".into(),
414                }],
415            }],
416            definition_links: vec![DefinitionLink {
417                role: "http://example.com/role/definition".into(),
418                locators: vec![
419                    Locator {
420                        label: "loc1".into(),
421                        href: "schema.xsd#concept1".into(),
422                    },
423                    Locator {
424                        label: "loc2".into(),
425                        href: "schema.xsd#concept2".into(),
426                    },
427                ],
428                arcs: vec![RawDefinitionArc {
429                    from: "loc1".into(),
430                    to: "loc2".into(),
431                    order: Some(Decimal::new(1, 0)),
432                    arcrole: "http://www.xbrl.org/2003/arcrole/parent-child".into(),
433                }],
434            }],
435            label_links: vec![LabelLink {
436                role: "http://example.com/role/label".into(),
437                locators: vec![Locator {
438                    label: "loc1".into(),
439                    href: "schema.xsd#concept1".into(),
440                }],
441                labels: vec![LabelResource {
442                    label: "lab1".into(),
443                    role: Some("http://www.xbrl.org/2003/role/label".into()),
444                    lang: "en".into(),
445                    text: "Concept 1 Label".into(),
446                }],
447                arcs: vec![RawLabelArc {
448                    from: "loc1".into(),
449                    to: "lab1".into(),
450                }],
451            }],
452            reference_links: vec![ReferenceLink {
453                role: "http://example.com/role/reference".into(),
454                locators: vec![Locator {
455                    label: "loc1".into(),
456                    href: "schema.xsd#concept1".into(),
457                }],
458                references: vec![ReferenceResource {
459                    label: "ref1".into(),
460                    role: Some("http://www.xbrl.org/2003/role/reference".into()),
461                }],
462                arcs: vec![RawReferenceArc {
463                    from: "loc1".into(),
464                    to: "ref1".into(),
465                }],
466            }],
467        };
468        let linkbases = resolve_linkbases(raw_presentation, &concepts_by_id).unwrap();
469        assert_eq!(linkbases.presentations.len(), 1);
470        assert_eq!(linkbases.calculations.len(), 1);
471        assert_eq!(linkbases.definitions.len(), 1);
472        assert_eq!(linkbases.labels.len(), 1);
473        assert_eq!(linkbases.references.len(), 1);
474
475        let presentation_arc = &linkbases.presentations["http://example.com/role/presentation"][0];
476        assert_eq!(
477            presentation_arc,
478            &PresentationArc {
479                from: ExpandedName::new("http://example.com".into(), "concept1".to_string()),
480                to: ExpandedName::new("http://example.com".into(), "concept2".to_string()),
481                order: Some(Decimal::new(1, 0)),
482                preferred_label: None,
483                arcrole: "http://www.xbrl.org/2003/arcrole/parent-child".into(),
484            }
485        );
486
487        let calculation_arc = &linkbases.calculations["http://example.com/role/calculation"][0];
488        assert_eq!(
489            calculation_arc,
490            &CalculationArc {
491                from: ExpandedName::new("http://example.com".into(), "concept1".to_string()),
492                to: ExpandedName::new("http://example.com".into(), "concept2".to_string()),
493                order: Some(Decimal::new(1, 0)),
494                weight: Decimal::new(1, 0),
495                arcrole: "http://www.xbrl.org/2003/arcrole/summation-item".into(),
496            }
497        );
498
499        let definition_arc = &linkbases.definitions["http://example.com/role/definition"][0];
500        assert_eq!(
501            definition_arc,
502            &DefinitionArc {
503                from: ExpandedName::new("http://example.com".into(), "concept1".to_string()),
504                to: ExpandedName::new("http://example.com".into(), "concept2".to_string()),
505                order: Some(Decimal::new(1, 0)),
506                arcrole: "http://www.xbrl.org/2003/arcrole/parent-child".into(),
507            }
508        );
509
510        let label = &linkbases.labels
511            [&ExpandedName::new("http://example.com".into(), "concept1".to_string())][0];
512        assert_eq!(
513            label,
514            &Label {
515                role: "http://www.xbrl.org/2003/role/label".into(),
516                lang: "en".into(),
517                text: "Concept 1 Label".into(),
518            }
519        );
520
521        let reference = &linkbases.references[&ConceptId::from("concept1".to_string())][0];
522        assert_eq!(
523            reference,
524            &Reference {
525                role: "http://www.xbrl.org/2003/role/reference".into(),
526            }
527        );
528    }
529}