use std::fmt;
use serde_json::Value as JsonValue;
use crate::catalog::{Catalog, Locale};
use crate::core::error::WorkshopError;
use crate::core::source::{Position, Span};
use crate::frontend::keywords::structural_keyword_prefix;
use crate::frontend::lexer::{Token, tokenize};
use crate::program::{Action, Program};
mod emit;
mod layout;
use crate::rules::statement::{Statement, StatementContentError, statement_from_content};
use emit::{emit_section_keyword, emit_statement_rows};
use layout::{
RuleLayout, StatementChunk, StatementSection, byte_is_whitespace, indent_unit, is_whitespace,
line_prefix, newline_of,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StatementList {
Conditions,
Actions,
}
impl StatementList {
fn section_id(self) -> &'static str {
match self {
Self::Conditions => "conditions",
Self::Actions => "actions",
}
}
fn list_len(self, rule: &crate::Rule) -> usize {
match self {
Self::Conditions => rule.conditions.len(),
Self::Actions => rule.actions.len(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct StatementAddress {
pub rule: usize,
pub list: StatementList,
pub index: usize,
}
impl StatementAddress {
pub fn new(rule: usize, list: StatementList, index: usize) -> Self {
Self { rule, list, index }
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum StatementEdit<'a> {
Insert(&'a [JsonValue]),
Replace(&'a JsonValue),
Delete,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum StatementEditError {
Content(StatementContentError),
UnknownRule {
rule: usize,
},
IndexOutOfRange {
rule: usize,
list: StatementList,
index: usize,
},
StatementKind {
rule: usize,
list: StatementList,
},
EmptyInsert {
rule: usize,
list: StatementList,
},
SourceUnavailable {
rule: usize,
},
StaleAddress {
rule: usize,
list: StatementList,
},
Emission(WorkshopError),
Edit(crate::source::SourceEditError),
}
impl fmt::Display for StatementEditError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Content(error) => write!(f, "{error}"),
Self::UnknownRule { rule } => write!(f, "no rule at index {rule}"),
Self::IndexOutOfRange { rule, list, index } => {
write!(f, "no {list:?} statement at index {index} in rule {rule}")
}
Self::StatementKind { rule, list } => write!(
f,
"statement kind does not match the {list:?} list of rule {rule}"
),
Self::EmptyInsert { rule, list } => {
write!(f, "insert into {list:?} of rule {rule} needs a statement")
}
Self::SourceUnavailable { rule } => {
write!(f, "rule {rule} has no editable source")
}
Self::StaleAddress { rule, list } => write!(
f,
"the {list:?} of rule {rule} no longer matches its retained source"
),
Self::Emission(error) => write!(f, "statement emission failed: {error}"),
Self::Edit(error) => write!(f, "source edit failed: {error}"),
}
}
}
impl std::error::Error for StatementEditError {}
impl From<StatementContentError> for StatementEditError {
fn from(error: StatementContentError) -> Self {
Self::Content(error)
}
}
impl From<WorkshopError> for StatementEditError {
fn from(error: WorkshopError) -> Self {
Self::Emission(error)
}
}
impl From<crate::source::SourceEditError> for StatementEditError {
fn from(error: crate::source::SourceEditError) -> Self {
Self::Edit(error)
}
}
type Result<T> = std::result::Result<T, StatementEditError>;
const ACTION_KEYWORD_IDS: &[&str] = &[
"disabled",
"if",
"elseIf",
"else",
"while",
"forGlobalVariable",
"forPlayerVariable",
"end",
];
impl Program {
pub fn edit_statements(
&self,
catalog: &Catalog,
locale: &Locale,
address: StatementAddress,
edit: StatementEdit<'_>,
) -> Result<crate::source::SourceEdit> {
let rule = self
.rules
.get(address.rule)
.ok_or(StatementEditError::UnknownRule { rule: address.rule })?;
let statements = convert_statements(catalog, address, &edit)?;
let list_len = address.list.list_len(rule);
match edit {
StatementEdit::Insert(_) => {
if address.index > list_len {
return Err(StatementEditError::IndexOutOfRange {
rule: address.rule,
list: address.list,
index: address.index,
});
}
}
StatementEdit::Replace(_) | StatementEdit::Delete => {
if address.index >= list_len {
return Err(StatementEditError::IndexOutOfRange {
rule: address.rule,
list: address.list,
index: address.index,
});
}
}
}
let rows = emit_statement_rows(&statements, self, catalog, locale)?;
let rule_span = self
.rule_span(address.rule)
.ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
let document = self
.source(rule_span.file)
.ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
let text = document.text();
let tokens = tokenize(text)
.map_err(|_| StatementEditError::SourceUnavailable { rule: address.rule })?;
let layout = RuleLayout::scan(&tokens, rule_span, catalog, locale)
.ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
let section = layout
.sections
.iter()
.find(|section| section.id == address.list.section_id());
let Some(section) = section else {
let unit = indent_unit(text, &tokens, &layout, rule_span);
return self.insert_created_section(
catalog, locale, address, list_len, &rows, &unit, &tokens, &layout, text, rule_span,
);
};
let synthesized_tail = match address.list {
StatementList::Conditions => section.chunks.len() != list_len,
StatementList::Actions => {
section.chunks.len() > list_len
|| !rule.actions[section.chunks.len()..]
.iter()
.all(|action| matches!(action, Action::End))
}
};
if synthesized_tail {
return Err(StatementEditError::StaleAddress {
rule: address.rule,
list: address.list,
});
}
match edit {
StatementEdit::Insert(_) => {
if let Some(chunk) = section.chunks.get(address.index) {
self.verify_statement(
address,
*chunk,
&tokens,
catalog,
locale,
rule_span.file,
)?;
self.insert_before_chunk(address, &rows, *chunk, &tokens, text, rule_span)
} else {
let unit = indent_unit(text, &tokens, &layout, rule_span);
self.insert_at_close(address, &rows, &unit, section, &tokens, text, rule_span)
}
}
StatementEdit::Replace(_) => {
let chunk =
section
.chunks
.get(address.index)
.ok_or(StatementEditError::StaleAddress {
rule: address.rule,
list: address.list,
})?;
self.verify_statement(address, *chunk, &tokens, catalog, locale, rule_span.file)?;
let row = rows.first().expect("replace emits one row");
self.replace_chunk(address, row, *chunk, &tokens, rule_span)
}
StatementEdit::Delete => {
let chunk =
section
.chunks
.get(address.index)
.ok_or(StatementEditError::StaleAddress {
rule: address.rule,
list: address.list,
})?;
self.verify_statement(address, *chunk, &tokens, catalog, locale, rule_span.file)?;
self.delete_chunk(address, *chunk, &tokens, text, rule_span)
}
}
}
fn verify_statement(
&self,
address: StatementAddress,
chunk: StatementChunk,
tokens: &[Token],
catalog: &Catalog,
locale: &Locale,
file: crate::source::FileId,
) -> Result<()> {
let chunk_start = tokens[chunk.first_content].start;
let chunk_end = tokens[chunk.semi].end;
let within = |start: Position| {
(start.line, start.col) >= (chunk_start.line, chunk_start.col)
&& (start.line, start.col) <= (chunk_end.line, chunk_end.col)
};
let verified = match address.list {
StatementList::Conditions => self
.condition_span(address.rule, address.index)
.is_some_and(|span| span.file == file && within(span.start)),
StatementList::Actions => match &self.rules[address.rule].actions[address.index] {
Action::Else | Action::End => {
let expected = if matches!(
&self.rules[address.rule].actions[address.index],
Action::Else
) {
"else"
} else {
"end"
};
structural_keyword_prefix(
tokens,
chunk.first_content,
catalog,
locale,
ACTION_KEYWORD_IDS,
)
.is_some_and(|(id, _)| id == expected)
}
Action::ElseIf { .. } => self
.action_argument_span(address.rule, address.index, 0)
.is_some_and(|span| span.file == file && within(span.start)),
_ => self
.action_span(address.rule, address.index)
.is_some_and(|span| span.file == file && within(span.start)),
},
};
if verified {
Ok(())
} else {
Err(StatementEditError::StaleAddress {
rule: address.rule,
list: address.list,
})
}
}
fn insert_before_chunk(
&self,
address: StatementAddress,
rows: &[String],
chunk: StatementChunk,
tokens: &[Token],
text: &str,
rule_span: Span,
) -> Result<crate::source::SourceEdit> {
let anchor = tokens[chunk.first].start;
let newline = newline_of(text, anchor);
let line_start = Position::new(anchor.line, 1);
let indent = line_prefix(text, anchor);
let (position, replacement) = if is_whitespace(indent) {
let mut replacement = String::new();
for row in rows {
replacement.push_str(indent);
replacement.push_str(row);
replacement.push_str(newline);
}
(line_start, replacement)
} else {
let left = if byte_is_whitespace(text, anchor) {
""
} else {
" "
};
(anchor, format!("{left}{} ", rows.join(" ")))
};
self.checked_edit(rule_span, position, position, replacement)
.map_err(|error| wrap_edit_error(address, error))
}
#[allow(clippy::too_many_arguments)]
fn insert_at_close(
&self,
address: StatementAddress,
rows: &[String],
unit: &str,
section: &StatementSection,
tokens: &[Token],
text: &str,
rule_span: Span,
) -> Result<crate::source::SourceEdit> {
let close = tokens[section.dangling.unwrap_or(section.close)].start;
let newline = newline_of(text, close);
let close_indent = line_prefix(text, close);
let (position, replacement) = if is_whitespace(close_indent) {
let indent = section
.chunks
.last()
.map(|chunk| line_prefix(text, tokens[chunk.first].start))
.filter(|prefix| is_whitespace(prefix))
.map(str::to_string)
.unwrap_or_else(|| format!("{close_indent}{unit}"));
let mut replacement = String::new();
for row in rows {
replacement.push_str(&indent);
replacement.push_str(row);
replacement.push_str(newline);
}
(Position::new(close.line, 1), replacement)
} else {
let left = if byte_is_whitespace(text, close) {
""
} else {
" "
};
(close, format!("{left}{} ", rows.join(" ")))
};
self.checked_edit(rule_span, position, position, replacement)
.map_err(|error| wrap_edit_error(address, error))
}
#[allow(clippy::too_many_arguments)]
fn insert_created_section(
&self,
catalog: &Catalog,
locale: &Locale,
address: StatementAddress,
list_len: usize,
rows: &[String],
unit: &str,
tokens: &[Token],
layout: &RuleLayout,
text: &str,
rule_span: Span,
) -> Result<crate::source::SourceEdit> {
if list_len != 0 || address.index != 0 || rows.is_empty() {
return Err(if list_len == 0 {
StatementEditError::IndexOutOfRange {
rule: address.rule,
list: address.list,
index: address.index,
}
} else {
StatementEditError::StaleAddress {
rule: address.rule,
list: address.list,
}
});
}
let keyword = emit_section_keyword(address.list, catalog, locale)?;
let anchor = layout
.sections
.iter()
.find(|section| section.id == "actions")
.map(|section| section.name_start)
.unwrap_or_else(|| tokens[layout.close].start);
let newline = newline_of(text, anchor);
let section_indent = layout
.sections
.last()
.map(|section| line_prefix(text, section.name_start))
.filter(|prefix| is_whitespace(prefix))
.map(str::to_string);
let anchor_indent = line_prefix(text, anchor);
let (position, replacement) = if is_whitespace(anchor_indent) {
let indent = section_indent.unwrap_or_else(|| format!("{anchor_indent}{unit}"));
let statement_indent = format!("{indent}{unit}");
let mut replacement = format!("{indent}{keyword} {{{newline}");
for row in rows {
replacement.push_str(&statement_indent);
replacement.push_str(row);
replacement.push_str(newline);
}
replacement.push_str(&indent);
replacement.push('}');
replacement.push_str(newline);
(Position::new(anchor.line, 1), replacement)
} else {
let left = if byte_is_whitespace(text, anchor) {
""
} else {
" "
};
(anchor, format!("{left}{keyword} {{ {} }} ", rows.join(" ")))
};
self.checked_edit(rule_span, position, position, replacement)
.map_err(|error| wrap_edit_error(address, error))
}
fn replace_chunk(
&self,
address: StatementAddress,
row: &str,
chunk: StatementChunk,
tokens: &[Token],
rule_span: Span,
) -> Result<crate::source::SourceEdit> {
let start = tokens[chunk.first_content].start;
let end = tokens[chunk.semi].end;
self.checked_edit(rule_span, start, end, row)
.map_err(|error| wrap_edit_error(address, error))
}
fn delete_chunk(
&self,
address: StatementAddress,
chunk: StatementChunk,
tokens: &[Token],
text: &str,
rule_span: Span,
) -> Result<crate::source::SourceEdit> {
let first = tokens[chunk.first].start;
let semi_end = tokens[chunk.semi].end;
let starts_line = is_whitespace(line_prefix(text, first));
let next = tokens.get(chunk.semi + 1).map(|token| token.start);
let content_follows = next.is_some_and(|next| next.line == semi_end.line);
let (start, end) = match (starts_line, content_follows) {
(true, false) => (
Position::new(first.line, 1),
Position::new(semi_end.line + 1, 1),
),
(_, true) => (first, next.expect("content follows")),
(false, false) => {
let blanks = line_prefix(text, first)
.chars()
.rev()
.take_while(|character| matches!(character, ' ' | '\t'))
.count();
(
Position::new(first.line, first.col - blanks as u32),
semi_end,
)
}
};
self.checked_edit(rule_span, start, end, "")
.map_err(|error| wrap_edit_error(address, error))
}
fn checked_edit(
&self,
rule_span: Span,
start: Position,
end: Position,
replacement: impl Into<String>,
) -> std::result::Result<crate::source::SourceEdit, crate::source::SourceEditError> {
self.edit_source(Span::new(rule_span.file, start, end), replacement)
}
}
fn wrap_edit_error(
address: StatementAddress,
error: crate::source::SourceEditError,
) -> StatementEditError {
match error {
crate::source::SourceEditError::InvalidRange => {
StatementEditError::SourceUnavailable { rule: address.rule }
}
other => StatementEditError::Edit(other),
}
}
fn convert_statements(
catalog: &Catalog,
address: StatementAddress,
edit: &StatementEdit<'_>,
) -> Result<Vec<Statement>> {
let nodes: &[JsonValue] = match edit {
StatementEdit::Insert(nodes) => {
if nodes.is_empty() {
return Err(StatementEditError::EmptyInsert {
rule: address.rule,
list: address.list,
});
}
nodes
}
StatementEdit::Replace(node) => std::slice::from_ref(node),
StatementEdit::Delete => &[],
};
let mut statements = Vec::with_capacity(nodes.len());
for node in nodes {
let statement = statement_from_content(catalog, node)?;
let fits = matches!(
(address.list, &statement),
(StatementList::Conditions, Statement::Condition(_))
| (StatementList::Actions, Statement::Action(_))
);
if !fits {
return Err(StatementEditError::StatementKind {
rule: address.rule,
list: address.list,
});
}
statements.push(statement);
}
Ok(statements)
}