use std::io::{self, Write};
pub(crate) const RDF_TYPE: &str = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>";
pub(crate) const WELL_KNOWN: &[(&str, &str)] = &[
("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"),
("rdfs", "http://www.w3.org/2000/01/rdf-schema#"),
("owl", "http://www.w3.org/2002/07/owl#"),
("xsd", "http://www.w3.org/2001/XMLSchema#"),
("sh", "http://www.w3.org/ns/shacl#"),
("dc", "http://purl.org/dc/elements/1.1/"),
("dct", "http://purl.org/dc/terms/"),
("dcat", "http://www.w3.org/ns/dcat#"),
("void", "http://rdfs.org/ns/void#"),
("skos", "http://www.w3.org/2004/02/skos/core#"),
("prov", "http://www.w3.org/ns/prov#"),
("foaf", "http://xmlns.com/foaf/0.1/"),
("vcard", "http://www.w3.org/2006/vcard/ns#"),
("org", "http://www.w3.org/ns/org#"),
("adms", "http://www.w3.org/ns/adms#"),
("time", "http://www.w3.org/2006/time#"),
("geo", "http://www.w3.org/2003/01/geo/wgs84_pos#"),
("oa", "http://www.w3.org/ns/oa#"),
("schema", "http://schema.org/"),
("schemas", "https://schema.org/"),
("bibo", "http://purl.org/ontology/bibo/"),
("cito", "http://purl.org/spar/cito/"),
("fabio", "http://purl.org/spar/fabio/"),
("datacite", "http://purl.org/spar/datacite/"),
("pro", "http://purl.org/spar/pro/"),
("frapo", "http://purl.org/cerif/frapo/"),
("doi", "https://doi.org/"),
("orcid", "https://orcid.org/"),
("ror", "https://ror.org/"),
("openalex", "https://openalex.org/"),
("wd", "http://www.wikidata.org/entity/"),
("wdt", "http://www.wikidata.org/prop/direct/"),
("wds", "http://www.wikidata.org/entity/statement/"),
("wdref", "http://www.wikidata.org/reference/"),
("wdv", "http://www.wikidata.org/value/"),
("p", "http://www.wikidata.org/prop/"),
("ps", "http://www.wikidata.org/prop/statement/"),
("pq", "http://www.wikidata.org/prop/qualifier/"),
("wikibase", "http://wikiba.se/ontology#"),
];
fn is_pn_chars_base(c: char) -> bool {
matches!(c,
'A'..='Z' | 'a'..='z'
| '\u{00C0}'..='\u{00D6}' | '\u{00D8}'..='\u{00F6}' | '\u{00F8}'..='\u{02FF}'
| '\u{0370}'..='\u{037D}' | '\u{037F}'..='\u{1FFF}'
| '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}'
| '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}'
| '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}'
| '\u{10000}'..='\u{EFFFF}')
}
fn is_pn_chars_u(c: char) -> bool {
is_pn_chars_base(c) || c == '_'
}
fn is_pn_chars(c: char) -> bool {
is_pn_chars_u(c)
|| c == '-'
|| c.is_ascii_digit()
|| c == '\u{00B7}'
|| matches!(c, '\u{0300}'..='\u{036F}' | '\u{203F}'..='\u{2040}')
}
pub(crate) fn is_pn_local(s: &str) -> bool {
if s.is_empty() {
return false;
}
let b = s.as_bytes();
let mut i = 0usize;
let mut first = true;
let mut final_ok = false;
while i < b.len() {
if b[i] == b'%' {
if i + 2 >= b.len() || !b[i + 1].is_ascii_hexdigit() || !b[i + 2].is_ascii_hexdigit() {
return false;
}
i += 3;
first = false;
final_ok = true;
continue;
}
let c = match s[i..].chars().next() {
Some(c) => c,
None => return false,
};
let ok = if first {
is_pn_chars_u(c) || c == ':' || c.is_ascii_digit()
} else {
is_pn_chars(c) || c == '.' || c == ':'
};
if !ok {
return false;
}
final_ok = is_pn_chars(c) || c == ':';
i += c.len_utf8();
first = false;
}
final_ok
}
pub(crate) fn is_pn_prefix(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if is_pn_chars_base(c) => {}
_ => return false,
}
let mut last = 'a';
for c in chars {
if !(is_pn_chars(c) || c == '.') {
return false;
}
last = c;
}
last != '.'
}
pub(crate) fn literal_end(token: &str) -> Option<usize> {
let b = token.as_bytes();
if b.first() != Some(&b'"') {
return None;
}
let mut i = 1;
while i < b.len() {
match b[i] {
b'\\' => i += 2,
b'"' => return Some(i),
_ => i += 1,
}
}
None
}
struct Entry {
prefix: String,
ns: String,
declared: bool,
}
pub(crate) struct PrefixTable {
entries: Vec<Entry>,
}
const SHARE_DIVISOR: u64 = 1000;
pub(crate) const MAX_SAMPLED_NAMESPACES: usize = 4096;
#[derive(Default)]
pub(crate) struct NamespaceSample {
counts: std::collections::HashMap<String, u64>,
pub(crate) terms: u64,
}
impl NamespaceSample {
pub(crate) fn observe(&mut self, token: &str) {
self.terms += 1;
if let Some(iri) = token
.strip_prefix('<')
.and_then(|rest| rest.strip_suffix('>'))
{
self.observe_iri(iri);
return;
}
if let Some(end) = literal_end(token) {
if let Some(dt) = token[end + 1..]
.strip_prefix("^^<")
.and_then(|rest| rest.strip_suffix('>'))
{
self.observe_iri(dt);
}
}
}
fn observe_iri(&mut self, iri: &str) {
let Some(split) = namespace_split(iri) else {
return;
};
if !is_pn_local(&iri[split..]) {
return;
}
let ns = &iri[..split];
if let Some(c) = self.counts.get_mut(ns) {
*c += 1;
} else if self.counts.len() < MAX_SAMPLED_NAMESPACES {
self.counts.insert(ns.to_string(), 1);
}
}
}
fn namespace_split(iri: &str) -> Option<usize> {
let cut = iri.rfind(['#', '/'])? + 1;
if cut >= iri.len() {
return None;
}
if cut < 8 {
return None;
}
Some(cut)
}
impl PrefixTable {
pub(crate) fn empty() -> Self {
Self {
entries: Vec::new(),
}
}
pub(crate) fn from_sample(sample: &NamespaceSample, max_auto: usize, min_count: u64) -> Self {
let min_count = min_count.max(sample.terms / SHARE_DIVISOR);
let mut entries: Vec<Entry> = Vec::new();
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
for (prefix, ns) in WELL_KNOWN {
if sample.counts.contains_key(*ns) {
taken.insert((*prefix).to_string());
entries.push(Entry {
prefix: (*prefix).to_string(),
ns: (*ns).to_string(),
declared: false,
});
}
}
let known: std::collections::HashSet<&str> = WELL_KNOWN.iter().map(|(_, ns)| *ns).collect();
let mut candidates: Vec<(&String, &u64)> = sample
.counts
.iter()
.filter(|(ns, c)| **c >= min_count && !known.contains(ns.as_str()))
.collect();
candidates.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
for (ns, _) in candidates.into_iter().take(max_auto) {
let name = unique_prefix_name(ns, &mut taken);
entries.push(Entry {
prefix: name,
ns: ns.clone(),
declared: false,
});
}
entries.sort_by(|a, b| a.ns.cmp(&b.ns));
Self { entries }
}
pub(crate) fn bindings(&self) -> impl Iterator<Item = (&str, &str)> {
self.entries
.iter()
.map(|e| (e.prefix.as_str(), e.ns.as_str()))
}
pub(crate) fn len(&self) -> usize {
self.entries.len()
}
fn lookup(&self, iri: &str) -> Option<usize> {
let at = self.entries.partition_point(|e| e.ns.as_str() <= iri);
(0..at)
.rev()
.find(|&i| iri.starts_with(&self.entries[i].ns))
}
}
fn unique_prefix_name(ns: &str, taken: &mut std::collections::HashSet<String>) -> String {
let base = derive_prefix_name(ns);
if taken.insert(base.clone()) {
return base;
}
for n in 2u32.. {
let candidate = format!("{base}{n}");
if taken.insert(candidate.clone()) {
return candidate;
}
}
unreachable!("the integers are not exhausted")
}
fn derive_prefix_name(ns: &str) -> String {
let trimmed = ns.trim_end_matches(['#', '/']);
let seg = trimmed
.rsplit(['#', '/'])
.find(|s| !s.is_empty())
.unwrap_or("");
let mut out = String::new();
for c in seg.chars() {
let c = c.to_ascii_lowercase();
if out.is_empty() {
if is_pn_chars_base(c) {
out.push(c);
}
} else if is_pn_chars(c) {
out.push(c);
}
}
while out.ends_with('.') {
out.pop();
}
out.truncate(24);
if out.is_empty() || !is_pn_prefix(&out) {
"ns".to_string()
} else {
out
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Grouping {
BySubject,
PerStatement,
}
pub(crate) struct TurtleWriter<W: Write> {
out: W,
table: PrefixTable,
grouping: Grouping,
subject: String,
predicate: String,
open: bool,
graph: Option<String>,
sb: String,
pb: String,
ob: String,
pending: Vec<usize>,
}
impl<W: Write> TurtleWriter<W> {
pub(crate) fn new(out: W, table: PrefixTable, grouping: Grouping) -> Self {
Self {
out,
table,
grouping,
subject: String::new(),
predicate: String::new(),
open: false,
graph: None,
sb: String::new(),
pb: String::new(),
ob: String::new(),
pending: Vec::new(),
}
}
pub(crate) fn table(&self) -> &PrefixTable {
&self.table
}
pub(crate) fn declare_all(&mut self) -> io::Result<()> {
for i in 0..self.table.entries.len() {
self.declare(i)?;
}
Ok(())
}
fn declare(&mut self, i: usize) -> io::Result<()> {
if self.table.entries[i].declared {
return Ok(());
}
let e = &self.table.entries[i];
writeln!(self.out, "@prefix {}: <{}> .", e.prefix, e.ns)?;
self.table.entries[i].declared = true;
Ok(())
}
pub(crate) fn begin_graph(&mut self, label: &str) -> io::Result<()> {
self.close_statement()?;
writeln!(self.out, "GRAPH {label} {{")?;
self.graph = Some(label.to_string());
Ok(())
}
pub(crate) fn end_graph(&mut self) -> io::Result<()> {
self.close_statement()?;
if self.graph.take().is_some() {
writeln!(self.out, "}}")?;
}
Ok(())
}
fn close_statement(&mut self) -> io::Result<()> {
if self.open {
self.out.write_all(b" .\n")?;
self.open = false;
self.subject.clear();
self.predicate.clear();
}
Ok(())
}
pub(crate) fn write_triple(&mut self, s: &str, p: &str, o: &str) -> io::Result<()> {
self.pending.clear();
abbreviate(&self.table, &mut self.pending, &mut self.sb, s);
if p == RDF_TYPE {
self.pb.clear();
self.pb.push('a');
} else {
abbreviate(&self.table, &mut self.pending, &mut self.pb, p);
}
abbreviate(&self.table, &mut self.pending, &mut self.ob, o);
if !self.pending.is_empty() {
self.flush_declarations()?;
}
match self.grouping {
Grouping::PerStatement => {
self.out.write_all(self.sb.as_bytes())?;
self.out.write_all(b" ")?;
self.out.write_all(self.pb.as_bytes())?;
self.out.write_all(b" ")?;
self.out.write_all(self.ob.as_bytes())?;
self.out.write_all(b" .\n")?;
}
Grouping::BySubject => {
if !self.open || self.subject != self.sb {
self.close_statement()?;
self.out.write_all(self.sb.as_bytes())?;
self.out.write_all(b"\n ")?;
self.out.write_all(self.pb.as_bytes())?;
self.out.write_all(b" ")?;
self.out.write_all(self.ob.as_bytes())?;
self.subject.clear();
self.subject.push_str(&self.sb);
self.predicate.clear();
self.predicate.push_str(&self.pb);
self.open = true;
} else if self.predicate != self.pb {
self.out.write_all(b" ;\n ")?;
self.out.write_all(self.pb.as_bytes())?;
self.out.write_all(b" ")?;
self.out.write_all(self.ob.as_bytes())?;
self.predicate.clear();
self.predicate.push_str(&self.pb);
} else {
self.out.write_all(b" ,\n ")?;
self.out.write_all(self.ob.as_bytes())?;
}
}
}
Ok(())
}
fn flush_declarations(&mut self) -> io::Result<()> {
self.close_statement()?;
let reopen = self.graph.clone();
if reopen.is_some() {
self.out.write_all(b"}\n")?;
}
let pending = std::mem::take(&mut self.pending);
for i in pending {
self.declare(i)?;
}
if let Some(g) = reopen {
writeln!(self.out, "GRAPH {g} {{")?;
}
Ok(())
}
pub(crate) fn finish(mut self) -> io::Result<W> {
self.end_graph()?;
self.close_statement()?;
Ok(self.out)
}
}
fn abbreviate(table: &PrefixTable, pending: &mut Vec<usize>, buf: &mut String, token: &str) {
buf.clear();
if rete_core::terms::is_quoted_triple(token) {
buf.push_str(token);
return;
}
if let Some(iri) = token
.strip_prefix('<')
.and_then(|rest| rest.strip_suffix('>'))
{
if !push_qname(table, pending, buf, iri) {
buf.push_str(token);
}
return;
}
if let Some(end) = literal_end(token) {
let rest = &token[end + 1..];
if let Some(dt) = rest
.strip_prefix("^^<")
.and_then(|rest| rest.strip_suffix('>'))
{
buf.push_str(&token[..=end]);
buf.push_str("^^");
if !push_qname(table, pending, buf, dt) {
buf.push('<');
buf.push_str(dt);
buf.push('>');
}
return;
}
}
buf.push_str(token);
}
fn push_qname(table: &PrefixTable, pending: &mut Vec<usize>, buf: &mut String, iri: &str) -> bool {
let Some(i) = table.lookup(iri) else {
return false;
};
let local = &iri[table.entries[i].ns.len()..];
if !is_pn_local(local) {
return false;
}
if !table.entries[i].declared && !pending.contains(&i) {
pending.push(i);
}
buf.push_str(&table.entries[i].prefix);
buf.push(':');
buf.push_str(local);
true
}
#[cfg(test)]
mod tests {
use super::*;
fn table_of(pairs: &[(&str, &str)]) -> PrefixTable {
let mut entries: Vec<Entry> = pairs
.iter()
.map(|(p, n)| Entry {
prefix: (*p).to_string(),
ns: (*n).to_string(),
declared: false,
})
.collect();
entries.sort_by(|a, b| a.ns.cmp(&b.ns));
PrefixTable { entries }
}
fn render(table: PrefixTable, grouping: Grouping, rows: &[(&str, &str, &str)]) -> String {
let mut w = TurtleWriter::new(Vec::new(), table, grouping);
for (s, p, o) in rows {
w.write_triple(s, p, o).unwrap();
}
String::from_utf8(w.finish().unwrap()).unwrap()
}
#[test]
fn pn_local_accepts_the_ordinary_shapes() {
for s in [
"type",
"Person",
"_under",
"a1",
"1starts_with_a_digit", "has.dot.inside",
"has-dash",
"co:lon",
"Q42",
"caf\u{e9}", "pct%20escape", "%41", "ends_with_colon:",
] {
assert!(is_pn_local(s), "should accept {s:?}");
}
}
#[test]
fn pn_local_rejects_what_would_not_parse() {
for s in [
"", ".leading.dot", "-leading-dash", "trailing.", "has space",
"has\"quote",
"has<angle>",
"has,comma", "has;semi",
"has(paren)",
"pct%2", "pct%zz", "pct%", "a/slash",
"a\\backslash",
] {
assert!(!is_pn_local(s), "should reject {s:?}");
}
}
#[test]
fn pn_prefix_is_stricter_than_pn_local_at_both_ends() {
assert!(is_pn_prefix("rdf"));
assert!(is_pn_prefix("s66"));
assert!(is_pn_prefix("a.b"));
assert!(!is_pn_prefix(""));
assert!(
!is_pn_prefix("_leading"),
"PN_PREFIX may not start with '_'"
);
assert!(!is_pn_prefix("1abc"), "nor with a digit");
assert!(!is_pn_prefix("abc."), "nor end with '.'");
assert!(!is_pn_prefix("a:b"), "a colon is not a prefix character");
}
#[test]
fn literal_end_walks_escapes_rather_than_stopping_at_the_first_quote() {
assert_eq!(literal_end(r#""plain""#), Some(6));
let t = r#""he said \"no\"""#;
assert_eq!(literal_end(t), Some(t.len() - 1));
let t = r#""ends with a backslash \\""#;
assert_eq!(literal_end(t), Some(t.len() - 1));
assert_eq!(literal_end("<http://ex/x>"), None);
assert_eq!(literal_end("_:b0"), None);
assert_eq!(literal_end(r#""no end"#), None);
}
#[test]
fn datatype_is_abbreviated_without_touching_the_lexical_form() {
let table = table_of(&[("xsd", "http://www.w3.org/2001/XMLSchema#")]);
let out = render(
table,
Grouping::PerStatement,
&[(
"<http://ex/s>",
"<http://ex/p>",
r#""he said \"no\""^^<http://www.w3.org/2001/XMLSchema#string>"#,
)],
);
assert!(
out.contains(r#""he said \"no\""^^xsd:string"#),
"got: {out}"
);
}
#[test]
fn a_literal_containing_the_datatype_marker_is_not_misparsed() {
let table = table_of(&[("xsd", "http://www.w3.org/2001/XMLSchema#")]);
let token = r#""a \"^^<http://evil>\" b"@en"#;
let out = render(
table,
Grouping::PerStatement,
&[("<http://ex/s>", "<http://ex/p>", token)],
);
assert!(out.contains(token), "literal must pass through: {out}");
assert!(!out.contains("^^xsd:"), "no datatype here: {out}");
}
#[test]
fn an_unsafe_local_part_falls_back_to_the_full_iri() {
let table = table_of(&[("ex", "http://ex/")]);
let out = render(
table,
Grouping::PerStatement,
&[
("<http://ex/ok>", "<http://ex/p>", "<http://ex/has space>"),
("<http://ex/a>", "<http://ex/b>", "<http://ex/trailing.>"),
],
);
assert!(out.contains("ex:ok"), "got: {out}");
assert!(out.contains("<http://ex/has space>"), "got: {out}");
assert!(out.contains("<http://ex/trailing.>"), "got: {out}");
}
#[test]
fn the_longest_matching_namespace_wins() {
let table = table_of(&[("short", "http://ex/"), ("long", "http://ex/deep/")]);
let out = render(
table,
Grouping::PerStatement,
&[("<http://ex/deep/x>", "<http://ex/p>", "<http://ex/y>")],
);
assert!(out.contains("long:x"), "got: {out}");
assert!(out.contains("short:y"), "got: {out}");
}
#[test]
fn rdf_type_becomes_a_and_needs_no_prefix() {
let out = render(
PrefixTable::empty(),
Grouping::PerStatement,
&[("<http://ex/s>", RDF_TYPE, "<http://ex/T>")],
);
assert_eq!(out, "<http://ex/s> a <http://ex/T> .\n");
}
#[test]
fn an_rdf_star_quoted_triple_passes_through() {
let table = table_of(&[("ex", "http://ex/")]);
let token = "<<<http://ex/s> <http://ex/p> <http://ex/o>>>";
let out = render(
table,
Grouping::PerStatement,
&[(token, "<http://ex/said>", token)],
);
assert_eq!(
out,
format!(
"@prefix ex: <http://ex/> .
{token} ex:said {token} .
"
)
);
}
#[test]
fn a_blank_node_passes_through() {
let out = render(
PrefixTable::empty(),
Grouping::PerStatement,
&[("_:b0", "<http://ex/p>", "_:b1")],
);
assert_eq!(out, "_:b0 <http://ex/p> _:b1 .\n");
}
#[test]
fn only_used_prefixes_are_declared() {
let table = table_of(&[("used", "http://used/"), ("unused", "http://unused/")]);
let out = render(
table,
Grouping::PerStatement,
&[("<http://used/s>", "<http://used/p>", "<http://used/o>")],
);
assert!(out.contains("@prefix used: <http://used/> ."), "got: {out}");
assert!(!out.contains("unused"), "got: {out}");
}
#[test]
fn a_declaration_precedes_its_first_use() {
let table = table_of(&[("ex", "http://ex/")]);
let out = render(
table,
Grouping::PerStatement,
&[
("<http://other/s>", "<http://other/p>", "<http://other/o>"),
("<http://ex/s>", "<http://ex/p>", "<http://ex/o>"),
],
);
let decl = out.find("@prefix ex:").expect("declared");
let use_ = out.find("ex:s").expect("used");
assert!(decl < use_, "declaration must come first:\n{out}");
}
#[test]
fn subject_grouping_shares_subject_and_predicate() {
let out = render(
PrefixTable::empty(),
Grouping::BySubject,
&[
("<http://ex/a>", RDF_TYPE, "<http://ex/T>"),
("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
("<http://ex/a>", "<http://ex/p>", "<http://ex/2>"),
("<http://ex/b>", "<http://ex/p>", "<http://ex/3>"),
],
);
assert_eq!(
out,
"<http://ex/a>\n a <http://ex/T> ;\n <http://ex/p> <http://ex/1> ,\n <http://ex/2> .\n\
<http://ex/b>\n <http://ex/p> <http://ex/3> .\n"
);
}
#[test]
fn per_statement_grouping_repeats_the_subject() {
let out = render(
PrefixTable::empty(),
Grouping::PerStatement,
&[
("<http://ex/a>", "<http://ex/p>", "<http://ex/1>"),
("<http://ex/a>", "<http://ex/p>", "<http://ex/2>"),
],
);
assert_eq!(
out,
"<http://ex/a> <http://ex/p> <http://ex/1> .\n\
<http://ex/a> <http://ex/p> <http://ex/2> .\n"
);
}
#[test]
fn trig_wraps_statements_in_a_graph_block() {
let mut w = TurtleWriter::new(Vec::new(), PrefixTable::empty(), Grouping::BySubject);
w.write_triple("<http://ex/d>", "<http://ex/p>", "<http://ex/o>")
.unwrap();
w.begin_graph("<http://g/1>").unwrap();
w.write_triple("<http://ex/a>", "<http://ex/p>", "<http://ex/o>")
.unwrap();
w.end_graph().unwrap();
let out = String::from_utf8(w.finish().unwrap()).unwrap();
assert_eq!(
out,
"<http://ex/d>\n <http://ex/p> <http://ex/o> .\n\
GRAPH <http://g/1> {\n\
<http://ex/a>\n <http://ex/p> <http://ex/o> .\n}\n"
);
}
#[test]
fn a_prefix_first_needed_inside_a_graph_block_closes_and_reopens_it() {
let table = table_of(&[("late", "http://late/")]);
let mut w = TurtleWriter::new(Vec::new(), table, Grouping::BySubject);
w.begin_graph("<http://g/1>").unwrap();
w.write_triple("<http://early/s>", "<http://early/p>", "<http://early/o>")
.unwrap();
w.write_triple("<http://late/s>", "<http://late/p>", "<http://late/o>")
.unwrap();
w.end_graph().unwrap();
let out = String::from_utf8(w.finish().unwrap()).unwrap();
assert_eq!(
out,
"GRAPH <http://g/1> {\n\
<http://early/s>\n <http://early/p> <http://early/o> .\n\
}\n\
@prefix late: <http://late/> .\n\
GRAPH <http://g/1> {\n\
late:s\n late:p late:o .\n\
}\n"
);
}
#[test]
fn well_known_namespaces_are_unique() {
let mut seen = std::collections::HashSet::new();
for (p, ns) in WELL_KNOWN {
assert!(seen.insert(*ns), "duplicate namespace {ns}");
assert!(is_pn_prefix(p), "{p} is not a legal PN_PREFIX");
}
}
#[test]
fn the_sample_prefers_frequent_namespaces_and_names_them_readably() {
let mut sample = NamespaceSample::default();
for i in 0..100 {
sample.observe(&format!("<http://data.europa.eu/s66/resource/grants/{i}>"));
}
for i in 0..10 {
sample.observe(&format!("<http://www.w3.org/2000/01/rdf-schema#p{i}>"));
}
sample.observe("<http://rare.example/x>");
let table = PrefixTable::from_sample(&sample, 8, 5);
let got: Vec<(&str, &str)> = table.bindings().collect();
assert!(
got.contains(&("rdfs", "http://www.w3.org/2000/01/rdf-schema#")),
"well-known keeps its conventional name: {got:?}"
);
assert!(
got.contains(&("grants", "http://data.europa.eu/s66/resource/grants/")),
"the dataset namespace is named from its last segment: {got:?}"
);
assert!(
!got.iter().any(|(_, ns)| *ns == "http://rare.example/"),
"below the floor: {got:?}"
);
}
#[test]
fn a_namespace_local_to_a_few_subjects_does_not_earn_a_line() {
let mut sample = NamespaceSample::default();
for doc in 0..20 {
for author in 0..20 {
sample.observe(&format!(
"<https://doi.org/10.1234/paper{doc}/creator/a{author}>"
));
}
}
for i in 0..100_000 {
sample.observe(&format!("<https://w3id.org/rete/datacite#p{i}>"));
}
let table = PrefixTable::from_sample(&sample, 32, 16);
let got: Vec<(&str, &str)> = table.bindings().collect();
assert_eq!(
got,
vec![("datacite", "https://w3id.org/rete/datacite#")],
"only the file-wide namespace should earn a line"
);
}
#[test]
fn generated_prefix_names_never_collide() {
let mut sample = NamespaceSample::default();
for i in 0..50 {
sample.observe(&format!("<http://a.example/v1/authors/x{i}>"));
sample.observe(&format!("<http://b.example/v2/authors/x{i}>"));
}
let table = PrefixTable::from_sample(&sample, 8, 5);
let names: Vec<&str> = table.bindings().map(|(p, _)| p).collect();
let unique: std::collections::HashSet<&&str> = names.iter().collect();
assert_eq!(names.len(), unique.len(), "collision in {names:?}");
assert!(names.contains(&"authors"), "{names:?}");
assert!(names.contains(&"authors2"), "{names:?}");
}
#[test]
fn a_namespace_whose_locals_never_abbreviate_is_not_sampled() {
let mut sample = NamespaceSample::default();
for i in 0..50 {
sample.observe(&format!("<http://bad.example/ns/has space {i}>"));
}
let table = PrefixTable::from_sample(&sample, 8, 5);
assert_eq!(table.len(), 0, "{:?}", table.bindings().collect::<Vec<_>>());
}
#[test]
fn the_sample_is_capped() {
let mut sample = NamespaceSample::default();
for i in 0..(MAX_SAMPLED_NAMESPACES + 500) {
sample.observe(&format!("<http://ex{i}.example/ns/local>"));
}
assert_eq!(sample.counts.len(), MAX_SAMPLED_NAMESPACES);
}
#[test]
fn a_typed_literal_contributes_its_datatype_namespace() {
let mut sample = NamespaceSample::default();
for i in 0..20 {
sample.observe(&format!(
r#""{i}"^^<http://www.w3.org/2001/XMLSchema#integer>"#
));
}
let table = PrefixTable::from_sample(&sample, 8, 5);
let got: Vec<(&str, &str)> = table.bindings().collect();
assert!(
got.contains(&("xsd", "http://www.w3.org/2001/XMLSchema#")),
"{got:?}"
);
}
#[test]
fn a_plain_or_lang_tagged_literal_contributes_nothing() {
let mut sample = NamespaceSample::default();
for _ in 0..50 {
sample.observe(r#""just text""#);
sample.observe(r#""texte"@fr"#);
}
assert_eq!(sample.counts.len(), 0);
assert_eq!(sample.terms, 100);
}
#[test]
fn a_bare_scheme_is_not_a_namespace() {
assert_eq!(namespace_split("http://ex"), None);
assert_eq!(namespace_split("http://"), None);
assert_eq!(namespace_split("urn:x"), None);
assert_eq!(namespace_split("http://ex/"), None, "empty local part");
assert_eq!(namespace_split("http://ex/a"), Some(10));
}
}