Skip to main content

oxirs_ttl/
pretty_printer.rs

1//! # Turtle Pretty Printer with Prefix Analysis
2//!
3//! Analyses IRI frequency, suggests optimal prefix declarations, and outputs
4//! well-formatted Turtle with aligned predicates.
5//!
6//! ## Features
7//!
8//! - **IRI frequency analysis**: counts IRI namespaces and suggests prefixes
9//! - **Prefix suggestion**: automatically picks short prefixes for common namespaces
10//! - **Aligned predicates**: aligns predicate columns for readability
11//! - **Subject grouping**: groups triples by subject
12//! - **Configurable indentation and line width
13//!
14//! ## Usage
15//!
16//! ```rust
17//! use oxirs_ttl::pretty_printer::{TurtlePrettyPrinter, PrettyPrinterConfig, RawTriple};
18//!
19//! let triples = vec![
20//!     RawTriple::new(
21//!         "http://example.org/alice",
22//!         "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
23//!         "http://xmlns.com/foaf/0.1/Person",
24//!     ),
25//!     RawTriple::new(
26//!         "http://example.org/alice",
27//!         "http://xmlns.com/foaf/0.1/name",
28//!         "\"Alice\"",
29//!     ),
30//! ];
31//!
32//! let printer = TurtlePrettyPrinter::new();
33//! let output = printer.format(&triples);
34//! assert!(output.contains("@prefix"));
35//! ```
36
37use std::collections::{BTreeMap, HashMap};
38
39use serde::{Deserialize, Serialize};
40
41// ---------------------------------------------------------------------------
42// RawTriple
43// ---------------------------------------------------------------------------
44
45/// A simple triple with string components.
46#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub struct RawTriple {
48    /// Subject IRI or blank node.
49    pub subject: String,
50    /// Predicate IRI.
51    pub predicate: String,
52    /// Object IRI, literal, or blank node.
53    pub object: String,
54}
55
56impl RawTriple {
57    /// Create a new raw triple.
58    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// ---------------------------------------------------------------------------
72// PrefixSuggestion
73// ---------------------------------------------------------------------------
74
75/// A suggested prefix declaration.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct PrefixSuggestion {
78    /// Short prefix name (e.g. "foaf").
79    pub prefix: String,
80    /// Namespace IRI (e.g. `"http://xmlns.com/foaf/0.1/"`).
81    pub namespace: String,
82    /// Number of IRIs that use this namespace.
83    pub usage_count: usize,
84}
85
86// ---------------------------------------------------------------------------
87// Configuration
88// ---------------------------------------------------------------------------
89
90/// Configuration for the pretty printer.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct PrettyPrinterConfig {
93    /// Indentation string for predicates under a subject.
94    pub indent: String,
95    /// Whether to align predicate columns.
96    pub align_predicates: bool,
97    /// Minimum namespace usage count to suggest a prefix.
98    pub min_prefix_usage: usize,
99    /// Whether to sort subjects alphabetically.
100    pub sort_subjects: bool,
101    /// Whether to use `a` shorthand for rdf:type.
102    pub use_a_shorthand: bool,
103    /// Maximum line width (best-effort).
104    pub max_line_width: usize,
105    /// Custom prefix overrides (prefix -> namespace).
106    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
123// ---------------------------------------------------------------------------
124// Well-known prefixes
125// ---------------------------------------------------------------------------
126
127fn 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
148// ---------------------------------------------------------------------------
149// TurtlePrettyPrinter
150// ---------------------------------------------------------------------------
151
152/// The Turtle pretty printer.
153pub struct TurtlePrettyPrinter {
154    config: PrettyPrinterConfig,
155}
156
157impl Default for TurtlePrettyPrinter {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163impl TurtlePrettyPrinter {
164    /// Create a printer with default configuration.
165    pub fn new() -> Self {
166        Self {
167            config: PrettyPrinterConfig::default(),
168        }
169    }
170
171    /// Create a printer with custom configuration.
172    pub fn with_config(config: PrettyPrinterConfig) -> Self {
173        Self { config }
174    }
175
176    /// Analyse IRI frequency and suggest prefix declarations.
177    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        // Add custom prefixes first
201        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        // Sort namespaces by usage (descending)
213        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                // Generate a prefix like ns0, ns1, ...
230                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    /// Format triples as pretty-printed Turtle.
247    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        // Emit prefix declarations
257        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        // Group by subject
265        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            // Preserve insertion order from BTreeMap (sorted anyway)
277            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            // Compute max predicate length for alignment
285            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    /// Count the number of distinct namespaces in the triples.
341    pub fn count_namespaces(&self, triples: &[RawTriple]) -> usize {
342        let suggestions = self.analyse_prefixes(triples);
343        suggestions.len()
344    }
345
346    // -- internal helpers --
347
348    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            // literal
368            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
377// ---------------------------------------------------------------------------
378// helpers
379// ---------------------------------------------------------------------------
380
381/// Extract namespace from an IRI (everything up to and including the last `#` or `/`).
382fn extract_namespace(iri: &str) -> Option<String> {
383    if iri.starts_with('"') || iri.starts_with("_:") {
384        return None;
385    }
386    // Try hash first, then slash
387    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// ===========================================================================
395// Tests
396// ===========================================================================
397
398#[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    // -- extract_namespace --
433
434    #[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    // -- prefix analysis --
465
466    #[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        // Should get a generated prefix like ns0
498        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    // -- format output --
533
534    #[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        // Alice's triples should use semicolons
547        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    // -- compact_iri --
618
619    #[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    // -- count_namespaces --
641
642    #[test]
643    fn test_count_namespaces() {
644        let printer = TurtlePrettyPrinter::new();
645        let count = printer.count_namespaces(&foaf_triples());
646        assert!(count >= 2); // At least foaf and rdf
647    }
648
649    // -- PrettyPrinterConfig --
650
651    #[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    // -- RawTriple --
661
662    #[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    // -- PrefixSuggestion --
678
679    #[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    // -- well-known prefixes --
689
690    #[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    // -- multi-namespace scenario --
702
703    #[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    // -- alignment --
730
731    #[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    // -- edge cases --
755
756    #[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        // Count lines that are NOT prefix declarations ending with " ."
771        // Prefix lines look like "@prefix ...: <...> ."
772        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}