ass_core/parser/sections/
styles.rs

1//! Styles section parser for ASS scripts.
2//!
3//! Handles parsing of the [V4+ Styles] section which contains style definitions
4//! with format specifications and style entries.
5
6use crate::parser::{
7    ast::{Section, Span, Style},
8    errors::{IssueCategory, IssueSeverity, ParseError, ParseIssue},
9    position_tracker::PositionTracker,
10    sections::SectionParseResult,
11    ParseResult,
12};
13use alloc::{format, vec, vec::Vec};
14
15/// Parser for [V4+ Styles] section content
16///
17/// Parses format definitions and style entries from the styles section.
18/// Uses format mapping to handle different field orderings and missing fields.
19///
20/// # Performance
21///
22/// - Time complexity: O(n * m) for n styles and m fields per style
23/// - Memory: Zero allocations via lifetime-generic spans
24/// - Target: <1ms for typical style sections with 50 styles
25pub struct StylesParser<'a> {
26    /// Position tracker for accurate span generation
27    tracker: PositionTracker<'a>,
28    /// Parse issues and warnings collected during parsing
29    issues: Vec<ParseIssue>,
30    /// Format fields for the styles section
31    format: Option<Vec<&'a str>>,
32}
33
34impl<'a> StylesParser<'a> {
35    /// Parse a single style line
36    ///
37    /// Parses a single style definition line using the provided format specification.
38    /// This method is exposed for incremental parsing support.
39    ///
40    /// # Arguments
41    ///
42    /// * `line` - The style line to parse (without "Style:" prefix)
43    /// * `format` - The format fields from the Format line
44    /// * `line_number` - The line number for error reporting
45    ///
46    /// # Returns
47    ///
48    /// Parsed Style or error if the line is malformed
49    ///
50    /// # Errors
51    ///
52    /// Returns [`ParseError::InsufficientFields`] if the line has fewer fields than expected by format
53    pub fn parse_style_line(
54        line: &'a str,
55        format: &[&'a str],
56        line_number: u32,
57    ) -> core::result::Result<Style<'a>, ParseError> {
58        // First check if this is an inheritance style
59        let (adjusted_line, parent_style) = if line.trim_start().starts_with('*') {
60            // Find the first comma after the asterisk to extract parent style
61            line.find(',').map_or((line, None), |first_comma| {
62                let parent_part = &line[0..first_comma];
63                let parent_name = parent_part.trim_start().trim_start_matches('*').trim();
64                let remaining = &line[first_comma + 1..];
65                (remaining, Some(parent_name))
66            })
67        } else {
68            (line, None)
69        };
70
71        let parts: Vec<&str> = adjusted_line.split(',').collect();
72
73        let format = if format.is_empty() {
74            &[
75                "Name",
76                "Fontname",
77                "Fontsize",
78                "PrimaryColour",
79                "SecondaryColour",
80                "OutlineColour",
81                "BackColour",
82                "Bold",
83                "Italic",
84                "Underline",
85                "StrikeOut",
86                "ScaleX",
87                "ScaleY",
88                "Spacing",
89                "Angle",
90                "BorderStyle",
91                "Outline",
92                "Shadow",
93                "Alignment",
94                "MarginL",
95                "MarginR",
96                "MarginV",
97                "Encoding",
98            ]
99        } else {
100            format
101        };
102
103        if parts.len() < format.len() {
104            return Err(ParseError::InsufficientFields {
105                expected: format.len(),
106                found: parts.len(),
107                line: line_number as usize,
108            });
109        }
110
111        let get_field = |name: &str| -> &'a str {
112            format
113                .iter()
114                .position(|&field| field.eq_ignore_ascii_case(name))
115                .and_then(|idx| parts.get(idx))
116                .map_or("", |s| s.trim())
117        };
118
119        // Create span for the style (caller will need to adjust this)
120        let span = Span::new(0, 0, line_number, 1);
121
122        Ok(Style {
123            name: get_field("Name"),
124            parent: parent_style,
125            fontname: get_field("Fontname"),
126            fontsize: get_field("Fontsize"),
127            primary_colour: get_field("PrimaryColour"),
128            secondary_colour: get_field("SecondaryColour"),
129            outline_colour: get_field("OutlineColour"),
130            back_colour: get_field("BackColour"),
131            bold: get_field("Bold"),
132            italic: get_field("Italic"),
133            underline: get_field("Underline"),
134            strikeout: get_field("StrikeOut"),
135            scale_x: get_field("ScaleX"),
136            scale_y: get_field("ScaleY"),
137            spacing: get_field("Spacing"),
138            angle: get_field("Angle"),
139            border_style: get_field("BorderStyle"),
140            outline: get_field("Outline"),
141            shadow: get_field("Shadow"),
142            alignment: get_field("Alignment"),
143            margin_l: get_field("MarginL"),
144            margin_r: get_field("MarginR"),
145            margin_v: get_field("MarginV"),
146            margin_t: format
147                .iter()
148                .any(|&f| f.eq_ignore_ascii_case("MarginT"))
149                .then(|| get_field("MarginT")),
150            margin_b: format
151                .iter()
152                .any(|&f| f.eq_ignore_ascii_case("MarginB"))
153                .then(|| get_field("MarginB")),
154            encoding: get_field("Encoding"),
155            relative_to: format
156                .iter()
157                .any(|&f| f.eq_ignore_ascii_case("RelativeTo"))
158                .then(|| get_field("RelativeTo")),
159            span,
160        })
161    }
162    /// Create new styles parser for source text
163    ///
164    /// # Arguments
165    ///
166    /// * `source` - Source text to parse
167    /// * `start_position` - Starting byte position in source
168    /// * `start_line` - Starting line number for error reporting
169    #[must_use]
170    #[allow(clippy::missing_const_for_fn)] // Can't be const due to Vec::new()
171    pub fn new(source: &'a str, start_position: usize, start_line: usize) -> Self {
172        Self {
173            tracker: PositionTracker::new_at(
174                source,
175                start_position,
176                u32::try_from(start_line).unwrap_or(u32::MAX),
177                1,
178            ),
179            issues: Vec::new(),
180            format: None,
181        }
182    }
183
184    /// Create a new parser with a pre-known format for incremental parsing
185    #[must_use]
186    pub fn with_format(
187        source: &'a str,
188        format: &[&'a str],
189        start_position: usize,
190        start_line: u32,
191    ) -> Self {
192        Self {
193            tracker: PositionTracker::new_at(source, start_position, start_line, 1),
194            issues: Vec::new(),
195            format: Some(format.to_vec()),
196        }
197    }
198
199    /// Parse styles section content
200    ///
201    /// Returns the parsed section and any issues encountered during parsing.
202    /// Handles Format line parsing and style entry validation.
203    ///
204    /// # Returns
205    ///
206    /// Tuple of (`parsed_section`, `format_fields`, `parse_issues`, `final_position`, `final_line`)
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the styles section contains malformed format lines or
211    /// other unrecoverable syntax errors.
212    pub fn parse(mut self) -> ParseResult<SectionParseResult<'a>> {
213        let mut styles = Vec::new();
214
215        while !self.tracker.is_at_end() && !self.at_next_section() {
216            self.skip_whitespace_and_comments();
217
218            if self.tracker.is_at_end() || self.at_next_section() {
219                break;
220            }
221
222            let line_start = self.tracker.checkpoint();
223            let line = self.current_line().trim();
224
225            if line.is_empty() {
226                self.tracker.skip_line();
227                continue;
228            }
229
230            if line.starts_with("Format:") {
231                self.parse_format_line(line);
232            } else if line.starts_with("Style:") {
233                if let Some(style_data) = line.strip_prefix("Style:") {
234                    if let Some(style) =
235                        self.parse_style_line_internal(style_data.trim(), &line_start)
236                    {
237                        styles.push(style);
238                    }
239                }
240            }
241
242            self.tracker.skip_line();
243        }
244
245        // If no explicit format was provided but styles were parsed, use default format
246        let format_to_return = if self.format.is_none() && !styles.is_empty() {
247            Some(vec![
248                "Name",
249                "Fontname",
250                "Fontsize",
251                "PrimaryColour",
252                "SecondaryColour",
253                "OutlineColour",
254                "BackColour",
255                "Bold",
256                "Italic",
257                "Underline",
258                "StrikeOut",
259                "ScaleX",
260                "ScaleY",
261                "Spacing",
262                "Angle",
263                "BorderStyle",
264                "Outline",
265                "Shadow",
266                "Alignment",
267                "MarginL",
268                "MarginR",
269                "MarginV",
270                "Encoding",
271            ])
272        } else {
273            self.format
274        };
275
276        Ok((
277            Section::Styles(styles),
278            format_to_return,
279            self.issues,
280            self.tracker.offset(),
281            self.tracker.line() as usize,
282        ))
283    }
284
285    /// Parse format specification line
286    fn parse_format_line(&mut self, line: &'a str) {
287        if let Some(format_data) = line.strip_prefix("Format:") {
288            let fields: Vec<&'a str> = format_data.split(',').map(str::trim).collect();
289            self.format = Some(fields);
290        }
291    }
292
293    /// Parse single style definition line
294    fn parse_style_line_internal(
295        &mut self,
296        line: &'a str,
297        line_start: &PositionTracker<'a>,
298    ) -> Option<Style<'a>> {
299        // First check if this is an inheritance style
300        let (adjusted_line, parent_style) = if line.trim_start().starts_with('*') {
301            // Find the first comma after the asterisk to extract parent style
302            line.find(',').map_or((line, None), |first_comma| {
303                let parent_part = &line[0..first_comma];
304                let parent_name = parent_part.trim_start().trim_start_matches('*').trim();
305                let remaining = &line[first_comma + 1..];
306                (remaining, Some(parent_name))
307            })
308        } else {
309            (line, None)
310        };
311
312        let parts: Vec<&str> = adjusted_line.split(',').collect();
313
314        let format = self.format.as_deref().unwrap_or(&[
315            "Name",
316            "Fontname",
317            "Fontsize",
318            "PrimaryColour",
319            "SecondaryColour",
320            "OutlineColour",
321            "BackColour",
322            "Bold",
323            "Italic",
324            "Underline",
325            "StrikeOut",
326            "ScaleX",
327            "ScaleY",
328            "Spacing",
329            "Angle",
330            "BorderStyle",
331            "Outline",
332            "Shadow",
333            "Alignment",
334            "MarginL",
335            "MarginR",
336            "MarginV",
337            "Encoding",
338        ]);
339
340        if parts.len() != format.len() {
341            self.issues.push(ParseIssue::new(
342                IssueSeverity::Warning,
343                IssueCategory::Format,
344                format!(
345                    "Style line has {} fields, expected {}",
346                    parts.len(),
347                    format.len()
348                ),
349                line_start.line() as usize,
350            ));
351            if parts.len() < format.len() {
352                return None;
353            }
354        }
355
356        let get_field = |name: &str| -> &'a str {
357            format
358                .iter()
359                .position(|&field| field.eq_ignore_ascii_case(name))
360                .and_then(|idx| parts.get(idx))
361                .map_or("", |s| s.trim())
362        };
363
364        // Calculate span for this style line
365        let full_line = self.current_line();
366        let span = line_start.span_for(full_line.len());
367
368        Some(Style {
369            name: get_field("Name"),
370            parent: parent_style,
371            fontname: get_field("Fontname"),
372            fontsize: get_field("Fontsize"),
373            primary_colour: get_field("PrimaryColour"),
374            secondary_colour: get_field("SecondaryColour"),
375            outline_colour: get_field("OutlineColour"),
376            back_colour: get_field("BackColour"),
377            bold: get_field("Bold"),
378            italic: get_field("Italic"),
379            underline: get_field("Underline"),
380            strikeout: get_field("StrikeOut"),
381            scale_x: get_field("ScaleX"),
382            scale_y: get_field("ScaleY"),
383            spacing: get_field("Spacing"),
384            angle: get_field("Angle"),
385            border_style: get_field("BorderStyle"),
386            outline: get_field("Outline"),
387            shadow: get_field("Shadow"),
388            alignment: get_field("Alignment"),
389            margin_l: get_field("MarginL"),
390            margin_r: get_field("MarginR"),
391            margin_v: get_field("MarginV"),
392            margin_t: format
393                .iter()
394                .any(|&f| f.eq_ignore_ascii_case("MarginT"))
395                .then(|| get_field("MarginT")),
396            margin_b: format
397                .iter()
398                .any(|&f| f.eq_ignore_ascii_case("MarginB"))
399                .then(|| get_field("MarginB")),
400            encoding: get_field("Encoding"),
401            relative_to: format
402                .iter()
403                .any(|&f| f.eq_ignore_ascii_case("RelativeTo"))
404                .then(|| get_field("RelativeTo")),
405            span,
406        })
407    }
408
409    /// Get current line from source
410    fn current_line(&self) -> &'a str {
411        let remaining = self.tracker.remaining();
412        let end = remaining.find('\n').unwrap_or(remaining.len());
413        &remaining[..end]
414    }
415
416    /// Check if at start of next section
417    fn at_next_section(&self) -> bool {
418        self.tracker.remaining().trim_start().starts_with('[')
419    }
420
421    /// Skip whitespace and comment lines
422    fn skip_whitespace_and_comments(&mut self) {
423        loop {
424            self.tracker.skip_whitespace();
425
426            let remaining = self.tracker.remaining();
427            if remaining.is_empty() {
428                break;
429            }
430
431            if remaining.starts_with(';') || remaining.starts_with('#') {
432                self.tracker.skip_line();
433                continue;
434            }
435
436            // Check for newlines in whitespace
437            if remaining.starts_with('\n') {
438                self.tracker.advance(1);
439                continue;
440            }
441
442            break;
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::parser::ast::Section;
451
452    #[test]
453    fn parse_empty_section() {
454        let parser = StylesParser::new("", 0, 1);
455        let result = parser.parse();
456        assert!(result.is_ok());
457        let (section, ..) = result.unwrap();
458        if let Section::Styles(styles) = section {
459            assert!(styles.is_empty());
460        } else {
461            panic!("Expected Styles section");
462        }
463    }
464
465    #[test]
466    fn parse_basic_style() {
467        let content = "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1\n";
468        let parser = StylesParser::new(content, 0, 1);
469        let result = parser.parse();
470        assert!(result.is_ok());
471
472        let (section, ..) = result.unwrap();
473        if let Section::Styles(styles) = section {
474            assert_eq!(styles.len(), 1);
475            let style = &styles[0];
476            assert_eq!(style.name, "Default");
477            assert_eq!(style.fontname, "Arial");
478            assert_eq!(style.fontsize, "20");
479            // Check span
480            assert!(style.span.start > 0);
481            assert!(style.span.end > style.span.start);
482        } else {
483            panic!("Expected Styles section");
484        }
485    }
486
487    #[test]
488    fn parse_without_format_line() {
489        let content = "Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1\n";
490        let parser = StylesParser::new(content, 0, 1);
491        let result = parser.parse();
492        assert!(result.is_ok());
493
494        let (section, ..) = result.unwrap();
495        if let Section::Styles(styles) = section {
496            assert_eq!(styles.len(), 1);
497            assert_eq!(styles[0].name, "Default");
498        } else {
499            panic!("Expected Styles section");
500        }
501    }
502
503    #[test]
504    fn parse_with_inheritance() {
505        let content = "Style: *Default,NewStyle,Arial,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1\n";
506        let parser = StylesParser::new(content, 0, 1);
507        let result = parser.parse();
508        assert!(result.is_ok());
509
510        let (section, ..) = result.unwrap();
511        if let Section::Styles(styles) = section {
512            assert_eq!(styles.len(), 1);
513            assert_eq!(styles[0].name, "NewStyle");
514            assert_eq!(styles[0].parent, Some("Default"));
515        } else {
516            panic!("Expected Styles section");
517        }
518    }
519
520    #[test]
521    fn parse_with_position_tracking() {
522        // Create a larger content that simulates a full file
523        let prefix = "a".repeat(100); // 100 bytes of padding
524        let section_content = "Style: Test,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1\n";
525        let full_content = format!("{prefix}{section_content}");
526
527        // Parser starts at position 100
528        let parser = StylesParser::new(&full_content, 100, 10);
529        let result = parser.parse();
530        assert!(result.is_ok());
531
532        let (section, _, _, final_pos, final_line) = result.unwrap();
533        if let Section::Styles(styles) = section {
534            assert_eq!(styles.len(), 1);
535            let style = &styles[0];
536            assert_eq!(style.span.start, 100);
537            assert_eq!(style.span.line, 10);
538        } else {
539            panic!("Expected Styles section");
540        }
541
542        assert_eq!(final_pos, 100 + section_content.len());
543        assert_eq!(final_line, 11);
544    }
545
546    #[test]
547    fn test_public_parse_style_line() {
548        let format = vec![
549            "Name",
550            "Fontname",
551            "Fontsize",
552            "PrimaryColour",
553            "SecondaryColour",
554            "OutlineColour",
555            "BackColour",
556            "Bold",
557            "Italic",
558            "Underline",
559            "StrikeOut",
560            "ScaleX",
561            "ScaleY",
562            "Spacing",
563            "Angle",
564            "BorderStyle",
565            "Outline",
566            "Shadow",
567            "Alignment",
568            "MarginL",
569            "MarginR",
570            "MarginV",
571            "Encoding",
572        ];
573        let line = "Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1";
574
575        let result = StylesParser::parse_style_line(line, &format, 1);
576        assert!(result.is_ok());
577
578        let style = result.unwrap();
579        assert_eq!(style.name, "Default");
580        assert_eq!(style.fontname, "Arial");
581        assert_eq!(style.fontsize, "20");
582        assert!(style.parent.is_none());
583    }
584
585    #[test]
586    fn test_parse_style_line_with_inheritance() {
587        let format = vec![
588            "Name",
589            "Fontname",
590            "Fontsize",
591            "PrimaryColour",
592            "SecondaryColour",
593            "OutlineColour",
594            "BackColour",
595            "Bold",
596            "Italic",
597            "Underline",
598            "StrikeOut",
599            "ScaleX",
600            "ScaleY",
601            "Spacing",
602            "Angle",
603            "BorderStyle",
604            "Outline",
605            "Shadow",
606            "Alignment",
607            "MarginL",
608            "MarginR",
609            "MarginV",
610            "Encoding",
611        ];
612        let line = "*Default,NewStyle,Arial,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1";
613
614        let result = StylesParser::parse_style_line(line, &format, 1);
615        assert!(result.is_ok());
616
617        let style = result.unwrap();
618        assert_eq!(style.name, "NewStyle");
619        assert_eq!(style.parent, Some("Default"));
620        assert_eq!(style.fontsize, "24");
621    }
622
623    #[test]
624    fn test_parse_style_line_insufficient_fields() {
625        let format = vec![
626            "Name",
627            "Fontname",
628            "Fontsize",
629            "PrimaryColour",
630            "SecondaryColour",
631            "OutlineColour",
632            "BackColour",
633            "Bold",
634            "Italic",
635            "Underline",
636            "StrikeOut",
637            "ScaleX",
638            "ScaleY",
639            "Spacing",
640            "Angle",
641            "BorderStyle",
642            "Outline",
643            "Shadow",
644            "Alignment",
645            "MarginL",
646            "MarginR",
647            "MarginV",
648            "Encoding",
649        ];
650        let line = "Default,Arial,20"; // Missing fields
651
652        let result = StylesParser::parse_style_line(line, &format, 1);
653        assert!(result.is_err());
654
655        if let Err(e) = result {
656            assert!(matches!(e, ParseError::InsufficientFields { .. }));
657        }
658    }
659
660    #[test]
661    fn test_parse_style_line_with_empty_format() {
662        // Test with empty format array - should use default
663        let format = vec![];
664        let line = "Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1";
665
666        let result = StylesParser::parse_style_line(line, &format, 1);
667        assert!(result.is_ok());
668
669        let style = result.unwrap();
670        assert_eq!(style.name, "Default");
671        assert_eq!(style.fontname, "Arial");
672    }
673}