use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::error::{ComponentsJsError, Result};
#[derive(Debug, Clone, Default)]
pub struct ContextResolver {
pub vocab: Option<String>,
pub prefixes: HashMap<String, String>,
pub terms: HashMap<String, TermDef>,
}
#[derive(Debug, Clone)]
pub struct TermDef {
pub iri: String,
pub type_coercion: Option<String>,
pub container: Option<String>,
}
impl ContextResolver {
pub fn new() -> Self {
Self::default()
}
pub fn from_context_value(
context_value: &serde_json::Value,
known_contexts: &HashMap<String, serde_json::Value>,
) -> Result<Self> {
let mut resolver = Self::new();
resolver.load_context_value(context_value, known_contexts)?;
Ok(resolver)
}
fn load_context_value(
&mut self,
value: &serde_json::Value,
known_contexts: &HashMap<String, serde_json::Value>,
) -> Result<()> {
match value {
serde_json::Value::Array(arr) => {
for item in arr {
self.load_context_value(item, known_contexts)?;
}
}
serde_json::Value::String(url) => {
if let Some(ctx_doc) = known_contexts.get(url.as_str()) {
if let Some(inner) = ctx_doc.get("@context") {
self.load_context_value(inner, known_contexts)?;
} else {
self.load_context_object(ctx_doc)?;
}
} else {
tracing::warn!("Unknown context URL: {url} — skipping");
}
}
serde_json::Value::Object(_) => {
self.load_context_object(value)?;
}
_ => {}
}
Ok(())
}
fn load_context_object(&mut self, obj: &serde_json::Value) -> Result<()> {
let map = obj
.as_object()
.ok_or_else(|| ComponentsJsError::ContextResolution("Expected object".into()))?;
for (key, val) in map {
match key.as_str() {
"@vocab" => {
if let Some(s) = val.as_str() {
self.vocab = Some(s.to_string());
}
}
k if k.starts_with('@') => {
}
_ => match val {
serde_json::Value::String(iri) => {
if iri.ends_with('/') || iri.ends_with('#') || iri.ends_with(':') {
self.prefixes.insert(key.clone(), iri.clone());
} else {
self.terms.insert(
key.clone(),
TermDef {
iri: iri.clone(),
type_coercion: None,
container: None,
},
);
}
}
serde_json::Value::Object(def) => {
if let Some(id) = def.get("@id").and_then(|v| v.as_str()) {
let type_coercion =
def.get("@type").and_then(|v| v.as_str()).map(String::from);
let container = def
.get("@container")
.and_then(|v| v.as_str())
.map(String::from);
self.terms.insert(
key.clone(),
TermDef {
iri: id.to_string(),
type_coercion,
container,
},
);
}
}
_ => {}
},
}
}
Ok(())
}
pub fn expand_term(&self, term: &str) -> String {
self.expand_term_depth(term, 0)
}
fn expand_term_depth(&self, term: &str, depth: usize) -> String {
if depth > 10 {
return term.to_string();
}
if let Some(def) = self.terms.get(term) {
return self.expand_term_depth(&def.iri, depth + 1);
}
if let Some((prefix, suffix)) = term.split_once(':') {
if !suffix.starts_with("//") {
if let Some(base) = self.prefixes.get(prefix) {
let expanded_base = self.expand_term_depth(base, depth + 1);
return format!("{expanded_base}{suffix}");
}
}
}
if term.contains("://") {
return term.to_string();
}
if let Some(vocab) = &self.vocab {
return format!("{vocab}{term}");
}
term.to_string()
}
pub fn compact_iri(&self, iri: &str) -> String {
for (term, def) in &self.terms {
let expanded = self.expand_term(&def.iri);
if expanded == iri {
return term.clone();
}
}
let mut best: Option<(String, usize)> = None; for (prefix, base_iri) in &self.prefixes {
let expanded_base = self.expand_term(base_iri);
if let Some(suffix) = iri.strip_prefix(expanded_base.as_str()) {
let base_len = expanded_base.len();
if best.as_ref().is_none_or(|(_, bl)| base_len > *bl) {
best = Some((format!("{prefix}:{suffix}"), base_len));
}
}
}
if let Some((compact, _)) = best {
return compact;
}
if let Some(vocab) = &self.vocab {
if let Some(suffix) = iri.strip_prefix(vocab.as_str()) {
if !suffix.contains('/') && !suffix.contains('#') {
return suffix.to_string();
}
}
}
iri.to_string()
}
}
#[derive(Debug, Clone, Default)]
pub struct IriCompactor {
prefixes: Vec<(String, String)>,
terms: Vec<(String, String)>,
vocab: Option<String>,
}
impl IriCompactor {
pub fn from_contexts(known_contexts: &HashMap<String, serde_json::Value>) -> Result<Self> {
let mut resolver = ContextResolver::new();
for ctx_doc in known_contexts.values() {
if let Some(inner) = ctx_doc.get("@context") {
resolver.load_context_value(inner, known_contexts)?;
} else {
resolver.load_context_object(ctx_doc)?;
}
}
let mut prefixes: Vec<(String, String)> = resolver
.prefixes
.iter()
.map(|(name, base)| {
let expanded = resolver.expand_term(base);
(name.clone(), expanded)
})
.collect();
prefixes.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
let terms: Vec<(String, String)> = resolver
.terms
.iter()
.map(|(name, def)| {
let expanded = resolver.expand_term(&def.iri);
(name.clone(), expanded)
})
.collect();
Ok(Self {
prefixes,
terms,
vocab: resolver.vocab,
})
}
pub fn compact(&self, iri: &str) -> String {
for (term, expanded) in &self.terms {
if expanded == iri {
return term.clone();
}
}
for (prefix, base) in &self.prefixes {
if let Some(suffix) = iri.strip_prefix(base.as_str()) {
return format!("{prefix}:{suffix}");
}
}
if let Some(vocab) = &self.vocab {
if let Some(suffix) = iri.strip_prefix(vocab.as_str()) {
if !suffix.contains('/') && !suffix.contains('#') {
return suffix.to_string();
}
}
}
iri.to_string()
}
pub fn expand(&self, term: &str) -> String {
for (name, expanded) in &self.terms {
if name == term {
return expanded.clone();
}
}
if let Some((prefix, suffix)) = term.split_once(':') {
if !suffix.starts_with("//") {
for (name, base) in &self.prefixes {
if name == prefix {
return format!("{base}{suffix}");
}
}
}
}
if term.contains("://") {
return term.to_string();
}
if let Some(vocab) = &self.vocab {
return format!("{vocab}{term}");
}
term.to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpandedNode {
pub id: Option<String>,
pub types: Vec<String>,
pub properties: HashMap<String, Vec<serde_json::Value>>,
}
pub fn extract_graph_nodes(
doc: &serde_json::Value,
known_contexts: &HashMap<String, serde_json::Value>,
) -> Result<Vec<ExpandedNode>> {
let resolver = if let Some(ctx) = doc.get("@context") {
ContextResolver::from_context_value(ctx, known_contexts)?
} else {
ContextResolver::new()
};
let entries: Vec<&serde_json::Value> = if let Some(graph) = doc.get("@graph") {
if let Some(arr) = graph.as_array() {
arr.iter().collect()
} else {
vec![graph]
}
} else if doc.get("@id").is_some() || doc.get("@type").is_some() {
vec![doc]
} else {
vec![]
};
let mut nodes = Vec::new();
for entry in entries {
if let Some(node) = expand_node(entry, &resolver) {
nodes.push(node);
}
}
Ok(nodes)
}
fn expand_node(value: &serde_json::Value, resolver: &ContextResolver) -> Option<ExpandedNode> {
let obj = value.as_object()?;
let id = obj.get("@id").and_then(|v| v.as_str()).map(|s| resolver.expand_term(s));
let types: Vec<String> = match obj.get("@type") {
Some(serde_json::Value::String(t)) => vec![resolver.expand_term(t)],
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str())
.map(|s| resolver.expand_term(s))
.collect(),
_ => vec![],
};
let mut properties = HashMap::new();
for (key, val) in obj {
if key.starts_with('@') {
continue;
}
let expanded_key = resolver.expand_term(key);
let values = normalize_to_array(val);
properties.insert(expanded_key, values);
}
Some(ExpandedNode {
id,
types,
properties,
})
}
fn normalize_to_array(value: &serde_json::Value) -> Vec<serde_json::Value> {
match value {
serde_json::Value::Array(arr) => arr.clone(),
other => vec![other.clone()],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cjs_context() -> HashMap<String, serde_json::Value> {
let ctx_json: serde_json::Value = serde_json::json!({
"@context": {
"oo": "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#",
"Module": { "@id": "oo:Module" },
"Class": { "@id": "oo:Class" },
"AbstractClass": { "@id": "oo:AbstractClass" },
"components": { "@id": "oo:component" },
"parameters": { "@id": "oo:parameter" },
"extends": { "@id": "rdfs:subClassOf", "@type": "@id" },
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"doap": "http://usefulinc.com/ns/doap#",
"requireName": { "@id": "doap:name" },
"requireElement": { "@id": "oo:componentPath" },
"import": { "@id": "rdfs:seeAlso", "@type": "@id" }
}
});
let mut known = HashMap::new();
known.insert(
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld".to_string(),
ctx_json,
);
known
}
#[test]
fn test_expand_term_direct_mapping() {
let known = make_cjs_context();
let ctx_ref = serde_json::json!([
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"
]);
let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
assert_eq!(
resolver.expand_term("Class"),
"https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class"
);
assert_eq!(
resolver.expand_term("Module"),
"https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Module"
);
}
#[test]
fn test_expand_term_prefix() {
let known = make_cjs_context();
let ctx_ref = serde_json::json!([
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"
]);
let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
assert_eq!(
resolver.expand_term("oo:Class"),
"https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class"
);
}
#[test]
fn test_expand_term_with_local_context() {
let known = make_cjs_context();
let ctx_ref = serde_json::json!([
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld",
{
"ex": "http://example.org/",
"hello": "http://example.org/hello/"
}
]);
let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
assert_eq!(resolver.expand_term("ex:MyModule"), "http://example.org/MyModule");
assert_eq!(resolver.expand_term("hello:say"), "http://example.org/hello/say");
}
#[test]
fn test_extract_graph_nodes() {
let known = make_cjs_context();
let doc = serde_json::json!({
"@context": [
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld",
{ "ex": "http://example.org/", "hello": "http://example.org/hello/" }
],
"@graph": [
{
"@id": "ex:HelloWorldModule",
"@type": "Module",
"requireName": "helloworld",
"components": [
{
"@id": "ex:HelloWorldModule#SayHelloComponent",
"@type": "Class",
"requireElement": "Hello",
"parameters": [
{ "@id": "hello:say" },
{ "@id": "hello:hello" }
]
}
]
}
]
});
let nodes = extract_graph_nodes(&doc, &known).unwrap();
assert_eq!(nodes.len(), 1);
let module = &nodes[0];
assert_eq!(module.id.as_deref(), Some("http://example.org/HelloWorldModule"));
assert_eq!(
module.types,
vec!["https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Module"]
);
}
#[test]
fn test_vocab_expansion() {
let known = HashMap::new();
let ctx = serde_json::json!({
"@vocab": "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#",
"ex": "http://example.org/"
});
let resolver = ContextResolver::from_context_value(&ctx, &known).unwrap();
assert_eq!(
resolver.expand_term("SomeUnknownTerm"),
"https://linkedsoftwaredependencies.org/vocabularies/object-oriented#SomeUnknownTerm"
);
}
#[test]
fn test_compact_iri_term() {
let known = make_cjs_context();
let ctx_ref = serde_json::json!([
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"
]);
let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
assert_eq!(
resolver.compact_iri(
"https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class"
),
"Class"
);
assert_eq!(
resolver.compact_iri(
"http://www.w3.org/2000/01/rdf-schema#label"
),
"rdfs:label"
);
}
#[test]
fn test_compact_iri_prefix() {
let known = make_cjs_context();
let ctx_ref = serde_json::json!([
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld",
{ "ex": "http://example.org/" }
]);
let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
assert_eq!(
resolver.compact_iri("http://example.org/Foo"),
"ex:Foo"
);
}
#[test]
fn test_compact_iri_unknown() {
let known = make_cjs_context();
let ctx_ref = serde_json::json!([
"https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"
]);
let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
assert_eq!(
resolver.compact_iri("https://unknown.example.org/Something"),
"https://unknown.example.org/Something"
);
}
#[test]
fn test_iri_compactor_roundtrip() {
let known = make_cjs_context();
let compactor = IriCompactor::from_contexts(&known).unwrap();
let full = "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class";
let compact = compactor.compact(full);
assert_eq!(compact, "Class");
assert_eq!(compactor.expand(&compact), full);
let full2 = "http://www.w3.org/2000/01/rdf-schema#subClassOf";
let compact2 = compactor.compact(full2);
assert_eq!(compact2, "extends");
assert_eq!(compactor.expand(&compact2), full2);
}
#[test]
fn test_iri_compactor_expand() {
let known = make_cjs_context();
let compactor = IriCompactor::from_contexts(&known).unwrap();
assert_eq!(
compactor.expand("oo:Module"),
"https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Module"
);
}
}