oxirs_ttl/
turtle_validator.rs1use std::collections::HashSet;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ValidationIssue {
13 Warning(String),
15 Error(String),
17}
18
19impl ValidationIssue {
20 pub fn message(&self) -> &str {
22 match self {
23 Self::Warning(m) | Self::Error(m) => m,
24 }
25 }
26
27 pub fn is_error(&self) -> bool {
29 matches!(self, Self::Error(_))
30 }
31}
32
33#[derive(Debug, Default)]
37pub struct ValidationReport {
38 pub issues: Vec<ValidationIssue>,
40 pub line_count: usize,
42 pub triple_count: usize,
44}
45
46impl ValidationReport {
47 pub fn is_valid(&self) -> bool {
49 !self.issues.iter().any(|i| i.is_error())
50 }
51
52 pub fn error_count(&self) -> usize {
54 self.issues.iter().filter(|i| i.is_error()).count()
55 }
56
57 pub fn warning_count(&self) -> usize {
59 self.issues.iter().filter(|i| !i.is_error()).count()
60 }
61
62 pub fn errors(&self) -> Vec<&str> {
64 self.issues
65 .iter()
66 .filter(|i| i.is_error())
67 .map(|i| i.message())
68 .collect()
69 }
70
71 pub fn warnings(&self) -> Vec<&str> {
73 self.issues
74 .iter()
75 .filter(|i| !i.is_error())
76 .map(|i| i.message())
77 .collect()
78 }
79}
80
81pub struct TurtleValidator {
85 strict: bool,
86}
87
88impl TurtleValidator {
89 pub fn new() -> Self {
91 Self { strict: false }
92 }
93
94 pub fn strict(mut self) -> Self {
96 self.strict = true;
97 self
98 }
99
100 pub fn validate(&self, input: &str) -> ValidationReport {
102 let mut report = ValidationReport {
103 triple_count: Self::count_triples_approx(input),
104 ..Default::default()
105 };
106
107 let mut known_prefixes: Vec<String> = vec![
108 "rdf".to_string(),
109 "rdfs".to_string(),
110 "owl".to_string(),
111 "xsd".to_string(),
112 ];
113
114 for (lineno, raw_line) in input.lines().enumerate() {
115 let line = raw_line.trim();
116 if line.is_empty() || line.starts_with('#') {
118 continue;
119 }
120 report.line_count += 1;
121
122 if line.to_lowercase().starts_with("@prefix")
124 || line.to_lowercase().starts_with("prefix")
125 {
126 if let Some(issue) = self.validate_prefix_declaration(line) {
127 report.issues.push(issue);
128 } else {
129 if let Some(name) = extract_prefix_name(line) {
131 if !known_prefixes.contains(&name) {
132 known_prefixes.push(name);
133 }
134 }
135 }
136 continue;
137 }
138
139 if let Some(issue) = self.validate_triple_line(line, &known_prefixes) {
141 if self.strict {
142 let upgraded = match issue {
144 ValidationIssue::Warning(msg) => {
145 ValidationIssue::Error(format!("[strict] {msg} (line {})", lineno + 1))
146 }
147 other => other,
148 };
149 report.issues.push(upgraded);
150 } else {
151 report.issues.push(issue);
152 }
153 }
154 }
155
156 report
157 }
158
159 pub fn validate_prefix_declaration(&self, line: &str) -> Option<ValidationIssue> {
163 let lc = line.to_lowercase();
164 let rest = if lc.starts_with("@prefix") {
165 line["@prefix".len()..].trim()
166 } else if lc.starts_with("prefix") {
167 line["prefix".len()..].trim()
168 } else {
169 return None;
170 };
171
172 let colon_pos = match rest.find(':') {
177 Some(pos) => pos,
178 None => {
179 return Some(ValidationIssue::Error(format!(
180 "prefix declaration missing colon: {line}"
181 )));
182 }
183 };
184
185 let prefix_name = &rest[..colon_pos].trim();
186 if !prefix_name.is_empty() && !self.validate_prefix_name(prefix_name) {
187 return Some(ValidationIssue::Error(format!(
188 "invalid prefix name '{prefix_name}' in: {line}"
189 )));
190 }
191
192 let after_colon = rest[colon_pos + 1..].trim();
194 if !after_colon.starts_with('<') {
195 return Some(ValidationIssue::Error(format!(
196 "prefix IRI must be enclosed in <...>: {line}"
197 )));
198 }
199 if !after_colon.contains('>') {
200 return Some(ValidationIssue::Error(format!(
201 "prefix IRI not closed with '>': {line}"
202 )));
203 }
204
205 if let Some(iri) = extract_iri(after_colon) {
207 if !self.validate_iri(&iri) {
208 return Some(ValidationIssue::Error(format!(
209 "invalid IRI in prefix declaration: {iri}"
210 )));
211 }
212 }
213
214 None
215 }
216
217 pub fn validate_triple_line(
222 &self,
223 line: &str,
224 known_prefixes: &[String],
225 ) -> Option<ValidationIssue> {
226 let open_angles = line.chars().filter(|&c| c == '<').count();
228 let close_angles = line.chars().filter(|&c| c == '>').count();
229 if open_angles != close_angles {
230 return Some(ValidationIssue::Error(format!(
231 "unbalanced angle brackets in: {line}"
232 )));
233 }
234
235 let double_quote_count = line.chars().filter(|&c| c == '"').count();
238 if double_quote_count % 2 != 0 {
239 return Some(ValidationIssue::Warning(format!(
240 "possible unclosed string literal in: {line}"
241 )));
242 }
243
244 let known: HashSet<&str> = known_prefixes.iter().map(String::as_str).collect();
246 for token in tokenize_turtle_line(line) {
247 if let Some(prefix) = extract_prefix_from_token(token) {
248 if !prefix.is_empty() && !known.contains(prefix) {
249 return Some(ValidationIssue::Warning(format!(
250 "unknown prefix '{prefix}' in: {line}"
251 )));
252 }
253 }
254 }
255
256 None
257 }
258
259 pub fn validate_iri(&self, iri: &str) -> bool {
264 if iri.is_empty() {
265 return false;
266 }
267 if iri.contains(' ') || iri.contains('\t') {
268 return false;
269 }
270 true
273 }
274
275 pub fn validate_prefix_name(&self, prefix: &str) -> bool {
280 if prefix.is_empty() {
281 return true; }
283 let mut chars = prefix.chars();
284 let first = match chars.next() {
286 Some(c) => c,
287 None => return true,
288 };
289 if !first.is_alphabetic() && first != '_' {
290 return false;
291 }
292 let remaining: Vec<char> = chars.collect();
294 if let Some(&last) = remaining.last() {
295 if last == '.' || last == '-' {
296 return false;
297 }
298 }
299 remaining
300 .iter()
301 .all(|&c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
302 }
303
304 pub fn count_triples_approx(input: &str) -> usize {
307 let mut count = 0usize;
308 for line in input.lines() {
309 let trimmed = line.trim();
310 if trimmed.is_empty() || trimmed.starts_with('#') {
311 continue;
312 }
313 let lc = trimmed.to_lowercase();
315 if lc.starts_with("@prefix") || lc.starts_with("prefix") {
316 continue;
317 }
318 let without_comment = if let Some(pos) = trimmed.find(" #") {
322 trimmed[..pos].trim()
323 } else {
324 trimmed
325 };
326 if without_comment.ends_with('.') {
327 count += 1;
328 }
329 }
330 count
331 }
332}
333
334impl Default for TurtleValidator {
335 fn default() -> Self {
336 Self::new()
337 }
338}
339
340fn extract_prefix_name(line: &str) -> Option<String> {
344 let lc = line.to_lowercase();
345 let rest = if lc.starts_with("@prefix") {
346 line["@prefix".len()..].trim()
347 } else if lc.starts_with("prefix") {
348 line["prefix".len()..].trim()
349 } else {
350 return None;
351 };
352 let colon = rest.find(':')?;
353 Some(rest[..colon].trim().to_string())
354}
355
356fn extract_iri(s: &str) -> Option<String> {
358 let start = s.find('<')? + 1;
359 let end = s[start..].find('>')? + start;
360 Some(s[start..end].to_string())
361}
362
363fn tokenize_turtle_line(line: &str) -> Vec<&str> {
366 let mut tokens = Vec::new();
367 let mut in_string = false;
368 let mut in_iri = false;
369 let mut start = 0usize;
370
371 for (i, ch) in line.char_indices() {
372 match ch {
373 '"' => in_string = !in_string,
374 '<' if !in_string => in_iri = true,
375 '>' if in_iri => in_iri = false,
376 ' ' | '\t' if !in_string && !in_iri => {
377 if i > start {
378 tokens.push(&line[start..i]);
379 }
380 start = i + 1;
381 }
382 _ => {}
383 }
384 }
385 if start < line.len() {
386 tokens.push(&line[start..]);
387 }
388 tokens
389}
390
391fn extract_prefix_from_token(token: &str) -> Option<&str> {
393 if token.starts_with('<') || token.starts_with('"') || token.starts_with('_') {
395 return None;
396 }
397 let cleaned = token.trim_end_matches(['.', ';', ',']);
399 let colon = cleaned.find(':')?;
400 Some(&cleaned[..colon])
401}
402
403#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
412 fn test_valid_turtle_passes() {
413 let ttl = r#"
414@prefix ex: <http://example.org/> .
415ex:Alice ex:knows ex:Bob .
416"#;
417 let v = TurtleValidator::new();
418 let report = v.validate(ttl);
419 assert!(report.is_valid());
420 assert_eq!(report.error_count(), 0);
421 }
422
423 #[test]
424 fn test_empty_input_is_valid() {
425 let v = TurtleValidator::new();
426 let report = v.validate("");
427 assert!(report.is_valid());
428 assert_eq!(report.line_count, 0);
429 }
430
431 #[test]
432 fn test_comments_only_is_valid() {
433 let input = "# This is a comment\n# Another comment\n";
434 let v = TurtleValidator::new();
435 let report = v.validate(input);
436 assert!(report.is_valid());
437 assert_eq!(report.line_count, 0);
438 }
439
440 #[test]
441 fn test_missing_prefix_error() {
442 let input = "unk:Alice unk:knows unk:Bob .\n";
444 let v = TurtleValidator::new();
445 let report = v.validate(input);
446 assert!(!report.issues.is_empty());
448 }
449
450 #[test]
451 fn test_unknown_prefix_produces_warning() {
452 let input = "foo:s foo:p foo:o .\n";
453 let v = TurtleValidator::new();
454 let report = v.validate(input);
455 assert!(report.warning_count() >= 1);
456 }
457
458 #[test]
461 fn test_valid_prefix_declaration() {
462 let v = TurtleValidator::new();
463 let issue = v.validate_prefix_declaration("@prefix ex: <http://example.org/> .");
464 assert!(issue.is_none());
465 }
466
467 #[test]
468 fn test_sparql_prefix_declaration() {
469 let v = TurtleValidator::new();
470 let issue = v.validate_prefix_declaration("PREFIX ex: <http://example.org/>");
471 assert!(issue.is_none());
472 }
473
474 #[test]
475 fn test_prefix_missing_colon() {
476 let v = TurtleValidator::new();
477 let issue = v.validate_prefix_declaration("@prefix ex <http://example.org/> .");
478 assert!(matches!(issue, Some(ValidationIssue::Error(_))));
479 }
480
481 #[test]
482 fn test_prefix_iri_not_in_angle_brackets() {
483 let v = TurtleValidator::new();
484 let issue = v.validate_prefix_declaration("@prefix ex: http://example.org/ .");
485 assert!(matches!(issue, Some(ValidationIssue::Error(_))));
486 }
487
488 #[test]
489 fn test_prefix_iri_not_closed() {
490 let v = TurtleValidator::new();
491 let issue = v.validate_prefix_declaration("@prefix ex: <http://example.org/ .");
492 assert!(matches!(issue, Some(ValidationIssue::Error(_))));
493 }
494
495 #[test]
496 fn test_prefix_invalid_name() {
497 let v = TurtleValidator::new();
498 let issue = v.validate_prefix_declaration("@prefix 1bad: <http://example.org/> .");
500 assert!(matches!(issue, Some(ValidationIssue::Error(_))));
501 }
502
503 #[test]
504 fn test_prefix_empty_name_allowed() {
505 let v = TurtleValidator::new();
506 let issue = v.validate_prefix_declaration("@prefix : <http://example.org/> .");
507 assert!(issue.is_none());
508 }
509
510 #[test]
513 fn test_valid_triple_line_with_known_prefixes() {
514 let v = TurtleValidator::new();
515 let issue = v.validate_triple_line("ex:Alice ex:knows ex:Bob .", &["ex".to_string()]);
516 assert!(issue.is_none());
517 }
518
519 #[test]
520 fn test_triple_line_unbalanced_angle_brackets() {
521 let v = TurtleValidator::new();
522 let issue = v.validate_triple_line(
523 "<http://example.org/Alice ex:knows ex:Bob .",
524 &["ex".to_string()],
525 );
526 assert!(matches!(issue, Some(ValidationIssue::Error(_))));
527 }
528
529 #[test]
530 fn test_triple_line_unclosed_string_literal() {
531 let v = TurtleValidator::new();
532 let issue = v.validate_triple_line(r#"ex:s ex:p "unclosed ."#, &["ex".to_string()]);
533 assert!(matches!(issue, Some(ValidationIssue::Warning(_))));
534 }
535
536 #[test]
537 fn test_triple_line_unknown_prefix_warning() {
538 let v = TurtleValidator::new();
539 let issue = v.validate_triple_line("unkn:s unkn:p unkn:o .", &["ex".to_string()]);
540 assert!(matches!(issue, Some(ValidationIssue::Warning(_))));
541 }
542
543 #[test]
546 fn test_valid_iri() {
547 let v = TurtleValidator::new();
548 assert!(v.validate_iri("http://example.org/"));
549 }
550
551 #[test]
552 fn test_invalid_iri_empty() {
553 let v = TurtleValidator::new();
554 assert!(!v.validate_iri(""));
555 }
556
557 #[test]
558 fn test_invalid_iri_with_space() {
559 let v = TurtleValidator::new();
560 assert!(!v.validate_iri("http://example.org/ foo"));
561 }
562
563 #[test]
564 fn test_invalid_iri_with_tab() {
565 let v = TurtleValidator::new();
566 assert!(!v.validate_iri("http://example.org/\t"));
567 }
568
569 #[test]
572 fn test_valid_prefix_name_simple() {
573 let v = TurtleValidator::new();
574 assert!(v.validate_prefix_name("ex"));
575 }
576
577 #[test]
578 fn test_valid_prefix_name_with_dot() {
579 let v = TurtleValidator::new();
580 assert!(v.validate_prefix_name("ex.org"));
581 }
582
583 #[test]
584 fn test_valid_prefix_name_empty() {
585 let v = TurtleValidator::new();
586 assert!(v.validate_prefix_name(""));
587 }
588
589 #[test]
590 fn test_invalid_prefix_name_starts_with_digit() {
591 let v = TurtleValidator::new();
592 assert!(!v.validate_prefix_name("1bad"));
593 }
594
595 #[test]
596 fn test_invalid_prefix_name_ends_with_dot() {
597 let v = TurtleValidator::new();
598 assert!(!v.validate_prefix_name("bad."));
599 }
600
601 #[test]
602 fn test_invalid_prefix_name_ends_with_dash() {
603 let v = TurtleValidator::new();
604 assert!(!v.validate_prefix_name("bad-"));
605 }
606
607 #[test]
608 fn test_valid_prefix_name_with_underscore() {
609 let v = TurtleValidator::new();
610 assert!(v.validate_prefix_name("my_prefix"));
611 }
612
613 #[test]
616 fn test_count_triples_approx_simple() {
617 let input = r#"
618@prefix ex: <http://example.org/> .
619ex:Alice ex:knows ex:Bob .
620ex:Bob ex:knows ex:Carol .
621"#;
622 assert_eq!(TurtleValidator::count_triples_approx(input), 2);
623 }
624
625 #[test]
626 fn test_count_triples_approx_zero_on_empty() {
627 assert_eq!(TurtleValidator::count_triples_approx(""), 0);
628 }
629
630 #[test]
631 fn test_count_triples_approx_ignores_prefixes() {
632 let input = "@prefix ex: <http://example.org/> .\nex:s ex:p ex:o .\n";
633 assert_eq!(TurtleValidator::count_triples_approx(input), 1);
634 }
635
636 #[test]
637 fn test_count_triples_approx_ignores_comments() {
638 let input = "# comment .\nex:s ex:p ex:o .\n";
639 assert_eq!(TurtleValidator::count_triples_approx(input), 1);
640 }
641
642 #[test]
645 fn test_report_is_valid_no_errors() {
646 let mut report = ValidationReport::default();
647 report.issues.push(ValidationIssue::Warning("w".into()));
648 assert!(report.is_valid());
649 }
650
651 #[test]
652 fn test_report_is_invalid_with_error() {
653 let mut report = ValidationReport::default();
654 report.issues.push(ValidationIssue::Error("e".into()));
655 assert!(!report.is_valid());
656 }
657
658 #[test]
659 fn test_report_error_count() {
660 let mut report = ValidationReport::default();
661 report.issues.push(ValidationIssue::Error("e1".into()));
662 report.issues.push(ValidationIssue::Error("e2".into()));
663 report.issues.push(ValidationIssue::Warning("w".into()));
664 assert_eq!(report.error_count(), 2);
665 assert_eq!(report.warning_count(), 1);
666 }
667
668 #[test]
669 fn test_report_errors_and_warnings_vecs() {
670 let mut report = ValidationReport::default();
671 report.issues.push(ValidationIssue::Error("err".into()));
672 report.issues.push(ValidationIssue::Warning("warn".into()));
673 assert!(report.errors().contains(&"err"));
674 assert!(report.warnings().contains(&"warn"));
675 }
676
677 #[test]
680 fn test_strict_mode_upgrades_warnings_to_errors() {
681 let input = "unk:s unk:p unk:o .\n";
682 let v = TurtleValidator::new().strict();
683 let report = v.validate(input);
684 assert!(!report.is_valid());
686 assert!(report.error_count() >= 1);
687 }
688
689 #[test]
690 fn test_lenient_mode_keeps_warnings() {
691 let input = "unk:s unk:p unk:o .\n";
692 let v = TurtleValidator::new();
693 let report = v.validate(input);
694 assert!(report.is_valid()); assert!(report.warning_count() >= 1);
696 }
697
698 #[test]
701 fn test_validation_issue_message_error() {
702 let i = ValidationIssue::Error("oops".into());
703 assert_eq!(i.message(), "oops");
704 assert!(i.is_error());
705 }
706
707 #[test]
708 fn test_validation_issue_message_warning() {
709 let i = ValidationIssue::Warning("hmm".into());
710 assert_eq!(i.message(), "hmm");
711 assert!(!i.is_error());
712 }
713
714 #[test]
717 fn test_validate_valid_base_and_triple() {
718 let input = "@base <http://example.org/> .\n@prefix ex: <http://example.org/> .\nex:s ex:p ex:o .\n";
719 let v = TurtleValidator::new();
720 let report = v.validate(input);
721 assert!(report.is_valid());
722 }
723
724 #[test]
725 fn test_validate_empty_string() {
726 let v = TurtleValidator::new();
727 let report = v.validate("");
728 assert!(report.is_valid());
729 assert_eq!(report.triple_count, 0);
730 }
731
732 #[test]
733 fn test_validate_comment_only() {
734 let input = "# This is just a comment\n";
735 let v = TurtleValidator::new();
736 let report = v.validate(input);
737 assert!(report.is_valid());
738 }
739
740 #[test]
741 fn test_count_triples_approx_multiple() {
742 let input = "ex:a ex:b ex:c .\nex:d ex:e ex:f .\nex:g ex:h ex:i .\n";
743 assert_eq!(TurtleValidator::count_triples_approx(input), 3);
744 }
745
746 #[test]
747 fn test_validate_prefix_name_empty() {
748 let v = TurtleValidator::new();
749 assert!(v.validate_prefix_name(""));
751 }
752
753 #[test]
754 fn test_validate_prefix_name_digits_after_start() {
755 let v = TurtleValidator::new();
756 assert!(v.validate_prefix_name("abc123"));
758 }
759
760 #[test]
761 fn test_validate_invalid_prefix_starts_with_digit() {
762 let v = TurtleValidator::new();
763 assert!(!v.validate_prefix_name("1bad"));
764 }
765
766 #[test]
767 fn test_report_default_has_no_issues() {
768 let report = ValidationReport::default();
769 assert!(report.issues.is_empty());
770 assert_eq!(report.triple_count, 0);
771 assert_eq!(report.line_count, 0);
772 }
773
774 #[test]
775 fn test_validation_issue_clone() {
776 let i = ValidationIssue::Error("err".into());
777 assert_eq!(i, i.clone());
778 }
779
780 #[test]
781 fn test_validation_issue_eq() {
782 assert_eq!(
783 ValidationIssue::Warning("w".into()),
784 ValidationIssue::Warning("w".into())
785 );
786 assert_ne!(
787 ValidationIssue::Error("e".into()),
788 ValidationIssue::Warning("e".into())
789 );
790 }
791}