518 lines
16 KiB
Rust
518 lines
16 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct TypstContractError {
|
|
message: String,
|
|
}
|
|
|
|
impl TypstContractError {
|
|
fn new(message: impl Into<String>) -> 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<TypstContract, TypstContractError> {
|
|
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<Vec<String>, TypstContractError> {
|
|
if path.trim().is_empty() {
|
|
return Err(TypstContractError::new(
|
|
"Invoice template field path cannot be empty",
|
|
));
|
|
}
|
|
let segments = path.split('.').collect::<Vec<_>>();
|
|
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<Vec<String>, 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<usize, TypstContractError> {
|
|
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<Token>,
|
|
}
|
|
|
|
impl<'a> ContractParser<'a> {
|
|
fn new(input: &'a str) -> Self {
|
|
Self {
|
|
lexer: ContractLexer::new(input),
|
|
lookahead: None,
|
|
}
|
|
}
|
|
|
|
fn parse_string_list(&mut self) -> Result<Vec<String>, 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<Option<&Token>, TypstContractError> {
|
|
if self.lookahead.is_none() {
|
|
self.lookahead = self.lexer.next_token()?;
|
|
}
|
|
Ok(self.lookahead.as_ref())
|
|
}
|
|
|
|
fn next(&mut self) -> Result<Option<Token>, 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<Option<Token>, 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::<String>()
|
|
)));
|
|
}
|
|
};
|
|
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<String, TypstContractError> {
|
|
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<Token>) -> 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}"
|
|
);
|
|
}
|
|
}
|
|
}
|