Skip to main content

ontocore_owl/
span.rs

1use ontocore_core::{Annotation, Axiom, Entity, SourceLocation};
2use std::collections::BTreeMap;
3
4pub fn short_name_from_iri(iri: &str) -> String {
5    if let Some((_, name)) = iri.rsplit_once('#') {
6        return name.to_string();
7    }
8    if let Some((_, name)) = iri.rsplit_once('/') {
9        return name.to_string();
10    }
11    iri.to_string()
12}
13
14/// Parse `@prefix` declarations from Turtle source (skips `#` comments; requires `<iri>` form).
15pub fn prefixes_from_turtle(source_text: &str) -> BTreeMap<String, String> {
16    let mut map = BTreeMap::new();
17    for line in source_text.lines() {
18        let trimmed = line.trim();
19        if trimmed.is_empty() || trimmed.starts_with('#') {
20            continue;
21        }
22        if let Some(rest) = trimmed.strip_prefix("@prefix ") {
23            let mut parts = rest.split_whitespace();
24            let Some(prefix_raw) = parts.next() else { continue };
25            let Some(uri_raw) = parts.next() else { continue };
26            let prefix = prefix_raw.trim_end_matches(':').to_string();
27            if let Some(uri) = parse_turtle_prefix_iri(uri_raw) {
28                if !prefix.is_empty() {
29                    map.insert(prefix, uri);
30                }
31            }
32        } else if !trimmed.starts_with('@') {
33            break;
34        }
35    }
36    map
37}
38
39fn parse_turtle_prefix_iri(token: &str) -> Option<String> {
40    let token = token.trim_end_matches('.');
41    if !token.starts_with('<') || !token.ends_with('>') {
42        return None;
43    }
44    let inner = &token[1..token.len() - 1];
45    if inner.is_empty() || inner.contains('<') || inner.contains('>') {
46        return None;
47    }
48    Some(inner.to_string())
49}
50
51pub fn namespaces_for_text(
52    source_text: &str,
53    declared: &BTreeMap<String, String>,
54) -> BTreeMap<String, String> {
55    let mut merged = declared.clone();
56    merged.extend(prefixes_from_turtle(source_text));
57    merged
58}
59
60fn subject_needles(
61    iri: &str,
62    short_name: &str,
63    namespaces: &BTreeMap<String, String>,
64) -> Vec<String> {
65    let mut needles = vec![format!("<{iri}>")];
66    // Only match default-prefix form when the empty prefix is bound to this IRI's namespace.
67    if let Some(default_ns) = namespaces.get("") {
68        if iri.starts_with(default_ns.as_str()) {
69            needles.push(format!(":{short_name}"));
70        }
71    }
72    for (prefix, ns) in namespaces {
73        if iri.starts_with(ns.as_str()) && !prefix.is_empty() {
74            needles.push(format!("{prefix}:{short_name}"));
75        }
76    }
77    needles
78}
79
80/// Byte offset immediately after a line's content (handles `\n` and `\r\n`).
81fn line_byte_offset_after(text: &str, line_start: usize, line: &str) -> usize {
82    let after_content = line_start + line.len();
83    let bytes = text.as_bytes();
84    if bytes.get(after_content) == Some(&b'\r') && bytes.get(after_content + 1) == Some(&b'\n') {
85        after_content + 2
86    } else if bytes.get(after_content) == Some(&b'\n') {
87        after_content + 1
88    } else {
89        after_content
90    }
91}
92
93/// Locate the byte range of an entity's subject token (first Turtle statement for that subject).
94#[allow(dead_code)]
95pub fn find_subject_statement(
96    source_text: &str,
97    iri: &str,
98    short_name: &str,
99    namespaces: &BTreeMap<String, String>,
100) -> Option<ByteRange> {
101    all_subject_statements(source_text, iri, short_name, namespaces).into_iter().next()
102}
103
104/// All statement start positions for a subject IRI in document order.
105pub fn all_subject_statements(
106    source_text: &str,
107    iri: &str,
108    short_name: &str,
109    namespaces: &BTreeMap<String, String>,
110) -> Vec<ByteRange> {
111    let needles = subject_needles(iri, short_name, namespaces);
112    let mut byte_offset = 0usize;
113    let mut out = Vec::new();
114    for line in source_text.lines() {
115        let trimmed = line.trim_start();
116        if trimmed.starts_with('@') {
117            byte_offset = line_byte_offset_after(source_text, byte_offset, line);
118            continue;
119        }
120        if let Some(col_in_trimmed) = subject_column_at_line_start(trimmed, &needles) {
121            let ws = line.len() - trimmed.len();
122            let start = byte_offset + ws + col_in_trimmed;
123            out.push(ByteRange { start: start as u64, end: start as u64 });
124        }
125        byte_offset = line_byte_offset_after(source_text, byte_offset, line);
126    }
127    out
128}
129
130/// Full byte ranges for every Turtle statement whose subject is `iri`.
131pub fn all_entity_statement_ranges(
132    source_text: &str,
133    iri: &str,
134    short_name: &str,
135    namespaces: &BTreeMap<String, String>,
136) -> Vec<ByteRange> {
137    all_subject_statements(source_text, iri, short_name, namespaces)
138        .into_iter()
139        .filter_map(|start_range| {
140            let end = statement_end_byte(source_text, start_range.start as usize)?;
141            if end > start_range.start as usize {
142                Some(ByteRange { start: start_range.start, end: end as u64 })
143            } else {
144                None
145            }
146        })
147        .collect()
148}
149
150/// Prefer the subject statement that declares a type (` a `), e.g. `ex:Foo a owl:Class`.
151fn primary_entity_statement_start(
152    source_text: &str,
153    iri: &str,
154    short_name: &str,
155    namespaces: &BTreeMap<String, String>,
156) -> Option<ByteRange> {
157    let starts = all_subject_statements(source_text, iri, short_name, namespaces);
158    for start in &starts {
159        if let Some(end) = statement_end_byte(source_text, start.start as usize) {
160            let stmt = &source_text[start.start as usize..end];
161            if stmt.contains(" a ") {
162                return Some(*start);
163            }
164        }
165    }
166    starts.first().copied()
167}
168
169/// True when `needle` is the subject at the start of a trimmed Turtle line.
170fn subject_column_at_line_start(trimmed: &str, needles: &[String]) -> Option<usize> {
171    for needle in needles {
172        if !trimmed.starts_with(needle) {
173            continue;
174        }
175        let rest = &trimmed[needle.len()..];
176        if rest.is_empty() || rest.starts_with(|c: char| c.is_whitespace() || c == ';' || c == '.')
177        {
178            return Some(0);
179        }
180    }
181    None
182}
183
184/// Locate the primary entity block (type declaration) in Turtle source.
185pub fn find_entity_block(
186    source_text: &str,
187    iri: &str,
188    short_name: &str,
189    namespaces: &BTreeMap<String, String>,
190) -> SourceLocation {
191    if let Some(range) = primary_entity_statement_start(source_text, iri, short_name, namespaces) {
192        let start = range.start as usize;
193        let line_idx = source_text[..start].matches('\n').count();
194        let line_start = source_text[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);
195        let col = start - line_start;
196        return SourceLocation {
197            line: Some((line_idx + 1) as u64),
198            column: Some(col as u64),
199            start_byte: Some(range.start),
200            end_byte: None,
201        };
202    }
203    SourceLocation::default()
204}
205
206/// Fill byte spans for labels, comments, and subclass triples.
207pub fn annotate_spans(
208    source_text: &str,
209    entities: &mut [Entity],
210    annotations: &mut [Annotation],
211    axioms: &mut [Axiom],
212) {
213    for entity in entities.iter_mut() {
214        if let Some(block) = entity_block_range(source_text, entity, &BTreeMap::new()) {
215            if entity.source_location.end_byte.is_none() {
216                entity.source_location.end_byte = Some(block.end);
217            }
218        }
219    }
220
221    for ann in annotations.iter_mut() {
222        if let Some(span) = find_predicate_literal_span(
223            source_text,
224            &ann.subject,
225            predicate_local_name(&ann.predicate),
226            &ann.object,
227        ) {
228            ann.source_location = span;
229        }
230    }
231
232    for axiom in axioms.iter_mut() {
233        if axiom.axiom_kind == ontocore_core::AXIOM_KIND_SUB_CLASS_OF {
234            if let Some(span) = find_subclass_span(source_text, &axiom.subject, &axiom.object) {
235                axiom.source_location = span;
236            }
237        }
238    }
239}
240
241pub fn entity_block_range(
242    source_text: &str,
243    entity: &Entity,
244    declared_namespaces: &BTreeMap<String, String>,
245) -> Option<ByteRange> {
246    let namespaces = namespaces_for_text(source_text, declared_namespaces);
247    let start = if let Some(s) = entity.source_location.start_byte {
248        s as usize
249    } else {
250        primary_entity_statement_start(source_text, &entity.iri, &entity.short_name, &namespaces)
251            .map(|r| r.start as usize)?
252    };
253    let end = statement_end_byte(source_text, start)?;
254    if end > start {
255        Some(ByteRange { start: start as u64, end: end as u64 })
256    } else {
257        None
258    }
259}
260
261/// Primary declaration block for patch insertions (type statement, not trailing triples).
262pub fn entity_primary_block_range(
263    source_text: &str,
264    entity_iri: &str,
265    namespaces: &BTreeMap<String, String>,
266) -> Option<ByteRange> {
267    let short_name = short_name_from_iri(entity_iri);
268    let start = primary_entity_statement_start(source_text, entity_iri, &short_name, namespaces)?;
269    let end = statement_end_byte(source_text, start.start as usize)?;
270    if end > start.start as usize {
271        Some(ByteRange { start: start.start, end: end as u64 })
272    } else {
273        None
274    }
275}
276
277/// True when `b` can appear inside a Turtle PN_LOCAL / prefixed-name token.
278pub(crate) fn is_turtle_name_char(b: u8) -> bool {
279    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'~' | b'%' | b'\\' | b'.')
280}
281
282/// True when `.` at `i` is a statement/object terminator, not part of PN_LOCAL or an IRI.
283///
284/// Turtle allows `.` inside local names (`ex:foo.bar`) and absolute IRIs (`<http://a.b/c>`).
285/// A terminating `.` is never both preceded and followed by name characters.
286pub(crate) fn is_turtle_terminating_dot(bytes: &[u8], i: usize) -> bool {
287    if bytes.get(i) != Some(&b'.') {
288        return false;
289    }
290    let prev_name = i > 0 && is_turtle_name_char(bytes[i - 1]) && bytes[i - 1] != b'.';
291    let next_name = bytes.get(i + 1).is_some_and(|b| is_turtle_name_char(*b) && *b != b'.');
292    !(prev_name && next_name)
293}
294
295#[derive(Clone, Copy, PartialEq, Eq)]
296enum TurtleStringKind {
297    ShortDouble,
298    ShortSingle,
299    LongDouble,
300    LongSingle,
301}
302
303/// True when `byte_offset` lies inside a `#` line comment or any Turtle string literal.
304///
305/// Handles `"…"`, `'…'`, `"""…"""`, and `'''…'''` forms (same lexer rules as
306/// [`statement_end_byte`]).
307pub fn is_in_comment_or_string(text: &str, byte_offset: usize) -> bool {
308    let bytes = text.as_bytes();
309    let mut i = 0usize;
310    let mut string_kind: Option<TurtleStringKind> = None;
311    let mut in_iri = false;
312    let mut line_comment = false;
313    let mut escape = false;
314
315    while i < byte_offset && i < bytes.len() {
316        let b = bytes[i];
317        if line_comment {
318            if b == b'\n' {
319                line_comment = false;
320            }
321            i += 1;
322            continue;
323        }
324        if let Some(kind) = string_kind {
325            match kind {
326                TurtleStringKind::ShortDouble | TurtleStringKind::ShortSingle => {
327                    let quote = if kind == TurtleStringKind::ShortDouble { b'"' } else { b'\'' };
328                    if escape {
329                        escape = false;
330                    } else if b == b'\\' {
331                        escape = true;
332                    } else if b == quote {
333                        string_kind = None;
334                    }
335                    i += 1;
336                    continue;
337                }
338                TurtleStringKind::LongDouble => {
339                    if bytes.get(i..i + 3) == Some(br#"""""#) {
340                        string_kind = None;
341                        i += 3;
342                        continue;
343                    }
344                    i += 1;
345                    continue;
346                }
347                TurtleStringKind::LongSingle => {
348                    if bytes.get(i..i + 3) == Some(br"'''") {
349                        string_kind = None;
350                        i += 3;
351                        continue;
352                    }
353                    i += 1;
354                    continue;
355                }
356            }
357        }
358        if in_iri {
359            if b == b'>' {
360                in_iri = false;
361            }
362            i += 1;
363            continue;
364        }
365
366        if bytes.get(i..i + 3) == Some(br#"""""#) {
367            string_kind = Some(TurtleStringKind::LongDouble);
368            i += 3;
369            continue;
370        }
371        if bytes.get(i..i + 3) == Some(br"'''") {
372            string_kind = Some(TurtleStringKind::LongSingle);
373            i += 3;
374            continue;
375        }
376
377        match b {
378            b'#' => line_comment = true,
379            b'"' => string_kind = Some(TurtleStringKind::ShortDouble),
380            b'\'' => string_kind = Some(TurtleStringKind::ShortSingle),
381            b'<' => in_iri = true,
382            _ => {}
383        }
384        i += 1;
385    }
386
387    string_kind.is_some() || line_comment
388}
389
390/// Walk from subject start to the terminating `.` of this statement.
391///
392/// Tracks `"…"`, `'…'`, `"""…"""`, `'''…'''` strings, IRIs (`<...>`), `#` line comments,
393/// brackets, and parens. Does not treat `.` inside IRIs, comments, strings, or PN_LOCAL
394/// names (`ex:foo.bar`) as terminators.
395pub(crate) fn statement_end_byte(source_text: &str, start: usize) -> Option<usize> {
396    let bytes = source_text.as_bytes();
397    if start >= bytes.len() {
398        return None;
399    }
400    let mut i = start;
401    let mut bracket_depth = 0i32;
402    let mut paren_depth = 0i32;
403    let mut string_kind: Option<TurtleStringKind> = None;
404    let mut in_iri = false;
405    let mut in_comment = false;
406    let mut escape = false;
407
408    while i < bytes.len() {
409        let b = bytes[i];
410        if in_comment {
411            if b == b'\n' {
412                in_comment = false;
413            }
414            i += 1;
415            continue;
416        }
417        if let Some(kind) = string_kind {
418            match kind {
419                TurtleStringKind::ShortDouble | TurtleStringKind::ShortSingle => {
420                    let quote = match kind {
421                        TurtleStringKind::ShortDouble => b'"',
422                        _ => b'\'',
423                    };
424                    if escape {
425                        escape = false;
426                    } else if b == b'\\' {
427                        escape = true;
428                    } else if b == quote {
429                        string_kind = None;
430                    }
431                    i += 1;
432                    continue;
433                }
434                TurtleStringKind::LongDouble => {
435                    if bytes.get(i..i + 3) == Some(br#"""""#) {
436                        string_kind = None;
437                        i += 3;
438                        continue;
439                    }
440                    i += 1;
441                    continue;
442                }
443                TurtleStringKind::LongSingle => {
444                    if bytes.get(i..i + 3) == Some(br"'''") {
445                        string_kind = None;
446                        i += 3;
447                        continue;
448                    }
449                    i += 1;
450                    continue;
451                }
452            }
453        }
454        if in_iri {
455            if b == b'>' {
456                in_iri = false;
457            }
458            i += 1;
459            continue;
460        }
461
462        if bytes.get(i..i + 3) == Some(br#"""""#) {
463            string_kind = Some(TurtleStringKind::LongDouble);
464            i += 3;
465            continue;
466        }
467        if bytes.get(i..i + 3) == Some(br"'''") {
468            string_kind = Some(TurtleStringKind::LongSingle);
469            i += 3;
470            continue;
471        }
472
473        match b {
474            b'#' => in_comment = true,
475            b'"' => string_kind = Some(TurtleStringKind::ShortDouble),
476            b'\'' => string_kind = Some(TurtleStringKind::ShortSingle),
477            b'<' => in_iri = true,
478            b'[' => bracket_depth += 1,
479            b']' => bracket_depth = bracket_depth.saturating_sub(1),
480            b'(' => paren_depth += 1,
481            b')' => paren_depth = paren_depth.saturating_sub(1),
482            b'.' if bracket_depth == 0
483                && paren_depth == 0
484                && is_turtle_terminating_dot(bytes, i) =>
485            {
486                return Some(i + 1);
487            }
488            _ => {}
489        }
490        i += 1;
491    }
492    None
493}
494
495#[derive(Debug, Clone, Copy)]
496pub struct ByteRange {
497    pub start: u64,
498    pub end: u64,
499}
500
501fn line_start_byte(source_text: &str, line_idx: usize) -> u64 {
502    let mut offset = 0usize;
503    for (i, line) in source_text.lines().enumerate() {
504        if i == line_idx {
505            return offset as u64;
506        }
507        offset = line_byte_offset_after(source_text, offset, line);
508    }
509    0
510}
511
512fn predicate_local_name(predicate: &str) -> &str {
513    if predicate.contains("label") {
514        "label"
515    } else if predicate.contains("comment") {
516        "comment"
517    } else if predicate.contains("subClassOf") {
518        "subClassOf"
519    } else {
520        predicate
521    }
522}
523
524fn find_predicate_literal_span(
525    source_text: &str,
526    subject: &str,
527    predicate: &str,
528    object: &str,
529) -> Option<SourceLocation> {
530    let object_needle = object.trim_matches('"');
531    for (line_idx, line) in source_text.lines().enumerate() {
532        if !line.contains(subject) && !line.contains(&short_name_from_iri(subject)) {
533            continue;
534        }
535        if line.contains(predicate) && line.contains(object_needle) {
536            let col = line.find(predicate).unwrap_or(0);
537            let start_byte = line_start_byte(source_text, line_idx) + col as u64;
538            return Some(SourceLocation {
539                line: Some((line_idx + 1) as u64),
540                column: Some(col as u64),
541                start_byte: Some(start_byte),
542                end_byte: Some(start_byte + line.len() as u64),
543            });
544        }
545    }
546    None
547}
548
549fn find_subclass_span(source_text: &str, subject: &str, parent: &str) -> Option<SourceLocation> {
550    let parent_short = short_name_from_iri(parent);
551    for (line_idx, line) in source_text.lines().enumerate() {
552        if !line.contains("subClassOf") {
553            continue;
554        }
555        if (line.contains(subject) || line.contains(&short_name_from_iri(subject)))
556            && (line.contains(parent) || line.contains(&parent_short))
557        {
558            let start_byte = line_start_byte(source_text, line_idx);
559            return Some(SourceLocation {
560                line: Some((line_idx + 1) as u64),
561                column: Some(0),
562                start_byte: Some(start_byte),
563                end_byte: Some(start_byte + line.len() as u64),
564            });
565        }
566    }
567    None
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use ontocore_core::{Entity, EntityKind};
574
575    fn ex_ns() -> BTreeMap<String, String> {
576        BTreeMap::from([
577            ("ex".into(), "http://example.org/people#".into()),
578            ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
579        ])
580    }
581
582    fn clinic_ns() -> BTreeMap<String, String> {
583        BTreeMap::from([("ex".into(), "http://example.org/clinic#".into())])
584    }
585
586    #[test]
587    fn finds_entity_line() {
588        let ttl = include_str!("../../../fixtures/example.ttl");
589        let loc = find_entity_block(ttl, "http://example.org/people#Person", "Person", &ex_ns());
590        assert!(loc.line.is_some());
591        let line = ttl.lines().nth(loc.line.unwrap() as usize - 1).unwrap();
592        assert!(line.contains("ex:Person a owl:Class"));
593    }
594
595    #[test]
596    fn subject_not_domain_mention() {
597        let ttl = include_str!("../../../fixtures/example.ttl");
598        let range =
599            find_subject_statement(ttl, "http://example.org/people#Person", "Person", &ex_ns())
600                .expect("subject");
601        let line = ttl[..range.start as usize].rfind('\n').map(|p| p + 1).unwrap_or(0);
602        let subject_line = &ttl[line..];
603        assert!(subject_line.starts_with("ex:Person a"));
604    }
605
606    #[test]
607    fn patient_block_includes_multiline_restriction() {
608        let ttl = include_str!("../../../fixtures/complex-classes.ttl");
609        let entity = Entity {
610            iri: "http://example.org/clinic#Patient".into(),
611            short_name: "Patient".into(),
612            kind: EntityKind::Class,
613            ontology_id: String::new(),
614            source_location: find_entity_block(
615                ttl,
616                "http://example.org/clinic#Patient",
617                "Patient",
618                &clinic_ns(),
619            ),
620            labels: vec![],
621            comments: vec![],
622            deprecated: false,
623            obo_id: None,
624            ..Default::default()
625        };
626        let range = entity_block_range(ttl, &entity, &clinic_ns()).expect("block");
627        let block = &ttl[range.start as usize..range.end as usize];
628        assert!(block.contains("owl:Restriction"));
629        assert!(block.contains("owl:someValuesFrom"));
630        assert!(block.trim_end().ends_with('.'));
631    }
632
633    #[test]
634    fn add_label_targets_class_block_not_trailing_triple() {
635        use crate::patch::{apply_patches_to_text, PatchOp};
636        let ttl = include_str!("../../../fixtures/example.ttl");
637        let patches = vec![PatchOp::AddLabel {
638            entity_iri: "http://example.org/people#Person".into(),
639            value: "Human".into(),
640        }];
641        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
642        let preview = result.preview_text.expect("preview");
643        let person_block_start = preview.find("ex:Person a owl:Class").expect("class decl");
644        let human_pos = preview.find("Human").expect("label");
645        assert!(human_pos > person_block_start);
646        let trailing = preview.find("ex:Person rdfs:subClassOf ex:Thing");
647        if let Some(t) = trailing {
648            assert!(human_pos < t, "label must be in class block, not trailing triple");
649        }
650    }
651
652    #[test]
653    fn statement_end_respects_dots_in_absolute_iris() {
654        let ttl = "<http://example.org/people#Person> a owl:Class ;\n    rdfs:label \"Person\" .\n";
655        let end = statement_end_byte(ttl, 0).expect("end");
656        let block = &ttl[..end];
657        assert!(block.contains("rdfs:label"));
658        assert!(block.trim_end().ends_with('.'));
659        assert!(!block.ends_with("example."));
660    }
661
662    #[test]
663    fn statement_end_respects_dots_in_comments() {
664        let ttl = "ex:Person a owl:Class ; # see docs.\n    rdfs:label \"Person\" .\n";
665        let end = statement_end_byte(ttl, 0).expect("end");
666        let block = &ttl[..end];
667        assert!(block.contains("rdfs:label"));
668        assert!(block.contains("# see docs."));
669    }
670
671    #[test]
672    fn statement_end_respects_dots_in_local_names() {
673        let ttl = "ex:foo.bar a owl:Class .\n";
674        let end = statement_end_byte(ttl, 0).expect("end");
675        assert_eq!(&ttl[..end], "ex:foo.bar a owl:Class .");
676    }
677
678    #[test]
679    fn is_in_comment_or_string_detects_all_turtle_string_forms() {
680        let double = "rdfs:label \"http://example.org#Person\" .";
681        let person_in_double = double.find("http://example.org#Person").unwrap();
682        assert!(is_in_comment_or_string(double, person_in_double));
683
684        let single = "rdfs:comment 'http://example.org#Person' .";
685        let person_in_single = single.find("http://example.org#Person").unwrap();
686        assert!(is_in_comment_or_string(single, person_in_single));
687
688        let long_double = "rdfs:comment \"\"\"http://example.org#Person\"\"\" .";
689        let person_in_long_double = long_double.find("http://example.org#Person").unwrap();
690        assert!(is_in_comment_or_string(long_double, person_in_long_double));
691
692        let long_single = "rdfs:comment '''http://example.org#Person''' .";
693        let person_in_long_single = long_single.find("http://example.org#Person").unwrap();
694        assert!(is_in_comment_or_string(long_single, person_in_long_single));
695
696        let outside = "ex:Person a owl:Class .";
697        let person_outside = outside.find("ex:Person").unwrap();
698        assert!(!is_in_comment_or_string(outside, person_outside));
699    }
700
701    #[test]
702    fn statement_end_respects_dots_in_long_strings() {
703        let ttl = "ex:Person a owl:Class ;\n    rdfs:comment '''See Dr. Smith.''' .\n";
704        let end = statement_end_byte(ttl, 0).expect("end");
705        let block = &ttl[..end];
706        assert!(block.contains("Dr. Smith"));
707        assert!(block.trim_end().ends_with('.'));
708    }
709}