typst table creation

This commit is contained in:
Filipriec
2026-08-30 20:49:30 +02:00
parent fedf89d94a
commit 04b45e125c
7 changed files with 572 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ pub mod grpc_error;
pub mod money;
pub mod relationship;
pub mod system_column;
pub mod typst_contract;
pub mod proto {
pub mod komp_ac {

View File

@@ -0,0 +1,517 @@
//! 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}"
);
}
}
}