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 {
134        /// The out-of-range rule index.
135        rule: usize,
136    },
137    /// `address.index` addresses no statement: past the end of the list, or
138    /// into the synthesized tail a rule-final unclosed `if` group carries in
139    /// the public model but not in the source.
140    IndexOutOfRange {
141        /// The rule index.
142        rule: usize,
143        /// The addressed list.
144        list: StatementList,
145        /// The out-of-range statement index.
146        index: usize,
147    },
148    /// A condition node was addressed at `actions`, or an action node at
149    /// `conditions`.
150    StatementKind {
151        /// The rule index.
152        rule: usize,
153        /// The addressed list.
154        list: StatementList,
155    },
156    /// `insert` was given an empty statement list.
157    EmptyInsert {
158        /// The rule index.
159        rule: usize,
160        /// The addressed list.
161        list: StatementList,
162    },
163    /// The program carries no retained source for the addressed rule, or
164    /// the retained text can no longer be read back.
165    SourceUnavailable {
166        /// The rule index.
167        rule: usize,
168    },
169    /// The retained source does not match the public model at the addressed
170    /// position: the statement count differs, the addressed index is a
171    /// source-synthesized entry, or the statement recorded there no longer
172    /// has the model's content.
173    StaleAddress {
174        /// The rule index.
175        rule: usize,
176        /// The addressed list.
177        list: StatementList,
178    },
179    /// Statement emission failed (for example a spelling missing in the
180    /// requested locale, or a statement the Workshop grammar cannot carry).
181    Emission(WorkshopError),
182    /// The computed edit failed `Program::edit_source`'s checked range.
183    Edit(crate::source::SourceEditError),
184}
185
186impl fmt::Display for StatementEditError {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            Self::Content(error) => write!(f, "{error}"),
190            Self::UnknownRule { rule } => write!(f, "no rule at index {rule}"),
191            Self::IndexOutOfRange { rule, list, index } => {
192                write!(f, "no {list:?} statement at index {index} in rule {rule}")
193            }
194            Self::StatementKind { rule, list } => write!(
195                f,
196                "statement kind does not match the {list:?} list of rule {rule}"
197            ),
198            Self::EmptyInsert { rule, list } => {
199                write!(f, "insert into {list:?} of rule {rule} needs a statement")
200            }
201            Self::SourceUnavailable { rule } => {
202                write!(f, "rule {rule} has no editable source")
203            }
204            Self::StaleAddress { rule, list } => write!(
205                f,
206                "the {list:?} of rule {rule} no longer matches its retained source"
207            ),
208            Self::Emission(error) => write!(f, "statement emission failed: {error}"),
209            Self::Edit(error) => write!(f, "source edit failed: {error}"),
210        }
211    }
212}
213
214impl std::error::Error for StatementEditError {}
215
216impl From<StatementContentError> for StatementEditError {
217    fn from(error: StatementContentError) -> Self {
218        Self::Content(error)
219    }
220}
221
222impl From<WorkshopError> for StatementEditError {
223    fn from(error: WorkshopError) -> Self {
224        Self::Emission(error)
225    }
226}
227
228impl From<crate::source::SourceEditError> for StatementEditError {
229    fn from(error: crate::source::SourceEditError) -> Self {
230        Self::Edit(error)
231    }
232}
233
234type Result<T> = std::result::Result<T, StatementEditError>;
235
236/// The structural keyword ids an actions chunk can open with. `else`/`else
237/// if`/`end` have no recorded span, so this is how their chunks verify the
238/// model entry kind at the addressed position.
239const ACTION_KEYWORD_IDS: &[&str] = &[
240    "disabled",
241    "if",
242    "elseIf",
243    "else",
244    "while",
245    "forGlobalVariable",
246    "forPlayerVariable",
247    "end",
248];
249
250impl Program {
251    /// Produce the checked [`crate::source::SourceEdit`] that inserts,
252    /// replaces, or deletes one statement position of `rule`'s
253    /// `conditions` or `actions` list. See the module documentation for the
254    /// address model and the layout/comment rules.
255    ///
256    /// `locale` is the session's resolved Workshop locale: inserted and
257    /// replacement statements are emitted with its spellings, and missing
258    /// spellings fail explicitly. The returned edit is checked again on
259    /// apply — [`crate::source::SourceDocument::apply`] refuses it if the
260    /// source bytes at its range changed — and consumers reparse the edited
261    /// text for updated spans.
262    ///
263    /// ```
264    /// use serde_json::json;
265    /// use workshop_rs::catalog::{Catalog, Locale};
266    /// use workshop_rs::parser;
267    /// use workshop_rs::rules::statement_edit::{
268    ///     StatementAddress, StatementEdit, StatementList,
269    /// };
270    ///
271    /// let catalog = Catalog::builtin().unwrap();
272    /// let source = "rule (\"x\") {\n    event { Ongoing - Global; }\n    actions { Wait(1, Ignore Condition); }\n}\n";
273    /// let program = parser::parse(source, &catalog, &Locale::new("en-US")).unwrap();
274    /// let edit = program
275    ///     .edit_statements(
276    ///         &catalog,
277    ///         &Locale::new("en-US"),
278    ///         StatementAddress::new(0, StatementList::Actions, 0),
279    ///         StatementEdit::Replace(&json!({"call": "wait", "args": [2.0]})),
280    ///     )
281    ///     .unwrap();
282    /// let updated = edit.apply(source).unwrap();
283    /// assert!(updated.contains("Wait(2);"));
284    /// ```
285    pub fn edit_statements(
286        &self,
287        catalog: &Catalog,
288        locale: &Locale,
289        address: StatementAddress,
290        edit: StatementEdit<'_>,
291    ) -> Result<crate::source::SourceEdit> {
292        let rule = self
293            .rules
294            .get(address.rule)
295            .ok_or(StatementEditError::UnknownRule { rule: address.rule })?;
296
297        let statements = convert_statements(catalog, address, &edit)?;
298        let list_len = address.list.list_len(rule);
299        match edit {
300            StatementEdit::Insert(_) => {
301                if address.index > list_len {
302                    return Err(StatementEditError::IndexOutOfRange {
303                        rule: address.rule,
304                        list: address.list,
305                        index: address.index,
306                    });
307                }
308            }
309            StatementEdit::Replace(_) | StatementEdit::Delete => {
310                if address.index >= list_len {
311                    return Err(StatementEditError::IndexOutOfRange {
312                        rule: address.rule,
313                        list: address.list,
314                        index: address.index,
315                    });
316                }
317            }
318        }
319
320        let rows = emit_statement_rows(&statements, self, catalog, locale)?;
321
322        let rule_span = self
323            .rule_span(address.rule)
324            .ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
325        let document = self
326            .source(rule_span.file)
327            .ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
328        let text = document.text();
329        let tokens = tokenize(text)
330            .map_err(|_| StatementEditError::SourceUnavailable { rule: address.rule })?;
331        let layout = RuleLayout::scan(&tokens, rule_span, catalog, locale)
332            .ok_or(StatementEditError::SourceUnavailable { rule: address.rule })?;
333
334        let section = layout
335            .sections
336            .iter()
337            .find(|section| section.id == address.list.section_id());
338        let Some(section) = section else {
339            let unit = indent_unit(text, &tokens, &layout, rule_span);
340            return self.insert_created_section(
341                catalog, locale, address, list_len, &rows, &unit, &tokens, &layout, text, rule_span,
342            );
343        };
344
345        let synthesized_tail = match address.list {
346            StatementList::Conditions => section.chunks.len() != list_len,
347            StatementList::Actions => {
348                section.chunks.len() > list_len
349                    || !rule.actions[section.chunks.len()..]
350                        .iter()
351                        .all(|action| matches!(action, Action::End))
352            }
353        };
354        if synthesized_tail {
355            return Err(StatementEditError::StaleAddress {
356                rule: address.rule,
357                list: address.list,
358            });
359        }
360
361        match edit {
362            StatementEdit::Insert(_) => {
363                // Past the last source chunk is the end of the section. With a
364                // synthesized trailing `End` that covers `index == len` and the
365                // `End`'s own index: both append after the last chunk.
366                if let Some(chunk) = section.chunks.get(address.index) {
367                    self.verify_statement(
368                        address,
369                        *chunk,
370                        &tokens,
371                        catalog,
372                        locale,
373                        rule_span.file,
374                    )?;
375                    self.insert_before_chunk(address, &rows, *chunk, &tokens, text, rule_span)
376                } else {
377                    let unit = indent_unit(text, &tokens, &layout, rule_span);
378                    self.insert_at_close(address, &rows, &unit, section, &tokens, text, rule_span)
379                }
380            }
381            StatementEdit::Replace(_) => {
382                let chunk =
383                    section
384                        .chunks
385                        .get(address.index)
386                        .ok_or(StatementEditError::StaleAddress {
387                            rule: address.rule,
388                            list: address.list,
389                        })?;
390                self.verify_statement(address, *chunk, &tokens, catalog, locale, rule_span.file)?;
391                let row = rows.first().expect("replace emits one row");
392                self.replace_chunk(address, row, *chunk, &tokens, rule_span)
393            }
394            StatementEdit::Delete => {
395                let chunk =
396                    section
397                        .chunks
398                        .get(address.index)
399                        .ok_or(StatementEditError::StaleAddress {
400                            rule: address.rule,
401                            list: address.list,
402                        })?;
403                self.verify_statement(address, *chunk, &tokens, catalog, locale, rule_span.file)?;
404                self.delete_chunk(address, *chunk, &tokens, text, rule_span)
405            }
406        }
407    }
408
409    /// The retained source ties the model entry at `address.index` to
410    /// `chunk`: span-bearing entries resolve through the recorded spans,
411    /// spanless `Else`/`End` markers through the chunk's leading keyword.
412    fn verify_statement(
413        &self,
414        address: StatementAddress,
415        chunk: StatementChunk,
416        tokens: &[Token],
417        catalog: &Catalog,
418        locale: &Locale,
419        file: crate::source::FileId,
420    ) -> Result<()> {
421        let chunk_start = tokens[chunk.first_content].start;
422        let chunk_end = tokens[chunk.semi].end;
423        let within = |start: Position| {
424            (start.line, start.col) >= (chunk_start.line, chunk_start.col)
425                && (start.line, start.col) <= (chunk_end.line, chunk_end.col)
426        };
427        let verified = match address.list {
428            StatementList::Conditions => self
429                .condition_span(address.rule, address.index)
430                .is_some_and(|span| span.file == file && within(span.start)),
431            StatementList::Actions => match &self.rules[address.rule].actions[address.index] {
432                Action::Else | Action::End => {
433                    let expected = if matches!(
434                        &self.rules[address.rule].actions[address.index],
435                        Action::Else
436                    ) {
437                        "else"
438                    } else {
439                        "end"
440                    };
441                    structural_keyword_prefix(
442                        tokens,
443                        chunk.first_content,
444                        catalog,
445                        locale,
446                        ACTION_KEYWORD_IDS,
447                    )
448                    .is_some_and(|(id, _)| id == expected)
449                }
450                Action::ElseIf { .. } => self
451                    .action_argument_span(address.rule, address.index, 0)
452                    .is_some_and(|span| span.file == file && within(span.start)),
453                _ => self
454                    .action_span(address.rule, address.index)
455                    .is_some_and(|span| span.file == file && within(span.start)),
456            },
457        };
458        if verified {
459            Ok(())
460        } else {
461            Err(StatementEditError::StaleAddress {
462                rule: address.rule,
463                list: address.list,
464            })
465        }
466    }
467
468    /// Insert `rows` on their own lines before `chunk` — or inline before
469    /// it when the chunk does not start its line.
470    fn insert_before_chunk(
471        &self,
472        address: StatementAddress,
473        rows: &[String],
474        chunk: StatementChunk,
475        tokens: &[Token],
476        text: &str,
477        rule_span: Span,
478    ) -> Result<crate::source::SourceEdit> {
479        let anchor = tokens[chunk.first].start;
480        let newline = newline_of(text, anchor);
481        let line_start = Position::new(anchor.line, 1);
482        let indent = line_prefix(text, anchor);
483        let (position, replacement) = if is_whitespace(indent) {
484            let mut replacement = String::new();
485            for row in rows {
486                replacement.push_str(indent);
487                replacement.push_str(row);
488                replacement.push_str(newline);
489            }
490            (line_start, replacement)
491        } else {
492            let left = if byte_is_whitespace(text, anchor) {
493                ""
494            } else {
495                " "
496            };
497            (anchor, format!("{left}{} ", rows.join(" ")))
498        };
499        self.checked_edit(rule_span, position, position, replacement)
500            .map_err(|error| wrap_edit_error(address, error))
501    }
502
503    /// Append `rows` at the end of an existing (possibly empty) section,
504    /// before its closing `}`.
505    #[allow(clippy::too_many_arguments)]
506    fn insert_at_close(
507        &self,
508        address: StatementAddress,
509        rows: &[String],
510        unit: &str,
511        section: &StatementSection,
512        tokens: &[Token],
513        text: &str,
514        rule_span: Span,
515    ) -> Result<crate::source::SourceEdit> {
516        // Annotations after the last statement have no statement to attach
517        // to: the new statement goes before them so it does not take them.
518        let close = tokens[section.dangling.unwrap_or(section.close)].start;
519        let newline = newline_of(text, close);
520        let close_indent = line_prefix(text, close);
521        let (position, replacement) = if is_whitespace(close_indent) {
522            let indent = section
523                .chunks
524                .last()
525                .map(|chunk| line_prefix(text, tokens[chunk.first].start))
526                .filter(|prefix| is_whitespace(prefix))
527                .map(str::to_string)
528                .unwrap_or_else(|| format!("{close_indent}{unit}"));
529            let mut replacement = String::new();
530            for row in rows {
531                replacement.push_str(&indent);
532                replacement.push_str(row);
533                replacement.push_str(newline);
534            }
535            (Position::new(close.line, 1), replacement)
536        } else {
537            let left = if byte_is_whitespace(text, close) {
538                ""
539            } else {
540                " "
541            };
542            (close, format!("{left}{} ", rows.join(" ")))
543        };
544        self.checked_edit(rule_span, position, position, replacement)
545            .map_err(|error| wrap_edit_error(address, error))
546    }
547
548    /// Insert into a rule whose section does not exist: create the section
549    /// immediately before `actions` when it exists, else before the rule's
550    /// closing `}`. Only reachable for an empty-list insert — any other
551    /// address was already refused as out of range or stale.
552    #[allow(clippy::too_many_arguments)]
553    fn insert_created_section(
554        &self,
555        catalog: &Catalog,
556        locale: &Locale,
557        address: StatementAddress,
558        list_len: usize,
559        rows: &[String],
560        unit: &str,
561        tokens: &[Token],
562        layout: &RuleLayout,
563        text: &str,
564        rule_span: Span,
565    ) -> Result<crate::source::SourceEdit> {
566        // A section can only be absent together with its list: model
567        // statements with no section to hold them mean the model and the
568        // retained source disagree.
569        if list_len != 0 || address.index != 0 || rows.is_empty() {
570            return Err(if list_len == 0 {
571                StatementEditError::IndexOutOfRange {
572                    rule: address.rule,
573                    list: address.list,
574                    index: address.index,
575                }
576            } else {
577                StatementEditError::StaleAddress {
578                    rule: address.rule,
579                    list: address.list,
580                }
581            });
582        }
583        let keyword = emit_section_keyword(address.list, catalog, locale)?;
584        let anchor = layout
585            .sections
586            .iter()
587            .find(|section| section.id == "actions")
588            .map(|section| section.name_start)
589            .unwrap_or_else(|| tokens[layout.close].start);
590        let newline = newline_of(text, anchor);
591        // The section indent follows the sibling sections (or, for a rule
592        // with no sections at all, one level inside the closing brace).
593        let section_indent = layout
594            .sections
595            .last()
596            .map(|section| line_prefix(text, section.name_start))
597            .filter(|prefix| is_whitespace(prefix))
598            .map(str::to_string);
599        let anchor_indent = line_prefix(text, anchor);
600        let (position, replacement) = if is_whitespace(anchor_indent) {
601            let indent = section_indent.unwrap_or_else(|| format!("{anchor_indent}{unit}"));
602            let statement_indent = format!("{indent}{unit}");
603            let mut replacement = format!("{indent}{keyword} {{{newline}");
604            for row in rows {
605                replacement.push_str(&statement_indent);
606                replacement.push_str(row);
607                replacement.push_str(newline);
608            }
609            replacement.push_str(&indent);
610            replacement.push('}');
611            replacement.push_str(newline);
612            (Position::new(anchor.line, 1), replacement)
613        } else {
614            let left = if byte_is_whitespace(text, anchor) {
615                ""
616            } else {
617                " "
618            };
619            (anchor, format!("{left}{keyword} {{ {} }} ", rows.join(" ")))
620        };
621        self.checked_edit(rule_span, position, position, replacement)
622            .map_err(|error| wrap_edit_error(address, error))
623    }
624
625    /// Replace one statement's own tokens: `chunk.first_content`..`chunk.semi`
626    /// — leading `"…"` annotations stay where they are.
627    fn replace_chunk(
628        &self,
629        address: StatementAddress,
630        row: &str,
631        chunk: StatementChunk,
632        tokens: &[Token],
633        rule_span: Span,
634    ) -> Result<crate::source::SourceEdit> {
635        let start = tokens[chunk.first_content].start;
636        let end = tokens[chunk.semi].end;
637        self.checked_edit(rule_span, start, end, row)
638            .map_err(|error| wrap_edit_error(address, error))
639    }
640
641    /// Delete one chunk: leading annotations and the statement. A chunk
642    /// alone on its line takes the whole line, including a `//` tail
643    /// comment. A chunk that shares its line takes only its own bytes and
644    /// one adjacent whitespace run, so the other tokens, the indentation,
645    /// and a `//` comment after them stay.
646    fn delete_chunk(
647        &self,
648        address: StatementAddress,
649        chunk: StatementChunk,
650        tokens: &[Token],
651        text: &str,
652        rule_span: Span,
653    ) -> Result<crate::source::SourceEdit> {
654        let first = tokens[chunk.first].start;
655        let semi_end = tokens[chunk.semi].end;
656        let starts_line = is_whitespace(line_prefix(text, first));
657        // `//` comments are not tokens, so a later token on the same line
658        // means real content follows the statement.
659        let next = tokens.get(chunk.semi + 1).map(|token| token.start);
660        let content_follows = next.is_some_and(|next| next.line == semi_end.line);
661        let (start, end) = match (starts_line, content_follows) {
662            (true, false) => (
663                Position::new(first.line, 1),
664                Position::new(semi_end.line + 1, 1),
665            ),
666            (_, true) => (first, next.expect("content follows")),
667            (false, false) => {
668                let blanks = line_prefix(text, first)
669                    .chars()
670                    .rev()
671                    .take_while(|character| matches!(character, ' ' | '\t'))
672                    .count();
673                (
674                    Position::new(first.line, first.col - blanks as u32),
675                    semi_end,
676                )
677            }
678        };
679        self.checked_edit(rule_span, start, end, "")
680            .map_err(|error| wrap_edit_error(address, error))
681    }
682
683    fn checked_edit(
684        &self,
685        rule_span: Span,
686        start: Position,
687        end: Position,
688        replacement: impl Into<String>,
689    ) -> std::result::Result<crate::source::SourceEdit, crate::source::SourceEditError> {
690        self.edit_source(Span::new(rule_span.file, start, end), replacement)
691    }
692}
693
694fn wrap_edit_error(
695    address: StatementAddress,
696    error: crate::source::SourceEditError,
697) -> StatementEditError {
698    match error {
699        crate::source::SourceEditError::InvalidRange => {
700            StatementEditError::SourceUnavailable { rule: address.rule }
701        }
702        other => StatementEditError::Edit(other),
703    }
704}
705
706fn convert_statements(
707    catalog: &Catalog,
708    address: StatementAddress,
709    edit: &StatementEdit<'_>,
710) -> Result<Vec<Statement>> {
711    let nodes: &[JsonValue] = match edit {
712        StatementEdit::Insert(nodes) => {
713            if nodes.is_empty() {
714                return Err(StatementEditError::EmptyInsert {
715                    rule: address.rule,
716                    list: address.list,
717                });
718            }
719            nodes
720        }
721        StatementEdit::Replace(node) => std::slice::from_ref(node),
722        StatementEdit::Delete => &[],
723    };
724    let mut statements = Vec::with_capacity(nodes.len());
725    for node in nodes {
726        let statement = statement_from_content(catalog, node)?;
727        let fits = matches!(
728            (address.list, &statement),
729            (StatementList::Conditions, Statement::Condition(_))
730                | (StatementList::Actions, Statement::Action(_))
731        );
732        if !fits {
733            return Err(StatementEditError::StatementKind {
734                rule: address.rule,
735                list: address.list,
736            });
737        }
738        statements.push(statement);
739    }
740    Ok(statements)
741}