Skip to main content

oxirs_ttl/toolkit/
serializer.rs

1//! Generic serializer framework for writing RDF elements to streams
2//!
3//! This module provides the serialization infrastructure for converting
4//! RDF triples and quads back to text formats.
5
6use crate::error::TurtleResult;
7// use oxirs_core::model::{Quad, Triple};
8use std::io::Write;
9
10/// A generic serializer trait for RDF formats
11pub trait Serializer<Input> {
12    /// Serialize to a writer
13    fn serialize<W: Write>(&self, input: &[Input], writer: W) -> TurtleResult<()>;
14
15    /// Serialize a single item
16    fn serialize_item<W: Write>(&self, input: &Input, writer: W) -> TurtleResult<()>;
17}
18
19/// Async serializer trait for Tokio integration
20#[cfg(feature = "async-tokio")]
21pub trait AsyncSerializer<Input> {
22    /// Serialize to an async writer
23    fn serialize_async<W: tokio::io::AsyncWrite + Unpin>(
24        &self,
25        input: &[Input],
26        writer: W,
27    ) -> impl std::future::Future<Output = TurtleResult<()>> + Send;
28
29    /// Serialize a single item async
30    fn serialize_item_async<W: tokio::io::AsyncWrite + Unpin>(
31        &self,
32        input: &Input,
33        writer: W,
34    ) -> impl std::future::Future<Output = TurtleResult<()>> + Send;
35}
36
37/// Configuration for serialization
38#[derive(Debug, Clone)]
39pub struct SerializationConfig {
40    /// Whether to use pretty printing (with indentation and spacing)
41    pub pretty: bool,
42    /// Base IRI for relative IRI generation
43    pub base_iri: Option<String>,
44    /// Prefix declarations to use
45    pub prefixes: std::collections::HashMap<String, String>,
46    /// Whether to use prefix abbreviations
47    pub use_prefixes: bool,
48    /// Maximum line length for formatting
49    pub max_line_length: Option<usize>,
50    /// Indentation string (typically spaces or tabs)
51    pub indent: String,
52    /// Whether to normalize IRIs per RFC 3987 during serialization
53    ///
54    /// When enabled, IRIs are normalized to canonical form for consistent output:
55    /// - Case normalization (scheme and host to lowercase)
56    /// - Percent-encoding normalization (decode unreserved characters)
57    /// - Path normalization (remove dot segments)
58    /// - Default port removal (http:80, https:443, etc.)
59    ///
60    /// This helps ensure consistent IRI representation across different systems.
61    pub normalize_iris: bool,
62}
63
64impl Default for SerializationConfig {
65    fn default() -> Self {
66        Self {
67            pretty: true,
68            base_iri: None,
69            prefixes: std::collections::HashMap::new(),
70            use_prefixes: true,
71            max_line_length: Some(80),
72            indent: "  ".to_string(),
73            normalize_iris: false, // Disabled by default for backward compatibility
74        }
75    }
76}
77
78impl SerializationConfig {
79    /// Create a new serialization config
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Enable or disable pretty printing
85    pub fn with_pretty(mut self, pretty: bool) -> Self {
86        self.pretty = pretty;
87        self
88    }
89
90    /// Set the base IRI
91    pub fn with_base_iri(mut self, base_iri: String) -> Self {
92        self.base_iri = Some(base_iri);
93        self
94    }
95
96    /// Add a prefix declaration
97    pub fn with_prefix(mut self, prefix: String, iri: String) -> Self {
98        self.prefixes.insert(prefix, iri);
99        self
100    }
101
102    /// Set whether to use prefix abbreviations
103    pub fn with_use_prefixes(mut self, use_prefixes: bool) -> Self {
104        self.use_prefixes = use_prefixes;
105        self
106    }
107
108    /// Set the maximum line length
109    pub fn with_max_line_length(mut self, max_length: Option<usize>) -> Self {
110        self.max_line_length = max_length;
111        self
112    }
113
114    /// Set the indentation string
115    pub fn with_indent(mut self, indent: String) -> Self {
116        self.indent = indent;
117        self
118    }
119
120    /// Enable or disable IRI normalization
121    ///
122    /// When enabled, all IRIs in serialized output are normalized per RFC 3987
123    /// for consistent canonical representation.
124    ///
125    /// # Example
126    ///
127    /// ```rust
128    /// use oxirs_ttl::toolkit::SerializationConfig;
129    ///
130    /// let config = SerializationConfig::new()
131    ///     .with_normalize_iris(true);
132    ///
133    /// assert!(config.normalize_iris);
134    /// ```
135    pub fn with_normalize_iris(mut self, normalize: bool) -> Self {
136        self.normalize_iris = normalize;
137        self
138    }
139}
140
141// ─── Turtle PN_LOCAL grammar helpers ─────────────────────────────────────────
142//
143// Per the Turtle 1.1 / SPARQL grammar:
144//   PN_CHARS_BASE ::= [A-Z] | [a-z] | [#x00C0-#x00D6] | [#x00D8-#x00F6]
145//                   | [#x00F8-#x02FF] | [#x0370-#x037D] | [#x037F-#x1FFF]
146//                   | [#x200C-#x200D] | [#x2070-#x218F] | [#x2C00-#x2FEF]
147//                   | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD]
148//                   | [#x10000-#xEFFFF]
149//   PN_CHARS_U    ::= PN_CHARS_BASE | '_'
150//   PN_CHARS      ::= PN_CHARS_U | '-' | [0-9] | #x00B7
151//                   | [#x0300-#x036F] | [#x203F-#x2040]
152//   PN_LOCAL_ESC  ::= '\' ('_' | '~' | '.' | '-' | '!' | '$' | '&' | "'"
153//                   | '(' | ')' | '*' | '+' | ',' | ';' | '=' | '/' | '?'
154//                   | '#' | '@' | '%')
155//   PLX           ::= PERCENT | PN_LOCAL_ESC
156//   PN_LOCAL      ::= (PN_CHARS_U | ':' | [0-9] | PLX)
157//                     ((PN_CHARS | '.' | ':' | PLX)* (PN_CHARS | ':' | PLX))?
158
159fn is_pn_chars_base(c: char) -> bool {
160    matches!(c,
161        'A'..='Z' | 'a'..='z' |
162        '\u{00C0}'..='\u{00D6}' | '\u{00D8}'..='\u{00F6}' | '\u{00F8}'..='\u{02FF}' |
163        '\u{0370}'..='\u{037D}' | '\u{037F}'..='\u{1FFF}' |
164        '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' |
165        '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' |
166        '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' |
167        '\u{10000}'..='\u{EFFFF}'
168    )
169}
170
171fn is_pn_chars_u(c: char) -> bool {
172    is_pn_chars_base(c) || c == '_'
173}
174
175fn is_pn_chars(c: char) -> bool {
176    is_pn_chars_u(c)
177        || c == '-'
178        || c.is_ascii_digit()
179        || c == '\u{00B7}'
180        || ('\u{0300}'..='\u{036F}').contains(&c)
181        || ('\u{203F}'..='\u{2040}').contains(&c)
182}
183
184/// Characters that `PN_LOCAL_ESC` permits escaping with a leading backslash.
185fn is_pn_local_esc_char(c: char) -> bool {
186    matches!(
187        c,
188        '_' | '~'
189            | '.'
190            | '-'
191            | '!'
192            | '$'
193            | '&'
194            | '\''
195            | '('
196            | ')'
197            | '*'
198            | '+'
199            | ','
200            | ';'
201            | '='
202            | '/'
203            | '?'
204            | '#'
205            | '@'
206            | '%'
207    )
208}
209
210/// Attempt to render `local` — the portion of an IRI following a matched
211/// namespace prefix — as a syntactically legal Turtle `PN_LOCAL`, escaping
212/// any `PN_LOCAL_ESC`-eligible character with a backslash where required by
213/// its position. Returns `None` when `local` contains a character (or an
214/// invalid `%` escape) that cannot legally appear in, or be escaped into, a
215/// `PN_LOCAL` — the caller must then fall back to the full `<iri>` form
216/// rather than emit a broken prefixed name.
217fn escape_pn_local(local: &str) -> Option<String> {
218    if local.is_empty() {
219        // `prefix:` with an empty local part is legal.
220        return Some(String::new());
221    }
222
223    let chars: Vec<char> = local.chars().collect();
224    let n = chars.len();
225    let mut result = String::with_capacity(local.len());
226    let mut i = 0;
227
228    while i < n {
229        let c = chars[i];
230        let is_first = i == 0;
231
232        if c == '%' {
233            // Only a well-formed PERCENT triplet ('%' HEX HEX) may pass
234            // through unescaped; anything else cannot be represented.
235            if i + 2 < n && chars[i + 1].is_ascii_hexdigit() && chars[i + 2].is_ascii_hexdigit() {
236                result.push('%');
237                result.push(chars[i + 1]);
238                result.push(chars[i + 2]);
239                i += 3;
240                continue;
241            }
242            return None;
243        }
244
245        let allowed_unescaped = if is_first {
246            is_pn_chars_u(c) || c.is_ascii_digit() || c == ':'
247        } else {
248            is_pn_chars(c) || c == ':' || c == '.'
249        };
250
251        if allowed_unescaped {
252            result.push(c);
253            i += 1;
254            continue;
255        }
256
257        if is_pn_local_esc_char(c) {
258            result.push('\\');
259            result.push(c);
260            i += 1;
261            continue;
262        }
263
264        // No legal unescaped or escaped representation exists for this
265        // character (e.g. '<', '>', '"', whitespace, control characters).
266        return None;
267    }
268
269    // The final character of a multi-character PN_LOCAL must not be an
270    // unescaped '.': only PN_CHARS | ':' | PLX are permitted there. If the
271    // loop above emitted a raw trailing '.', escape it now.
272    if result.ends_with('.') && !result.ends_with("\\.") {
273        result.pop();
274        result.push('\\');
275        result.push('.');
276    }
277
278    Some(result)
279}
280
281/// Helper for writing formatted output
282pub struct FormattedWriter<W: Write> {
283    writer: W,
284    config: SerializationConfig,
285    current_line_length: usize,
286    indent_level: usize,
287}
288
289impl<W: Write> FormattedWriter<W> {
290    /// Create a new formatted writer
291    pub fn new(writer: W, config: SerializationConfig) -> Self {
292        Self {
293            writer,
294            config,
295            current_line_length: 0,
296            indent_level: 0,
297        }
298    }
299
300    /// Write a string, handling line breaks and indentation
301    pub fn write_str(&mut self, s: &str) -> std::io::Result<()> {
302        if self.config.pretty {
303            // Check if we need to break the line
304            if let Some(max_len) = self.config.max_line_length {
305                if self.current_line_length + s.len() > max_len && self.current_line_length > 0 {
306                    self.write_newline()?;
307                }
308            }
309        }
310
311        self.writer.write_all(s.as_bytes())?;
312        self.current_line_length += s.len();
313        Ok(())
314    }
315
316    /// Write a newline and appropriate indentation
317    pub fn write_newline(&mut self) -> std::io::Result<()> {
318        self.writer.write_all(b"\n")?;
319        self.current_line_length = 0;
320
321        if self.config.pretty {
322            for _ in 0..self.indent_level {
323                self.writer.write_all(self.config.indent.as_bytes())?;
324                self.current_line_length += self.config.indent.len();
325            }
326        }
327        Ok(())
328    }
329
330    /// Increase indentation level
331    pub fn increase_indent(&mut self) {
332        self.indent_level += 1;
333    }
334
335    /// Decrease indentation level
336    pub fn decrease_indent(&mut self) {
337        if self.indent_level > 0 {
338            self.indent_level -= 1;
339        }
340    }
341
342    /// Write a space if pretty printing is enabled
343    pub fn write_space(&mut self) -> std::io::Result<()> {
344        if self.config.pretty {
345            self.write_str(" ")
346        } else {
347            Ok(())
348        }
349    }
350
351    /// Abbreviate an IRI using prefixes if possible
352    ///
353    /// An IRI is only compacted to `prefix:local` when the portion of the
354    /// IRI after the matched namespace can be legally represented as a
355    /// Turtle `PN_LOCAL` (escaping any `PN_LOCAL_ESC`-eligible character as
356    /// needed). If no registered prefix yields a legal `PN_LOCAL` — e.g.
357    /// because the local part contains a character such as `<`, `>`, `"`,
358    /// whitespace, or a control character that cannot appear in, or be
359    /// escaped into, a prefixed name — the full `<iri>` form is emitted
360    /// instead so the output always remains syntactically valid Turtle that
361    /// round-trips through a conformant parser.
362    pub fn abbreviate_iri(&self, iri: &str) -> String {
363        if !self.config.use_prefixes {
364            return format!("<{iri}>");
365        }
366
367        // Consider every registered namespace prefix that matches, preferring
368        // the longest (most specific) match first, and only accept a match
369        // whose local part can be legally escaped into a PN_LOCAL.
370        let mut candidates: Vec<(&String, &String)> = self
371            .config
372            .prefixes
373            .iter()
374            .filter(|(_, prefix_iri)| {
375                !prefix_iri.is_empty() && iri.starts_with(prefix_iri.as_str())
376            })
377            .collect();
378        candidates.sort_by_key(|(_, prefix_iri)| std::cmp::Reverse(prefix_iri.len()));
379
380        for (prefix, prefix_iri) in candidates {
381            let local = &iri[prefix_iri.len()..];
382            if let Some(escaped_local) = escape_pn_local(local) {
383                return format!("{prefix}:{escaped_local}");
384            }
385        }
386
387        // Try relative IRI if base is set
388        if let Some(ref base) = self.config.base_iri {
389            if iri.starts_with(base) {
390                let relative = &iri[base.len()..];
391                return format!("<{relative}>");
392            }
393        }
394
395        format!("<{iri}>")
396    }
397
398    /// Escape a string literal
399    pub fn escape_string(&self, s: &str) -> String {
400        let mut result = String::with_capacity(s.len() + 2);
401        result.push('"');
402
403        for ch in s.chars() {
404            match ch {
405                '"' => result.push_str("\\\""),
406                '\\' => result.push_str("\\\\"),
407                '\n' => result.push_str("\\n"),
408                '\r' => result.push_str("\\r"),
409                '\t' => result.push_str("\\t"),
410                c if c.is_control() => {
411                    result.push_str(&format!("\\u{:04X}", c as u32));
412                }
413                c => result.push(c),
414            }
415        }
416
417        result.push('"');
418        result
419    }
420
421    /// Get the underlying writer
422    pub fn into_inner(self) -> W {
423        self.writer
424    }
425}
426
427impl<W: Write> Write for FormattedWriter<W> {
428    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
429        let s = std::str::from_utf8(buf)
430            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
431        self.write_str(s)?;
432        Ok(buf.len())
433    }
434
435    fn flush(&mut self) -> std::io::Result<()> {
436        self.writer.flush()
437    }
438}
439
440#[cfg(test)]
441mod regression_tests {
442    use super::*;
443    use std::io::Cursor;
444
445    fn writer_with_prefix(prefix: &str, ns: &str) -> FormattedWriter<Cursor<Vec<u8>>> {
446        let config = SerializationConfig::new().with_prefix(prefix.to_string(), ns.to_string());
447        FormattedWriter::new(Cursor::new(Vec::new()), config)
448    }
449
450    #[test]
451    fn regression_abbreviate_iri_escapes_parentheses_in_local_part() {
452        let w = writer_with_prefix("ex", "http://example.org/");
453        let out = w.abbreviate_iri("http://example.org/page(disambiguation)");
454        assert_eq!(out, "ex:page\\(disambiguation\\)");
455    }
456
457    #[test]
458    fn regression_abbreviate_iri_falls_back_to_full_iri_for_illegal_local() {
459        // '<' cannot legally appear in, or be escaped into, a PN_LOCAL.
460        let w = writer_with_prefix("ex", "http://example.org/");
461        let out = w.abbreviate_iri("http://example.org/a<b");
462        assert_eq!(out, "<http://example.org/a<b>");
463    }
464
465    #[test]
466    fn regression_abbreviate_iri_escapes_extra_slash_in_local_part() {
467        let w = writer_with_prefix("ex", "http://example.org/");
468        let out = w.abbreviate_iri("http://example.org/a/b");
469        assert_eq!(out, "ex:a\\/b");
470    }
471
472    #[test]
473    fn regression_abbreviate_iri_escapes_trailing_dot() {
474        let w = writer_with_prefix("ex", "http://example.org/");
475        let out = w.abbreviate_iri("http://example.org/v1.0.");
476        assert_eq!(out, "ex:v1.0\\.");
477    }
478
479    #[test]
480    fn regression_abbreviate_iri_plain_local_unchanged() {
481        let w = writer_with_prefix("ex", "http://example.org/");
482        let out = w.abbreviate_iri("http://example.org/alice");
483        assert_eq!(out, "ex:alice");
484    }
485
486    #[test]
487    fn regression_abbreviate_iri_escapes_every_reserved_char() {
488        let w = writer_with_prefix("ex", "http://example.org/");
489        let out = w.abbreviate_iri("http://example.org/page(disambiguation),v2");
490        assert_eq!(out, "ex:page\\(disambiguation\\)\\,v2");
491    }
492}