//! Parser for the deliberately restricted invoice-template field contract. //! //! `#let komp_ac_fields = (...)` is required to be the first meaningful Typst //! declaration. Only whitespace and line/block comments may precede it. Its //! value is a non-empty literal tuple of quoted field paths; the rest of the //! Typst source is not parsed or searched. use std::collections::HashSet; use std::error::Error; use std::fmt::{self, Display, Formatter}; pub const CONTRACT_NAME: &str = "komp_ac_fields"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct TypstContract { pub field_paths: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct TypstContractError { message: String, } impl TypstContractError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl Display for TypstContractError { fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str(&self.message) } } impl Error for TypstContractError {} pub fn parse(source: &str) -> Result { let field_paths = parse_path_declaration(source)?; if field_paths.is_empty() { return Err(TypstContractError::new(format!( "'{CONTRACT_NAME}' must declare at least one field" ))); } let mut unique_fields = HashSet::new(); for path in &field_paths { if !unique_fields.insert(path.clone()) { return Err(TypstContractError::new(format!( "Duplicate invoice template field path '{path}'" ))); } collection_paths(path)?; if path.ends_with("[]") { return Err(TypstContractError::new(format!( "Invoice template field path '{path}' names a collection, not a field inside it" ))); } } Ok(TypstContract { field_paths }) } pub fn collection_paths(path: &str) -> Result, TypstContractError> { if path.trim().is_empty() { return Err(TypstContractError::new( "Invoice template field path cannot be empty", )); } let segments = path.split('.').collect::>(); if segments.iter().any(|segment| segment.is_empty()) { return Err(TypstContractError::new(format!( "Invoice template field path '{path}' contains an empty segment" ))); } let mut prefix = Vec::new(); let mut collections = Vec::new(); for segment in segments { prefix.push(segment); if segment.ends_with("[]") { if segment == "[]" || segment.trim_end_matches("[]").contains(['[', ']']) { return Err(TypstContractError::new(format!( "Invoice template field path '{path}' has an invalid collection segment" ))); } collections.push(prefix.join(".")); } else if segment.contains(['[', ']']) { return Err(TypstContractError::new(format!( "Invoice template field path '{path}' has invalid array syntax" ))); } } Ok(collections) } fn parse_path_declaration(source: &str) -> Result, TypstContractError> { let value_offset = HeaderParser::new(source).contract_value_offset()?; let mut parser = ContractParser::new(&source[value_offset..]); parser.parse_string_list() } struct HeaderParser<'a> { input: &'a str, offset: usize, } impl<'a> HeaderParser<'a> { fn new(input: &'a str) -> Self { Self { input, offset: 0 } } fn contract_value_offset(mut self) -> Result { self.skip_trivia()?; if !self.remaining().starts_with("#let") || !self.keyword_boundary(4) { return Err(self.not_first_error()); } self.offset += 4; self.skip_trivia()?; if self.read_identifier() != CONTRACT_NAME { return Err(self.not_first_error()); } self.skip_trivia()?; if !self.remaining().starts_with('=') { return Err(TypstContractError::new(format!( "Invalid '{CONTRACT_NAME}' declaration: expected '='" ))); } self.offset += 1; Ok(self.offset) } fn not_first_error(&self) -> TypstContractError { TypstContractError::new(format!( "'#let {CONTRACT_NAME} = (...)' must be the first meaningful declaration" )) } fn skip_trivia(&mut self) -> Result<(), TypstContractError> { loop { self.skip_whitespace(); if self.remaining().starts_with("//") { self.skip_line_comment(); } else if self.remaining().starts_with("/*") { self.skip_block_comment()?; } else { return Ok(()); } } } fn skip_whitespace(&mut self) { while self .remaining() .chars() .next() .is_some_and(char::is_whitespace) { self.advance_character(); } } fn skip_line_comment(&mut self) { self.offset += self .remaining() .find('\n') .unwrap_or(self.remaining().len()); } fn skip_block_comment(&mut self) -> Result<(), TypstContractError> { self.offset += 2; let mut depth = 1_usize; while depth > 0 { if self.remaining().is_empty() { return Err(TypstContractError::new( "Unterminated block comment before Typst field contract", )); } if self.remaining().starts_with("/*") { self.offset += 2; depth += 1; } else if self.remaining().starts_with("*/") { self.offset += 2; depth -= 1; } else { self.advance_character(); } } Ok(()) } fn read_identifier(&mut self) -> &'a str { let start = self.offset; while self.remaining().chars().next().is_some_and(is_identifier_character) { self.advance_character(); } &self.input[start..self.offset] } fn keyword_boundary(&self, length: usize) -> bool { self.remaining()[length..] .chars() .next() .is_none_or(|character| !is_identifier_character(character)) } fn advance_character(&mut self) { if let Some(character) = self.remaining().chars().next() { self.offset += character.len_utf8(); } } fn remaining(&self) -> &'a str { &self.input[self.offset..] } } fn is_identifier_character(character: char) -> bool { character.is_ascii_alphanumeric() || character == '_' || character == '-' } #[derive(Clone, Debug, PartialEq, Eq)] enum Token { Text(String), Ident(String), LParen, RParen, Colon, Comma, } struct ContractParser<'a> { lexer: ContractLexer<'a>, lookahead: Option, } impl<'a> ContractParser<'a> { fn new(input: &'a str) -> Self { Self { lexer: ContractLexer::new(input), lookahead: None, } } fn parse_string_list(&mut self) -> Result, TypstContractError> { self.expect(Token::LParen)?; let mut paths = Vec::new(); loop { if self.peek()? == Some(&Token::RParen) { self.next()?; return Ok(paths); } let path = match self.next()? { Some(Token::Text(path)) => path, token => { return Err(contract_syntax_error("expected a quoted field path", token)); } }; paths.push(path); match self.peek()? { Some(Token::Comma) => { self.next()?; } Some(Token::RParen) => {} token => { return Err(contract_syntax_error( "expected ',' or ')' after a value", token.cloned(), )); } } } } fn expect(&mut self, expected: Token) -> Result<(), TypstContractError> { let actual = self.next()?; if actual.as_ref() == Some(&expected) { Ok(()) } else { Err(contract_syntax_error( &format!("expected {expected:?}"), actual, )) } } fn peek(&mut self) -> Result, TypstContractError> { if self.lookahead.is_none() { self.lookahead = self.lexer.next_token()?; } Ok(self.lookahead.as_ref()) } fn next(&mut self) -> Result, TypstContractError> { if self.lookahead.is_some() { Ok(self.lookahead.take()) } else { self.lexer.next_token() } } } struct ContractLexer<'a> { input: &'a str, offset: usize, } impl<'a> ContractLexer<'a> { fn new(input: &'a str) -> Self { Self { input, offset: 0 } } fn next_token(&mut self) -> Result, TypstContractError> { self.skip_trivia()?; let Some(character) = self.remaining().chars().next() else { return Ok(None); }; let token = match character { '(' => { self.offset += 1; Token::LParen } ')' => { self.offset += 1; Token::RParen } ':' => { self.offset += 1; Token::Colon } ',' => { self.offset += 1; Token::Comma } '"' => Token::Text(self.read_string()?), character if character.is_ascii_alphabetic() || character == '_' => { Token::Ident(self.read_identifier()) } _ => { return Err(TypstContractError::new(format!( "Invalid token in '{CONTRACT_NAME}' near '{}'", self.remaining().chars().take(16).collect::() ))); } }; Ok(Some(token)) } fn skip_trivia(&mut self) -> Result<(), TypstContractError> { loop { let remaining = self.remaining(); let whitespace = remaining .char_indices() .take_while(|(_, character)| character.is_whitespace()) .map(|(index, character)| index + character.len_utf8()) .last() .unwrap_or(0); self.offset += whitespace; if self.remaining().starts_with("//") { self.offset += self .remaining() .find('\n') .unwrap_or(self.remaining().len()); continue; } if self.remaining().starts_with("/*") { self.skip_block_comment()?; continue; } return Ok(()); } } fn skip_block_comment(&mut self) -> Result<(), TypstContractError> { self.offset += 2; let mut depth = 1_usize; while depth > 0 { if self.remaining().is_empty() { return Err(TypstContractError::new(format!( "Unterminated comment in '{CONTRACT_NAME}'" ))); } if self.remaining().starts_with("/*") { self.offset += 2; depth += 1; } else if self.remaining().starts_with("*/") { self.offset += 2; depth -= 1; } else if let Some(character) = self.remaining().chars().next() { self.offset += character.len_utf8(); } } Ok(()) } fn read_string(&mut self) -> Result { let start = self.offset; self.offset += 1; let mut escaped = false; while let Some(character) = self.remaining().chars().next() { self.offset += character.len_utf8(); if escaped { escaped = false; } else if character == '\\' { escaped = true; } else if character == '"' { let literal = &self.input[start..self.offset]; return serde_json::from_str(literal).map_err(|error| { TypstContractError::new(format!( "Invalid string in '{CONTRACT_NAME}': {error}" )) }); } } Err(TypstContractError::new(format!( "Unterminated string in '{CONTRACT_NAME}'" ))) } fn read_identifier(&mut self) -> String { let start = self.offset; while let Some(character) = self.remaining().chars().next() { if !character.is_ascii_alphanumeric() && character != '_' && character != '-' { break; } self.offset += character.len_utf8(); } self.input[start..self.offset].to_string() } fn remaining(&self) -> &'a str { &self.input[self.offset..] } } fn contract_syntax_error(message: &str, token: Option) -> TypstContractError { TypstContractError::new(format!( "Invalid '{CONTRACT_NAME}' declaration: {message}, found {token:?}" )) } #[cfg(test)] mod tests { use super::{collection_paths, parse}; #[test] fn parses_fields_comments_escapes_and_nested_collection_scopes() { let contract = parse( r#"#let komp_ac_fields = ( // Header field. "invoice.number", /* Repeated child. /* Nested Typst comment. */ */ "packages[].items[].description", "customer.legal_name", )"#, ) .unwrap(); assert_eq!( contract.field_paths, [ "invoice.number", "packages[].items[].description", "customer.legal_name", ] ); assert_eq!( collection_paths(&contract.field_paths[1]).unwrap(), ["packages[]", "packages[].items[]"] ); } #[test] fn rejects_duplicate_missing_empty_and_invalid_collection_contracts() { for source in [ "#let title = [Invoice]", "#let komp_ac_fields = ()", "#let komp_ac_fields = (\"number\", \"number\")", "#let komp_ac_fields = (\"items[]\",)", "#let komp_ac_fields = (\"items[0].name\",)", ] { assert!(parse(source).is_err(), "unexpectedly accepted {source}"); } } #[test] fn allows_only_whitespace_and_comments_before_the_contract() { let contract = parse( r#" // #let komp_ac_fields = ("comment.fake",) /* Outer comment. /* Nested comment. */ */ #let /* allowed trivia */ komp_ac_fields /* here too */ = ( "invoice.number", ) "#, ) .unwrap(); assert_eq!(contract.field_paths, ["invoice.number"]); } #[test] fn rejects_a_contract_that_is_not_the_first_meaningful_declaration() { for source in [ "#let title = [Invoice]\n#let komp_ac_fields = (\"number\",)", "Invoice\n#let komp_ac_fields = (\"number\",)", "#let komp_ac_fields_backup = (\"fake\",)\n#let komp_ac_fields = (\"number\",)", ] { let error = parse(source).unwrap_err(); assert!( error.to_string().contains("must be the first meaningful declaration"), "unexpected error for {source}: {error}" ); } } }