ass_core/parser/sections/
script_info.rs

1//! Script Info section parser for ASS scripts.
2//!
3//! Handles parsing of the [Script Info] section which contains metadata
4//! and configuration parameters for the subtitle script.
5
6use crate::{
7    parser::{
8        ast::{ScriptInfo, Section},
9        errors::{IssueCategory, IssueSeverity, ParseIssue},
10        position_tracker::PositionTracker,
11        sections::ScriptInfoParseResult,
12        ParseResult,
13    },
14    ScriptVersion,
15};
16use alloc::vec::Vec;
17
18/// Parser for [Script Info] section content
19///
20/// Parses key-value pairs from the script info section and handles
21/// special fields like `ScriptType` that affect parsing behavior.
22///
23/// # Performance
24///
25/// - Time complexity: O(n) for n lines in section
26/// - Memory: Zero allocations via lifetime-generic spans
27/// - Target: <0.5ms for typical script info sections
28pub struct ScriptInfoParser<'a> {
29    /// Position tracker for accurate span generation
30    tracker: PositionTracker<'a>,
31    /// Parse issues and warnings collected during parsing
32    issues: Vec<ParseIssue>,
33}
34
35impl<'a> ScriptInfoParser<'a> {
36    /// Create new script info parser for source text
37    ///
38    /// # Arguments
39    ///
40    /// * `source` - Source text to parse
41    /// * `start_position` - Starting byte position in source
42    /// * `start_line` - Starting line number for error reporting
43    #[must_use]
44    #[allow(clippy::missing_const_for_fn)] // Can't be const due to Vec::new()
45    pub fn new(source: &'a str, start_position: usize, start_line: usize) -> Self {
46        Self {
47            tracker: PositionTracker::new_at(
48                source,
49                start_position,
50                u32::try_from(start_line).unwrap_or(u32::MAX),
51                1,
52            ),
53            issues: Vec::new(),
54        }
55    }
56
57    /// Parse script info section content
58    ///
59    /// Returns the parsed section and any issues encountered during parsing.
60    /// Handles `ScriptType` field detection and version updating.
61    ///
62    /// # Returns
63    ///
64    /// Tuple of (`parsed_section`, `detected_version`, `parse_issues`, `final_position`, `final_line`)
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the script info section contains malformed key-value pairs or
69    /// other unrecoverable syntax errors.
70    pub fn parse(mut self) -> ParseResult<ScriptInfoParseResult<'a>> {
71        let section_start = self.tracker.checkpoint();
72        let mut fields = Vec::new();
73        let mut detected_version = None;
74
75        while !self.tracker.is_at_end() && !self.at_next_section() {
76            self.skip_whitespace_and_comments();
77
78            if self.tracker.is_at_end() || self.at_next_section() {
79                break;
80            }
81
82            let line_start = self.tracker.checkpoint();
83            let line = self.current_line().trim();
84
85            if line.is_empty() {
86                self.tracker.skip_line();
87                continue;
88            }
89
90            if let Some(colon_pos) = line.find(':') {
91                let key = line[..colon_pos].trim();
92                let value = line[colon_pos + 1..].trim();
93
94                if key == "ScriptType" {
95                    if let Some(version) = ScriptVersion::from_header(value) {
96                        detected_version = Some(version);
97                    }
98                }
99
100                fields.push((key, value));
101            } else {
102                self.issues.push(ParseIssue::new(
103                    IssueSeverity::Warning,
104                    IssueCategory::Format,
105                    "Invalid script info line format".into(),
106                    line_start.line() as usize,
107                ));
108            }
109
110            self.tracker.skip_line();
111        }
112
113        let span = self.tracker.span_from(&section_start);
114        let section = Section::ScriptInfo(ScriptInfo { fields, span });
115
116        Ok((
117            section,
118            detected_version,
119            self.issues,
120            self.tracker.offset(),
121            self.tracker.line() as usize,
122        ))
123    }
124
125    /// Get current line from source
126    fn current_line(&self) -> &'a str {
127        let remaining = self.tracker.remaining();
128        let end = remaining.find('\n').unwrap_or(remaining.len());
129        &remaining[..end]
130    }
131
132    /// Check if at start of next section
133    fn at_next_section(&self) -> bool {
134        self.tracker.remaining().trim_start().starts_with('[')
135    }
136
137    /// Skip whitespace and comment lines
138    fn skip_whitespace_and_comments(&mut self) {
139        loop {
140            self.tracker.skip_whitespace();
141
142            let remaining = self.tracker.remaining();
143            if remaining.is_empty() {
144                break;
145            }
146
147            if remaining.starts_with(';') || remaining.starts_with('#') {
148                self.tracker.skip_line();
149                continue;
150            }
151
152            // Check for newlines in whitespace
153            if remaining.starts_with('\n') {
154                self.tracker.advance(1);
155                continue;
156            }
157
158            break;
159        }
160    }
161
162    /// Get accumulated parse issues
163    #[must_use]
164    pub fn issues(self) -> Vec<ParseIssue> {
165        self.issues
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    #[cfg(not(feature = "std"))]
173    use alloc::format;
174
175    #[test]
176    fn parse_empty_section() {
177        let parser = ScriptInfoParser::new("", 0, 1);
178        let result = parser.parse();
179        assert!(result.is_ok());
180
181        let (section, version, ..) = result.unwrap();
182        if let Section::ScriptInfo(info) = section {
183            assert!(info.fields.is_empty());
184            assert_eq!(info.span.start, 0);
185            assert_eq!(info.span.end, 0);
186        } else {
187            panic!("Expected ScriptInfo section");
188        }
189        assert!(version.is_none());
190    }
191
192    #[test]
193    fn parse_basic_fields() {
194        let content = "Title: Test Script\nScriptType: v4.00+\n";
195        let parser = ScriptInfoParser::new(content, 0, 1);
196        let result = parser.parse();
197        assert!(result.is_ok());
198
199        let (section, version, ..) = result.unwrap();
200        if let Section::ScriptInfo(info) = section {
201            assert_eq!(info.fields.len(), 2);
202            assert_eq!(info.get_field("Title"), Some("Test Script"));
203            assert_eq!(info.get_field("ScriptType"), Some("v4.00+"));
204            assert_eq!(info.span.start, 0);
205            assert_eq!(info.span.end, content.len());
206            assert_eq!(info.span.line, 1);
207            assert_eq!(info.span.column, 1);
208        } else {
209            panic!("Expected ScriptInfo section");
210        }
211        assert!(version.is_some());
212    }
213
214    #[test]
215    fn skip_comments_and_whitespace() {
216        let content = "; Comment\n# Another comment\n\nTitle: Test\n";
217        let parser = ScriptInfoParser::new(content, 0, 1);
218        let result = parser.parse();
219        assert!(result.is_ok());
220
221        let (section, ..) = result.unwrap();
222        if let Section::ScriptInfo(info) = section {
223            assert_eq!(info.fields.len(), 1);
224            assert_eq!(info.get_field("Title"), Some("Test"));
225        } else {
226            panic!("Expected ScriptInfo section");
227        }
228    }
229
230    #[test]
231    fn handle_invalid_lines() {
232        let content = "Title: Test\nInvalidLine\nAuthor: Someone\n";
233        let parser = ScriptInfoParser::new(content, 0, 1);
234        let result = parser.parse();
235        assert!(result.is_ok());
236
237        let (section, _, issues, ..) = result.unwrap();
238        if let Section::ScriptInfo(info) = section {
239            assert_eq!(info.fields.len(), 2);
240            assert_eq!(info.get_field("Title"), Some("Test"));
241            assert_eq!(info.get_field("Author"), Some("Someone"));
242        } else {
243            panic!("Expected ScriptInfo section");
244        }
245
246        // Should have a warning about the invalid line
247        assert_eq!(issues.len(), 1);
248        assert_eq!(issues[0].severity, IssueSeverity::Warning);
249    }
250
251    #[test]
252    fn parse_with_position_tracking() {
253        // Create a larger content that simulates a full file
254        let prefix = "Some prefix\n"; // 12 bytes
255        let section_content = "Title: Test\nAuthor: Someone\n";
256        let full_content = format!("{prefix}{section_content}");
257
258        // Parser starts at position 12 (after prefix)
259        let parser = ScriptInfoParser::new(&full_content, 12, 2);
260        let result = parser.parse();
261        assert!(result.is_ok());
262
263        let (section, _, _, final_pos, final_line) = result.unwrap();
264        if let Section::ScriptInfo(info) = section {
265            assert_eq!(info.fields.len(), 2);
266            assert_eq!(info.fields[0], ("Title", "Test"));
267            assert_eq!(info.fields[1], ("Author", "Someone"));
268            assert_eq!(info.span.start, 12);
269            assert_eq!(info.span.end, 12 + section_content.len());
270            assert_eq!(info.span.line, 2);
271            assert_eq!(info.span.column, 1);
272        } else {
273            panic!("Expected ScriptInfo section");
274        }
275
276        assert_eq!(final_pos, 12 + section_content.len());
277        assert_eq!(final_line, 4); // Started at line 2, added 2 lines
278    }
279}