1use std::collections::{BTreeMap, HashMap};
38
39use serde::{Deserialize, Serialize};
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub struct RawTriple {
48 pub subject: String,
50 pub predicate: String,
52 pub object: String,
54}
55
56impl RawTriple {
57 pub fn new(
59 subject: impl Into<String>,
60 predicate: impl Into<String>,
61 object: impl Into<String>,
62 ) -> Self {
63 Self {
64 subject: subject.into(),
65 predicate: predicate.into(),
66 object: object.into(),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PrefixSuggestion {
78 pub prefix: String,
80 pub namespace: String,
82 pub usage_count: usize,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct PrettyPrinterConfig {
93 pub indent: String,
95 pub align_predicates: bool,
97 pub min_prefix_usage: usize,
99 pub sort_subjects: bool,
101 pub use_a_shorthand: bool,
103 pub max_line_width: usize,
105 pub custom_prefixes: HashMap<String, String>,
107}
108
109impl Default for PrettyPrinterConfig {
110 fn default() -> Self {
111 Self {
112 indent: " ".to_string(),
113 align_predicates: true,
114 min_prefix_usage: 1,
115 sort_subjects: true,
116 use_a_shorthand: true,
117 max_line_width: 80,
118 custom_prefixes: HashMap::new(),
119 }
120 }
121}
122
123fn well_known_prefixes() -> Vec<(&'static str, &'static str)> {
128 vec![
129 ("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"),
130 ("rdfs", "http://www.w3.org/2000/01/rdf-schema#"),
131 ("xsd", "http://www.w3.org/2001/XMLSchema#"),
132 ("owl", "http://www.w3.org/2002/07/owl#"),
133 ("foaf", "http://xmlns.com/foaf/0.1/"),
134 ("dc", "http://purl.org/dc/elements/1.1/"),
135 ("dcterms", "http://purl.org/dc/terms/"),
136 ("skos", "http://www.w3.org/2004/02/skos/core#"),
137 ("schema", "http://schema.org/"),
138 ("sh", "http://www.w3.org/ns/shacl#"),
139 ("geo", "http://www.opengis.net/ont/geosparql#"),
140 ("prov", "http://www.w3.org/ns/prov#"),
141 ("dcat", "http://www.w3.org/ns/dcat#"),
142 ("void", "http://rdfs.org/ns/void#"),
143 ("doap", "http://usefulinc.com/ns/doap#"),
144 ("vcard", "http://www.w3.org/2006/vcard/ns#"),
145 ]
146}
147
148pub struct TurtlePrettyPrinter {
154 config: PrettyPrinterConfig,
155}
156
157impl Default for TurtlePrettyPrinter {
158 fn default() -> Self {
159 Self::new()
160 }
161}
162
163impl TurtlePrettyPrinter {
164 pub fn new() -> Self {
166 Self {
167 config: PrettyPrinterConfig::default(),
168 }
169 }
170
171 pub fn with_config(config: PrettyPrinterConfig) -> Self {
173 Self { config }
174 }
175
176 pub fn analyse_prefixes(&self, triples: &[RawTriple]) -> Vec<PrefixSuggestion> {
178 let mut namespace_counts: HashMap<String, usize> = HashMap::new();
179
180 for triple in triples {
181 if let Some(ns) = extract_namespace(&triple.subject) {
182 *namespace_counts.entry(ns).or_insert(0) += 1;
183 }
184 if let Some(ns) = extract_namespace(&triple.predicate) {
185 *namespace_counts.entry(ns).or_insert(0) += 1;
186 }
187 if let Some(ns) = extract_namespace(&triple.object) {
188 *namespace_counts.entry(ns).or_insert(0) += 1;
189 }
190 }
191
192 let well_known: HashMap<&str, &str> = well_known_prefixes()
193 .into_iter()
194 .map(|(p, ns)| (ns, p))
195 .collect();
196
197 let mut suggestions: Vec<PrefixSuggestion> = Vec::new();
198 let mut used_prefixes: HashMap<String, bool> = HashMap::new();
199
200 for (prefix, ns) in &self.config.custom_prefixes {
202 if let Some(count) = namespace_counts.get(ns) {
203 suggestions.push(PrefixSuggestion {
204 prefix: prefix.clone(),
205 namespace: ns.clone(),
206 usage_count: *count,
207 });
208 used_prefixes.insert(ns.clone(), true);
209 }
210 }
211
212 let mut sorted_ns: Vec<(String, usize)> = namespace_counts.into_iter().collect();
214 sorted_ns.sort_by_key(|b| std::cmp::Reverse(b.1));
215
216 let mut prefix_counter = 0_usize;
217
218 for (ns, count) in &sorted_ns {
219 if count < &self.config.min_prefix_usage {
220 continue;
221 }
222 if used_prefixes.contains_key(ns) {
223 continue;
224 }
225
226 let prefix = if let Some(known) = well_known.get(ns.as_str()) {
227 known.to_string()
228 } else {
229 let p = format!("ns{prefix_counter}");
231 prefix_counter += 1;
232 p
233 };
234
235 suggestions.push(PrefixSuggestion {
236 prefix,
237 namespace: ns.clone(),
238 usage_count: *count,
239 });
240 used_prefixes.insert(ns.clone(), true);
241 }
242
243 suggestions
244 }
245
246 pub fn format(&self, triples: &[RawTriple]) -> String {
248 let suggestions = self.analyse_prefixes(triples);
249 let prefix_map: HashMap<String, String> = suggestions
250 .iter()
251 .map(|s| (s.namespace.clone(), s.prefix.clone()))
252 .collect();
253
254 let mut out = String::new();
255
256 if !suggestions.is_empty() {
258 for s in &suggestions {
259 out.push_str(&format!("@prefix {}: <{}> .\n", s.prefix, s.namespace));
260 }
261 out.push('\n');
262 }
263
264 let mut subject_groups: BTreeMap<String, Vec<&RawTriple>> = BTreeMap::new();
266 for triple in triples {
267 subject_groups
268 .entry(triple.subject.clone())
269 .or_default()
270 .push(triple);
271 }
272
273 let subjects: Vec<String> = if self.config.sort_subjects {
274 subject_groups.keys().cloned().collect()
275 } else {
276 subject_groups.keys().cloned().collect()
278 };
279
280 for (si, subject) in subjects.iter().enumerate() {
281 let triples_for_subject = &subject_groups[subject];
282 let compact_subject = self.compact_iri(subject, &prefix_map);
283
284 let max_pred_len = if self.config.align_predicates {
286 triples_for_subject
287 .iter()
288 .map(|t| {
289 let p = self.compact_predicate(&t.predicate, &prefix_map);
290 p.len()
291 })
292 .max()
293 .unwrap_or(0)
294 } else {
295 0
296 };
297
298 for (i, triple) in triples_for_subject.iter().enumerate() {
299 let pred = self.compact_predicate(&triple.predicate, &prefix_map);
300 let obj = self.compact_object(&triple.object, &prefix_map);
301
302 if i == 0 {
303 out.push_str(&compact_subject);
304 } else {
305 out.push_str(&self.config.indent);
306 }
307
308 if self.config.align_predicates && i > 0 {
309 out.push_str(&format!("{:width$}", pred, width = max_pred_len));
310 } else if i == 0 {
311 out.push(' ');
312 if self.config.align_predicates {
313 out.push_str(&format!("{:width$}", pred, width = max_pred_len));
314 } else {
315 out.push_str(&pred);
316 }
317 } else {
318 out.push_str(&pred);
319 }
320
321 out.push(' ');
322 out.push_str(&obj);
323
324 let is_last = i == triples_for_subject.len() - 1;
325 if is_last {
326 out.push_str(" .\n");
327 } else {
328 out.push_str(" ;\n");
329 }
330 }
331
332 if si < subjects.len() - 1 {
333 out.push('\n');
334 }
335 }
336
337 out
338 }
339
340 pub fn count_namespaces(&self, triples: &[RawTriple]) -> usize {
342 let suggestions = self.analyse_prefixes(triples);
343 suggestions.len()
344 }
345
346 fn compact_iri(&self, iri: &str, prefix_map: &HashMap<String, String>) -> String {
349 for (ns, prefix) in prefix_map {
350 if iri.starts_with(ns.as_str()) {
351 let local = &iri[ns.len()..];
352 return format!("{prefix}:{local}");
353 }
354 }
355 format!("<{iri}>")
356 }
357
358 fn compact_predicate(&self, iri: &str, prefix_map: &HashMap<String, String>) -> String {
359 if self.config.use_a_shorthand && iri == "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" {
360 return "a".to_string();
361 }
362 self.compact_iri(iri, prefix_map)
363 }
364
365 fn compact_object(&self, obj: &str, prefix_map: &HashMap<String, String>) -> String {
366 if obj.starts_with('"') {
367 return obj.to_string();
369 }
370 if obj.starts_with("_:") {
371 return obj.to_string();
372 }
373 self.compact_iri(obj, prefix_map)
374 }
375}
376
377fn extract_namespace(iri: &str) -> Option<String> {
383 if iri.starts_with('"') || iri.starts_with("_:") {
384 return None;
385 }
386 if let Some(pos) = iri.rfind('#') {
388 Some(iri[..=pos].to_string())
389 } else {
390 iri.rfind('/').map(|pos| iri[..=pos].to_string())
391 }
392}
393
394#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn foaf_triples() -> Vec<RawTriple> {
403 vec![
404 RawTriple::new(
405 "http://example.org/alice",
406 "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
407 "http://xmlns.com/foaf/0.1/Person",
408 ),
409 RawTriple::new(
410 "http://example.org/alice",
411 "http://xmlns.com/foaf/0.1/name",
412 "\"Alice\"",
413 ),
414 RawTriple::new(
415 "http://example.org/alice",
416 "http://xmlns.com/foaf/0.1/age",
417 "\"30\"",
418 ),
419 RawTriple::new(
420 "http://example.org/bob",
421 "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
422 "http://xmlns.com/foaf/0.1/Person",
423 ),
424 RawTriple::new(
425 "http://example.org/bob",
426 "http://xmlns.com/foaf/0.1/name",
427 "\"Bob\"",
428 ),
429 ]
430 }
431
432 #[test]
435 fn test_extract_namespace_hash() {
436 let ns = extract_namespace("http://xmlns.com/foaf/0.1/Person");
437 assert_eq!(ns, Some("http://xmlns.com/foaf/0.1/".to_string()));
438 }
439
440 #[test]
441 fn test_extract_namespace_with_hash_char() {
442 let ns = extract_namespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
443 assert_eq!(
444 ns,
445 Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#".to_string())
446 );
447 }
448
449 #[test]
450 fn test_extract_namespace_literal() {
451 assert_eq!(extract_namespace("\"hello\""), None);
452 }
453
454 #[test]
455 fn test_extract_namespace_blank_node() {
456 assert_eq!(extract_namespace("_:b0"), None);
457 }
458
459 #[test]
460 fn test_extract_namespace_no_separator() {
461 assert_eq!(extract_namespace("justAstring"), None);
462 }
463
464 #[test]
467 fn test_analyse_prefixes_empty() {
468 let printer = TurtlePrettyPrinter::new();
469 let suggestions = printer.analyse_prefixes(&[]);
470 assert!(suggestions.is_empty());
471 }
472
473 #[test]
474 fn test_analyse_prefixes_finds_foaf() {
475 let printer = TurtlePrettyPrinter::new();
476 let suggestions = printer.analyse_prefixes(&foaf_triples());
477 assert!(suggestions.iter().any(|s| s.prefix == "foaf"));
478 }
479
480 #[test]
481 fn test_analyse_prefixes_finds_rdf() {
482 let printer = TurtlePrettyPrinter::new();
483 let suggestions = printer.analyse_prefixes(&foaf_triples());
484 assert!(suggestions.iter().any(|s| s.prefix == "rdf"));
485 }
486
487 #[test]
488 fn test_analyse_prefixes_unknown_namespace() {
489 let printer = TurtlePrettyPrinter::new();
490 let triples = vec![RawTriple::new(
491 "http://custom.example.com/foo/bar",
492 "http://custom.example.com/foo/pred",
493 "http://custom.example.com/foo/obj",
494 )];
495 let suggestions = printer.analyse_prefixes(&triples);
496 assert!(!suggestions.is_empty());
497 assert!(suggestions.iter().any(|s| s.prefix.starts_with("ns")));
499 }
500
501 #[test]
502 fn test_analyse_prefixes_custom_override() {
503 let config = PrettyPrinterConfig {
504 custom_prefixes: {
505 let mut m = HashMap::new();
506 m.insert("ex".to_string(), "http://example.org/".to_string());
507 m
508 },
509 ..Default::default()
510 };
511 let printer = TurtlePrettyPrinter::with_config(config);
512 let suggestions = printer.analyse_prefixes(&foaf_triples());
513 assert!(suggestions.iter().any(|s| s.prefix == "ex"));
514 }
515
516 #[test]
517 fn test_analyse_prefixes_min_usage_filter() {
518 let config = PrettyPrinterConfig {
519 min_prefix_usage: 100,
520 ..Default::default()
521 };
522 let printer = TurtlePrettyPrinter::with_config(config);
523 let triples = vec![RawTriple::new(
524 "http://rare.example.org/x",
525 "http://rare.example.org/p",
526 "http://rare.example.org/o",
527 )];
528 let suggestions = printer.analyse_prefixes(&triples);
529 assert!(suggestions.is_empty());
530 }
531
532 #[test]
535 fn test_format_contains_prefix_declarations() {
536 let printer = TurtlePrettyPrinter::new();
537 let output = printer.format(&foaf_triples());
538 assert!(output.contains("@prefix"));
539 assert!(output.contains("foaf:"));
540 }
541
542 #[test]
543 fn test_format_groups_by_subject() {
544 let printer = TurtlePrettyPrinter::new();
545 let output = printer.format(&foaf_triples());
546 assert!(output.contains(";"));
548 assert!(output.contains("."));
549 }
550
551 #[test]
552 fn test_format_uses_a_shorthand() {
553 let printer = TurtlePrettyPrinter::new();
554 let output = printer.format(&foaf_triples());
555 assert!(output.contains(" a "));
556 }
557
558 #[test]
559 fn test_format_no_a_shorthand() {
560 let config = PrettyPrinterConfig {
561 use_a_shorthand: false,
562 ..Default::default()
563 };
564 let printer = TurtlePrettyPrinter::with_config(config);
565 let triples = vec![RawTriple::new(
566 "http://example.org/alice",
567 "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
568 "http://xmlns.com/foaf/0.1/Person",
569 )];
570 let output = printer.format(&triples);
571 assert!(!output.contains(" a "));
572 }
573
574 #[test]
575 fn test_format_empty_triples() {
576 let printer = TurtlePrettyPrinter::new();
577 let output = printer.format(&[]);
578 assert!(output.is_empty() || output.trim().is_empty());
579 }
580
581 #[test]
582 fn test_format_single_triple() {
583 let printer = TurtlePrettyPrinter::new();
584 let triples = vec![RawTriple::new(
585 "http://example.org/s",
586 "http://example.org/p",
587 "http://example.org/o",
588 )];
589 let output = printer.format(&triples);
590 assert!(output.contains("."));
591 }
592
593 #[test]
594 fn test_format_preserves_literals() {
595 let printer = TurtlePrettyPrinter::new();
596 let triples = vec![RawTriple::new(
597 "http://example.org/s",
598 "http://example.org/p",
599 "\"hello world\"",
600 )];
601 let output = printer.format(&triples);
602 assert!(output.contains("\"hello world\""));
603 }
604
605 #[test]
606 fn test_format_preserves_blank_nodes() {
607 let printer = TurtlePrettyPrinter::new();
608 let triples = vec![RawTriple::new(
609 "_:b0",
610 "http://example.org/p",
611 "http://example.org/o",
612 )];
613 let output = printer.format(&triples);
614 assert!(output.contains("_:b0"));
615 }
616
617 #[test]
620 fn test_compact_iri_known_prefix() {
621 let printer = TurtlePrettyPrinter::new();
622 let mut map = HashMap::new();
623 map.insert("http://xmlns.com/foaf/0.1/".to_string(), "foaf".to_string());
624 assert_eq!(
625 printer.compact_iri("http://xmlns.com/foaf/0.1/Person", &map),
626 "foaf:Person"
627 );
628 }
629
630 #[test]
631 fn test_compact_iri_unknown() {
632 let printer = TurtlePrettyPrinter::new();
633 let map = HashMap::new();
634 assert_eq!(
635 printer.compact_iri("http://example.org/test", &map),
636 "<http://example.org/test>"
637 );
638 }
639
640 #[test]
643 fn test_count_namespaces() {
644 let printer = TurtlePrettyPrinter::new();
645 let count = printer.count_namespaces(&foaf_triples());
646 assert!(count >= 2); }
648
649 #[test]
652 fn test_default_config() {
653 let config = PrettyPrinterConfig::default();
654 assert_eq!(config.indent, " ");
655 assert!(config.align_predicates);
656 assert!(config.use_a_shorthand);
657 assert!(config.sort_subjects);
658 }
659
660 #[test]
663 fn test_raw_triple_new() {
664 let t = RawTriple::new("s", "p", "o");
665 assert_eq!(t.subject, "s");
666 assert_eq!(t.predicate, "p");
667 assert_eq!(t.object, "o");
668 }
669
670 #[test]
671 fn test_raw_triple_eq() {
672 let a = RawTriple::new("s", "p", "o");
673 let b = RawTriple::new("s", "p", "o");
674 assert_eq!(a, b);
675 }
676
677 #[test]
680 fn test_prefix_suggestion_usage_count() {
681 let printer = TurtlePrettyPrinter::new();
682 let suggestions = printer.analyse_prefixes(&foaf_triples());
683 let foaf_suggestion = suggestions.iter().find(|s| s.prefix == "foaf");
684 assert!(foaf_suggestion.is_some());
685 assert!(foaf_suggestion.map(|s| s.usage_count).unwrap_or(0) > 0);
686 }
687
688 #[test]
691 fn test_well_known_prefixes_not_empty() {
692 assert!(!well_known_prefixes().is_empty());
693 }
694
695 #[test]
696 fn test_well_known_contains_rdf() {
697 let wk = well_known_prefixes();
698 assert!(wk.iter().any(|(p, _)| *p == "rdf"));
699 }
700
701 #[test]
704 fn test_many_namespaces() {
705 let triples = vec![
706 RawTriple::new(
707 "http://example.org/s",
708 "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
709 "http://xmlns.com/foaf/0.1/Person",
710 ),
711 RawTriple::new(
712 "http://example.org/s",
713 "http://www.w3.org/2000/01/rdf-schema#label",
714 "\"Test\"",
715 ),
716 RawTriple::new(
717 "http://example.org/s",
718 "http://purl.org/dc/terms/title",
719 "\"Title\"",
720 ),
721 ];
722 let printer = TurtlePrettyPrinter::new();
723 let output = printer.format(&triples);
724 assert!(output.contains("rdf:") || output.contains(" a "));
725 assert!(output.contains("rdfs:"));
726 assert!(output.contains("dcterms:"));
727 }
728
729 #[test]
732 fn test_no_alignment() {
733 let config = PrettyPrinterConfig {
734 align_predicates: false,
735 ..Default::default()
736 };
737 let printer = TurtlePrettyPrinter::with_config(config);
738 let triples = vec![
739 RawTriple::new(
740 "http://example.org/s",
741 "http://xmlns.com/foaf/0.1/name",
742 "\"Alice\"",
743 ),
744 RawTriple::new(
745 "http://example.org/s",
746 "http://xmlns.com/foaf/0.1/age",
747 "\"30\"",
748 ),
749 ];
750 let output = printer.format(&triples);
751 assert!(output.contains("foaf:name"));
752 }
753
754 #[test]
757 fn test_format_many_triples() {
758 let triples: Vec<RawTriple> = (0..50)
759 .map(|i| {
760 RawTriple::new(
761 format!("http://example.org/s{i}"),
762 "http://example.org/p",
763 format!("http://example.org/o{i}"),
764 )
765 })
766 .collect();
767 let printer = TurtlePrettyPrinter::new();
768 let output = printer.format(&triples);
769 assert!(!output.is_empty());
770 let dot_count = output
773 .lines()
774 .filter(|l| l.ends_with(" .") && !l.starts_with("@prefix"))
775 .count();
776 assert_eq!(dot_count, 50);
777 }
778}