1mod lex;
20pub mod project;
21
22use std::collections::BTreeMap;
23use std::path::Path;
24
25use crate::error::{Error, Result};
26use lex::{Token, Tokenizer};
27
28pub const OPT_SCHEMA_ID: &str = "majordomus.schema_id";
31pub const OPT_ALLOW_LIST: &str = "majordomus.allow_list";
33pub const OPT_DOCUMENT_FORMAT: &str = "majordomus.document_format";
35
36pub const OPT_CLOSED: &str = "majordomus.closed";
39pub const OPT_BODY: &str = "majordomus.body";
41
42pub const OPT_KEY: &str = "majordomus.key";
45pub const OPT_REQUIRED: &str = "majordomus.required";
47pub const OPT_CONST: &str = "majordomus.const";
49pub const OPT_PATTERN: &str = "majordomus.pattern";
51pub const OPT_MIN_LENGTH: &str = "majordomus.min_length";
53pub const OPT_MIN_ITEMS: &str = "majordomus.min_items";
55pub const OPT_MINIMUM: &str = "majordomus.minimum";
57pub const OPT_MAXIMUM: &str = "majordomus.maximum";
59pub const OPT_HEADING: &str = "majordomus.heading";
61pub const OPT_LEVEL: &str = "majordomus.level";
63pub const OPT_NON_EMPTY: &str = "majordomus.non_empty";
65
66pub const OPT_VALUE: &str = "majordomus.value";
68
69pub const MSG_DOCUMENT: &str = "Document";
72pub const MSG_HEADER: &str = "Header";
74pub const MSG_BODY: &str = "Body";
76
77pub const PROTO_SUFFIX: &str = ".proto";
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ProtoFile {
83 pub source: String,
85 pub package: String,
87 pub schema_id: String,
89 pub allow_list: Option<String>,
91 pub document_format: String,
93 pub messages: Vec<Message>,
95 pub enums: Vec<Enum>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Message {
102 pub name: String,
104 pub doc: String,
106 pub closed: bool,
108 pub body: bool,
110 pub fields: Vec<Field>,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Field {
117 pub name: String,
119 pub doc: String,
121 pub type_name: String,
124 pub repeated: bool,
126 pub number: u32,
128 pub options: FieldOptions,
130}
131
132impl Field {
133 pub fn key(&self) -> &str {
136 self.options.key.as_deref().unwrap_or(&self.name)
137 }
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
142pub struct FieldOptions {
143 pub key: Option<String>,
145 pub required: bool,
147 pub const_value: Option<String>,
149 pub pattern: Option<String>,
151 pub min_length: Option<u64>,
153 pub min_items: Option<u64>,
155 pub minimum: Option<i64>,
157 pub maximum: Option<i64>,
159 pub heading: Option<String>,
161 pub level: Option<u64>,
163 pub non_empty: bool,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Enum {
170 pub name: String,
172 pub doc: String,
174 pub values: Vec<EnumValue>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct EnumValue {
181 pub name: String,
183 pub number: i64,
185 pub written: Option<String>,
188}
189
190impl ProtoFile {
191 pub fn message(&self, name: &str) -> Option<&Message> {
193 self.messages.iter().find(|m| m.name == name)
194 }
195
196 pub fn enumeration(&self, name: &str) -> Option<&Enum> {
198 self.enums.iter().find(|e| e.name == name)
199 }
200
201 pub fn header(&self) -> Result<&Message> {
203 self.message(MSG_HEADER).ok_or_else(|| Error::KindSchema {
204 reason: format!("{}: declares no message {MSG_HEADER}", self.source),
205 })
206 }
207
208 pub fn body(&self) -> Option<&Message> {
210 self.message(MSG_BODY).filter(|m| m.body)
211 }
212
213 pub fn expected_path(schema_id: &str) -> Result<String> {
217 let (qualified, version) = schema_id.split_once('/').ok_or_else(|| Error::KindSchema {
218 reason: format!(
219 "schema id '{schema_id}' is not <vendor>.<name>/v<n>: it carries no '/'"
220 ),
221 })?;
222 let (vendor, name) = qualified
223 .rsplit_once('.')
224 .ok_or_else(|| Error::KindSchema {
225 reason: format!(
226 "schema id '{schema_id}' is not <vendor>.<name>/v<n>: '{qualified}' carries no '.'"
227 ),
228 })?;
229 if vendor.is_empty() || name.is_empty() || !version.starts_with('v') || version.len() < 2 {
230 return Err(Error::KindSchema {
231 reason: format!("schema id '{schema_id}' is not <vendor>.<name>/v<n>"),
232 });
233 }
234 Ok(format!("{vendor}/{name}/{name}.{version}{PROTO_SUFFIX}"))
235 }
236
237 pub fn identity_of(relative: &str) -> Option<String> {
240 let stem = relative.strip_suffix(PROTO_SUFFIX)?;
241 let mut parts = stem.split('/');
242 let vendor = parts.next()?;
243 let name = parts.next()?;
244 let file = parts.next()?;
245 if parts.next().is_some() {
246 return None;
247 }
248 let version = file.strip_prefix(&format!("{name}."))?;
249 Some(format!("{vendor}.{name}/{version}"))
250 }
251}
252
253pub fn parse(source: &str, text: &str) -> Result<ProtoFile> {
255 Parser::new(source, text).file()
256}
257
258pub fn read(path: &Path, source: &str) -> Result<ProtoFile> {
260 let text = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
261 parse(source, &text)
262}
263
264pub fn read_dir(root: &Path, source_prefix: &str) -> Result<BTreeMap<String, ProtoFile>> {
268 let mut out = BTreeMap::new();
269 if !root.is_dir() {
270 return Ok(out);
271 }
272 for relative in proto_paths(root, "")? {
273 if relative == format!("majordomus/options{PROTO_SUFFIX}") {
275 continue;
276 }
277 let source = format!("{source_prefix}/{relative}");
278 let file = read(&root.join(&relative), &source)?;
279 let expected = ProtoFile::expected_path(&file.schema_id)?;
280 if expected != relative {
281 return Err(Error::KindSchema {
282 reason: format!(
283 "{source}: declares schema id '{}', whose path is {expected}; \
284 a schema's identity and its path fix each other",
285 file.schema_id
286 ),
287 });
288 }
289 if let Some(first) = out.insert(file.schema_id.clone(), file) {
290 return Err(Error::KindSchema {
291 reason: format!(
292 "schema id '{}' is declared by both {} and {source}",
293 first.schema_id, first.source
294 ),
295 });
296 }
297 }
298 Ok(out)
299}
300
301fn proto_paths(root: &Path, prefix: &str) -> Result<Vec<String>> {
303 let dir = if prefix.is_empty() {
304 root.to_path_buf()
305 } else {
306 root.join(prefix)
307 };
308 let mut out = Vec::new();
309 for entry in std::fs::read_dir(&dir).map_err(|e| Error::io(&dir, e))? {
310 let entry = entry.map_err(|e| Error::io(&dir, e))?;
311 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
312 continue;
313 };
314 let relative = if prefix.is_empty() {
315 name.clone()
316 } else {
317 format!("{prefix}/{name}")
318 };
319 if entry.path().is_dir() {
320 out.extend(proto_paths(root, &relative)?);
321 } else if name.ends_with(PROTO_SUFFIX) {
322 out.push(relative);
323 }
324 }
325 out.sort();
326 Ok(out)
327}
328
329struct Parser<'a> {
332 source: String,
333 tokens: Vec<Token<'a>>,
334 at: usize,
335}
336
337impl<'a> Parser<'a> {
338 fn new(source: &str, text: &'a str) -> Self {
339 Parser {
340 source: source.to_string(),
341 tokens: Tokenizer::new(text).collect(),
342 at: 0,
343 }
344 }
345
346 fn err<T>(&self, what: impl std::fmt::Display) -> Result<T> {
347 let line = self.tokens.get(self.at).map(|t| t.line).unwrap_or(0);
348 Err(Error::KindSchema {
349 reason: format!("{}:{line}: {what}", self.source),
350 })
351 }
352
353 fn peek(&self) -> Option<&Token<'a>> {
354 self.tokens.get(self.at)
355 }
356
357 fn next(&mut self) -> Option<Token<'a>> {
358 let t = self.tokens.get(self.at).cloned();
359 if t.is_some() {
360 self.at += 1;
361 }
362 t
363 }
364
365 fn word(&mut self) -> Result<Token<'a>> {
367 match self.next() {
368 Some(t) => Ok(t),
369 None => self.err("unexpected end of file"),
370 }
371 }
372
373 fn expect(&mut self, want: &str) -> Result<()> {
375 let t = self.word()?;
376 if t.text == want {
377 Ok(())
378 } else {
379 self.at -= 1;
380 self.err(format!("expected '{want}', found '{}'", t.text))
381 }
382 }
383
384 fn eat(&mut self, want: &str) -> bool {
386 if self.peek().map(|t| t.text) == Some(want) {
387 self.at += 1;
388 true
389 } else {
390 false
391 }
392 }
393
394 fn file(mut self) -> Result<ProtoFile> {
395 let mut package = String::new();
396 let mut options: BTreeMap<String, OptionValue> = BTreeMap::new();
397 let mut messages = Vec::new();
398 let mut enums = Vec::new();
399
400 while let Some(token) = self.peek().cloned() {
401 match token.text {
402 "syntax" => {
403 self.at += 1;
404 self.expect("=")?;
405 let v = self.word()?;
406 if v.string() != Some("proto3".to_string()) {
407 self.at -= 1;
408 return self.err("only proto3 is read here");
409 }
410 self.expect(";")?;
411 }
412 "package" => {
413 self.at += 1;
414 package = self.dotted_name()?;
415 self.expect(";")?;
416 }
417 "import" => {
418 self.at += 1;
419 self.word()?;
420 self.expect(";")?;
421 }
422 "option" => {
423 self.at += 1;
424 let (name, value) = self.option_assignment()?;
425 self.expect(";")?;
426 options.insert(name, value);
427 }
428 "message" => {
429 self.at += 1;
430 messages.push(self.message(token.doc)?);
431 }
432 "enum" => {
433 self.at += 1;
434 enums.push(self.enumeration(token.doc)?);
435 }
436 "extend" => {
437 self.at += 1;
438 self.dotted_name()?;
440 self.skip_block()?;
441 }
442 other => {
443 let other = other.to_string();
444 return self.err(format!("unexpected '{other}' at the top level"));
445 }
446 }
447 }
448
449 let schema_id = match options.get(OPT_SCHEMA_ID) {
450 Some(OptionValue::Str(s)) => s.clone(),
451 _ => {
452 return Err(Error::KindSchema {
453 reason: format!("{}: declares no option ({OPT_SCHEMA_ID})", self.source),
454 })
455 }
456 };
457 let document_format = match options.get(OPT_DOCUMENT_FORMAT) {
458 Some(OptionValue::Str(s)) => s.clone(),
459 _ => {
460 return Err(Error::KindSchema {
461 reason: format!(
462 "{}: declares no option ({OPT_DOCUMENT_FORMAT})",
463 self.source
464 ),
465 })
466 }
467 };
468 if document_format != "markdown" {
469 return Err(Error::KindSchema {
470 reason: format!(
471 "{}: document_format '{document_format}' is not 'markdown'; a YAML kind is described by JSON Schema",
472 self.source
473 ),
474 });
475 }
476 let allow_list = match options.get(OPT_ALLOW_LIST) {
477 Some(OptionValue::Str(s)) => Some(s.clone()),
478 _ => None,
479 };
480
481 Ok(ProtoFile {
482 source: self.source,
483 package,
484 schema_id,
485 allow_list,
486 document_format,
487 messages,
488 enums,
489 })
490 }
491
492 fn dotted_name(&mut self) -> Result<String> {
493 let mut out = self.word()?.text.to_string();
494 while self.eat(".") {
495 out.push('.');
496 out.push_str(self.word()?.text);
497 }
498 Ok(out)
499 }
500
501 fn option_assignment(&mut self) -> Result<(String, OptionValue)> {
503 let name = if self.eat("(") {
504 let n = self.dotted_name()?;
505 self.expect(")")?;
506 n
507 } else {
508 self.dotted_name()?
509 };
510 self.expect("=")?;
511 let token = self.word()?;
512 let value = if let Some(s) = token.string() {
513 OptionValue::Str(s)
514 } else if token.text == "true" {
515 OptionValue::Bool(true)
516 } else if token.text == "false" {
517 OptionValue::Bool(false)
518 } else if let Ok(n) = token.text.parse::<i64>() {
519 OptionValue::Int(n)
520 } else {
521 self.at -= 1;
522 return self.err(format!(
523 "option value '{}' is not a string, a boolean or an integer",
524 token.text
525 ));
526 };
527 Ok((name, value))
528 }
529
530 fn message(&mut self, doc: String) -> Result<Message> {
531 let name = self.word()?.text.to_string();
532 self.expect("{")?;
533 let mut closed = false;
534 let mut body = false;
535 let mut fields = Vec::new();
536 loop {
537 let token = match self.peek().cloned() {
538 Some(t) => t,
539 None => return self.err("unexpected end of file inside a message"),
540 };
541 match token.text {
542 "}" => {
543 self.at += 1;
544 break;
545 }
546 "option" => {
547 self.at += 1;
548 let (name, value) = self.option_assignment()?;
549 self.expect(";")?;
550 match (name.as_str(), value) {
551 (OPT_CLOSED, OptionValue::Bool(b)) => closed = b,
552 (OPT_BODY, OptionValue::Bool(b)) => body = b,
553 (other, _) => return self.err(format!("unknown message option ({other})")),
554 }
555 }
556 _ => fields.push(self.field(token.doc)?),
557 }
558 }
559 Ok(Message {
560 name,
561 doc,
562 closed,
563 body,
564 fields,
565 })
566 }
567
568 fn field(&mut self, doc: String) -> Result<Field> {
569 let repeated = self.eat("repeated");
570 if self.eat("optional") || self.eat("required") {
571 return self.err(
572 "proto3 label 'optional'/'required' is not read here; use (majordomus.required)",
573 );
574 }
575 let type_name = self.dotted_name()?;
576 let name = self.word()?.text.to_string();
577 self.expect("=")?;
578 let number_token = self.word()?;
579 let number: u32 = match number_token.text.parse() {
580 Ok(n) => n,
581 Err(_) => {
582 self.at -= 1;
583 return self.err(format!(
584 "field number '{}' is not an integer",
585 number_token.text
586 ));
587 }
588 };
589 let mut options = FieldOptions::default();
590 if self.eat("[") {
591 loop {
592 let (name, value) = self.option_assignment()?;
593 self.apply_field_option(&name, value, &mut options)?;
594 if !self.eat(",") {
595 break;
596 }
597 }
598 self.expect("]")?;
599 }
600 self.expect(";")?;
601 Ok(Field {
602 name,
603 doc,
604 type_name,
605 repeated,
606 number,
607 options,
608 })
609 }
610
611 fn apply_field_option(
612 &mut self,
613 name: &str,
614 value: OptionValue,
615 out: &mut FieldOptions,
616 ) -> Result<()> {
617 match (name, value) {
618 (OPT_KEY, OptionValue::Str(s)) => out.key = Some(s),
619 (OPT_REQUIRED, OptionValue::Bool(b)) => out.required = b,
620 (OPT_NON_EMPTY, OptionValue::Bool(b)) => out.non_empty = b,
621 (OPT_CONST, OptionValue::Str(s)) => out.const_value = Some(s),
622 (OPT_PATTERN, OptionValue::Str(s)) => out.pattern = Some(s),
623 (OPT_HEADING, OptionValue::Str(s)) => out.heading = Some(s),
624 (OPT_MIN_LENGTH, OptionValue::Int(n)) if n >= 0 => out.min_length = Some(n as u64),
625 (OPT_MIN_ITEMS, OptionValue::Int(n)) if n >= 0 => out.min_items = Some(n as u64),
626 (OPT_LEVEL, OptionValue::Int(n)) if n >= 0 => out.level = Some(n as u64),
627 (OPT_MINIMUM, OptionValue::Int(n)) => out.minimum = Some(n),
628 (OPT_MAXIMUM, OptionValue::Int(n)) => out.maximum = Some(n),
629 (other, _) => {
630 return self.err(format!(
631 "unknown or ill-typed field option ({other}); the vocabulary is share/schemas/majordomus/options.proto"
632 ))
633 }
634 }
635 Ok(())
636 }
637
638 fn enumeration(&mut self, doc: String) -> Result<Enum> {
639 let name = self.word()?.text.to_string();
640 self.expect("{")?;
641 let mut values = Vec::new();
642 loop {
643 if self.eat("}") {
644 break;
645 }
646 let value_name = self.word()?.text.to_string();
647 self.expect("=")?;
648 let number_token = self.word()?;
649 let number: i64 = match number_token.text.parse() {
650 Ok(n) => n,
651 Err(_) => {
652 self.at -= 1;
653 return self.err(format!(
654 "enum value number '{}' is not an integer",
655 number_token.text
656 ));
657 }
658 };
659 let mut written = None;
660 if self.eat("[") {
661 loop {
662 let (option_name, value) = self.option_assignment()?;
663 match (option_name.as_str(), value) {
664 (OPT_VALUE, OptionValue::Str(s)) => written = Some(s),
665 (other, _) => {
666 return self.err(format!("unknown enum value option ({other})"))
667 }
668 }
669 if !self.eat(",") {
670 break;
671 }
672 }
673 self.expect("]")?;
674 }
675 self.expect(";")?;
676 values.push(EnumValue {
677 name: value_name,
678 number,
679 written,
680 });
681 }
682 Ok(Enum { name, doc, values })
683 }
684
685 fn skip_block(&mut self) -> Result<()> {
687 self.expect("{")?;
688 let mut depth = 1;
689 while depth > 0 {
690 let t = self.word()?;
691 match t.text {
692 "{" => depth += 1,
693 "}" => depth -= 1,
694 _ => {}
695 }
696 }
697 Ok(())
698 }
699}
700
701#[derive(Debug, Clone, PartialEq, Eq)]
703enum OptionValue {
704 Str(String),
705 Bool(bool),
706 Int(i64),
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 const ADR: &str = r#"
714syntax = "proto3";
715package majordomus.adr.v1;
716import "majordomus/options.proto";
717option (majordomus.schema_id) = "majordomus.adr/v1";
718option (majordomus.allow_list) = "adr";
719option (majordomus.document_format) = "markdown";
720
721// The front matter.
722message Header {
723 option (majordomus.closed) = true;
724 // The format version.
725 string schema = 1 [(majordomus.required) = true, (majordomus.const) = "adr/v1"];
726 repeated string tags = 7 [(majordomus.pattern) = "^[a-z][a-z0-9-]*$"];
727 Status status = 5 [(majordomus.required) = true];
728}
729
730enum Status {
731 STATUS_UNSPECIFIED = 0;
732 STATUS_PROPOSED = 1 [(majordomus.value) = "proposed"];
733}
734
735message Body {
736 option (majordomus.body) = true;
737 Section context = 1 [(majordomus.heading) = "Context", (majordomus.level) = 2, (majordomus.required) = true, (majordomus.non_empty) = true];
738}
739
740message Section {
741 string heading = 1;
742 uint32 level = 2;
743 bool empty = 3;
744}
745"#;
746
747 fn adr() -> ProtoFile {
748 parse("adr.v1.proto", ADR).expect("parses")
749 }
750
751 #[test]
752 fn reads_the_file_options() {
753 let f = adr();
754 assert_eq!(f.package, "majordomus.adr.v1");
755 assert_eq!(f.schema_id, "majordomus.adr/v1");
756 assert_eq!(f.allow_list.as_deref(), Some("adr"));
757 assert_eq!(f.document_format, "markdown");
758 }
759
760 #[test]
761 fn reads_fields_their_options_and_their_comments() {
762 let f = adr();
763 let header = f.header().expect("Header");
764 assert!(header.closed);
765 let schema = &header.fields[0];
766 assert_eq!(schema.name, "schema");
767 assert_eq!(schema.doc, "The format version.");
768 assert!(schema.options.required);
769 assert_eq!(schema.options.const_value.as_deref(), Some("adr/v1"));
770 let tags = &header.fields[1];
771 assert!(tags.repeated);
772 assert_eq!(tags.options.pattern.as_deref(), Some("^[a-z][a-z0-9-]*$"));
773 }
774
775 #[test]
776 fn reads_an_enum_with_the_form_each_value_is_written_in() {
777 let f = adr();
778 let status = f.enumeration("Status").expect("Status");
779 assert_eq!(status.values.len(), 2);
780 assert_eq!(status.values[0].written, None);
781 assert_eq!(status.values[1].written.as_deref(), Some("proposed"));
782 }
783
784 #[test]
785 fn reads_the_body_sections_in_order() {
786 let f = adr();
787 let body = f.body().expect("Body");
788 assert_eq!(body.fields[0].options.heading.as_deref(), Some("Context"));
789 assert_eq!(body.fields[0].options.level, Some(2));
790 assert!(body.fields[0].options.non_empty);
791 }
792
793 #[test]
794 fn an_identity_and_a_path_fix_each_other() {
795 assert_eq!(
796 ProtoFile::expected_path("majordomus.adr/v1").unwrap(),
797 "majordomus/adr/adr.v1.proto"
798 );
799 assert_eq!(
800 ProtoFile::identity_of("majordomus/adr/adr.v1.proto").as_deref(),
801 Some("majordomus.adr/v1")
802 );
803 assert!(ProtoFile::expected_path("adr").is_err());
804 assert!(ProtoFile::expected_path("majordomus.adr").is_err());
805 assert_eq!(ProtoFile::identity_of("majordomus/adr.proto"), None);
806 }
807
808 #[test]
809 fn refuses_a_dialect_it_does_not_model() {
810 let bad = ADR.replace("syntax = \"proto3\";", "syntax = \"proto2\";");
811 assert!(parse("x.proto", &bad).is_err());
812 let yaml = ADR.replace("\"markdown\"", "\"yaml\"");
813 let e = parse("x.proto", &yaml).unwrap_err().to_string();
814 assert!(e.contains("described by JSON Schema"), "{e}");
815 let unknown = ADR.replace("(majordomus.const)", "(majordomus.nonesuch)");
816 assert!(parse("x.proto", &unknown).is_err());
817 }
818}