typst table creation
This commit is contained in:
@@ -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 {
|
||||
|
||||
517
common/src/typst_contract.rs
Normal file
517
common/src/typst_contract.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user