use oxigraph::io::RdfFormat;
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Syntax {
Turtle,
NTriples,
NQuads,
TriG,
RdfXml,
}
impl Syntax {
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::Turtle,
Self::NTriples,
Self::NQuads,
Self::TriG,
Self::RdfXml,
]
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Turtle => "turtle",
Self::NTriples => "ntriples",
Self::NQuads => "nquads",
Self::TriG => "trig",
Self::RdfXml => "rdfxml",
}
}
#[must_use]
pub const fn media_type(self) -> &'static str {
match self {
Self::Turtle => "text/turtle",
Self::NTriples => "application/n-triples",
Self::NQuads => "application/n-quads",
Self::TriG => "application/trig",
Self::RdfXml => "application/rdf+xml",
}
}
#[must_use]
pub const fn extension(self) -> &'static str {
match self {
Self::Turtle => "ttl",
Self::NTriples => "nt",
Self::NQuads => "nq",
Self::TriG => "trig",
Self::RdfXml => "rdf",
}
}
#[must_use]
pub const fn supports_datasets(self) -> bool {
matches!(self, Self::NQuads | Self::TriG)
}
#[must_use]
pub const fn can_parse(self) -> bool {
true
}
#[must_use]
pub const fn can_serialize(self) -> bool {
true
}
pub fn from_name(name: &str) -> Result<Self> {
let normalized = normalize_token(name);
match normalized.as_str() {
"turtle" | "ttl" => Ok(Self::Turtle),
"ntriples" | "n-triples" | "nt" => Ok(Self::NTriples),
"nquads" | "n-quads" | "nq" => Ok(Self::NQuads),
"trig" => Ok(Self::TriG),
"rdfxml" | "rdf-xml" | "rdf/xml" | "xml" => Ok(Self::RdfXml),
"n3" | "notation3" => Err(Error::Unsupported(
"syntax 'n3' is not advertised by Oxiland; use turtle where N3-compatible input applies"
.into(),
)),
"jsonld" | "json-ld" | "ld+json" => Err(Error::Unsupported(
"syntax 'jsonld' is deferred; Oxiland does not advertise JSON-LD".into(),
)),
"guess" | "auto" => Err(Error::Unsupported(
"automatic syntax detection by content sniffing is unsupported; select an explicit Syntax"
.into(),
)),
other => Err(Error::Unsupported(format!(
"unknown RDF syntax name '{other}'"
))),
}
}
pub fn from_media_type(media_type: &str) -> Result<Self> {
let (base, params) = split_media_type(media_type);
for param in params {
if let Some((key, value)) = param.split_once('=') {
if normalize_token(key) == "charset" {
let charset = normalize_token(value.trim_matches('"'));
if charset != "utf-8" && charset != "utf8" && charset != "ascii" {
return Err(Error::Unsupported(format!(
"unsupported charset '{value}' in media type '{media_type}'"
)));
}
}
}
}
match base.as_str() {
"text/turtle" | "application/x-turtle" | "application/turtle" => Ok(Self::Turtle),
"application/n-triples" | "application/x-ntriples" => Ok(Self::NTriples),
"application/n-quads" | "text/x-nquads" | "application/x-nquads" => Ok(Self::NQuads),
"application/trig" | "application/x-trig" => Ok(Self::TriG),
"application/rdf+xml" => Ok(Self::RdfXml),
"text/plain" => Err(Error::Unsupported(
"media type 'text/plain' is ambiguous; use application/n-triples or an explicit Syntax"
.into(),
)),
"application/xml" | "text/xml" => Err(Error::Unsupported(
"media type is ambiguous XML; use application/rdf+xml or an explicit Syntax".into(),
)),
"text/n3" | "text/rdf+n3" | "application/n3" => Err(Error::Unsupported(
"media type maps to N3, which is not advertised by Oxiland".into(),
)),
"application/ld+json" | "application/jsonld" | "application/json" => {
Err(Error::Unsupported(
"media type maps to JSON-LD, which is not advertised by Oxiland".into(),
))
}
other => Err(Error::Unsupported(format!(
"unknown RDF media type '{other}'"
))),
}
}
pub fn from_extension(extension: &str) -> Result<Self> {
let normalized = normalize_token(extension.trim_start_matches('.'));
match normalized.as_str() {
"ttl" => Ok(Self::Turtle),
"nt" => Ok(Self::NTriples),
"nq" => Ok(Self::NQuads),
"trig" => Ok(Self::TriG),
"rdf" | "owl" => Ok(Self::RdfXml),
"txt" => Err(Error::Unsupported(
"extension '.txt' is ambiguous; use '.nt' or an explicit Syntax".into(),
)),
"xml" => Err(Error::Unsupported(
"extension '.xml' is ambiguous; use '.rdf' or an explicit Syntax".into(),
)),
"n3" => Err(Error::Unsupported(
"extension '.n3' maps to N3, which is not advertised by Oxiland".into(),
)),
"jsonld" | "json" => Err(Error::Unsupported(
"extension maps to JSON-LD, which is not advertised by Oxiland".into(),
)),
other => Err(Error::Unsupported(format!(
"unknown RDF file extension '{other}'"
))),
}
}
pub(crate) fn to_oxigraph(self) -> RdfFormat {
match self {
Self::Turtle => RdfFormat::Turtle,
Self::NTriples => RdfFormat::NTriples,
Self::NQuads => RdfFormat::NQuads,
Self::TriG => RdfFormat::TriG,
Self::RdfXml => RdfFormat::RdfXml,
}
}
}
fn normalize_token(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn split_media_type(media_type: &str) -> (String, Vec<&str>) {
let mut parts = media_type.split(';');
let base = normalize_token(parts.next().unwrap_or_default());
let params = parts
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
(base, params)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_aliases_resolve_deterministically() {
assert_eq!(Syntax::from_name("Turtle").unwrap(), Syntax::Turtle);
assert_eq!(Syntax::from_name("n-triples").unwrap(), Syntax::NTriples);
assert_eq!(Syntax::from_name("RDF/XML").unwrap(), Syntax::RdfXml);
assert!(matches!(
Syntax::from_name("jsonld"),
Err(Error::Unsupported(_))
));
assert!(matches!(
Syntax::from_name("guess"),
Err(Error::Unsupported(_))
));
}
#[test]
fn media_types_accept_utf8_charset() {
assert_eq!(
Syntax::from_media_type("text/turtle; charset=utf-8").unwrap(),
Syntax::Turtle
);
assert!(matches!(
Syntax::from_media_type("text/turtle; charset=iso-8859-1"),
Err(Error::Unsupported(_))
));
}
#[test]
fn extensions_cover_advertised_syntaxes() {
for syntax in Syntax::all() {
assert_eq!(Syntax::from_extension(syntax.extension()).unwrap(), *syntax);
assert!(syntax.can_parse());
assert!(syntax.can_serialize());
}
}
}