Skip to main content

workshop_rs/rules/statement_edit/
mod.rs

1//! Statement-level edits for raw Workshop source.
2//!
3//! [`Program::edit_statements`] is the write direction of the
4//! `workshop-rs/rule-content-v1` statement contract
5//! ([`crate::rules::statement`]): given a parsed program, it produces the
6//! checked [`crate::source::SourceEdit`] that inserts statement nodes before
7//! a statement index, replaces the statement at an index, or deletes it,
8//! inside one rule's `conditions` or `actions` section. Inserted and
9//! replacement statements are emitted canonically in `locale` and indented
10//! like the surrounding source. Bytes outside the returned edit's range are
11//! unchanged, and the edit itself re-verifies them when applied.
12//!
13//! Addresses are positional: [`StatementAddress`] names a rule, a statement
14//! list, and an index into the public [`crate::Rule`] model — the same
15//! order `rule-content-v1` exports. The call is fail-closed: an
16//! out-of-range or stale address (the public model no longer matching the
17//! retained source at that position), a node
18//! [`statement_from_content`] refuses, an emission failure, or a rule with
19//! no retained source produces an error and no edit.
20//!
21//! Layout and comment rules, in terms of a *statement chunk* (one statement
22//! plus the standalone `"…"` annotations directly attached to it):
23//!
24//! - `replace` rewrites only the statement's own tokens; a leading `"…"`
25//!   annotation in the same chunk stays.
26//! - `delete` removes the chunk: the statement and its leading `"…"`
27//!   annotations. A chunk alone on its line takes the whole line, with a
28//!   `//` tail comment; a chunk that shares its line takes only its own
29//!   bytes and one adjacent whitespace run.
30//! - `insert` writes no comments. Inserted rows take the anchor line's
31//!   indentation; `//` line comments are unowned positional trivia and are
32//!   never counted as part of a statement.
33//! - Inserting into a rule with no `conditions` section creates the section
34//!   immediately before `actions` (or the rule's closing brace when there
35//!   is no `actions` section).
36//! - Deleting the last statement leaves the empty section in place; it
37//!   reparses with an empty list.
38
39use std::fmt;
40
41use serde_json::Value as JsonValue;
42
43use crate::catalog::{Catalog, Locale};
44use crate::core::error::WorkshopError;
45use crate::core::source::{Position, Span};
46use crate::frontend::keywords::structural_keyword_prefix;
47use crate::frontend::lexer::{Token, tokenize};
48use crate::program::{Action, Program};
49
50mod emit;
51mod layout;
52
53use crate::rules::statement::{Statement, StatementContentError, statement_from_content};
54use emit::{emit_section_keyword, emit_statement_rows};
55use layout::{
56    RuleLayout, StatementChunk, StatementSection, byte_is_whitespace, indent_unit, is_whitespace,
57    line_prefix, newline_of,
58};
59
60/// The rule statement list a statement edit addresses.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum StatementList {
64    /// The rule's `conditions` section (`rule.conditions`).
65    Conditions,
66    /// The rule's `actions` section (`rule.actions`).
67    Actions,
68}
69
70impl StatementList {
71    fn section_id(self) -> &'static str {
72        match self {
73            Self::Conditions => "conditions",
74            Self::Actions => "actions",
75        }
76    }
77
78    fn list_len(self, rule: &crate::Rule) -> usize {
79        match self {
80            Self::Conditions => rule.conditions.len(),
81            Self::Actions => rule.actions.len(),
82        }
83    }
84}
85
86/// The `(rule, list, index)` address of a statement edit, by position in the
87/// public [`crate::Rule`] model — the same order `rule-content-v1` exports.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct StatementAddress {
91    /// The rule index in `program.rules`.
92    pub rule: usize,
93    /// The statement list inside the rule.
94    pub list: StatementList,
95    /// The statement index inside the list. For an insert this is the index
96    /// the new statements are placed before; `index == list.len()` appends.
97    pub index: usize,
98}
99
100impl StatementAddress {
101    /// An address by rule index, statement list, and statement index.
102    pub fn new(rule: usize, list: StatementList, index: usize) -> Self {
103        Self { rule, list, index }
104    }
105}
106
107/// One edit against a rule statement list, addressed by
108/// [`StatementAddress`]. The payload nodes are `rule-content-v1` statement
109/// documents — `{"value": …}` for conditions, `{"call": id, "args": […]}`
110/// for actions — converted through
111/// [`statement_from_content`].
112#[derive(Debug, Clone, Copy)]
113#[non_exhaustive]
114pub enum StatementEdit<'a> {
115    /// Insert a non-empty list of statements before `index`
116    /// (`index == list.len()` appends at the end of the section).
117    Insert(&'a [JsonValue]),
118    /// Replace exactly the statement at `index`, keeping its leading
119    /// comments.
120    Replace(&'a JsonValue),
121    /// Delete the statement at `index` together with its comments.
122    Delete,
123}
124
125/// Why [`Program::edit_statements`] refused an edit. Every refusal produces
126/// no [`crate::source::SourceEdit`].
127#[derive(Debug)]
128#[non_exhaustive]
129pub enum StatementEditError {
130    /// A statement node `statement_from_content` could not convert.
131    Content(StatementContentError),
132    /// `address.rule` indexes no rule of the program.
133    UnknownRule { rule: usize },
134    /// `address.index` addresses no statement: past the end of the list, or
135    /// into the synthesized tail a rule-final unclosed `if` group carries in
136    /// the public model but not in the source.
137    IndexOutOfRange {
138        rule: usize,
139        list: StatementList,
140        index: usize,
141    },
142    /// A condition node was addressed at `actions`, or an action node at
143    /// `conditions`.
144    StatementKind { rule: usize, list: StatementList },
145    /// `insert` was given an empty statement list.
146    EmptyInsert { rule: usize, list: StatementList },
147    /// The program carries no retained source for the addressed rule, or
148    /// the retained text can no longer be read back.
149    SourceUnavailable { rule: usize },
150    /// The retained source does not match the public model at the addressed
151    /// position: the statement count differs, the addressed index is a
152    /// source-synthesized entry, or the statement recorded there no longer
153    /// has the model's content.
154    StaleAddress { rule: usize, list: StatementList },
155    /// Statement emission failed (for example a spelling missing in the
156    /// requested locale, or a statement the Workshop grammar cannot carry).
157    Emission(WorkshopError),
158    /// The computed edit failed `Program::edit_source`'s checked range.
159    Edit(crate::source::SourceEditError),
160}
161
162impl fmt::Display for StatementEditError {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Self::Content(error) => write!(f, "{error}"),
166            Self::UnknownRule { rule } => write!(f, "no rule at index {rule}"),
167            Self::IndexOutOfRange { rule, list, index } => {
168                write!(f, "no {list:?} statement at index {index} in rule {rule}")
169            }
170            Self::StatementKind { rule, list } => write!(
171                f,
172                "statement kind does not match the {list:?} list of rule {rule}"
173            ),
174            Self::EmptyInsert { rule, list } => {
175                write!(f, "insert into {list:?} of rule {rule} needs a statement")
176            }
177            Self::SourceUnavailable { rule } => {
178                write!(f, "rule {rule} has no editable source")
179            }
180            Self::StaleAddress { rule, list } => write!(
181                f,
182                "the {list:?} of rule {rule} no longer matches its retained source"
183            ),
184            Self::Emission(error) => write!(f, "statement emission failed: {error}"),
185            Self::Edit(error) => write!(f, "source edit failed: {error}"),
186        }
187    }
188}
189
190impl std::error::Error for StatementEditError {}
191
192impl From<StatementContentError> for StatementEditError {
193    fn from(error: StatementContentError) -> Self {
194        Self::Content(error)
195    }
196}
197
198impl From<WorkshopError> for StatementEditError {
199    fn from(error: WorkshopError) -> Self {
200        Self::Emission(error)
201    }
202}
203
204impl From<crate::source::SourceEditError> for StatementEditError {
205    fn from(error: crate::source::SourceEditError) -> Self {
206        Self::Edit(error)
207    }
208}
209
210type Result<T> = std::result::Result<T, StatementEditError>;
211
212const ACTION_KEYWORD_IDS: &[&str] = &[
213    "disabled",
214    "if",
215    "elseIf",
216    "else",
217    "while",
218    "forGlobalVariable",
219    "forPlayerVariable",
220    "end",
221];
222
223impl Program {
224    /// Produce the checked [`crate::source::SourceEdit`] that inserts,
225    /// replaces, or deletes one statement position of `rule`'s
226    /// `conditions` or `actions` list. See the module documentation for the
227    /// address model and the layout/comment rules.
228    ///
229    /// `locale` is the session's resolved Workshop locale: inserted and
230    /// replacement statements are emitted with its spellings, and missing
231    /// spellings fail explicitly. The returned edit is checked again on
232    /// apply — [`crate::source::SourceDocument::apply`] refuses it if the
233    /// source bytes at its range changed — and consumers reparse the edited
234    /// text for updated spans.
235    ///
236    /// ```
237    /// use serde_json::json;
238    /// use workshop_rs::catalog::{Catalog, Locale};
239    /// use workshop_rs::parser;
240    /// use workshop_rs::rules::statement_edit::{
241    ///     StatementAddress, StatementEdit, StatementList,
242    /// };
243    ///
244    /// let catalog = Catalog::builtin().unwrap();
245    /// let source = "rule (\"x\") {\n    event { Ongoing - Global; }\n    actions { Wait(1, Ignore Condition); }\n}\n";
246    /// let program = parser::parse(source, &catalog, &Locale::new("en-US")).unwrap();
247    /// let edit = program
248    ///     .edit_statements(
249    ///         &catalog,
250    ///         &Locale::new("en-US"),
251    ///         StatementAddress::new(0, StatementList::Actions, 0),
252    ///         StatementEdit::Replace(&json!({"call": "wait", "args": [2.0]})),
253    ///     )
254    ///     .unwrap();
255    /// let updated = edit.apply(source).unwrap();
256    /// assert!(updated.contains("Wait(2);"));
257    /// ```
258    pub fn edit_statements(
259        &self,
260        catalog: &Catalog,
261        locale: &Locale,
262        address: StatementAddress,
263        edit: StatementEdit<'_>,
264    ) -> Result<crate::source::SourceEdit> {
265        let rule = self
266            .rules
267            .get(address.rule)
268            .ok_or(StatementEditError::UnknownRule { rule: address.rule })?;
269
270        let statements = convert_statements(catalog, address, &edit)?;
271        let list_len = address.list.list_len(rule);
272        match edit {
273            StatementEdit::Insert(_) => {
274                if address.index > list_len {
275                    return Err(StatementEditError::IndexOutOfRange {
276                        rule: address.rule,
277                        list: address.list,
278                        index: address.index,
279                    });
280                }
281            }
282            StatementEdit::Replace(_) | StatementEdit::Delete => {
283                if address.index >= list_len {
284                    return Err(StatementEditError::IndexOutOfRange {
285                        rule: address.rule,
286                        list: address.list,
287                        index: address.index,
288                    });
289                }
290            }
291        }
292
293        let rows = emit_statement_rows(&statements, self, catalog, locale)?;
294
295        let rule_span = self
296            .rule_span(address.rule)
297            .ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
298        let document = self
299            .source(rule_span.file)
300            .ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
301        let text = document.text();
302        let tokens = tokenize(text)
303            .map_err(|_| StatementEditError::SourceUnavailable { rule: address.rule })?;
304        let layout = RuleLayout::scan(&tokens, rule_span, catalog, locale)
305            .ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
306
307        let section = layout
308            .sections
309            .iter()
310            .find(|section| section.id == address.list.section_id());
311        let Some(section) = section else {
312            let unit = indent_unit(text, &tokens, &layout, rule_span);
313            return self.insert_created_section(
314                catalog, locale, address, list_len, &rows, &unit, &tokens, &layout, text, rule_span,
315            );
316        };
317
318        let synthesized_tail = match address.list {
319            StatementList::Conditions => section.chunks.len() != list_len,
320            StatementList::Actions => {
321                section.chunks.len() > list_len
322                    || !rule.actions[section.chunks.len()..]
323                        .iter()
324                        .all(|action| matches!(action, Action::End))
325            }
326        };
327        if synthesized_tail {
328            return Err(StatementEditError::StaleAddress {
329                rule: address.rule,
330                list: address.list,
331            });
332        }
333
334        match edit {
335            StatementEdit::Insert(_) => {
336                if let Some(chunk) = section.chunks.get(address.index) {
337                    self.verify_statement(
338                        address,
339                        *chunk,
340                        &tokens,
341                        catalog,
342                        locale,
343                        rule_span.file,
344                    )?;
345                    self.insert_before_chunk(address, &rows, *chunk, &tokens, text, rule_span)
346                } else {
347                    let unit = indent_unit(text, &tokens, &layout, rule_span);
348                    self.insert_at_close(address, &rows, &unit, section, &tokens, text, rule_span)
349                }
350            }
351            StatementEdit::Replace(_) => {
352                let chunk =
353                    section
354                        .chunks
355                        .get(address.index)
356                        .ok_or(StatementEditError::StaleAddress {
357                            rule: address.rule,
358                            list: address.list,
359                        })?;
360                self.verify_statement(address, *chunk, &tokens, catalog, locale, rule_span.file)?;
361                let row = rows.first().expect("replace emits one row");
362                self.replace_chunk(address, row, *chunk, &tokens, rule_span)
363            }
364            StatementEdit::Delete => {
365                let chunk =
366                    section
367                        .chunks
368                        .get(address.index)
369                        .ok_or(StatementEditError::StaleAddress {
370                            rule: address.rule,
371                            list: address.list,
372                        })?;
373                self.verify_statement(address, *chunk, &tokens, catalog, locale, rule_span.file)?;
374                self.delete_chunk(address, *chunk, &tokens, text, rule_span)
375            }
376        }
377    }
378
379    fn verify_statement(
380        &self,
381        address: StatementAddress,
382        chunk: StatementChunk,
383        tokens: &[Token],
384        catalog: &Catalog,
385        locale: &Locale,
386        file: crate::source::FileId,
387    ) -> Result<()> {
388        let chunk_start = tokens[chunk.first_content].start;
389        let chunk_end = tokens[chunk.semi].end;
390        let within = |start: Position| {
391            (start.line, start.col) >= (chunk_start.line, chunk_start.col)
392                && (start.line, start.col) <= (chunk_end.line, chunk_end.col)
393        };
394        let verified = match address.list {
395            StatementList::Conditions => self
396                .condition_span(address.rule, address.index)
397                .is_some_and(|span| span.file == file && within(span.start)),
398            StatementList::Actions => match &self.rules[address.rule].actions[address.index] {
399                Action::Else | Action::End => {
400                    let expected = if matches!(
401                        &self.rules[address.rule].actions[address.index],
402                        Action::Else
403                    ) {
404                        "else"
405                    } else {
406                        "end"
407                    };
408                    structural_keyword_prefix(
409                        tokens,
410                        chunk.first_content,
411                        catalog,
412                        locale,
413                        ACTION_KEYWORD_IDS,
414                    )
415                    .is_some_and(|(id, _)| id == expected)
416                }
417                Action::ElseIf { .. } => self
418                    .action_argument_span(address.rule, address.index, 0)
419                    .is_some_and(|span| span.file == file && within(span.start)),
420                _ => self
421                    .action_span(address.rule, address.index)
422                    .is_some_and(|span| span.file == file && within(span.start)),
423            },
424        };
425        if verified {
426            Ok(())
427        } else {
428            Err(StatementEditError::StaleAddress {
429                rule: address.rule,
430                list: address.list,
431            })
432        }
433    }
434
435    fn insert_before_chunk(
436        &self,
437        address: StatementAddress,
438        rows: &[String],
439        chunk: StatementChunk,
440        tokens: &[Token],
441        text: &str,
442        rule_span: Span,
443    ) -> Result<crate::source::SourceEdit> {
444        let anchor = tokens[chunk.first].start;
445        let newline = newline_of(text, anchor);
446        let line_start = Position::new(anchor.line, 1);
447        let indent = line_prefix(text, anchor);
448        let (position, replacement) = if is_whitespace(indent) {
449            let mut replacement = String::new();
450            for row in rows {
451                replacement.push_str(indent);
452                replacement.push_str(row);
453                replacement.push_str(newline);
454            }
455            (line_start, replacement)
456        } else {
457            let left = if byte_is_whitespace(text, anchor) {
458                ""
459            } else {
460                " "
461            };
462            (anchor, format!("{left}{} ", rows.join(" ")))
463        };
464        self.checked_edit(rule_span, position, position, replacement)
465            .map_err(|error| wrap_edit_error(address, error))
466    }
467
468    #[allow(clippy::too_many_arguments)]
469    fn insert_at_close(
470        &self,
471        address: StatementAddress,
472        rows: &[String],
473        unit: &str,
474        section: &StatementSection,
475        tokens: &[Token],
476        text: &str,
477        rule_span: Span,
478    ) -> Result<crate::source::SourceEdit> {
479        let close = tokens[section.dangling.unwrap_or(section.close)].start;
480        let newline = newline_of(text, close);
481        let close_indent = line_prefix(text, close);
482        let (position, replacement) = if is_whitespace(close_indent) {
483            let indent = section
484                .chunks
485                .last()
486                .map(|chunk| line_prefix(text, tokens[chunk.first].start))
487                .filter(|prefix| is_whitespace(prefix))
488                .map(str::to_string)
489                .unwrap_or_else(|| format!("{close_indent}{unit}"));
490            let mut replacement = String::new();
491            for row in rows {
492                replacement.push_str(&indent);
493                replacement.push_str(row);
494                replacement.push_str(newline);
495            }
496            (Position::new(close.line, 1), replacement)
497        } else {
498            let left = if byte_is_whitespace(text, close) {
499                ""
500            } else {
501                " "
502            };
503            (close, format!("{left}{} ", rows.join(" ")))
504        };
505        self.checked_edit(rule_span, position, position, replacement)
506            .map_err(|error| wrap_edit_error(address, error))
507    }
508
509    #[allow(clippy::too_many_arguments)]
510    fn insert_created_section(
511        &self,
512        catalog: &Catalog,
513        locale: &Locale,
514        address: StatementAddress,
515        list_len: usize,
516        rows: &[String],
517        unit: &str,
518        tokens: &[Token],
519        layout: &RuleLayout,
520        text: &str,
521        rule_span: Span,
522    ) -> Result<crate::source::SourceEdit> {
523        if list_len != 0 || address.index != 0 || rows.is_empty() {
524            return Err(if list_len == 0 {
525                StatementEditError::IndexOutOfRange {
526                    rule: address.rule,
527                    list: address.list,
528                    index: address.index,
529                }
530            } else {
531                StatementEditError::StaleAddress {
532                    rule: address.rule,
533                    list: address.list,
534                }
535            });
536        }
537        let keyword = emit_section_keyword(address.list, catalog, locale)?;
538        let anchor = layout
539            .sections
540            .iter()
541            .find(|section| section.id == "actions")
542            .map(|section| section.name_start)
543            .unwrap_or_else(|| tokens[layout.close].start);
544        let newline = newline_of(text, anchor);
545        let section_indent = layout
546            .sections
547            .last()
548            .map(|section| line_prefix(text, section.name_start))
549            .filter(|prefix| is_whitespace(prefix))
550            .map(str::to_string);
551        let anchor_indent = line_prefix(text, anchor);
552        let (position, replacement) = if is_whitespace(anchor_indent) {
553            let indent = section_indent.unwrap_or_else(|| format!("{anchor_indent}{unit}"));
554            let statement_indent = format!("{indent}{unit}");
555            let mut replacement = format!("{indent}{keyword} {{{newline}");
556            for row in rows {
557                replacement.push_str(&statement_indent);
558                replacement.push_str(row);
559                replacement.push_str(newline);
560            }
561            replacement.push_str(&indent);
562            replacement.push('}');
563            replacement.push_str(newline);
564            (Position::new(anchor.line, 1), replacement)
565        } else {
566            let left = if byte_is_whitespace(text, anchor) {
567                ""
568            } else {
569                " "
570            };
571            (anchor, format!("{left}{keyword} {{ {} }} ", rows.join(" ")))
572        };
573        self.checked_edit(rule_span, position, position, replacement)
574            .map_err(|error| wrap_edit_error(address, error))
575    }
576
577    fn replace_chunk(
578        &self,
579        address: StatementAddress,
580        row: &str,
581        chunk: StatementChunk,
582        tokens: &[Token],
583        rule_span: Span,
584    ) -> Result<crate::source::SourceEdit> {
585        let start = tokens[chunk.first_content].start;
586        let end = tokens[chunk.semi].end;
587        self.checked_edit(rule_span, start, end, row)
588            .map_err(|error| wrap_edit_error(address, error))
589    }
590
591    fn delete_chunk(
592        &self,
593        address: StatementAddress,
594        chunk: StatementChunk,
595        tokens: &[Token],
596        text: &str,
597        rule_span: Span,
598    ) -> Result<crate::source::SourceEdit> {
599        let first = tokens[chunk.first].start;
600        let semi_end = tokens[chunk.semi].end;
601        let starts_line = is_whitespace(line_prefix(text, first));
602        let next = tokens.get(chunk.semi + 1).map(|token| token.start);
603        let content_follows = next.is_some_and(|next| next.line == semi_end.line);
604        let (start, end) = match (starts_line, content_follows) {
605            (true, false) => (
606                Position::new(first.line, 1),
607                Position::new(semi_end.line + 1, 1),
608            ),
609            (_, true) => (first, next.expect("content follows")),
610            (false, false) => {
611                let blanks = line_prefix(text, first)
612                    .chars()
613                    .rev()
614                    .take_while(|character| matches!(character, ' ' | '\t'))
615                    .count();
616                (
617                    Position::new(first.line, first.col - blanks as u32),
618                    semi_end,
619                )
620            }
621        };
622        self.checked_edit(rule_span, start, end, "")
623            .map_err(|error| wrap_edit_error(address, error))
624    }
625
626    fn checked_edit(
627        &self,
628        rule_span: Span,
629        start: Position,
630        end: Position,
631        replacement: impl Into<String>,
632    ) -> std::result::Result<crate::source::SourceEdit, crate::source::SourceEditError> {
633        self.edit_source(Span::new(rule_span.file, start, end), replacement)
634    }
635}
636
637fn wrap_edit_error(
638    address: StatementAddress,
639    error: crate::source::SourceEditError,
640) -> StatementEditError {
641    match error {
642        crate::source::SourceEditError::InvalidRange => {
643            StatementEditError::SourceUnavailable { rule: address.rule }
644        }
645        other => StatementEditError::Edit(other),
646    }
647}
648
649fn convert_statements(
650    catalog: &Catalog,
651    address: StatementAddress,
652    edit: &StatementEdit<'_>,
653) -> Result<Vec<Statement>> {
654    let nodes: &[JsonValue] = match edit {
655        StatementEdit::Insert(nodes) => {
656            if nodes.is_empty() {
657                return Err(StatementEditError::EmptyInsert {
658                    rule: address.rule,
659                    list: address.list,
660                });
661            }
662            nodes
663        }
664        StatementEdit::Replace(node) => std::slice::from_ref(node),
665        StatementEdit::Delete => &[],
666    };
667    let mut statements = Vec::with_capacity(nodes.len());
668    for node in nodes {
669        let statement = statement_from_content(catalog, node)?;
670        let fits = matches!(
671            (address.list, &statement),
672            (StatementList::Conditions, Statement::Condition(_))
673                | (StatementList::Actions, Statement::Action(_))
674        );
675        if !fits {
676            return Err(StatementEditError::StatementKind {
677                rule: address.rule,
678                list: address.list,
679            });
680        }
681        statements.push(statement);
682    }
683    Ok(statements)
684}