lattix 0.8.0

Knowledge graph data structures
//! Internal helpers for RDF term rendering.

use std::fmt::Write as _;

use crate::{EntityId, RelationType, Triple};

const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
const LOCAL_IRI_PREFIX: &str = "urn:lattix:local:";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LiteralParts {
    pub(crate) lexical: String,
    pub(crate) language: Option<String>,
    pub(crate) datatype: Option<String>,
}

pub(crate) fn subject_to_string(s: &oxrdf::NamedOrBlankNode) -> String {
    match s {
        oxrdf::NamedOrBlankNode::NamedNode(n) => iri_to_string(n.as_str()),
        oxrdf::NamedOrBlankNode::BlankNode(b) => format!("_:{}", b.as_str()),
    }
}

#[cfg(feature = "formats")]
pub(crate) fn graph_name_to_string(g: &oxrdf::GraphName) -> Option<String> {
    match g {
        oxrdf::GraphName::NamedNode(n) => Some(iri_to_string(n.as_str())),
        oxrdf::GraphName::BlankNode(b) => Some(format!("_:{}", b.as_str())),
        oxrdf::GraphName::DefaultGraph => None,
    }
}

pub(crate) fn term_to_string(t: &oxrdf::Term) -> String {
    match t {
        oxrdf::Term::NamedNode(n) => iri_to_string(n.as_str()),
        oxrdf::Term::BlankNode(b) => format!("_:{}", b.as_str()),
        oxrdf::Term::Literal(l) => {
            if let Some(lang) = l.language() {
                render_literal(l.value(), Some(lang), None)
            } else {
                let datatype = l.datatype().as_str();
                if datatype == XSD_STRING {
                    render_literal(l.value(), None, None)
                } else {
                    render_literal(l.value(), None, Some(datatype))
                }
            }
        }
    }
}

pub(crate) fn parse_ntriples_line(line: &str) -> crate::Result<Triple> {
    let line = line.trim();
    if line.is_empty() || line.starts_with('#') {
        return Err(crate::Error::ParseTriple("empty or comment line".into()));
    }

    let mut parsed = None;
    for result in oxttl::NTriplesParser::new().for_reader(std::io::Cursor::new(line.as_bytes())) {
        let triple =
            result.map_err(|e| crate::Error::InvalidNTriples(format!("{}: {}", line, e)))?;
        if parsed.is_some() {
            return Err(crate::Error::InvalidNTriples(format!(
                "expected one triple: {}",
                line
            )));
        }
        parsed = Some(Triple::new(
            subject_to_string(&triple.subject),
            iri_to_string(triple.predicate.as_str()),
            term_to_string(&triple.object),
        ));
    }

    parsed.ok_or_else(|| crate::Error::InvalidNTriples(line.to_string()))
}

pub(crate) fn render_iri_or_blank(s: &str) -> String {
    if s.starts_with("_:") || (s.starts_with('<') && s.ends_with('>')) {
        s.to_string()
    } else {
        format!("<{}>", iri_body_for(s))
    }
}

pub(crate) fn render_object(s: &str) -> String {
    if s.starts_with("_:") {
        s.to_string()
    } else if let Some(literal) = normalize_literal(s) {
        literal
    } else if s.starts_with('<') && s.ends_with('>') {
        s.to_string()
    } else {
        format!("<{}>", iri_body_for(s))
    }
}

pub(crate) fn render_literal(
    lexical: &str,
    language: Option<&str>,
    datatype: Option<&str>,
) -> String {
    let mut rendered = format!("\"{}\"", escape_literal_lexical(lexical));
    if let Some(language) = language {
        rendered.push('@');
        rendered.push_str(language);
    } else if let Some(datatype) = datatype {
        rendered.push_str("^^<");
        rendered.push_str(&iri_body_for(datatype));
        rendered.push('>');
    }
    rendered
}

pub(crate) fn normalize_literal(s: &str) -> Option<String> {
    let parts = parse_literal(s)?;
    Some(render_literal(
        &parts.lexical,
        parts.language.as_deref(),
        parts.datatype.as_deref(),
    ))
}

pub(crate) fn parse_literal(s: &str) -> Option<LiteralParts> {
    let rest = s.strip_prefix('"')?;
    let mut escaped = false;

    for (idx, ch) in rest.char_indices() {
        if escaped {
            escaped = false;
            continue;
        }

        match ch {
            '\\' => escaped = true,
            '"' => {
                let suffix = &rest[idx + ch.len_utf8()..];
                if suffix.is_empty() || suffix.starts_with('@') || suffix.starts_with("^^<") {
                    let lexical = unescape_literal_lexical(&rest[..idx])?;
                    if suffix.is_empty() {
                        return Some(LiteralParts {
                            lexical,
                            language: None,
                            datatype: None,
                        });
                    }
                    if let Some(language) = suffix.strip_prefix('@') {
                        if language.is_empty() {
                            return None;
                        }
                        return Some(LiteralParts {
                            lexical,
                            language: Some(language.to_string()),
                            datatype: None,
                        });
                    }
                    if let Some(datatype) = suffix
                        .strip_prefix("^^<")
                        .and_then(|dt| dt.strip_suffix('>'))
                    {
                        if datatype.is_empty() {
                            return None;
                        }
                        return Some(LiteralParts {
                            lexical,
                            language: None,
                            datatype: Some(iri_to_string(datatype)),
                        });
                    }
                }
            }
            _ => {}
        }
    }

    None
}

