Skip to main content

ass_core/parser/script/
incremental.rs

1//! Incremental reparse support for editor-driven text changes.
2//!
3//! Implements [`Script::affected_sections`], which maps a text change to the
4//! sections it touches, and [`Script::parse_incremental`], which reparses only
5//! those sections while shifting the spans of unchanged sections.
6
7use alloc::{vec, vec::Vec};
8
9use crate::parser::ast::{Section, SectionType};
10use crate::parser::main::Parser;
11use crate::Result;
12
13use super::Script;
14
15impl<'a> Script<'a> {
16    // Incremental parsing support
17
18    /// Determine which sections are affected by a text change
19    ///
20    /// # Arguments
21    ///
22    /// * `change` - The text change to analyze
23    ///
24    /// # Returns
25    ///
26    /// A vector of section types that are affected by the change
27    #[must_use]
28    pub fn affected_sections(
29        &self,
30        change: &crate::parser::incremental::TextChange,
31    ) -> Vec<SectionType> {
32        self.sections
33            .iter()
34            .filter(|section| {
35                section.span().is_some_and(|span| {
36                    let section_range = span.start..span.end;
37
38                    // Check if change overlaps with section
39                    let overlaps = change.range.start < section_range.end
40                        && change.range.end > section_range.start;
41
42                    // Also check if this is an insertion at the end of the section
43                    // This handles cases like adding a new event at the end of the Events section
44                    let inserts_at_end =
45                        change.range.is_empty() && change.range.start == section_range.end;
46
47                    overlaps || inserts_at_end
48                })
49            })
50            .map(Section::section_type)
51            .collect()
52    }
53
54    /// Parse only changed portions and create new Script
55    ///
56    /// This method performs incremental parsing by identifying affected sections
57    /// and reparsing only those sections while preserving others.
58    ///
59    /// # Arguments
60    ///
61    /// * `new_source` - The complete new source text after the change
62    /// * `change` - Description of what changed in the text
63    ///
64    /// # Returns
65    ///
66    /// A new Script with the changes applied
67    ///
68    /// # Errors
69    ///
70    /// Returns parse errors if affected sections cannot be reparsed
71    pub fn parse_incremental(
72        &self,
73        new_source: &'a str,
74        change: &crate::parser::incremental::TextChange,
75    ) -> Result<Self> {
76        use crate::parser::sections::SectionFormats;
77
78        // Step 1: Identify affected sections
79        let affected_sections = self.affected_sections(change);
80
81        if affected_sections.is_empty() {
82            // Change was in whitespace/comments only
83            return Ok(Script::from_parts(
84                new_source,
85                self.version(),
86                self.sections.clone(),
87                vec![], // Clear issues, will be recalculated
88                self.styles_format.clone(),
89                self.events_format.clone(),
90            ));
91        }
92
93        // Step 2: Build section formats from existing script
94        let formats = SectionFormats {
95            styles_format: self.styles_format().map(<[&str]>::to_vec),
96            events_format: self.events_format().map(<[&str]>::to_vec),
97        };
98
99        // Step 3: Prepare new sections
100        let mut new_sections = Vec::with_capacity(self.sections.len());
101
102        // We need to find where each section actually starts in the document
103        // including its header. The current spans only track content.
104        let section_headers = [
105            ("[Script Info]", SectionType::ScriptInfo),
106            ("[V4+ Styles]", SectionType::Styles),
107            ("[Events]", SectionType::Events),
108            ("[Fonts]", SectionType::Fonts),
109            ("[Graphics]", SectionType::Graphics),
110        ];
111
112        // Step 4: Process each section
113        for (idx, section) in self.sections.iter().enumerate() {
114            let section_type = section.section_type();
115
116            if affected_sections.contains(&section_type) {
117                // Find the section header in the new source
118                let header_str = section_headers
119                    .iter()
120                    .find(|(_, t)| *t == section_type)
121                    .map_or("[Unknown]", |(h, _)| *h);
122
123                // Find where this section starts in the new source
124                if let Some(header_pos) = new_source.find(header_str) {
125                    // Find the end of this section (start of next section or end of file)
126                    let section_end = if idx + 1 < self.sections.len() {
127                        // Find the next section's header
128                        let next_section_type = self.sections[idx + 1].section_type();
129                        let next_header = section_headers
130                            .iter()
131                            .find(|(_, t)| *t == next_section_type)
132                            .map_or("[Unknown]", |(h, _)| *h);
133
134                        new_source[header_pos + header_str.len()..]
135                            .find(next_header)
136                            .map_or(new_source.len(), |pos| header_pos + header_str.len() + pos)
137                    } else {
138                        new_source.len()
139                    };
140
141                    // Extract the full section text including header
142                    let section_text = &new_source[header_pos..section_end];
143
144                    // Parse this section using a fresh parser
145                    let parser = Parser::new(section_text);
146                    let parsed_script = parser.parse();
147
148                    // The parser returns a Script, extract sections from it
149                    // We only want the one matching our type
150                    if let Some(parsed_section) = parsed_script
151                        .sections
152                        .into_iter()
153                        .find(|s| s.section_type() == section_type)
154                    {
155                        new_sections.push(parsed_section);
156                    }
157                }
158            } else {
159                // Section unchanged, but might need span adjustment if change was before it
160                let section_span = section.span();
161                if let Some(span) = section_span {
162                    if change.range.end <= span.start {
163                        // Change was before this section, adjust its spans
164                        new_sections.push(Self::adjust_section_spans(section, change));
165                    } else {
166                        // Change was after this section, keep as-is
167                        new_sections.push(section.clone());
168                    }
169                } else {
170                    new_sections.push(section.clone());
171                }
172            }
173        }
174
175        // Step 5: Create new Script with updated sections
176        Ok(Script::from_parts(
177            new_source,
178            self.version(),
179            new_sections,
180            vec![], // Issues will be recalculated
181            formats.styles_format.clone(),
182            formats.events_format.clone(),
183        ))
184    }
185}