use crate::model::{GamePath, GamePathBuf, Isolation, SourceRepair};
use serde::{Deserialize, Serialize};
use std::path::Path;
mod recovery;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Span {
pub line: usize,
pub column: usize,
pub offset: usize,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SpanRange {
pub start: Span,
pub end: Span,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ScalarValue {
Identifier(String),
String(String),
Number(String),
Bool(bool),
}
impl ScalarValue {
pub fn as_text(&self) -> String {
match self {
Self::Identifier(value) => value.clone(),
Self::String(value) => value.clone(),
Self::Number(value) => value.clone(),
Self::Bool(value) => {
if *value {
"yes".to_string()
} else {
"no".to_string()
}
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum AstValue {
Scalar {
value: ScalarValue,
span: SpanRange,
},
Block {
items: Vec<AstStatement>,
span: SpanRange,
},
}
impl AstValue {
pub fn span(&self) -> &SpanRange {
match self {
Self::Scalar { span, .. } => span,
Self::Block { span, .. } => span,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum AstStatement {
Assignment {
key: String,
key_span: SpanRange,
value: AstValue,
span: SpanRange,
},
Item {
value: AstValue,
span: SpanRange,
},
Comment {
text: String,
span: SpanRange,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AstFile {
pub path: GamePathBuf,
pub statements: Vec<AstStatement>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ParseDiagnosticCode {
ReadFailure,
UnterminatedLuaBlockComment,
MissingClosingBrace,
UnmatchedClosingBrace,
UnmatchedOpeningBrace,
MissingOpeningBrace,
UnterminatedString,
MissingValue,
InvalidStatementStart,
InvalidValue,
}
impl ParseDiagnosticCode {
pub fn name(self) -> &'static str {
match self {
Self::ReadFailure => "read_failure",
Self::UnterminatedLuaBlockComment => "unterminated_lua_block_comment",
Self::MissingClosingBrace => "missing_closing_brace",
Self::UnmatchedClosingBrace => "unmatched_closing_brace",
Self::UnmatchedOpeningBrace => "unmatched_opening_brace",
Self::MissingOpeningBrace => "missing_opening_brace",
Self::UnterminatedString => "unterminated_string",
Self::MissingValue => "missing_value",
Self::InvalidStatementStart => "invalid_statement_start",
Self::InvalidValue => "invalid_value",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ParseDiagnostic {
pub code: ParseDiagnosticCode,
pub message: String,
pub span: SpanRange,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repair: Option<SourceRepair>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isolation: Option<Isolation>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ParseResult {
pub ast: AstFile,
pub diagnostics: Vec<ParseDiagnostic>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ParsedStatements {
pub statements: Vec<AstStatement>,
pub diagnostics: Vec<ParseDiagnostic>,
}
impl ParsedStatements {
pub fn into_parse_result(self, path: GamePathBuf) -> ParseResult {
ParseResult {
ast: AstFile {
path,
statements: self.statements,
},
diagnostics: self.diagnostics,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum ScriptSyntax {
Clausewitz,
Lua,
}
impl ScriptSyntax {
pub fn from_extension(extension: Option<&str>) -> Self {
if extension.is_some_and(|extension| extension.eq_ignore_ascii_case("lua")) {
Self::Lua
} else {
Self::Clausewitz
}
}
pub fn for_game_path(path: &GamePath) -> Self {
Self::from_extension(path.extension())
}
pub fn for_physical_path(path: &Path) -> Self {
Self::from_extension(path.extension().and_then(|extension| extension.to_str()))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum TokenKind {
Identifier(String),
String(String),
Number(String),
Bool(bool),
Eq,
LBrace,
RBrace,
Comment(String),
Newline,
Comma,
Eof,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Token {
kind: TokenKind,
span: SpanRange,
}
struct Lexer<'a> {
source: &'a str,
bytes: &'a [u8],
index: usize,
line: usize,
column: usize,
lua_mode: bool,
close_string_at_line_end: Option<usize>,
diagnostics: Vec<ParseDiagnostic>,
}
impl<'a> Lexer<'a> {
fn new(source: &'a str, lua_mode: bool) -> Self {
Self {
source,
bytes: source.as_bytes(),
index: 0,
line: 1,
column: 1,
lua_mode,
close_string_at_line_end: None,
diagnostics: Vec::new(),
}
}
fn take_diagnostics(&mut self) -> Vec<ParseDiagnostic> {
std::mem::take(&mut self.diagnostics)
}
fn next_token(&mut self) -> Token {
self.skip_inline_whitespace();
let start = self.current_span();
let Some(byte) = self.peek_byte() else {
return Token {
kind: TokenKind::Eof,
span: SpanRange {
start: start.clone(),
end: start,
},
};
};
match byte {
b'\n' => {
self.advance_byte();
Token {
kind: TokenKind::Newline,
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b'=' => {
self.advance_byte();
Token {
kind: TokenKind::Eq,
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b'{' => {
self.advance_byte();
Token {
kind: TokenKind::LBrace,
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b'}' => {
self.advance_byte();
Token {
kind: TokenKind::RBrace,
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b',' if self.lua_mode => {
self.advance_byte();
Token {
kind: TokenKind::Comma,
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b'#' => {
self.advance_byte();
let text_start = self.index;
while let Some(next) = self.peek_byte() {
if next == b'\n' {
break;
}
self.advance_byte();
}
let text = self.source[text_start..self.index].trim().to_string();
Token {
kind: TokenKind::Comment(text),
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b'-' if self.lua_mode && self.peek_byte_at(1) == Some(b'-') => {
self.advance_byte();
self.advance_byte();
let text_start = self.index;
self.consume_lua_comment_body(start.clone());
let text = self.source[text_start..self.index].trim().to_string();
Token {
kind: TokenKind::Comment(text),
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b'"' => {
let quote = self.index;
let close_at_line_end = self.close_string_at_line_end == Some(quote);
self.advance_byte();
let text_start = self.index;
while let Some(next) = self.peek_byte() {
if next == b'"' || (close_at_line_end && matches!(next, b'\r' | b'\n')) {
break;
}
if next == b'\\' {
self.advance_byte();
if self.peek_byte().is_some() {
self.advance_byte();
}
continue;
}
self.advance_byte();
}
let text = self.source[text_start..self.index].to_string();
if self.peek_byte() == Some(b'"') {
self.advance_byte();
} else if self.peek_byte().is_none() {
self.diagnostics.push(ParseDiagnostic {
code: ParseDiagnosticCode::UnterminatedString,
message: "string has no closing quote before end of file".to_string(),
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
repair: None,
isolation: None,
});
}
Token {
kind: TokenKind::String(text),
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
b'-' | b'0'..=b'9' => {
let text_start = self.index;
self.advance_byte();
while let Some(next) = self.peek_byte() {
if is_token_delimiter(next)
|| (self.lua_mode && next == b',')
|| (self.lua_mode && next == b'-' && self.peek_byte_at(1) == Some(b'-'))
{
break;
}
self.advance_byte();
}
let text = self.source[text_start..self.index].to_string();
Token {
kind: if is_number_token(&text) {
TokenKind::Number(text)
} else {
TokenKind::Identifier(text)
},
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
_ => {
let text_start = self.index;
self.advance_byte();
while let Some(next) = self.peek_byte() {
if is_token_delimiter(next) || (self.lua_mode && next == b',') {
break;
}
if self.lua_mode && next == b'-' && self.peek_byte_at(1) == Some(b'-') {
break;
}
self.advance_byte();
}
let text = self.source[text_start..self.index].trim().to_string();
let kind = match text.as_str() {
"yes" => TokenKind::Bool(true),
"no" => TokenKind::Bool(false),
_ => TokenKind::Identifier(text),
};
Token {
kind,
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
}
}
}
}
fn skip_inline_whitespace(&mut self) {
while let Some(byte) = self.peek_byte() {
if byte == b' ' || byte == b'\t' || byte == b'\r' {
self.advance_byte();
} else {
break;
}
}
}
fn peek_byte(&self) -> Option<u8> {
self.bytes.get(self.index).copied()
}
fn peek_byte_at(&self, offset: usize) -> Option<u8> {
self.bytes.get(self.index + offset).copied()
}
fn consume_lua_comment_body(&mut self, start: Span) {
if self.peek_byte() == Some(b'[') {
let mut probe_index = self.index;
probe_index += 1;
let mut level: usize = 0;
while self.bytes.get(probe_index).copied() == Some(b'=') {
level += 1;
probe_index += 1;
}
if self.bytes.get(probe_index).copied() == Some(b'[') {
self.advance_byte(); for _ in 0..level {
self.advance_byte();
}
self.advance_byte();
loop {
let Some(byte) = self.peek_byte() else {
self.diagnostics.push(ParseDiagnostic {
code: ParseDiagnosticCode::UnterminatedLuaBlockComment,
message: format!(
"unterminated Lua block comment --[{}[",
"=".repeat(level)
),
span: SpanRange {
start: start.clone(),
end: self.current_span(),
},
repair: None,
isolation: None,
});
return;
};
if byte == b']' {
let mut close_probe = self.index + 1;
let mut close_level: usize = 0;
while self.bytes.get(close_probe).copied() == Some(b'=') {
close_level += 1;
close_probe += 1;
}
if close_level == level
&& self.bytes.get(close_probe).copied() == Some(b']')
{
self.advance_byte();
for _ in 0..level {
self.advance_byte();
}
self.advance_byte();
return;
}
self.advance_byte();
} else {
self.advance_byte();
}
}
}
}
while let Some(next) = self.peek_byte() {
if next == b'\n' {
break;
}
self.advance_byte();
}
}
fn advance_byte(&mut self) {
if let Some(byte) = self.peek_byte() {
self.index += 1;
if byte == b'\n' {
self.line += 1;
self.column = 1;
} else {
self.column += 1;
}
}
}
fn current_span(&self) -> Span {
Span {
line: self.line,
column: self.column,
offset: self.index,
}
}
}
fn is_token_delimiter(byte: u8) -> bool {
matches!(
byte,
b' ' | b'\t' | b'\r' | b'\n' | b'=' | b'{' | b'}' | b'#'
)
}
pub(crate) fn unquoted_scalar(text: &str) -> ScalarValue {
let number_leading = text
.bytes()
.next()
.is_some_and(|byte| byte == b'-' || byte.is_ascii_digit());
match text {
_ if number_leading && is_number_token(text) => ScalarValue::Number(text.to_string()),
"yes" => ScalarValue::Bool(true),
"no" => ScalarValue::Bool(false),
_ => ScalarValue::Identifier(text.to_string()),
}
}
fn is_number_token(text: &str) -> bool {
if text == "-" {
return true;
}
let unsigned = text.strip_prefix('-').unwrap_or(text);
let Some(exponent_index) = unsigned.find(['e', 'E']) else {
return unsigned.bytes().any(|byte| byte.is_ascii_digit())
&& unsigned
.bytes()
.all(|byte| byte.is_ascii_digit() || byte == b'.');
};
let (mantissa, exponent_with_marker) = unsigned.split_at(exponent_index);
let exponent = &exponent_with_marker[1..];
decimal_mantissa_is_valid(mantissa)
&& signed_digits_are_valid(exponent)
&& !exponent.bytes().any(|byte| matches!(byte, b'e' | b'E'))
}
fn decimal_mantissa_is_valid(mantissa: &str) -> bool {
match mantissa.split_once('.') {
Some((whole, fraction)) => {
!fraction.is_empty()
&& whole.bytes().all(|byte| byte.is_ascii_digit())
&& fraction.bytes().all(|byte| byte.is_ascii_digit())
}
None => !mantissa.is_empty() && mantissa.bytes().all(|byte| byte.is_ascii_digit()),
}
}
fn signed_digits_are_valid(value: &str) -> bool {
let digits = value.strip_prefix(['+', '-']).unwrap_or(value);
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
}
struct ParserState {
tokens: Vec<Token>,
index: usize,
diagnostics: Vec<ParseDiagnostic>,
lua: bool,
}
impl ParserState {
fn new(tokens: Vec<Token>) -> Self {
Self {
tokens,
index: 0,
diagnostics: Vec::new(),
lua: false,
}
}
fn reading_lua(mut self, lua: bool) -> Self {
self.lua = lua;
self
}
fn parse_file(mut self) -> ParsedStatements {
let statements = self.parse_statements(false);
ParsedStatements {
statements,
diagnostics: self.diagnostics,
}
}
fn parse_statements(&mut self, stop_at_rbrace: bool) -> Vec<AstStatement> {
let mut statements = Vec::new();
loop {
let token = self.peek();
match &token.kind {
TokenKind::Eof => {
if stop_at_rbrace {
let span = token.span.clone();
self.diagnostics.push(ParseDiagnostic {
code: ParseDiagnosticCode::MissingClosingBrace,
message: "missing closing brace before end of file".into(),
span,
repair: None,
isolation: None,
});
}
break;
}
TokenKind::RBrace if stop_at_rbrace => {
self.bump();
break;
}
TokenKind::RBrace => {
let span = token.span.clone();
self.bump();
self.diagnostics.push(ParseDiagnostic {
code: ParseDiagnosticCode::UnmatchedClosingBrace,
message: "unexpected closing brace without an opening block".into(),
span,
repair: None,
isolation: None,
});
}
TokenKind::Newline | TokenKind::Comma => {
self.bump();
}
TokenKind::Comment(text) => {
let span = token.span.clone();
let text = text.clone();
self.bump();
statements.push(AstStatement::Comment { text, span });
}
_ => {
if let Some(stmt) = self.parse_statement() {
statements.push(stmt);
}
}
}
}
statements
}
fn parse_statement(&mut self) -> Option<AstStatement> {
let first = self.bump();
match first.kind {
TokenKind::Identifier(key) => {
if matches!(self.peek().kind, TokenKind::Eq) {
if self.leave_out_empty_assignment(&first.span) {
return None;
}
self.bump();
let value = self.parse_value();
let end = value.span().end.clone();
Some(AstStatement::Assignment {
key,
key_span: first.span.clone(),
value,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else if matches!(self.peek().kind, TokenKind::LBrace) {
let value = self.parse_value();
let end = value.span().end.clone();
Some(AstStatement::Assignment {
key,
key_span: first.span.clone(),
value,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else {
let value = AstValue::Scalar {
value: ScalarValue::Identifier(key),
span: first.span.clone(),
};
Some(AstStatement::Item {
value,
span: SpanRange {
start: first.span.start,
end: first.span.end,
},
})
}
}
TokenKind::String(value) => {
if matches!(self.peek().kind, TokenKind::Eq) {
if self.leave_out_empty_assignment(&first.span) {
return None;
}
let _ = self.bump();
let value_node = self.parse_value();
let end = value_node.span().end.clone();
Some(AstStatement::Assignment {
key: value,
key_span: first.span.clone(),
value: value_node,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else if matches!(self.peek().kind, TokenKind::LBrace) {
let value_node = self.parse_value();
let end = value_node.span().end.clone();
Some(AstStatement::Assignment {
key: value,
key_span: first.span.clone(),
value: value_node,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else {
Some(AstStatement::Item {
value: AstValue::Scalar {
value: ScalarValue::String(value),
span: first.span.clone(),
},
span: first.span,
})
}
}
TokenKind::Number(value) => {
if matches!(self.peek().kind, TokenKind::Eq) {
if self.leave_out_empty_assignment(&first.span) {
return None;
}
let _ = self.bump();
let value_node = self.parse_value();
let end = value_node.span().end.clone();
Some(AstStatement::Assignment {
key: value,
key_span: first.span.clone(),
value: value_node,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else if matches!(self.peek().kind, TokenKind::LBrace) {
let value_node = self.parse_value();
let end = value_node.span().end.clone();
Some(AstStatement::Assignment {
key: value,
key_span: first.span.clone(),
value: value_node,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else {
Some(AstStatement::Item {
value: AstValue::Scalar {
value: ScalarValue::Number(value),
span: first.span.clone(),
},
span: first.span,
})
}
}
TokenKind::Bool(value) => {
if matches!(self.peek().kind, TokenKind::Eq) {
let _ = self.bump();
let value_node = self.parse_value();
let end = value_node.span().end.clone();
Some(AstStatement::Assignment {
key: if value {
"yes".to_string()
} else {
"no".to_string()
},
key_span: first.span.clone(),
value: value_node,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else if matches!(self.peek().kind, TokenKind::LBrace) {
let value_node = self.parse_value();
let end = value_node.span().end.clone();
Some(AstStatement::Assignment {
key: if value {
"yes".to_string()
} else {
"no".to_string()
},
key_span: first.span.clone(),
value: value_node,
span: SpanRange {
start: first.span.start.clone(),
end,
},
})
} else {
Some(AstStatement::Item {
value: AstValue::Scalar {
value: ScalarValue::Bool(value),
span: first.span.clone(),
},
span: first.span,
})
}
}
TokenKind::LBrace => {
let start = first.span.start;
let items = self.parse_statements(true);
let end = self.previous().span.end.clone();
Some(AstStatement::Item {
value: AstValue::Block {
items,
span: SpanRange {
start: start.clone(),
end: end.clone(),
},
},
span: SpanRange { start, end },
})
}
TokenKind::RBrace | TokenKind::Eof => None,
_ => {
self.diagnostics.push(ParseDiagnostic {
code: ParseDiagnosticCode::InvalidStatementStart,
message: "could not parse statement start token".to_string(),
span: first.span,
repair: None,
isolation: None,
});
None
}
}
}
fn leave_out_empty_assignment(&mut self, key: &SpanRange) -> bool {
let mut index = self.index + 1;
let mut crossed_line = false;
while let Some(token) = self.tokens.get(index) {
match token.kind {
TokenKind::Newline => crossed_line = true,
TokenKind::Comment(_) => {}
_ => break,
}
index += 1;
}
let Some(next) = self.tokens.get(index) else {
return false;
};
let empty = match next.kind {
TokenKind::RBrace | TokenKind::Eof => true,
TokenKind::Identifier(_)
| TokenKind::String(_)
| TokenKind::Number(_)
| TokenKind::Bool(_) => {
crossed_line
&& next.span.start.column <= key.start.column
&& matches!(
self.tokens.get(index + 1).map(|token| &token.kind),
Some(TokenKind::Eq)
)
}
_ => false,
};
if !empty {
return false;
}
let eq = self.bump();
self.diagnostics.push(ParseDiagnostic {
code: ParseDiagnosticCode::MissingValue,
message: "assignment has no value".to_string(),
span: SpanRange {
start: key.start.clone(),
end: eq.span.end,
},
repair: (!self.lua).then_some(SourceRepair {
edit: crate::model::SourceRepairEdit::RemovedEmptyAssignment,
evidence: crate::model::SourceRepairEvidence::NothingLost,
}),
isolation: None,
});
true
}
fn parse_value(&mut self) -> AstValue {
let mut token = self.bump();
while matches!(
token.kind,
TokenKind::Newline | TokenKind::Comma | TokenKind::Comment(_) | TokenKind::Eq
) {
token = self.bump();
}
match token.kind {
TokenKind::LBrace => {
let start = token.span.start;
let items = self.parse_statements(true);
let end = self.previous().span.end.clone();
AstValue::Block {
items,
span: SpanRange { start, end },
}
}
TokenKind::Identifier(value) => {
if matches!(self.peek().kind, TokenKind::Eq) {
self.bump();
self.parse_value()
} else {
AstValue::Scalar {
value: ScalarValue::Identifier(value),
span: token.span,
}
}
}
TokenKind::String(value) => {
if matches!(self.peek().kind, TokenKind::Eq) {
self.bump();
self.parse_value()
} else {
AstValue::Scalar {
value: ScalarValue::String(value),
span: token.span,
}
}
}
TokenKind::Number(value) => {
if matches!(self.peek().kind, TokenKind::Eq) {
self.bump();
self.parse_value()
} else {
AstValue::Scalar {
value: ScalarValue::Number(value),
span: token.span,
}
}
}
TokenKind::Bool(value) => {
if matches!(self.peek().kind, TokenKind::Eq) {
self.bump();
self.parse_value()
} else {
AstValue::Scalar {
value: ScalarValue::Bool(value),
span: token.span,
}
}
}
TokenKind::Comment(text) => AstValue::Scalar {
value: ScalarValue::Identifier(text),
span: token.span,
},
_ => {
self.diagnostics.push(ParseDiagnostic {
code: ParseDiagnosticCode::InvalidValue,
message: "value parse failed; downgraded to empty identifier".to_string(),
span: token.span.clone(),
repair: None,
isolation: None,
});
AstValue::Scalar {
value: ScalarValue::Identifier("<parse-error>".to_string()),
span: token.span,
}
}
}
}
fn peek(&self) -> &Token {
self.tokens.get(self.index).unwrap_or_else(|| {
self.tokens
.last()
.expect("token stream should always contain eof")
})
}
fn previous(&self) -> &Token {
if self.index == 0 {
self.tokens
.first()
.expect("token stream should always contain eof")
} else {
&self.tokens[self.index - 1]
}
}
fn bump(&mut self) -> Token {
let token = self.peek().clone();
if !matches!(token.kind, TokenKind::Eof) {
self.index += 1;
}
token
}
}
pub fn parse_clausewitz_file(path: &Path) -> ParsedStatements {
match std::fs::read(path) {
Ok(bytes) => {
let content = crate::game::eu4::text::decode_paradox_bytes(&bytes);
parse_clausewitz_statements(ScriptSyntax::for_physical_path(path), &content)
}
Err(err) => read_failure(&err),
}
}
pub(crate) fn read_failure(err: &std::io::Error) -> ParsedStatements {
let start = Span {
line: 1,
column: 1,
offset: 0,
};
ParsedStatements {
statements: Vec::new(),
diagnostics: vec![ParseDiagnostic {
code: ParseDiagnosticCode::ReadFailure,
message: format!("failed to read file: {err}"),
span: SpanRange {
start: start.clone(),
end: start,
},
repair: None,
isolation: None,
}],
}
}
pub fn parse_clausewitz_content(path: &GamePath, content: &str) -> ParseResult {
parse_clausewitz_statements(ScriptSyntax::for_game_path(path), content)
.into_parse_result(path.to_owned())
}
pub fn parse_clausewitz_statements(syntax: ScriptSyntax, content: &str) -> ParsedStatements {
recover_clausewitz_statements(
syntax,
content,
parse_unrecovered_statements(syntax, content),
None,
)
}
pub fn parse_unrecovered_statements(syntax: ScriptSyntax, content: &str) -> ParsedStatements {
let (tokens, lexer_diagnostics) = lex(content, syntax == ScriptSyntax::Lua, None);
let mut result = ParserState::new(tokens)
.reading_lua(syntax == ScriptSyntax::Lua)
.parse_file();
result.diagnostics.extend(lexer_diagnostics);
result
}
pub fn repair_text_edits(
content: &str,
diagnostics: &[ParseDiagnostic],
) -> Option<Vec<crate::game::eu4::text::TextEdit>> {
diagnostics
.iter()
.filter_map(|diagnostic| {
let repair = diagnostic.repair?;
Some(repair_text_edit(
content,
repair.edit,
diagnostic.span.start.offset,
))
})
.collect()
}
pub fn repair_text_edit(
content: &str,
edit: crate::model::SourceRepairEdit,
offset: usize,
) -> Option<crate::game::eu4::text::TextEdit> {
use crate::game::eu4::text::TextEdit;
use crate::model::SourceRepairEdit;
Some(match edit {
SourceRepairEdit::RemovedClosingBrace | SourceRepairEdit::RemovedOpeningBrace => {
let brace = if edit == SourceRepairEdit::RemovedClosingBrace {
"}"
} else {
"{"
};
if content.get(offset..offset + 1)? != brace {
return None;
}
TextEdit {
offset,
remove: 1,
insert: "",
}
}
SourceRepairEdit::InsertedClosingBrace => {
let line_start = content.get(..offset)?.rfind('\n').map_or(0, |at| at + 1);
if content[line_start..offset].trim().is_empty() {
TextEdit {
offset: line_start,
remove: 0,
insert: "}\n",
}
} else if offset == content.len() {
TextEdit {
offset,
remove: 0,
insert: "\n}\n",
}
} else {
return None;
}
}
SourceRepairEdit::RemovedEmptyAssignment => {
let eq = offset + content.get(offset..)?.find('=')? + 1;
let line_start = content[..offset].rfind('\n').map_or(0, |at| at + 1);
let rest = &content[eq..];
let line_end = rest.find('\n').map_or(content.len(), |at| eq + at + 1);
if content[line_start..offset].trim().is_empty()
&& content[eq..line_end].trim().is_empty()
{
TextEdit {
offset: line_start,
remove: line_end - line_start,
insert: "",
}
} else {
TextEdit {
offset,
remove: eq - offset,
insert: "",
}
}
}
SourceRepairEdit::InsertedOpeningBrace => {
if !content.get(..offset)?.ends_with('=') {
return None;
}
TextEdit {
offset,
remove: 0,
insert: " {",
}
}
SourceRepairEdit::ClosedStringAtLineEnd => {
let rest = content.get(offset..)?;
let line = rest.find('\n').map_or(rest.len(), |at| at);
let end = offset + line - usize::from(rest[..line].ends_with('\r'));
TextEdit {
offset: end,
remove: 0,
insert: "\"",
}
}
})
}
pub type SchemaCheck<'a> = dyn Fn(&[AstStatement]) -> usize + 'a;
pub fn recover_clausewitz_statements(
syntax: ScriptSyntax,
content: &str,
parsed: ParsedStatements,
schema: Option<&SchemaCheck<'_>>,
) -> ParsedStatements {
if syntax == ScriptSyntax::Clausewitz && unrepaired(&parsed.diagnostics) {
return recovery::recover(content, parsed, schema);
}
parsed
}
fn unrepaired(diagnostics: &[ParseDiagnostic]) -> bool {
diagnostics
.iter()
.any(|diagnostic| diagnostic.repair.is_none())
}
fn lex(
content: &str,
lua_mode: bool,
close_string_at_line_end: Option<usize>,
) -> (Vec<Token>, Vec<ParseDiagnostic>) {
let mut lexer = Lexer::new(content, lua_mode);
lexer.close_string_at_line_end = close_string_at_line_end;
let mut tokens = Vec::new();
loop {
let token = lexer.next_token();
let is_eof = matches!(token.kind, TokenKind::Eof);
tokens.push(token);
if is_eof {
break;
}
}
(tokens, lexer.take_diagnostics())
}
#[cfg(test)]
mod tests {
use super::{
AstStatement, AstValue, ParseDiagnosticCode, ScalarValue, ScriptSyntax,
parse_clausewitz_content, parse_clausewitz_file, parse_clausewitz_statements,
};
use crate::model::GamePath;
use std::fs;
use std::path::Path;
fn game_path(text: &str) -> &GamePath {
GamePath::new(text).expect("valid game path")
}
fn assignment_keys(statements: &[AstStatement]) -> Vec<&str> {
statements
.iter()
.filter_map(|statement| match statement {
AstStatement::Assignment { key, .. } => Some(key.as_str()),
_ => None,
})
.collect()
}
#[test]
fn the_ast_carries_the_game_path_it_was_parsed_at() {
let path = game_path("common/scripted_effects/a.txt");
let parsed = parse_clausewitz_content(path, "a = { }\n");
assert_eq!(parsed.ast.path.as_game_path(), path);
assert_eq!(assignment_keys(&parsed.ast.statements), vec!["a"]);
}
#[test]
fn a_lua_game_path_is_parsed_with_lua_comments_in_any_case() {
let has_comment = |statements: &[AstStatement]| {
statements
.iter()
.any(|statement| matches!(statement, AstStatement::Comment { .. }))
};
let source = "-- comment\nx = 1\n";
for text in [
"common/defines/00_defines.lua",
"common/defines/00_DEFINES.LUA",
] {
let parsed = parse_clausewitz_content(game_path(text), source);
assert!(
parsed.diagnostics.is_empty(),
"{text}: {:?}",
parsed.diagnostics
);
assert!(has_comment(&parsed.ast.statements), "{text}");
assert_eq!(assignment_keys(&parsed.ast.statements), vec!["x"], "{text}");
}
let script = parse_clausewitz_content(game_path("common/defines/00_defines.txt"), source);
assert!(
!has_comment(&script.ast.statements),
"`--` is not a comment outside Lua"
);
}
#[test]
fn syntax_follows_the_extension_of_either_kind_of_path() {
assert_eq!(
ScriptSyntax::for_game_path(game_path("map/random/tweaks.lua")),
ScriptSyntax::Lua
);
assert_eq!(
ScriptSyntax::for_game_path(game_path("interface/a.gui")),
ScriptSyntax::Clausewitz
);
assert_eq!(
ScriptSyntax::for_physical_path(Path::new("/tmp/Defines.Lua")),
ScriptSyntax::Lua
);
assert_eq!(
ScriptSyntax::for_physical_path(Path::new("/tmp/no_extension")),
ScriptSyntax::Clausewitz
);
}
#[test]
fn statements_are_parsed_without_any_path() {
let parsed = parse_clausewitz_statements(ScriptSyntax::Lua, "-- c\nx = 1\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(assignment_keys(&parsed.statements), vec!["x"]);
}
#[test]
fn a_physical_file_is_parsed_without_a_game_path() {
let temp = tempfile::tempdir().expect("temp dir");
let file = temp.path().join("tweaks.lua");
fs::write(&file, "-- c\nx = 1\n").expect("write script");
let parsed = parse_clausewitz_file(&file);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(assignment_keys(&parsed.statements), vec!["x"]);
let missing = parse_clausewitz_file(&temp.path().join("missing.txt"));
assert!(missing.statements.is_empty());
assert!(
missing.diagnostics[0]
.message
.starts_with("failed to read file")
);
}
#[test]
fn unclosed_block_reports_eof_and_retains_parsed_content() {
let source = "g = { old = { primary = AAA }";
let parsed = parse_clausewitz_content(game_path("common/cultures/test.txt"), source);
assert_eq!(parsed.diagnostics.len(), 1);
assert_eq!(
parsed.diagnostics[0].code,
ParseDiagnosticCode::MissingClosingBrace
);
assert_eq!(parsed.diagnostics[0].span.start.offset, source.len());
assert_eq!(parsed.ast.statements.len(), 1);
}
#[test]
fn extra_closing_brace_is_reported_without_discarding_following_groups() {
let source =
include_str!("../../../../tests/fixtures/cultures/malformed/extra_closing_brace.txt");
let parsed = parse_clausewitz_content(game_path("common/cultures/test.txt"), source);
assert_eq!(parsed.diagnostics.len(), 1);
assert_eq!(
parsed.diagnostics[0].code,
ParseDiagnosticCode::UnmatchedClosingBrace
);
assert_eq!(parsed.diagnostics[0].span.start.line, 5);
assert_eq!(
parsed
.ast
.statements
.iter()
.filter(|statement| matches!(statement, AstStatement::Assignment { .. }))
.count(),
2
);
}
#[test]
fn parser_handles_assignments_and_lists() {
let parsed = parse_clausewitz_content(
game_path("test.txt"),
"name = \"x\"\ntags = {\n\t\"A\"\n\t\"B\"\n}\n",
);
assert!(parsed.diagnostics.is_empty());
assert_eq!(parsed.ast.statements.len(), 2);
let AstStatement::Assignment { value, .. } = &parsed.ast.statements[1] else {
panic!("expected assignment");
};
let AstValue::Block { items, .. } = value else {
panic!("expected block value");
};
assert_eq!(items.len(), 2);
}
#[test]
fn parser_treats_digit_leading_identifier_as_assignment_key() {
let parsed = parse_clausewitz_content(
game_path("common/powerprojection/00_static.txt"),
"25_permanent_power_projection = { yearly_decay = 1 }\n",
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 1, "{:#?}", parsed.ast);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected digit-leading assignment");
};
assert_eq!(key, "25_permanent_power_projection");
let AstValue::Block { items, .. } = value else {
panic!("expected definition block");
};
let AstStatement::Assignment { value, .. } = &items[0] else {
panic!("expected numeric field assignment");
};
assert!(matches!(
value,
AstValue::Scalar {
value: ScalarValue::Number(number),
..
} if number == "1"
));
}
#[test]
fn parser_accepts_nested_equals_assignment_forms() {
let parsed = parse_clausewitz_content(
game_path("missions.txt"),
"custom_tooltip = njd_unite_arabia_tooltip = { factor = 1 }\ncenter_of_trade = 1 = yes\n286 = = { owner = ROOT }\n",
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 3);
for statement in &parsed.ast.statements {
let AstStatement::Assignment { value, .. } = statement else {
panic!("expected assignment");
};
match value {
AstValue::Block { .. } | AstValue::Scalar { .. } => {}
}
}
}
#[test]
fn parser_accepts_implicit_block_assignments_without_equals() {
let parsed = parse_clausewitz_content(
game_path("scripted_effects.txt"),
"some_effect {\n\t$who$ = {\n\t\ttrigger_switch = { 100 = { PREV = { add_prestige = 1 } } }\n\t}\n}\n",
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 1);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected implicit block assignment");
};
assert_eq!(key, "some_effect");
let AstValue::Block { items, .. } = value else {
panic!("expected block value");
};
assert!(!items.is_empty());
}
#[test]
fn only_lowercase_yes_and_no_are_boolean() {
for (source, expected) in [
("v = yes\n", ScalarValue::Bool(true)),
("v = no\n", ScalarValue::Bool(false)),
("v = YES\n", ScalarValue::Identifier("YES".to_string())),
("v = Yes\n", ScalarValue::Identifier("Yes".to_string())),
("v = NO\n", ScalarValue::Identifier("NO".to_string())),
] {
let parsed = parse_clausewitz_content(game_path("test.txt"), source);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
let [AstStatement::Assignment { value, .. }] = parsed.ast.statements.as_slice() else {
panic!("expected one assignment for {source:?}")
};
let AstValue::Scalar { value, .. } = value else {
panic!("expected a scalar for {source:?}")
};
assert_eq!(value, &expected, "{source:?}");
}
}
#[test]
fn emitting_a_non_lowercase_yes_keeps_its_spelling() {
let parsed = parse_clausewitz_content(game_path("test.txt"), "v = YES\n");
let rendered =
crate::game::eu4::script::emit::emit_clausewitz_statements(&parsed.ast.statements)
.expect("emit");
assert!(rendered.contains("v = YES"), "{rendered}");
}
#[test]
fn parser_keeps_escaped_quotes_inside_multiline_strings() {
let parsed = parse_clausewitz_content(
game_path("scripted_effects.txt"),
r#"event_wrapper = {
effect = "
if = {
limit = { has_dlc = \"Mandate of Heaven\" }
}
"
}
next_effect = { add_prestige = 1 }
"#,
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 2, "{:#?}", parsed.ast);
assert!(
parsed
.ast
.statements
.iter()
.all(|statement| matches!(statement, AstStatement::Assignment { .. }))
);
let AstStatement::Assignment {
value: AstValue::Block { items, .. },
..
} = &parsed.ast.statements[0]
else {
panic!("expected event wrapper block");
};
let AstStatement::Assignment {
key,
value: AstValue::Scalar { value, .. },
..
} = &items[0]
else {
panic!("expected multiline effect string");
};
assert_eq!(key, "effect");
assert!(value.as_text().contains(r#"\"Mandate of Heaven\""#));
}
#[test]
fn lua_mode_recognizes_double_dash_line_comment() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "-- foo\nbar=1\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 2);
let AstStatement::Comment { text, .. } = &parsed.ast.statements[0] else {
panic!("expected comment");
};
assert!(text.contains("foo"));
let AstStatement::Assignment { key, .. } = &parsed.ast.statements[1] else {
panic!("expected assignment");
};
assert_eq!(key, "bar");
}
#[test]
fn lua_mode_recognizes_inline_comment_after_value() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "x = 1 -- trail\ny = 2\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 3);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected assignment");
};
assert_eq!(key, "x");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("1".to_string()));
let AstStatement::Comment { text, .. } = &parsed.ast.statements[1] else {
panic!("expected comment");
};
assert!(text.contains("trail"));
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[2] else {
panic!("expected assignment");
};
assert_eq!(key, "y");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("2".to_string()));
}
#[test]
fn lua_mode_recognizes_inline_comment_after_identifier_no_space() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "x = yes--c\ny = 2\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 3);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected assignment");
};
assert_eq!(key, "x");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Bool(true));
let AstStatement::Comment { text, .. } = &parsed.ast.statements[1] else {
panic!("expected comment");
};
assert!(text.contains("c"));
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[2] else {
panic!("expected assignment");
};
assert_eq!(key, "y");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("2".to_string()));
}
#[test]
fn lua_mode_recognizes_inline_comment_after_number_no_space() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "x = 60--c\ny = 2\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 3);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected assignment");
};
assert_eq!(key, "x");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("60".to_string()));
let AstStatement::Comment { text, .. } = &parsed.ast.statements[1] else {
panic!("expected comment");
};
assert!(text.contains("c"));
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[2] else {
panic!("expected assignment");
};
assert_eq!(key, "y");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("2".to_string()));
}
#[test]
fn lua_mode_recognizes_inline_comment_after_string_no_space() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "x = \"a\"--c\ny = 2\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 3);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected assignment");
};
assert_eq!(key, "x");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::String("a".to_string()));
let AstStatement::Comment { text, .. } = &parsed.ast.statements[1] else {
panic!("expected comment");
};
assert!(text.contains("c"));
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[2] else {
panic!("expected assignment");
};
assert_eq!(key, "y");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("2".to_string()));
}
#[test]
fn lua_mode_negative_number_still_works() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "a = -1\nb = -0.5\nc = -\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 3);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected assignment");
};
assert_eq!(key, "a");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("-1".to_string()));
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[1] else {
panic!("expected assignment");
};
assert_eq!(key, "b");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("-0.5".to_string()));
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[2] else {
panic!("expected assignment");
};
assert_eq!(key, "c");
let AstValue::Scalar { .. } = value else {
panic!("expected scalar value");
};
}
#[test]
fn a_minus_before_a_parameter_stays_part_of_the_value() {
let source = "add_piety = -$amount$\nadd_karma = -$amount$ lone = -\n";
let parsed =
parse_clausewitz_content(game_path("common/scripted_effects/test.txt"), source);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
let values = parsed
.ast
.statements
.iter()
.map(|statement| {
let AstStatement::Assignment {
key,
value: AstValue::Scalar { value, .. },
..
} = statement
else {
panic!("expected scalar assignment, got {statement:?}");
};
(key.as_str(), value.clone())
})
.collect::<Vec<_>>();
assert_eq!(
values,
[
(
"add_piety",
ScalarValue::Identifier("-$amount$".to_string())
),
(
"add_karma",
ScalarValue::Identifier("-$amount$".to_string())
),
("lone", ScalarValue::Number("-".to_string())),
]
);
let emitted =
crate::game::eu4::script::emit::emit_clausewitz_statements(&parsed.ast.statements)
.expect("emit statements");
assert!(
emitted.starts_with("add_piety = -$amount$\nadd_karma = -$amount$\n"),
"{emitted}"
);
}
#[test]
fn lua_mode_comma_separates_numeric_scalars_and_preserves_exponents() {
let parsed = parse_clausewitz_content(
game_path("defines.lua"),
"plain = 75,\npositive_exp = 1e-5,\nnegative_exp = -1e-5,\nstandalone = -,\n",
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 4, "{:#?}", parsed.ast);
for (statement, (expected_key, expected_number)) in parsed.ast.statements.iter().zip([
("plain", "75"),
("positive_exp", "1e-5"),
("negative_exp", "-1e-5"),
("standalone", "-"),
]) {
let AstStatement::Assignment { key, value, .. } = statement else {
panic!("expected assignment");
};
assert_eq!(key, expected_key);
assert!(matches!(
value,
AstValue::Scalar {
value: ScalarValue::Number(number),
..
} if number == expected_number
));
}
}
#[test]
fn lua_mode_block_comment_level_zero() {
let parsed = parse_clausewitz_content(
game_path("test.lua"),
"--[[ first line\nsecond line ]]\nx = 1\n",
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 2);
let AstStatement::Comment { text, span } = &parsed.ast.statements[0] else {
panic!("expected comment");
};
assert!(text.contains("first line"));
assert!(text.contains("second line"));
assert_eq!(span.end.line, 2);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[1] else {
panic!("expected assignment");
};
assert_eq!(key, "x");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("1".to_string()));
}
#[test]
fn lua_mode_block_comment_level_two() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "--[==[ a ]==]\nx = 1\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 2);
let AstStatement::Comment { text, .. } = &parsed.ast.statements[0] else {
panic!("expected comment");
};
assert!(text.contains("a"));
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[1] else {
panic!("expected assignment");
};
assert_eq!(key, "x");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("1".to_string()));
}
fn keys(statements: &[AstStatement]) -> Vec<String> {
statements
.iter()
.filter_map(|statement| match statement {
AstStatement::Assignment { key, value, .. } => Some(match value {
AstValue::Block { items, .. } => format!("{key}{{{}}}", keys(items).join(" ")),
AstValue::Scalar { value, .. } => format!("{key}={}", value.as_text()),
}),
_ => None,
})
.collect()
}
#[test]
fn an_assignment_with_no_value_is_left_out_instead_of_taking_the_next_key() {
let parsed = parse_clausewitz_statements(
ScriptSyntax::Clausewitz,
"a = {\n\tx =\n\ty = 1\n\tz =\n}\n",
);
assert_eq!(keys(&parsed.statements), ["a{y=1}"]);
assert_eq!(
parsed
.diagnostics
.iter()
.map(|diagnostic| (
diagnostic.code,
diagnostic.span.start.line,
diagnostic.repair.is_some()
))
.collect::<Vec<_>>(),
[
(ParseDiagnosticCode::MissingValue, 2, true),
(ParseDiagnosticCode::MissingValue, 4, true),
]
);
}
#[test]
fn a_value_on_the_next_line_still_belongs_to_its_key() {
let parsed = parse_clausewitz_statements(
ScriptSyntax::Clausewitz,
"a = {\n\tOR =\n\t{\n\t\tx = 1\n\t}\n\tname =\n\t\tvalue\n}\n",
);
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(keys(&parsed.statements), ["a{OR{x=1} name=value}"]);
}
#[test]
fn each_code_name_is_how_it_serializes() {
for code in [
ParseDiagnosticCode::ReadFailure,
ParseDiagnosticCode::UnterminatedLuaBlockComment,
ParseDiagnosticCode::MissingClosingBrace,
ParseDiagnosticCode::UnmatchedClosingBrace,
ParseDiagnosticCode::UnmatchedOpeningBrace,
ParseDiagnosticCode::MissingOpeningBrace,
ParseDiagnosticCode::UnterminatedString,
ParseDiagnosticCode::MissingValue,
ParseDiagnosticCode::InvalidStatementStart,
ParseDiagnosticCode::InvalidValue,
] {
assert_eq!(
serde_json::to_string(&code).unwrap(),
format!("\"{}\"", code.name())
);
}
}
#[test]
fn lua_keeps_an_assignment_with_no_value_fatal() {
let parsed = parse_clausewitz_statements(ScriptSyntax::Lua, "x =\ny = 1\n");
assert_eq!(parsed.diagnostics.len(), 1, "{:?}", parsed.diagnostics);
assert_eq!(
parsed.diagnostics[0].code,
ParseDiagnosticCode::MissingValue
);
assert!(parsed.diagnostics[0].repair.is_none());
}
#[test]
fn lua_mode_unterminated_block_comment_emits_diagnostic() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "--[[ no end\n");
assert!(!parsed.diagnostics.is_empty());
assert!(parsed.diagnostics.iter().any(|diagnostic| {
diagnostic
.message
.to_ascii_lowercase()
.contains("unterminated")
}));
assert!(
parsed
.ast
.statements
.iter()
.any(|statement| matches!(statement, AstStatement::Comment { .. }))
);
}
#[test]
fn lua_mode_dotted_keys_normalize_to_assignment() {
let parsed = parse_clausewitz_content(game_path("test.lua"), "NDefines.NCountry.X = 0.5\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert_eq!(parsed.ast.statements.len(), 1);
let AstStatement::Assignment { key, value, .. } = &parsed.ast.statements[0] else {
panic!("expected assignment");
};
assert_eq!(key, "NDefines.NCountry.X");
let AstValue::Scalar { value, .. } = value else {
panic!("expected scalar value");
};
assert_eq!(value, &ScalarValue::Number("0.5".to_string()));
}
#[test]
fn non_lua_mode_reads_double_dash_as_a_value() {
let parsed = parse_clausewitz_content(game_path("test.txt"), "-- foo\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert!(parsed.ast.statements.len() >= 2);
assert!(
parsed
.ast
.statements
.iter()
.all(|statement| !matches!(statement, AstStatement::Comment { .. }))
);
assert!(parsed.ast.statements.iter().any(|statement| matches!(
statement,
AstStatement::Item {
value: AstValue::Scalar {
value: ScalarValue::Identifier(text),
..
},
..
} if text.as_str() == "--"
)));
}
#[test]
fn lua_mode_off_for_unknown_extension() {
let parsed = parse_clausewitz_content(game_path("test.gui"), "-- foo\n");
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
assert!(parsed.ast.statements.len() >= 2);
assert!(
parsed
.ast
.statements
.iter()
.all(|statement| !matches!(statement, AstStatement::Comment { .. }))
);
assert!(parsed.ast.statements.iter().any(|statement| matches!(
statement,
AstStatement::Item {
value: AstValue::Scalar {
value: ScalarValue::Identifier(text),
..
},
..
} if text.as_str() == "--"
)));
}
}