pub(crate) fn iri_to_string(iri: &str) -> String {
    decode_local_iri(iri).unwrap_or_else(|| iri.to_string())
}

pub(crate) fn iri_body_for(s: &str) -> String {
    if is_absolute_iri(s) && !s.starts_with(LOCAL_IRI_PREFIX) {
        escape_iri(s)
    } else {
        format!("{LOCAL_IRI_PREFIX}{}", percent_encode(s))
    }
}

fn is_absolute_iri(s: &str) -> bool {
    let Some((first, rest)) = s.split_once(':') else {
        return false;
    };
    let mut chars = first.chars();
    let Some(first_char) = chars.next() else {
        return false;
    };
    first_char.is_ascii_alphabetic()
        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
        && !rest.is_empty()
}

fn decode_local_iri(iri: &str) -> Option<String> {
    let encoded = iri.strip_prefix(LOCAL_IRI_PREFIX)?;
    percent_decode(encoded)
}

fn percent_encode(s: &str) -> String {
    let mut encoded = String::with_capacity(s.len());
    for byte in s.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
            encoded.push(byte as char);
        } else {
            write!(encoded, "%{byte:02X}").expect("write to string");
        }
    }
    encoded
}

fn percent_decode(s: &str) -> Option<String> {
    let bytes = s.as_bytes();
    let mut decoded = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] != b'%' {
            decoded.push(bytes[i]);
            i += 1;
            continue;
        }
        let hi = bytes.get(i + 1).copied()? as char;
        let lo = bytes.get(i + 2).copied()? as char;
        let value = (hi.to_digit(16)? << 4) | lo.to_digit(16)?;
        decoded.push(u8::try_from(value).ok()?);
        i += 3;
    }
    String::from_utf8(decoded).ok()
}

pub(crate) fn escape_literal_lexical(s: &str) -> String {
    let mut escaped = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '"' => escaped.push_str("\\\""),
            '\\' => escaped.push_str("\\\\"),
            '\n' => escaped.push_str("\\n"),
            '\r' => escaped.push_str("\\r"),
            '\t' => escaped.push_str("\\t"),
            _ => escaped.push(ch),
        }
    }
    escaped
}

pub(crate) fn escape_iri(s: &str) -> String {
    let mut escaped = String::with_capacity(s.len());
    for ch in s.chars() {
        if matches!(ch, '<' | '>' | '"' | '{' | '}' | '|' | '^' | '`' | '\\') || ch <= '\u{20}' {
            write_codepoint_escape(&mut escaped, ch);
        } else {
            escaped.push(ch);
        }
    }
    escaped
}

fn unescape_literal_lexical(s: &str) -> Option<String> {
    let mut unescaped = String::with_capacity(s.len());
    let mut chars = s.chars();

    while let Some(ch) = chars.next() {
        if ch != '\\' {
            unescaped.push(ch);
            continue;
        }

        let escaped = chars.next()?;
        match escaped {
            't' => unescaped.push('\t'),
            'b' => unescaped.push('\u{08}'),
            'n' => unescaped.push('\n'),
            'r' => unescaped.push('\r'),
            'f' => unescaped.push('\u{0C}'),
            '"' => unescaped.push('"'),
            '\'' => unescaped.push('\''),
            '\\' => unescaped.push('\\'),
            'u' => unescaped.push(read_hex_escape(&mut chars, 4)?),
            'U' => unescaped.push(read_hex_escape(&mut chars, 8)?),
            _ => return None,
        }
    }

    Some(unescaped)
}

fn read_hex_escape(chars: &mut impl Iterator<Item = char>, digits: usize) -> Option<char> {
    let mut value = 0_u32;
    for _ in 0..digits {
        let digit = chars.next()?.to_digit(16)?;
        value = (value << 4) | digit;
    }
    char::from_u32(value)
}

fn write_codepoint_escape(out: &mut String, ch: char) {
    let value = ch as u32;
    if value <= 0xFFFF {
        write!(out, "\\u{value:04X}").expect("write to string");
    } else {
        write!(out, "\\U{value:08X}").expect("write to string");
    }
}

pub(crate) fn statement_reification_triples(statement_id: &str, core: &Triple) -> [Triple; 3] {
    [
        Triple::new(
            EntityId::from(statement_id.to_string()),
            RelationType::from("rdf:subject"),
            core.subject().clone(),
        ),
        Triple::new(
            EntityId::from(statement_id.to_string()),
            RelationType::from("rdf:predicate"),
            EntityId::from(core.predicate().as_str()),
        ),
        Triple::new(
            EntityId::from(statement_id.to_string()),
            RelationType::from("rdf:object"),
            core.object().clone(),
        ),
    ]
}