From 04b45e125c0257cb4ef8e62c8c1f761da41fd9ca Mon Sep 17 00:00:00 2001 From: Filipriec Date: Sun, 30 Aug 2026 20:49:30 +0200 Subject: [PATCH] typst table creation --- client-gui2 | 2 +- common/src/lib.rs | 1 + common/src/typst_contract.rs | 517 +++++++++++++++++++++++++++++++++++ komp-app/src/lib.rs | 1 + komp-app/src/transport.rs | 4 +- komp-app/src/typst.rs | 48 ++++ server | 2 +- 7 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 common/src/typst_contract.rs create mode 100644 komp-app/src/typst.rs diff --git a/client-gui2 b/client-gui2 index acac1b05..87c989b9 160000 --- a/client-gui2 +++ b/client-gui2 @@ -1 +1 @@ -Subproject commit acac1b056ca82d5f9e477cd5cf192dd5b8067792 +Subproject commit 87c989b98a3775b4195b2aec5c6a27dc008f5127 diff --git a/common/src/lib.rs b/common/src/lib.rs index 7aa0124e..c0ea8548 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -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 { diff --git a/common/src/typst_contract.rs b/common/src/typst_contract.rs new file mode 100644 index 00000000..76f9126f --- /dev/null +++ b/common/src/typst_contract.rs @@ -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, +} + +#[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}" + ); + } + } +} diff --git a/komp-app/src/lib.rs b/komp-app/src/lib.rs index 50dc168c..4f794357 100644 --- a/komp-app/src/lib.rs +++ b/komp-app/src/lib.rs @@ -13,4 +13,5 @@ pub mod navigation; mod search; pub mod session; pub mod transport; +pub mod typst; pub mod value; diff --git a/komp-app/src/transport.rs b/komp-app/src/transport.rs index 1426e64a..4436f03f 100644 --- a/komp-app/src/transport.rs +++ b/komp-app/src/transport.rs @@ -120,6 +120,8 @@ mod tests { let mut second = Request::new(()); operation.add_metadata(&mut first).unwrap(); operation.add_metadata(&mut second).unwrap(); - assert_eq!(first.metadata(), second.metadata()); + for key in ["idempotency-key", "operation-created-at"] { + assert_eq!(first.metadata().get(key), second.metadata().get(key)); + } } } diff --git a/komp-app/src/typst.rs b/komp-app/src/typst.rs new file mode 100644 index 00000000..88e2661f --- /dev/null +++ b/komp-app/src/typst.rs @@ -0,0 +1,48 @@ +pub use common::typst_contract::{TypstContract, TypstContractError}; +use common::proto::komp_ac::document_data::TypstTemplateVersion; + +/// Parses the first meaningful declaration from stored Typst source for the +/// admin mapping workflow. It must be the literal `komp_ac_fields` tuple; +/// only whitespace and comments may precede it. The server independently runs +/// the same shared parser before accepting a submitted mapping. +pub fn parse_contract(source: &str) -> Result { + common::typst_contract::parse(source) +} + +/// Parses the source carried by an immutable template-version response. Admin +/// workflows should retain `version.id` separately and send it back when they +/// submit the reviewed table mapping. +pub fn parse_template_version( + version: &TypstTemplateVersion, +) -> Result { + parse_contract(&version.source_code) +} + +#[cfg(test)] +mod tests { + use super::{parse_contract, parse_template_version}; + use common::proto::komp_ac::document_data::TypstTemplateVersion; + + #[test] + fn exposes_typst_fields_to_client_workflows() { + let contract = parse_contract( + r#"#let komp_ac_fields = ("invoice.number", "items[].quantity")"#, + ) + .unwrap(); + + assert_eq!( + contract.field_paths, + ["invoice.number", "items[].quantity"] + ); + + let version = TypstTemplateVersion { + id: 42, + source_code: r#"#let komp_ac_fields = ("invoice.number",)"#.to_string(), + ..Default::default() + }; + assert_eq!( + parse_template_version(&version).unwrap().field_paths, + ["invoice.number"] + ); + } +} diff --git a/server b/server index 47476e10..15f69ae4 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 47476e10a6e81aef6fda1cf5b5429a4130e1894e +Subproject commit 15f69ae42cc22f280bb730333682565be66680d2