use crate::{Result, ScriptVersion};
#[cfg(not(feature = "std"))]
extern crate alloc;
#[cfg(feature = "stream")]
use alloc::format;
use alloc::{
boxed::Box,
string::{String, ToString},
vec,
vec::Vec,
};
#[cfg(feature = "stream")]
use core::ops::Range;
#[cfg(feature = "stream")]
use super::streaming;
use super::{
ast::{Event, Section, SectionType, Style},
errors::{ParseError, ParseIssue},
main::Parser,
};
#[cfg(feature = "stream")]
use super::ast::{Font, Graphic};
#[cfg(feature = "plugins")]
use crate::plugin::ExtensionRegistry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineContent<'a> {
Style(Box<Style<'a>>),
Event(Box<Event<'a>>),
Field(&'a str, &'a str),
}
#[derive(Debug, Clone)]
pub struct UpdateOperation<'a> {
pub offset: usize,
pub new_line: &'a str,
pub line_number: u32,
}
#[derive(Debug)]
pub struct BatchUpdateResult<'a> {
pub updated: Vec<(usize, LineContent<'a>)>,
pub failed: Vec<(usize, ParseError)>,
}
#[derive(Debug, Clone)]
pub struct StyleBatch<'a> {
pub styles: Vec<Style<'a>>,
}
#[derive(Debug, Clone)]
pub struct EventBatch<'a> {
pub events: Vec<Event<'a>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change<'a> {
Added {
offset: usize,
content: LineContent<'a>,
line_number: u32,
},
Removed {
offset: usize,
section_type: SectionType,
line_number: u32,
},
Modified {
offset: usize,
old_content: LineContent<'a>,
new_content: LineContent<'a>,
line_number: u32,
},
SectionAdded {
section: Section<'a>,
index: usize,
},
SectionRemoved {
section_type: SectionType,
index: usize,
},
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ChangeTracker<'a> {
changes: Vec<Change<'a>>,
enabled: bool,
}
impl<'a> ChangeTracker<'a> {
pub fn enable(&mut self) {
self.enabled = true;
}
pub fn disable(&mut self) {
self.enabled = false;
}
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.enabled
}
pub fn record(&mut self, change: Change<'a>) {
if self.enabled {
self.changes.push(change);
}
}
#[must_use]
pub fn changes(&self) -> &[Change<'a>] {
&self.changes
}
pub fn clear(&mut self) {
self.changes.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.changes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Script<'a> {
source: &'a str,
version: ScriptVersion,
sections: Vec<Section<'a>>,
issues: Vec<ParseIssue>,
styles_format: Option<Vec<&'a str>>,
events_format: Option<Vec<&'a str>>,
change_tracker: ChangeTracker<'a>,
}
impl<'a> Script<'a> {
pub fn parse(source: &'a str) -> Result<Self> {
let parser = Parser::new(source);
Ok(parser.parse())
}
#[must_use]
pub const fn builder() -> ScriptBuilder<'a> {
ScriptBuilder::new()
}
#[cfg(feature = "stream")]
pub fn parse_partial(&self, range: Range<usize>, new_text: &str) -> Result<ScriptDeltaOwned> {
let modified_source =
streaming::build_modified_source(self.source, range.clone(), new_text);
let change = crate::parser::incremental::TextChange {
range: range.clone(),
new_text: new_text.to_string(),
line_range: crate::parser::incremental::calculate_line_range(self.source, range),
};
let new_script = self.parse_incremental(&modified_source, &change)?;
let delta = calculate_delta(self, &new_script);
let mut owned_delta = ScriptDeltaOwned {
added: Vec::new(),
modified: Vec::new(),
removed: Vec::new(),
new_issues: Vec::new(),
};
for section in delta.added {
owned_delta.added.push(format!("{section:?}"));
}
for (idx, section) in delta.modified {
owned_delta.modified.push((idx, format!("{section:?}")));
}
owned_delta.removed = delta.removed;
owned_delta.new_issues = delta.new_issues;
Ok(owned_delta)
}
#[must_use]
pub const fn version(&self) -> ScriptVersion {
self.version
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn sections(&self) -> &[Section<'a>] {
&self.sections
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn issues(&self) -> &[ParseIssue] {
&self.issues
}
#[must_use]
pub const fn source(&self) -> &'a str {
self.source
}
#[must_use]
pub fn styles_format(&self) -> Option<&[&'a str]> {
self.styles_format.as_deref()
}
#[must_use]
pub fn events_format(&self) -> Option<&[&'a str]> {
self.events_format.as_deref()
}
pub fn parse_style_line_with_context(
&self,
line: &'a str,
line_number: u32,
) -> core::result::Result<Style<'a>, ParseError> {
use super::sections::StylesParser;
let format = self.styles_format.as_deref().unwrap_or(&[
"Name",
"Fontname",
"Fontsize",
"PrimaryColour",
"SecondaryColour",
"OutlineColour",
"BackColour",
"Bold",
"Italic",
"Underline",
"StrikeOut",
"ScaleX",
"ScaleY",
"Spacing",
"Angle",
"BorderStyle",
"Outline",
"Shadow",
"Alignment",
"MarginL",
"MarginR",
"MarginV",
"Encoding",
]);
StylesParser::parse_style_line(line, format, line_number)
}
pub fn parse_event_line_with_context(
&self,
line: &'a str,
line_number: u32,
) -> core::result::Result<Event<'a>, ParseError> {
use super::sections::EventsParser;
let format = self.events_format.as_deref().unwrap_or(&[
"Layer", "Start", "End", "Style", "Name", "MarginL", "MarginR", "MarginV", "Effect",
"Text",
]);
EventsParser::parse_event_line(line, format, line_number)
}
pub fn parse_line_auto(
&self,
line: &'a str,
line_number: u32,
) -> core::result::Result<(SectionType, LineContent<'a>), ParseError> {
let trimmed = line.trim();
if trimmed.starts_with("Style:") {
if let Some(style_data) = trimmed.strip_prefix("Style:") {
let style = self.parse_style_line_with_context(style_data.trim(), line_number)?;
return Ok((SectionType::Styles, LineContent::Style(Box::new(style))));
}
} else if trimmed.starts_with("Dialogue:")
|| trimmed.starts_with("Comment:")
|| trimmed.starts_with("Picture:")
|| trimmed.starts_with("Sound:")
|| trimmed.starts_with("Movie:")
|| trimmed.starts_with("Command:")
{
let event = self.parse_event_line_with_context(trimmed, line_number)?;
return Ok((SectionType::Events, LineContent::Event(Box::new(event))));
} else if trimmed.contains(':') && !trimmed.starts_with("Format:") {
if let Some(colon_pos) = trimmed.find(':') {
let key = trimmed[..colon_pos].trim();
let value = trimmed[colon_pos + 1..].trim();
return Ok((SectionType::ScriptInfo, LineContent::Field(key, value)));
}
}
Err(ParseError::InvalidFieldFormat {
line: line_number as usize,
})
}
#[must_use]
pub fn find_section(&self, section_type: SectionType) -> Option<&Section<'a>> {
self.sections
.iter()
.find(|s| s.section_type() == section_type)
}
#[cfg(debug_assertions)]
#[must_use]
pub fn validate_spans(&self) -> bool {
let source_ptr = self.source.as_ptr();
let source_range = source_ptr as usize..source_ptr as usize + self.source.len();
self.sections
.iter()
.all(|section| section.validate_spans(&source_range))
}
#[must_use]
pub fn section_range(&self, section_type: SectionType) -> Option<core::ops::Range<usize>> {
self.find_section(section_type)?
.span()
.map(|s| s.start..s.end)
}
#[must_use]
pub fn section_at_offset(&self, offset: usize) -> Option<&Section<'a>> {
self.sections.iter().find(|s| {
s.span()
.is_some_and(|span| span.start <= offset && offset < span.end)
})
}
#[must_use]
pub fn section_boundaries(&self) -> Vec<(SectionType, core::ops::Range<usize>)> {
self.sections
.iter()
.filter_map(|s| {
s.span()
.map(|span| (s.section_type(), span.start..span.end))
})
.collect()
}
pub(super) fn from_parts(
source: &'a str,
version: ScriptVersion,
sections: Vec<Section<'a>>,
issues: Vec<ParseIssue>,
styles_format: Option<Vec<&'a str>>,
events_format: Option<Vec<&'a str>>,
) -> Self {
Self {
source,
version,
sections,
issues,
styles_format,
events_format,
change_tracker: ChangeTracker::default(),
}
}
pub fn update_line_at_offset(
&mut self,
offset: usize,
new_line: &'a str,
line_number: u32,
) -> core::result::Result<LineContent<'a>, ParseError> {
let section_index = self
.sections
.iter()
.position(|s| {
s.span()
.is_some_and(|span| span.start <= offset && offset < span.end)
})
.ok_or(ParseError::SectionNotFound)?;
let (_, new_content) = self.parse_line_auto(new_line, line_number)?;
let result = match (&mut self.sections[section_index], new_content.clone()) {
(Section::Styles(styles), LineContent::Style(new_style)) => {
styles
.iter()
.position(|s| s.span.start <= offset && offset < s.span.end)
.map_or(Err(ParseError::IndexOutOfBounds), |style_index| {
let old_style = styles[style_index].clone();
styles[style_index] = *new_style;
Ok(LineContent::Style(Box::new(old_style)))
})
}
(Section::Events(events), LineContent::Event(new_event)) => {
events
.iter()
.position(|e| e.span.start <= offset && offset < e.span.end)
.map_or(Err(ParseError::IndexOutOfBounds), |event_index| {
let old_event = events[event_index].clone();
events[event_index] = *new_event;
Ok(LineContent::Event(Box::new(old_event)))
})
}
(Section::ScriptInfo(info), LineContent::Field(key, value)) => {
if let Some(field_index) = info.fields.iter().position(|(k, _)| *k == key) {
let old_value = info.fields[field_index].1;
info.fields[field_index] = (key, value);
Ok(LineContent::Field(key, old_value))
} else {
info.fields.push((key, value));
self.change_tracker.record(Change::Added {
offset,
content: LineContent::Field(key, value),
line_number,
});
Ok(LineContent::Field(key, ""))
}
}
_ => Err(ParseError::InvalidFieldFormat {
line: line_number as usize,
}),
};
if let Ok(old_content) = &result {
if !matches!(old_content, LineContent::Field(_, "")) {
self.change_tracker.record(Change::Modified {
offset,
old_content: old_content.clone(),
new_content,
line_number,
});
}
}
result
}
pub fn add_section(&mut self, section: Section<'a>) -> usize {
let index = self.sections.len();
self.change_tracker.record(Change::SectionAdded {
section: section.clone(),
index,
});
self.sections.push(section);
index
}
pub fn remove_section(
&mut self,
index: usize,
) -> core::result::Result<Section<'a>, ParseError> {
if index < self.sections.len() {
let section = self.sections.remove(index);
self.change_tracker.record(Change::SectionRemoved {
section_type: section.section_type(),
index,
});
Ok(section)
} else {
Err(ParseError::IndexOutOfBounds)
}
}
pub fn add_style(&mut self, style: Style<'a>) -> usize {
let styles_section_index = self
.sections
.iter()
.position(|s| matches!(s, Section::Styles(_)));
if let Some(index) = styles_section_index {
if let Section::Styles(styles) = &mut self.sections[index] {
styles.push(style);
styles.len() - 1
} else {
unreachable!("Section type mismatch");
}
} else {
self.sections.push(Section::Styles(vec![style]));
0
}
}
pub fn add_event(&mut self, event: Event<'a>) -> usize {
let events_section_index = self
.sections
.iter()
.position(|s| matches!(s, Section::Events(_)));
if let Some(index) = events_section_index {
if let Section::Events(events) = &mut self.sections[index] {
events.push(event);
events.len() - 1
} else {
unreachable!("Section type mismatch");
}
} else {
self.sections.push(Section::Events(vec![event]));
0
}
}
pub fn set_styles_format(&mut self, format: Vec<&'a str>) {
self.styles_format = Some(format);
}
pub fn set_events_format(&mut self, format: Vec<&'a str>) {
self.events_format = Some(format);
}
pub fn batch_update_lines(
&mut self,
operations: Vec<UpdateOperation<'a>>,
) -> BatchUpdateResult<'a> {
let mut result = BatchUpdateResult {
updated: Vec::with_capacity(operations.len()),
failed: Vec::new(),
};
let mut sorted_ops = operations;
sorted_ops.sort_by_key(|op| op.offset);
for op in sorted_ops {
match self.update_line_at_offset(op.offset, op.new_line, op.line_number) {
Ok(old_content) => {
result.updated.push((op.offset, old_content));
}
Err(e) => {
result.failed.push((op.offset, e));
}
}
}
result
}
pub fn batch_add_styles(&mut self, batch: StyleBatch<'a>) -> Vec<usize> {
let mut indices = Vec::with_capacity(batch.styles.len());
let styles_section_index = self
.sections
.iter()
.position(|s| matches!(s, Section::Styles(_)));
if let Some(index) = styles_section_index {
if let Section::Styles(styles) = &mut self.sections[index] {
let start_index = styles.len();
styles.extend(batch.styles);
indices.extend(start_index..styles.len());
}
} else {
let count = batch.styles.len();
self.sections.push(Section::Styles(batch.styles));
indices.extend(0..count);
}
indices
}
pub fn batch_add_events(&mut self, batch: EventBatch<'a>) -> Vec<usize> {
let mut indices = Vec::with_capacity(batch.events.len());
let events_section_index = self
.sections
.iter()
.position(|s| matches!(s, Section::Events(_)));
if let Some(index) = events_section_index {
if let Section::Events(events) = &mut self.sections[index] {
let start_index = events.len();
events.extend(batch.events);
indices.extend(start_index..events.len());
}
} else {
let count = batch.events.len();
self.sections.push(Section::Events(batch.events));
indices.extend(0..count);
}
indices
}
pub fn atomic_batch_update(
&mut self,
updates: Vec<UpdateOperation<'a>>,
style_additions: Option<StyleBatch<'a>>,
event_additions: Option<EventBatch<'a>>,
) -> core::result::Result<(), ParseError> {
for op in &updates {
let section_found = self.sections.iter().any(|s| {
s.span()
.is_some_and(|span| span.start <= op.offset && op.offset < span.end)
});
if !section_found {
return Err(ParseError::SectionNotFound);
}
self.parse_line_auto(op.new_line, op.line_number)?;
}
let mut temp_script = self.clone();
for op in updates {
temp_script.update_line_at_offset(op.offset, op.new_line, op.line_number)?;
}
if let Some(styles) = style_additions {
temp_script.batch_add_styles(styles);
}
if let Some(events) = event_additions {
temp_script.batch_add_events(events);
}
*self = temp_script;
Ok(())
}
pub fn enable_change_tracking(&mut self) {
self.change_tracker.enable();
}
pub fn disable_change_tracking(&mut self) {
self.change_tracker.disable();
}
#[must_use]
pub const fn is_change_tracking_enabled(&self) -> bool {
self.change_tracker.is_enabled()
}
#[must_use]
pub fn changes(&self) -> &[Change<'a>] {
self.change_tracker.changes()
}
pub fn clear_changes(&mut self) {
self.change_tracker.clear();
}
#[must_use]
pub fn change_count(&self) -> usize {
self.change_tracker.len()
}
#[must_use]
pub fn diff(&self, other: &Self) -> Vec<Change<'a>> {
let mut changes = Vec::new();
let max_sections = self.sections.len().max(other.sections.len());
for i in 0..max_sections {
match (self.sections.get(i), other.sections.get(i)) {
(Some(self_section), Some(other_section)) => {
if self_section != other_section {
changes.push(Change::SectionRemoved {
section_type: other_section.section_type(),
index: i,
});
changes.push(Change::SectionAdded {
section: self_section.clone(),
index: i,
});
}
}
(Some(self_section), None) => {
changes.push(Change::SectionAdded {
section: self_section.clone(),
index: i,
});
}
(None, Some(other_section)) => {
changes.push(Change::SectionRemoved {
section_type: other_section.section_type(),
index: i,
});
}
(None, None) => {
unreachable!("max_sections calculation error");
}
}
}
changes
}
#[must_use]
pub fn affected_sections(
&self,
change: &crate::parser::incremental::TextChange,
) -> Vec<SectionType> {
self.sections
.iter()
.filter(|section| {
section.span().is_some_and(|span| {
let section_range = span.start..span.end;
let overlaps = change.range.start < section_range.end
&& change.range.end > section_range.start;
let inserts_at_end =
change.range.is_empty() && change.range.start == section_range.end;
overlaps || inserts_at_end
})
})
.map(Section::section_type)
.collect()
}
pub fn parse_line_in_section(
&self,
section_type: SectionType,
line: &'a str,
line_number: u32,
) -> Result<LineContent<'a>> {
match section_type {
SectionType::Events => {
let format = self
.events_format()
.ok_or(crate::utils::errors::CoreError::Parse(
ParseError::MissingFormat,
))?;
crate::parser::sections::EventsParser::parse_event_line(line, format, line_number)
.map(|event| LineContent::Event(Box::new(event)))
.map_err(crate::utils::errors::CoreError::Parse)
}
SectionType::Styles => {
let format = self
.styles_format()
.ok_or(crate::utils::errors::CoreError::Parse(
ParseError::MissingFormat,
))?;
crate::parser::sections::StylesParser::parse_style_line(line, format, line_number)
.map(|style| LineContent::Style(Box::new(style)))
.map_err(crate::utils::errors::CoreError::Parse)
}
SectionType::ScriptInfo => {
if let Some((key, value)) = line.split_once(':') {
Ok(LineContent::Field(key.trim(), value.trim()))
} else {
Err(crate::utils::errors::CoreError::Parse(
ParseError::InvalidFieldFormat {
line: line_number as usize,
},
))
}
}
_ => Err(crate::utils::errors::CoreError::Parse(
ParseError::UnsupportedSection(section_type),
)),
}
}
pub fn parse_incremental(
&self,
new_source: &'a str,
change: &crate::parser::incremental::TextChange,
) -> Result<Self> {
use crate::parser::sections::SectionFormats;
let affected_sections = self.affected_sections(change);
if affected_sections.is_empty() {
return Ok(Script::from_parts(
new_source,
self.version(),
self.sections.clone(),
vec![], self.styles_format.clone(),
self.events_format.clone(),
));
}
let formats = SectionFormats {
styles_format: self.styles_format().map(<[&str]>::to_vec),
events_format: self.events_format().map(<[&str]>::to_vec),
};
let mut new_sections = Vec::with_capacity(self.sections.len());
let section_headers = [
("[Script Info]", SectionType::ScriptInfo),
("[V4+ Styles]", SectionType::Styles),
("[Events]", SectionType::Events),
("[Fonts]", SectionType::Fonts),
("[Graphics]", SectionType::Graphics),
];
for (idx, section) in self.sections.iter().enumerate() {
let section_type = section.section_type();
if affected_sections.contains(§ion_type) {
let header_str = section_headers
.iter()
.find(|(_, t)| *t == section_type)
.map_or("[Unknown]", |(h, _)| *h);
if let Some(header_pos) = new_source.find(header_str) {
let section_end = if idx + 1 < self.sections.len() {
let next_section_type = self.sections[idx + 1].section_type();
let next_header = section_headers
.iter()
.find(|(_, t)| *t == next_section_type)
.map_or("[Unknown]", |(h, _)| *h);
new_source[header_pos + header_str.len()..]
.find(next_header)
.map_or(new_source.len(), |pos| header_pos + header_str.len() + pos)
} else {
new_source.len()
};
let section_text = &new_source[header_pos..section_end];
let parser = Parser::new(section_text);
let parsed_script = parser.parse();
if let Some(parsed_section) = parsed_script
.sections
.into_iter()
.find(|s| s.section_type() == section_type)
{
new_sections.push(parsed_section);
}
}
} else {
let section_span = section.span();
if let Some(span) = section_span {
if change.range.end <= span.start {
new_sections.push(Self::adjust_section_spans(section, change));
} else {
new_sections.push(section.clone());
}
} else {
new_sections.push(section.clone());
}
}
}
Ok(Script::from_parts(
new_source,
self.version(),
new_sections,
vec![], formats.styles_format.clone(),
formats.events_format.clone(),
))
}
fn adjust_section_spans(
section: &Section<'a>,
change: &crate::parser::incremental::TextChange,
) -> Section<'a> {
use crate::parser::ast::Span;
let new_len = change.new_text.len();
let old_len = change.range.end - change.range.start;
let adjust_span = |span: &Span| -> Span {
let new_start = if new_len >= old_len {
span.start + (new_len - old_len)
} else {
span.start.saturating_sub(old_len - new_len)
};
let new_end = if new_len >= old_len {
span.end + (new_len - old_len)
} else {
span.end.saturating_sub(old_len - new_len)
};
Span::new(new_start, new_end, span.line, span.column)
};
match section {
Section::ScriptInfo(info) => {
let mut new_info = info.clone();
new_info.span = adjust_span(&info.span);
Section::ScriptInfo(new_info)
}
Section::Styles(styles) => {
let new_styles: Vec<_> = styles
.iter()
.map(|style| {
let mut new_style = style.clone();
new_style.span = adjust_span(&style.span);
new_style
})
.collect();
Section::Styles(new_styles)
}
Section::Events(events) => {
let new_events: Vec<_> = events
.iter()
.map(|event| {
let mut new_event = event.clone();
new_event.span = adjust_span(&event.span);
new_event
})
.collect();
Section::Events(new_events)
}
Section::Fonts(fonts) => {
let new_fonts: Vec<_> = fonts
.iter()
.map(|font| {
let mut new_font = font.clone();
new_font.span = adjust_span(&font.span);
new_font
})
.collect();
Section::Fonts(new_fonts)
}
Section::Graphics(graphics) => {
let new_graphics: Vec<_> = graphics
.iter()
.map(|graphic| {
let mut new_graphic = graphic.clone();
new_graphic.span = adjust_span(&graphic.span);
new_graphic
})
.collect();
Section::Graphics(new_graphics)
}
}
}
#[must_use]
pub fn to_ass_string(&self) -> alloc::string::String {
let mut result = String::new();
for (idx, section) in self.sections.iter().enumerate() {
if idx > 0 {
result.push('\n');
}
match section {
Section::ScriptInfo(info) => {
result.push_str(&info.to_ass_string());
}
Section::Styles(styles) => {
result.push_str("[V4+ Styles]\n");
if let Some(format) = &self.styles_format {
result.push_str("Format: ");
result.push_str(&format.join(", "));
result.push('\n');
}
for style in styles {
if let Some(format) = &self.styles_format {
result.push_str(&style.to_ass_string_with_format(format));
} else {
result.push_str(&style.to_ass_string());
}
result.push('\n');
}
}
Section::Events(events) => {
result.push_str("[Events]\n");
if let Some(format) = &self.events_format {
result.push_str("Format: ");
result.push_str(&format.join(", "));
result.push('\n');
}
for event in events {
if let Some(format) = &self.events_format {
result.push_str(&event.to_ass_string_with_format(format));
} else {
result.push_str(&event.to_ass_string());
}
result.push('\n');
}
}
Section::Fonts(fonts) => {
result.push_str("[Fonts]\n");
for font in fonts {
result.push_str(&font.to_ass_string());
}
}
Section::Graphics(graphics) => {
result.push_str("[Graphics]\n");
for graphic in graphics {
result.push_str(&graphic.to_ass_string());
}
}
}
}
result
}
}
#[cfg(feature = "stream")]
#[derive(Debug, Clone)]
pub struct ScriptDelta<'a> {
pub added: Vec<Section<'a>>,
pub modified: Vec<(usize, Section<'a>)>,
pub removed: Vec<usize>,
pub new_issues: Vec<ParseIssue>,
}
#[cfg(feature = "stream")]
fn sections_equal_ignoring_spans(old: &Section<'_>, new: &Section<'_>) -> bool {
use Section::{Events, Fonts, Graphics, ScriptInfo, Styles};
match (old, new) {
(ScriptInfo(old_info), ScriptInfo(new_info)) => {
old_info.fields == new_info.fields
}
(Styles(old_styles), Styles(new_styles)) => {
if old_styles.len() != new_styles.len() {
return false;
}
for (old_style, new_style) in old_styles.iter().zip(new_styles.iter()) {
if !styles_equal_ignoring_span(old_style, new_style) {
return false;
}
}
true
}
(Events(old_events), Events(new_events)) => {
if old_events.len() != new_events.len() {
return false;
}
for (old_event, new_event) in old_events.iter().zip(new_events.iter()) {
if !events_equal_ignoring_span(old_event, new_event) {
return false;
}
}
true
}
(Fonts(old_fonts), Fonts(new_fonts)) => {
if old_fonts.len() != new_fonts.len() {
return false;
}
for (old_font, new_font) in old_fonts.iter().zip(new_fonts.iter()) {
if !fonts_equal_ignoring_span(old_font, new_font) {
return false;
}
}
true
}
(Graphics(old_graphics), Graphics(new_graphics)) => {
if old_graphics.len() != new_graphics.len() {
return false;
}
for (old_graphic, new_graphic) in old_graphics.iter().zip(new_graphics.iter()) {
if !graphics_equal_ignoring_span(old_graphic, new_graphic) {
return false;
}
}
true
}
_ => false, }
}
#[cfg(feature = "stream")]
fn styles_equal_ignoring_span(old: &Style<'_>, new: &Style<'_>) -> bool {
old.name == new.name
&& old.parent == new.parent
&& old.fontname == new.fontname
&& old.fontsize == new.fontsize
&& old.primary_colour == new.primary_colour
&& old.secondary_colour == new.secondary_colour
&& old.outline_colour == new.outline_colour
&& old.back_colour == new.back_colour
&& old.bold == new.bold
&& old.italic == new.italic
&& old.underline == new.underline
&& old.strikeout == new.strikeout
&& old.scale_x == new.scale_x
&& old.scale_y == new.scale_y
&& old.spacing == new.spacing
&& old.angle == new.angle
&& old.border_style == new.border_style
&& old.outline == new.outline
&& old.shadow == new.shadow
&& old.alignment == new.alignment
&& old.margin_l == new.margin_l
&& old.margin_r == new.margin_r
&& old.margin_v == new.margin_v
&& old.margin_t == new.margin_t
&& old.margin_b == new.margin_b
&& old.encoding == new.encoding
&& old.relative_to == new.relative_to
}
#[cfg(feature = "stream")]
fn events_equal_ignoring_span(old: &Event<'_>, new: &Event<'_>) -> bool {
old.event_type == new.event_type
&& old.layer == new.layer
&& old.start == new.start
&& old.end == new.end
&& old.style == new.style
&& old.name == new.name
&& old.margin_l == new.margin_l
&& old.margin_r == new.margin_r
&& old.margin_v == new.margin_v
&& old.margin_t == new.margin_t
&& old.margin_b == new.margin_b
&& old.effect == new.effect
&& old.text == new.text
}
#[cfg(feature = "stream")]
fn fonts_equal_ignoring_span(old: &Font<'_>, new: &Font<'_>) -> bool {
old.filename == new.filename && old.data_lines == new.data_lines
}
#[cfg(feature = "stream")]
fn graphics_equal_ignoring_span(old: &Graphic<'_>, new: &Graphic<'_>) -> bool {
old.filename == new.filename && old.data_lines == new.data_lines
}
#[cfg(feature = "stream")]
#[must_use]
pub fn calculate_delta<'a>(old_script: &Script<'a>, new_script: &Script<'a>) -> ScriptDelta<'a> {
let mut added = Vec::new();
let mut modified = Vec::new();
let mut removed = Vec::new();
let old_sections: Vec<_> = old_script.sections().iter().collect();
let new_sections: Vec<_> = new_script.sections().iter().collect();
for (idx, old_section) in old_sections.iter().enumerate() {
let old_type = old_section.section_type();
if let Some((_new_idx, new_section)) = new_sections
.iter()
.enumerate()
.find(|(_, s)| s.section_type() == old_type)
{
if !sections_equal_ignoring_spans(old_section, new_section) {
modified.push((idx, (*new_section).clone()));
}
} else {
removed.push(idx);
}
}
for new_section in &new_sections {
let new_type = new_section.section_type();
if !old_sections.iter().any(|s| s.section_type() == new_type) {
added.push((*new_section).clone());
}
}
let new_issues: Vec<_> = new_script.issues().to_vec();
ScriptDelta {
added,
modified,
removed,
new_issues,
}
}
#[cfg(feature = "stream")]
#[derive(Debug, Clone)]
pub struct ScriptDeltaOwned {
pub added: Vec<String>,
pub modified: Vec<(usize, String)>,
pub removed: Vec<usize>,
pub new_issues: Vec<ParseIssue>,
}
#[cfg(feature = "stream")]
impl ScriptDelta<'_> {
#[must_use]
pub fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.modified.is_empty()
&& self.removed.is_empty()
&& self.new_issues.is_empty()
}
}
#[derive(Debug)]
pub struct ScriptBuilder<'a> {
#[cfg(feature = "plugins")]
registry: Option<&'a ExtensionRegistry>,
}
impl<'a> ScriptBuilder<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
#[cfg(feature = "plugins")]
registry: None,
}
}
#[cfg(feature = "plugins")]
#[must_use]
pub const fn with_registry(mut self, registry: &'a ExtensionRegistry) -> Self {
self.registry = Some(registry);
self
}
pub fn parse(self, source: &'a str) -> Result<Script<'a>> {
#[cfg(feature = "plugins")]
let parser = Parser::new_with_registry(source, self.registry);
#[cfg(not(feature = "plugins"))]
let parser = Parser::new(source);
Ok(parser.parse())
}
}
impl Default for ScriptBuilder<'_> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::ast::SectionType;
#[cfg(not(feature = "std"))]
use alloc::{format, string::String, vec};
#[test]
fn parse_minimal_script() {
let script = Script::parse("[Script Info]\nTitle: Test").unwrap();
assert_eq!(script.sections().len(), 1);
assert_eq!(script.version(), ScriptVersion::AssV4);
}
#[test]
fn parse_with_script_type() {
let script = Script::parse("[Script Info]\nScriptType: v4.00+\nTitle: Test").unwrap();
assert_eq!(script.version(), ScriptVersion::AssV4);
}
#[test]
fn parse_with_bom() {
let script = Script::parse("\u{FEFF}[Script Info]\nTitle: Test").unwrap();
assert_eq!(script.sections().len(), 1);
}
#[test]
fn parse_empty_input() {
let script = Script::parse("").unwrap();
assert_eq!(script.sections().len(), 0);
}
#[test]
fn parse_multiple_sections() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname\nStyle: Default,Arial\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello World";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 3);
assert_eq!(script.version(), ScriptVersion::AssV4);
}
#[test]
fn script_version_detection() {
let script = Script::parse("[Script Info]\nTitle: Test").unwrap();
assert_eq!(script.version(), ScriptVersion::AssV4);
}
#[test]
fn script_source_access() {
let content = "[Script Info]\nTitle: Test";
let script = Script::parse(content).unwrap();
assert_eq!(script.source(), content);
}
#[test]
fn script_sections_access() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name";
let script = Script::parse(content).unwrap();
let sections = script.sections();
assert_eq!(sections.len(), 2);
}
#[test]
fn script_issues_access() {
let script = Script::parse("[Script Info]\nTitle: Test").unwrap();
let issues = script.issues();
assert!(
issues.is_empty()
|| issues
.iter()
.all(|i| matches!(i.severity, crate::parser::errors::IssueSeverity::Warning))
);
}
#[test]
fn find_section_by_type() {
let content =
"[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name\n\n[Events]\nFormat: Layer";
let script = Script::parse(content).unwrap();
let script_info = script.find_section(SectionType::ScriptInfo);
assert!(script_info.is_some());
let styles = script.find_section(SectionType::Styles);
assert!(styles.is_some());
let events = script.find_section(SectionType::Events);
assert!(events.is_some());
}
#[test]
fn find_section_missing() {
let content = "[Script Info]\nTitle: Test";
let script = Script::parse(content).unwrap();
let styles = script.find_section(SectionType::Styles);
assert!(styles.is_none());
let events = script.find_section(SectionType::Events);
assert!(events.is_none());
}
#[test]
fn script_clone() {
let content = "[Script Info]\nTitle: Test";
let script = Script::parse(content).unwrap();
let cloned = script.clone();
assert_eq!(script, cloned);
assert_eq!(script.source(), cloned.source());
assert_eq!(script.version(), cloned.version());
assert_eq!(script.sections().len(), cloned.sections().len());
}
#[test]
fn script_debug() {
let script = Script::parse("[Script Info]\nTitle: Test").unwrap();
let debug_str = format!("{script:?}");
assert!(debug_str.contains("Script"));
}
#[test]
fn script_equality() {
let content = "[Script Info]\nTitle: Test";
let script1 = Script::parse(content).unwrap();
let script2 = Script::parse(content).unwrap();
assert_eq!(script1, script2);
let different_content = "[Script Info]\nTitle: Different";
let script3 = Script::parse(different_content).unwrap();
assert_ne!(script1, script3);
}
#[test]
fn parse_whitespace_only() {
let script = Script::parse(" \n\n \t \n").unwrap();
assert_eq!(script.sections().len(), 0);
}
#[test]
fn parse_comments_only() {
let script = Script::parse("!: This is a comment\n; Another comment").unwrap();
assert_eq!(script.sections().len(), 0);
}
#[test]
fn parse_multiple_script_info_sections() {
let content = "[Script Info]\nTitle: First\n\n[Script Info]\nTitle: Second";
let script = Script::parse(content).unwrap();
assert!(!script.sections().is_empty());
}
#[test]
fn parse_case_insensitive_sections() {
let content = "[script info]\nTitle: Test\n\n[v4+ styles]\nFormat: Name";
let _script = Script::parse(content).unwrap();
}
#[test]
fn parse_malformed_but_recoverable() {
let content = "[Script Info]\nTitle: Test\nMalformed line without colon\nAuthor: Someone";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 1);
let issues = script.issues();
assert!(issues.is_empty() || !issues.is_empty()); }
#[test]
fn parse_with_various_line_endings() {
let content_crlf = "[Script Info]\r\nTitle: Test\r\n";
let script_crlf = Script::parse(content_crlf).unwrap();
assert_eq!(script_crlf.sections().len(), 1);
let content_lf = "[Script Info]\nTitle: Test\n";
let script_lf = Script::parse(content_lf).unwrap();
assert_eq!(script_lf.sections().len(), 1);
}
#[test]
fn from_parts_constructor() {
let source = "[Script Info]\nTitle: Test";
let sections = Vec::new();
let issues = Vec::new();
let script = Script::from_parts(source, ScriptVersion::AssV4, sections, issues, None, None);
assert_eq!(script.source(), source);
assert_eq!(script.version(), ScriptVersion::AssV4);
assert_eq!(script.sections().len(), 0);
assert_eq!(script.issues().len(), 0);
}
#[cfg(debug_assertions)]
#[test]
fn validate_spans() {
let script = Script::parse("[Script Info]\nTitle: Test").unwrap();
assert!(script.validate_spans() || !script.validate_spans()); }
#[test]
fn parse_unicode_content() {
let content = "[Script Info]\nTitle: Unicode Test 测试 🎬\nAuthor: アニメ";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 1);
assert_eq!(script.source(), content);
}
#[test]
fn parse_very_long_content() {
#[cfg(not(feature = "std"))]
use alloc::fmt::Write;
#[cfg(feature = "std")]
use std::fmt::Write;
let mut content = String::from("[Script Info]\nTitle: Long Test\n");
for i in 0..1000 {
writeln!(
content,
"Comment{i}: This is a very long comment line to test performance"
)
.unwrap();
}
let script = Script::parse(&content).unwrap();
assert_eq!(script.sections().len(), 1);
}
#[test]
fn parse_nested_brackets() {
let content = "[Script Info]\nTitle: Test [with] brackets\nComment: [nested [brackets]]";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 1);
}
#[cfg(feature = "stream")]
#[test]
fn script_delta_is_empty() {
use crate::parser::ast::Span;
let delta = ScriptDelta {
added: Vec::new(),
modified: Vec::new(),
removed: Vec::new(),
new_issues: Vec::new(),
};
assert!(delta.is_empty());
let non_empty_delta = ScriptDelta {
added: vec![],
modified: vec![(
0,
Section::ScriptInfo(crate::parser::ast::ScriptInfo {
fields: Vec::new(),
span: Span::new(0, 0, 0, 0),
}),
)],
removed: Vec::new(),
new_issues: Vec::new(),
};
assert!(!non_empty_delta.is_empty());
}
#[cfg(feature = "stream")]
#[test]
fn script_delta_debug() {
let delta = ScriptDelta {
added: Vec::new(),
modified: Vec::new(),
removed: Vec::new(),
new_issues: Vec::new(),
};
let debug_str = format!("{delta:?}");
assert!(debug_str.contains("ScriptDelta"));
}
#[cfg(feature = "stream")]
#[test]
fn script_delta_owned_debug() {
let delta = ScriptDeltaOwned {
added: Vec::new(),
modified: Vec::new(),
removed: Vec::new(),
new_issues: Vec::new(),
};
let debug_str = format!("{delta:?}");
assert!(debug_str.contains("ScriptDeltaOwned"));
}
#[cfg(feature = "stream")]
#[test]
fn parse_partial_basic() {
let content = "[Script Info]\nTitle: Original";
let script = Script::parse(content).unwrap();
let result = script.parse_partial(0..content.len(), "[Script Info]\nTitle: Modified");
assert!(result.is_ok() || result.is_err());
}
#[test]
fn parse_empty_sections() {
let content = "[Script Info]\n\n[V4+ Styles]\n\n[Events]\n";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 3);
}
#[test]
fn parse_section_with_only_format() {
let content = "[V4+ Styles]\nFormat: Name, Fontname, Fontsize";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 1);
}
#[test]
fn parse_events_with_complex_text() {
let content = r"[Events]
Format: Layer, Start, End, Style, Text
Dialogue: 0,0:00:00.00,0:00:05.00,Default,{\b1}Bold text{\b0} and {\i1}italic{\i0}
Comment: 0,0:00:05.00,0:00:10.00,Default,This is a comment
";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 1);
}
#[cfg(debug_assertions)]
#[test]
fn validate_spans_comprehensive() {
let content = "[Script Info]\nTitle: Test\nAuthor: Someone";
let script = Script::parse(content).unwrap();
assert!(script.validate_spans());
assert_eq!(script.source(), content);
}
#[test]
fn script_accessor_methods() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name";
let script = Script::parse(content).unwrap();
assert_eq!(script.version(), ScriptVersion::AssV4);
assert_eq!(script.sections().len(), 2);
assert_eq!(script.source(), content);
let _ = script.issues();
assert!(script.find_section(SectionType::ScriptInfo).is_some());
assert!(script.find_section(SectionType::Styles).is_some());
assert!(script.find_section(SectionType::Events).is_none());
}
#[test]
fn from_parts_comprehensive() {
use crate::parser::ast::{ScriptInfo, Section, Span};
let source = "[Script Info]\nTitle: Custom";
let mut sections = Vec::new();
let issues = Vec::new();
let script1 = Script::from_parts(
source,
ScriptVersion::AssV4,
sections.clone(),
issues.clone(),
None,
None,
);
assert_eq!(script1.source(), source);
assert_eq!(script1.version(), ScriptVersion::AssV4);
assert_eq!(script1.sections().len(), 0);
assert_eq!(script1.issues().len(), 0);
let script_info = ScriptInfo {
fields: Vec::new(),
span: Span::new(0, 0, 0, 0),
};
sections.push(Section::ScriptInfo(script_info));
let script2 =
Script::from_parts(source, ScriptVersion::AssV4, sections, issues, None, None);
assert_eq!(script2.sections().len(), 1);
}
#[test]
fn format_preservation() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, Bold\nStyle: Default,Arial,20,1\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello";
let script = Script::parse(content).unwrap();
let styles_format = script.styles_format().unwrap();
assert_eq!(styles_format.len(), 4);
assert_eq!(styles_format[0], "Name");
assert_eq!(styles_format[1], "Fontname");
assert_eq!(styles_format[2], "Fontsize");
assert_eq!(styles_format[3], "Bold");
let events_format = script.events_format().unwrap();
assert_eq!(events_format.len(), 5);
assert_eq!(events_format[0], "Layer");
assert_eq!(events_format[1], "Start");
assert_eq!(events_format[2], "End");
assert_eq!(events_format[3], "Style");
assert_eq!(events_format[4], "Text");
}
#[test]
fn context_aware_style_parsing() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Bold\nStyle: Default,Arial,1";
let script = Script::parse(content).unwrap();
let style_line = "NewStyle,Times,0";
let result = script.parse_style_line_with_context(style_line, 10);
assert!(result.is_ok());
let style = result.unwrap();
assert_eq!(style.name, "NewStyle");
assert_eq!(style.fontname, "Times");
assert_eq!(style.bold, "0");
}
#[test]
fn context_aware_event_parsing() {
let content = "[Script Info]\nTitle: Test\n\n[Events]\nFormat: Start, End, Text\nDialogue: 0:00:00.00,0:00:05.00,Hello";
let script = Script::parse(content).unwrap();
let event_line = "Dialogue: 0:00:05.00,0:00:10.00,World";
let result = script.parse_event_line_with_context(event_line, 10);
assert!(result.is_ok());
let event = result.unwrap();
assert_eq!(event.start, "0:00:05.00");
assert_eq!(event.end, "0:00:10.00");
assert_eq!(event.text, "World");
}
#[test]
fn parse_line_auto_detection() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname\n\n[Events]\nFormat: Layer, Start, End, Style, Text";
let script = Script::parse(content).unwrap();
let style_line = "Style: Default,Arial";
let result = script.parse_line_auto(style_line, 10);
assert!(result.is_ok());
let (section_type, content) = result.unwrap();
assert_eq!(section_type, SectionType::Styles);
assert!(matches!(content, LineContent::Style(_)));
let event_line = "Dialogue: 0,0:00:00.00,0:00:05.00,Default,Test";
let result = script.parse_line_auto(event_line, 11);
assert!(result.is_ok());
let (section_type, content) = result.unwrap();
assert_eq!(section_type, SectionType::Events);
assert!(matches!(content, LineContent::Event(_)));
let info_line = "PlayResX: 1920";
let result = script.parse_line_auto(info_line, 12);
assert!(result.is_ok());
let (section_type, content) = result.unwrap();
assert_eq!(section_type, SectionType::ScriptInfo);
if let LineContent::Field(key, value) = content {
assert_eq!(key, "PlayResX");
assert_eq!(value, "1920");
} else {
panic!("Expected Field variant");
}
}
#[test]
fn context_parsing_with_default_format() {
let content = "[Script Info]\nTitle: Test";
let script = Script::parse(content).unwrap();
let style_line = "Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,0,0,0,1";
let result = script.parse_style_line_with_context(style_line, 10);
assert!(result.is_ok());
let event_line = "Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Test";
let result = script.parse_event_line_with_context(event_line, 11);
assert!(result.is_ok());
}
#[test]
fn update_style_line() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20\nStyle: Alt,Times,18";
let mut script = Script::parse(content).unwrap();
if let Some(Section::Styles(styles)) = script.find_section(SectionType::Styles) {
let default_style = &styles[0];
let offset = default_style.span.start;
let new_line = "Style: Default,Helvetica,24";
let result = script.update_line_at_offset(offset, new_line, 10);
assert!(result.is_ok());
if let Ok(LineContent::Style(old_style)) = result {
assert_eq!(old_style.name, "Default");
assert_eq!(old_style.fontname, "Arial");
assert_eq!(old_style.fontsize, "20");
}
if let Some(Section::Styles(updated_styles)) = script.find_section(SectionType::Styles)
{
assert_eq!(updated_styles[0].fontname, "Helvetica");
assert_eq!(updated_styles[0].fontsize, "24");
}
}
}
#[test]
fn update_event_line() {
let content = "[Script Info]\nTitle: Test\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello\nDialogue: 0,0:00:05.00,0:00:10.00,Default,World";
let mut script = Script::parse(content).unwrap();
if let Some(Section::Events(events)) = script.find_section(SectionType::Events) {
let first_event = &events[0];
let offset = first_event.span.start;
let new_line = "Dialogue: 0,0:00:00.00,0:00:05.00,Default,Updated Text";
let result = script.update_line_at_offset(offset, new_line, 10);
assert!(result.is_ok());
if let Ok(LineContent::Event(old_event)) = result {
assert_eq!(old_event.text, "Hello");
}
if let Some(Section::Events(updated_events)) = script.find_section(SectionType::Events)
{
assert_eq!(updated_events[0].text, "Updated Text");
}
}
}
#[test]
fn add_and_remove_sections() {
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
let styles_section = Section::Styles(vec![]);
let index = script.add_section(styles_section);
assert_eq!(index, 1);
assert_eq!(script.sections().len(), 2);
let removed = script.remove_section(index);
assert!(removed.is_ok());
assert_eq!(script.sections().len(), 1);
let invalid = script.remove_section(10);
assert!(invalid.is_err());
}
#[test]
fn add_style_creates_section() {
use crate::parser::ast::Span;
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
let style = Style {
name: "NewStyle",
parent: None,
fontname: "Arial",
fontsize: "20",
primary_colour: "&H00FFFFFF",
secondary_colour: "&H000000FF",
outline_colour: "&H00000000",
back_colour: "&H00000000",
bold: "0",
italic: "0",
underline: "0",
strikeout: "0",
scale_x: "100",
scale_y: "100",
spacing: "0",
angle: "0",
border_style: "1",
outline: "0",
shadow: "0",
alignment: "2",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
encoding: "1",
relative_to: None,
span: Span::new(0, 0, 0, 0),
};
let index = script.add_style(style);
assert_eq!(index, 0);
assert!(script.find_section(SectionType::Styles).is_some());
}
#[test]
fn add_event_to_existing_section() {
use crate::parser::ast::{EventType, Span};
let content = "[Script Info]\nTitle: Test\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello";
let mut script = Script::parse(content).unwrap();
let event = Event {
event_type: EventType::Dialogue,
layer: "0",
start: "0:00:05.00",
end: "0:00:10.00",
style: "Default",
name: "",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
effect: "",
text: "New Event",
span: Span::new(0, 0, 0, 0),
};
let index = script.add_event(event);
assert_eq!(index, 1);
if let Some(Section::Events(events)) = script.find_section(SectionType::Events) {
assert_eq!(events.len(), 2);
assert_eq!(events[1].text, "New Event");
}
}
#[test]
fn update_formats() {
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
let styles_format = vec!["Name", "Fontname", "Bold"];
script.set_styles_format(styles_format);
let events_format = vec!["Start", "End", "Text"];
script.set_events_format(events_format);
assert!(script.styles_format().is_some());
assert_eq!(script.styles_format().unwrap().len(), 3);
assert_eq!(script.styles_format().unwrap()[2], "Bold");
assert!(script.events_format().is_some());
assert_eq!(script.events_format().unwrap().len(), 3);
assert_eq!(script.events_format().unwrap()[0], "Start");
}
#[test]
fn batch_update_lines() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20\nStyle: Alt,Times,18\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello\nDialogue: 0,0:00:05.00,0:00:10.00,Default,World";
let mut script = Script::parse(content).unwrap();
let mut operations = Vec::new();
if let Some(Section::Styles(styles)) = script.find_section(SectionType::Styles) {
if styles.len() >= 2 {
operations.push(UpdateOperation {
offset: styles[0].span.start,
new_line: "Style: Default,Helvetica,24",
line_number: 10,
});
operations.push(UpdateOperation {
offset: styles[1].span.start,
new_line: "Style: Alt,Courier,16",
line_number: 11,
});
}
}
let result = script.batch_update_lines(operations);
assert_eq!(result.updated.len(), 2);
assert_eq!(result.failed.len(), 0);
if let Some(Section::Styles(styles)) = script.find_section(SectionType::Styles) {
assert_eq!(styles[0].fontname, "Helvetica");
assert_eq!(styles[0].fontsize, "24");
assert_eq!(styles[1].fontname, "Courier");
assert_eq!(styles[1].fontsize, "16");
}
}
#[test]
fn batch_add_styles() {
use crate::parser::ast::Span;
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
let styles = vec![
Style {
name: "Style1",
parent: None,
fontname: "Arial",
fontsize: "20",
primary_colour: "&H00FFFFFF",
secondary_colour: "&H000000FF",
outline_colour: "&H00000000",
back_colour: "&H00000000",
bold: "0",
italic: "0",
underline: "0",
strikeout: "0",
scale_x: "100",
scale_y: "100",
spacing: "0",
angle: "0",
border_style: "1",
outline: "0",
shadow: "0",
alignment: "2",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
encoding: "1",
relative_to: None,
span: Span::new(0, 0, 0, 0),
},
Style {
name: "Style2",
parent: None,
fontname: "Times",
fontsize: "18",
primary_colour: "&H00FFFFFF",
secondary_colour: "&H000000FF",
outline_colour: "&H00000000",
back_colour: "&H00000000",
bold: "1",
italic: "0",
underline: "0",
strikeout: "0",
scale_x: "100",
scale_y: "100",
spacing: "0",
angle: "0",
border_style: "1",
outline: "0",
shadow: "0",
alignment: "2",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
encoding: "1",
relative_to: None,
span: Span::new(0, 0, 0, 0),
},
];
let batch = StyleBatch { styles };
let indices = script.batch_add_styles(batch);
assert_eq!(indices, vec![0, 1]);
if let Some(Section::Styles(styles)) = script.find_section(SectionType::Styles) {
assert_eq!(styles.len(), 2);
assert_eq!(styles[0].name, "Style1");
assert_eq!(styles[1].name, "Style2");
}
}
#[test]
fn batch_add_events() {
use crate::parser::ast::{EventType, Span};
let content =
"[Script Info]\nTitle: Test\n\n[Events]\nFormat: Layer, Start, End, Style, Text";
let mut script = Script::parse(content).unwrap();
let events = vec![
Event {
event_type: EventType::Dialogue,
layer: "0",
start: "0:00:00.00",
end: "0:00:05.00",
style: "Default",
name: "",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
effect: "",
text: "Event 1",
span: Span::new(0, 0, 0, 0),
},
Event {
event_type: EventType::Comment,
layer: "0",
start: "0:00:05.00",
end: "0:00:10.00",
style: "Default",
name: "",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
effect: "",
text: "Comment 1",
span: Span::new(0, 0, 0, 0),
},
];
let batch = EventBatch { events };
let indices = script.batch_add_events(batch);
assert_eq!(indices, vec![0, 1]);
if let Some(Section::Events(events)) = script.find_section(SectionType::Events) {
assert_eq!(events.len(), 2);
assert_eq!(events[0].text, "Event 1");
assert_eq!(events[1].text, "Comment 1");
}
}
#[test]
fn atomic_batch_update_success() {
use crate::parser::ast::{EventType, Span};
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20";
let mut script = Script::parse(content).unwrap();
let updates =
if let Some(Section::Styles(styles)) = script.find_section(SectionType::Styles) {
vec![UpdateOperation {
offset: styles[0].span.start,
new_line: "Style: Default,Helvetica,24",
line_number: 10,
}]
} else {
vec![]
};
let events = vec![Event {
event_type: EventType::Dialogue,
layer: "0",
start: "0:00:00.00",
end: "0:00:05.00",
style: "Default",
name: "",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
effect: "",
text: "New Event",
span: Span::new(0, 0, 0, 0),
}];
let event_batch = EventBatch { events };
let result = script.atomic_batch_update(updates, None, Some(event_batch));
assert!(result.is_ok());
if let Some(Section::Styles(styles)) = script.find_section(SectionType::Styles) {
assert_eq!(styles[0].fontname, "Helvetica");
assert_eq!(styles[0].fontsize, "24");
}
if let Some(Section::Events(events)) = script.find_section(SectionType::Events) {
assert_eq!(events.len(), 1);
assert_eq!(events[0].text, "New Event");
}
}
#[test]
fn atomic_batch_update_rollback() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20";
let mut script = Script::parse(content).unwrap();
let original_script = script.clone();
let updates = vec![UpdateOperation {
offset: 999_999, new_line: "Style: Invalid,Arial,20",
line_number: 10,
}];
let result = script.atomic_batch_update(updates, None, None);
assert!(result.is_err());
assert_eq!(script, original_script);
}
#[test]
fn parse_malformed_comprehensive() {
let malformed_inputs = vec![
"[Script Info]\nTitleWithoutColon",
"[Script Info]\nTitle: Test\n\nInvalid line outside section",
];
for input in malformed_inputs {
let result = Script::parse(input);
assert!(result.is_ok() || result.is_err());
if let Ok(script) = result {
assert_eq!(script.source(), input);
let _ = script.sections();
let _ = script.issues();
}
}
}
#[test]
fn parse_edge_case_inputs() {
let edge_cases = vec![
"", "\n\n\n", " ", "\t\t\t", "[Script Info]", "[Script Info]\n", "[]", "[ ]", "[Script Info]\nTitle:", "[Script Info]\n:Value", ];
for input in edge_cases {
let result = Script::parse(input);
assert!(result.is_ok(), "Failed to parse edge case: {input:?}");
let script = result.unwrap();
assert_eq!(script.source(), input);
let _ = script.sections();
}
}
#[test]
fn script_version_handling() {
let v4_script = Script::parse("[Script Info]\nScriptType: v4.00").unwrap();
assert_eq!(v4_script.version(), ScriptVersion::SsaV4);
let v4_plus_script = Script::parse("[Script Info]\nScriptType: v4.00+").unwrap();
assert_eq!(v4_plus_script.version(), ScriptVersion::AssV4);
let no_version_script = Script::parse("[Script Info]\nTitle: Test").unwrap();
assert_eq!(no_version_script.version(), ScriptVersion::AssV4);
}
#[test]
fn parse_large_script_comprehensive() {
#[cfg(not(feature = "std"))]
use alloc::fmt::Write;
#[cfg(feature = "std")]
use std::fmt::Write;
let mut content = String::from("[Script Info]\nTitle: Large Test\n");
content.push_str("[V4+ Styles]\nFormat: Name, Fontname, Fontsize\n");
for i in 0..100 {
writeln!(content, "Style: Style{},Arial,{}", i, 16 + i % 10).unwrap();
}
content.push_str("\n[Events]\nFormat: Layer, Start, End, Style, Text\n");
for i in 0..100 {
let start_time = i * 5;
let end_time = start_time + 4;
writeln!(
content,
"Dialogue: 0,0:00:{:02}.00,0:00:{:02}.00,Style{},Text {}",
start_time / 60,
end_time / 60,
i % 10,
i
)
.unwrap();
}
let script = Script::parse(&content).unwrap();
assert_eq!(script.sections().len(), 3);
assert_eq!(script.source(), content);
}
#[cfg(feature = "stream")]
#[test]
fn streaming_features_comprehensive() {
use crate::parser::ast::{ScriptInfo, Section, Span};
let content = "[Script Info]\nTitle: Original\nAuthor: Test";
let _script = Script::parse(content).unwrap();
let empty_delta = ScriptDelta {
added: Vec::new(),
modified: Vec::new(),
removed: Vec::new(),
new_issues: Vec::new(),
};
assert!(empty_delta.is_empty());
let script_info = ScriptInfo {
fields: Vec::new(),
span: Span::new(0, 0, 0, 0),
};
let non_empty_delta = ScriptDelta {
added: vec![Section::ScriptInfo(script_info)],
modified: Vec::new(),
removed: Vec::new(),
new_issues: Vec::new(),
};
assert!(!non_empty_delta.is_empty());
let cloned_delta = empty_delta.clone();
assert!(cloned_delta.is_empty());
let owned_delta = ScriptDeltaOwned {
added: vec!["test".to_string()],
modified: Vec::new(),
removed: Vec::new(),
new_issues: Vec::new(),
};
let _debug_str = format!("{owned_delta:?}");
let _ = owned_delta;
}
#[cfg(feature = "stream")]
#[test]
fn parse_partial_error_handling() {
let content = "[Script Info]\nTitle: Test";
let script = Script::parse(content).unwrap();
let test_cases = vec![
(0..5, "[Invalid"),
(0..content.len(), "[Script Info]\nTitle: Modified"),
(5..10, "New"),
];
for (range, new_text) in test_cases {
let result = script.parse_partial(range, new_text);
assert!(result.is_ok() || result.is_err());
}
}
#[test]
fn script_equality_comprehensive() {
let content1 = "[Script Info]\nTitle: Test1";
let content2 = "[Script Info]\nTitle: Test2";
let content3 = "[Script Info]\nTitle: Test1";
let script1 = Script::parse(content1).unwrap();
let script2 = Script::parse(content2).unwrap();
let script3 = Script::parse(content3).unwrap();
assert_eq!(script1, script3);
assert_ne!(script1, script2);
let cloned1 = script1.clone();
assert_eq!(script1, cloned1);
let debug1 = format!("{script1:?}");
let debug2 = format!("{script2:?}");
assert!(debug1.contains("Script"));
assert!(debug2.contains("Script"));
assert_ne!(debug1, debug2);
}
#[test]
fn parse_special_characters() {
let content = "[Script Info]\nTitle: Test with émojis 🎬 and spëcial chars\nAuthor: テスト";
let script = Script::parse(content).unwrap();
assert_eq!(script.source(), content);
assert_eq!(script.sections().len(), 1);
assert!(script.find_section(SectionType::ScriptInfo).is_some());
}
#[test]
fn parse_different_section_orders() {
let content1 =
"[Events]\nFormat: Text\n\n[V4+ Styles]\nFormat: Name\n\n[Script Info]\nTitle: Test";
let script1 = Script::parse(content1).unwrap();
assert_eq!(script1.sections().len(), 3);
let content2 =
"[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name\n\n[Events]\nFormat: Text";
let script2 = Script::parse(content2).unwrap();
assert_eq!(script2.sections().len(), 3);
assert!(script1.find_section(SectionType::ScriptInfo).is_some());
assert!(script1.find_section(SectionType::Styles).is_some());
assert!(script1.find_section(SectionType::Events).is_some());
assert!(script2.find_section(SectionType::ScriptInfo).is_some());
assert!(script2.find_section(SectionType::Styles).is_some());
assert!(script2.find_section(SectionType::Events).is_some());
}
#[test]
fn parse_partial_comprehensive_scenarios() {
let content = "[Script Info]\nTitle: Original\nAuthor: Test\n[V4+ Styles]\nFormat: Name, Fontname\nStyle: Default,Arial\n[Events]\nFormat: Start, End, Text\nDialogue: 0:00:00.00,0:00:05.00,Original text";
let _script = Script::parse(content).unwrap();
let modified_content = content.replace("Title: Original", "Title: Modified");
let modified_script = Script::parse(&modified_content);
assert!(modified_script.is_ok());
}
#[test]
fn parse_error_scenarios() {
let malformed_cases = vec![
"[Unclosed Section",
"[Script Info\nMalformed",
"Invalid: : Content",
];
for malformed in malformed_cases {
let result = Script::parse(malformed);
assert!(result.is_ok() || result.is_err());
}
}
#[test]
fn script_modification_scenarios() {
let content =
"[Script Info]\nTitle: Test\n[V4+ Styles]\nFormat: Name\nStyle: Default,Arial";
let script = Script::parse(content).unwrap();
assert_eq!(script.sections().len(), 2);
assert!(script.find_section(SectionType::ScriptInfo).is_some());
assert!(script.find_section(SectionType::Styles).is_some());
let extended_content = format!(
"{content}\n[Events]\nFormat: Start, End, Text\nDialogue: 0:00:00.00,0:00:05.00,Test"
);
let extended_script = Script::parse(&extended_content).unwrap();
assert_eq!(extended_script.sections().len(), 3);
}
#[test]
fn incremental_parsing_simulation() {
let content = "[Script Info]\nTitle: Test";
let _script = Script::parse(content).unwrap();
let variations = vec![
"[Script Info]\n Title: Test", "!Script Info]\nTitle: Test", "[Script Info]\nTitle: Test\nAuthor: Someone", ];
for variation in variations {
let result = Script::parse(variation);
assert!(result.is_ok() || result.is_err());
}
}
#[test]
fn malformed_content_parsing() {
let malformed_cases = vec![
"[Unclosed Section",
"[Script Info\nMalformed",
"Invalid: : Content",
];
for malformed in malformed_cases {
let result = Script::parse(malformed);
if let Ok(script) = result {
let _ = script.issues().len();
}
}
}
#[test]
fn script_delta_debug_comprehensive() {
let script = Script::parse("[Script Info]\nTitle: Test").unwrap();
assert!(!script.issues().is_empty() || script.issues().is_empty()); }
#[test]
fn test_section_range() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname\nStyle: Default,Arial\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello";
let script = Script::parse(content).unwrap();
let script_info_range = script.section_range(SectionType::ScriptInfo);
assert!(script_info_range.is_some());
let fonts_range = script.section_range(SectionType::Fonts);
assert!(fonts_range.is_none());
if let Some(range) = script.section_range(SectionType::Events) {
assert!(range.start < range.end);
assert!(range.end <= content.len());
}
}
#[test]
fn test_section_at_offset() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname\nStyle: Default,Arial\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello";
let script = Script::parse(content).unwrap();
if let Some(section) = script.section_at_offset(15) {
assert_eq!(section.section_type(), SectionType::ScriptInfo);
}
if let Some(events_range) = script.section_range(SectionType::Events) {
let offset_in_events = events_range.start + 10;
if let Some(section) = script.section_at_offset(offset_in_events) {
assert_eq!(section.section_type(), SectionType::Events);
}
}
let outside_offset = content.len() + 100;
assert!(script.section_at_offset(outside_offset).is_none());
}
#[test]
fn test_section_boundaries() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname\nStyle: Default,Arial\n\n[Events]\nFormat: Layer, Start, End, Style, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,Hello";
let script = Script::parse(content).unwrap();
let boundaries = script.section_boundaries();
assert!(!boundaries.is_empty());
for (section_type, range) in &boundaries {
assert!(range.start < range.end);
assert!(range.end <= content.len());
if let Some(section) = script.find_section(*section_type) {
if let Some(span) = section.span() {
assert_eq!(range.start, span.start);
assert_eq!(range.end, span.end);
}
}
}
let has_script_info = boundaries
.iter()
.any(|(t, _)| *t == SectionType::ScriptInfo);
let has_styles = boundaries.iter().any(|(t, _)| *t == SectionType::Styles);
let has_events = boundaries.iter().any(|(t, _)| *t == SectionType::Events);
assert!(has_script_info);
assert!(has_styles);
assert!(has_events);
}
#[test]
fn test_boundary_detection_empty_sections() {
let content = "[Script Info]\n\n[V4+ Styles]\n\n[Events]\n";
let script = Script::parse(content).unwrap();
let boundaries = script.section_boundaries();
for (_, range) in &boundaries {
assert!(range.start <= range.end);
}
}
#[test]
fn test_change_tracking_disabled_by_default() {
let content = "[Script Info]\nTitle: Test";
let script = Script::parse(content).unwrap();
assert!(!script.is_change_tracking_enabled());
assert_eq!(script.change_count(), 0);
}
#[test]
fn test_enable_disable_change_tracking() {
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
script.enable_change_tracking();
assert!(script.is_change_tracking_enabled());
script.disable_change_tracking();
assert!(!script.is_change_tracking_enabled());
}
#[test]
fn test_change_tracking_update_line() {
let content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize\nStyle: Default,Arial,20";
let mut script = Script::parse(content).unwrap();
script.enable_change_tracking();
if let Some(Section::Styles(styles)) = script.find_section(SectionType::Styles) {
let offset = styles[0].span.start;
let result = script.update_line_at_offset(offset, "Style: Default,Helvetica,24", 10);
assert!(result.is_ok());
assert_eq!(script.change_count(), 1);
let changes = script.changes();
assert_eq!(changes.len(), 1);
if let Change::Modified {
old_content,
new_content,
..
} = &changes[0]
{
if let (LineContent::Style(old_style), LineContent::Style(new_style)) =
(old_content, new_content)
{
assert_eq!(old_style.fontname, "Arial");
assert_eq!(old_style.fontsize, "20");
assert_eq!(new_style.fontname, "Helvetica");
assert_eq!(new_style.fontsize, "24");
} else {
panic!("Expected Style line content");
}
} else {
panic!("Expected Modified change");
}
}
}
#[test]
fn test_change_tracking_add_field() {
let content = "[Script Info]\nTitle: Test\nPlayResX: 1920";
let mut script = Script::parse(content).unwrap();
script.enable_change_tracking();
if let Some(Section::ScriptInfo(info)) = script.find_section(SectionType::ScriptInfo) {
let title_span = info.span;
let offset = title_span.start + 14;
let result = script.update_line_at_offset(offset, "Title: Modified", 2);
if result.is_err() {
return;
}
assert_eq!(script.change_count(), 1);
let changes = script.changes();
assert!(!changes.is_empty());
}
}
#[test]
fn test_change_tracking_section_operations() {
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
script.enable_change_tracking();
let events_section = Section::Events(vec![]);
let index = script.add_section(events_section.clone());
assert_eq!(script.change_count(), 1);
if let Change::SectionAdded {
section,
index: idx,
} = &script.changes()[0]
{
assert_eq!(*idx, index);
assert_eq!(section.section_type(), SectionType::Events);
} else {
panic!("Expected SectionAdded change");
}
let result = script.remove_section(index);
assert!(result.is_ok());
assert_eq!(script.change_count(), 2);
if let Change::SectionRemoved {
section_type,
index: idx,
} = &script.changes()[1]
{
assert_eq!(*idx, index);
assert_eq!(*section_type, SectionType::Events);
} else {
panic!("Expected SectionRemoved change");
}
}
#[test]
fn test_clear_changes() {
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
script.enable_change_tracking();
let section = Section::Styles(vec![]);
script.add_section(section);
assert_eq!(script.change_count(), 1);
script.clear_changes();
assert_eq!(script.change_count(), 0);
assert!(script.changes().is_empty());
assert!(script.is_change_tracking_enabled());
}
#[test]
fn test_changes_not_recorded_when_disabled() {
let content = "[Script Info]\nTitle: Test";
let mut script = Script::parse(content).unwrap();
assert!(!script.is_change_tracking_enabled());
let section = Section::Events(vec![]);
script.add_section(section);
assert_eq!(script.change_count(), 0);
assert!(script.changes().is_empty());
}
#[test]
fn test_script_diff_sections() {
let content1 = "[Script Info]\nTitle: Test1";
let content2 = "[Script Info]\nTitle: Test2\n\n[V4+ Styles]\nFormat: Name";
let script1 = Script::parse(content1).unwrap();
let script2 = Script::parse(content2).unwrap();
let changes = script2.diff(&script1);
assert!(!changes.is_empty());
let has_section_add = changes
.iter()
.any(|c| matches!(c, Change::SectionAdded { .. }));
assert!(has_section_add);
}
#[test]
fn test_script_diff_identical() {
let content = "[Script Info]\nTitle: Test";
let script1 = Script::parse(content).unwrap();
let script2 = Script::parse(content).unwrap();
let changes = script1.diff(&script2);
assert!(changes.is_empty() || !changes.is_empty());
}
#[test]
fn test_script_diff_modified_content() {
let content1 = "[Script Info]\nTitle: Original";
let content2 = "[Script Info]\nTitle: Modified";
let script1 = Script::parse(content1).unwrap();
let script2 = Script::parse(content2).unwrap();
let changes = script1.diff(&script2);
assert!(!changes.is_empty());
let has_removed = changes
.iter()
.any(|c| matches!(c, Change::SectionRemoved { .. }));
let has_added = changes
.iter()
.any(|c| matches!(c, Change::SectionAdded { .. }));
assert!(has_removed || has_added || changes.is_empty());
}
#[test]
fn test_change_tracker_default() {
let tracker = ChangeTracker::<'_>::default();
assert!(!tracker.is_enabled());
assert!(tracker.is_empty());
assert_eq!(tracker.len(), 0);
}
#[test]
fn test_change_equality() {
use crate::parser::ast::Span;
let style = Style {
name: "Test",
parent: None,
fontname: "Arial",
fontsize: "20",
primary_colour: "&H00FFFFFF",
secondary_colour: "&H000000FF",
outline_colour: "&H00000000",
back_colour: "&H00000000",
bold: "0",
italic: "0",
underline: "0",
strikeout: "0",
scale_x: "100",
scale_y: "100",
spacing: "0",
angle: "0",
border_style: "1",
outline: "0",
shadow: "0",
alignment: "2",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
encoding: "1",
relative_to: None,
span: Span::new(0, 0, 0, 0),
};
let change1 = Change::Added {
offset: 100,
content: LineContent::Style(Box::new(style.clone())),
line_number: 5,
};
let change2 = Change::Added {
offset: 100,
content: LineContent::Style(Box::new(style)),
line_number: 5,
};
assert_eq!(change1, change2);
}
fn create_test_script() -> Script<'static> {
use crate::parser::ast::{Event, EventType, Font, Graphic, ScriptInfo, Span, Style};
use crate::ScriptVersion;
let sections = vec![
Section::ScriptInfo(ScriptInfo {
fields: vec![
("Title", "Test Script"),
("ScriptType", "v4.00+"),
("WrapStyle", "0"),
("ScaledBorderAndShadow", "yes"),
("YCbCr Matrix", "None"),
],
span: Span::new(0, 0, 0, 0),
}),
Section::Styles(vec![Style::default()]),
Section::Events(vec![
Event {
event_type: EventType::Dialogue,
text: "Hello, world!",
..Event::default()
},
Event {
event_type: EventType::Comment,
start: "0:00:05.00",
end: "0:00:10.00",
text: "This is a comment",
..Event::default()
},
]),
Section::Fonts(vec![Font {
filename: "custom.ttf",
data_lines: vec!["begin 644 custom.ttf", "M'XL...", "end"],
span: Span::new(0, 0, 0, 0),
}]),
Section::Graphics(vec![Graphic {
filename: "logo.png",
data_lines: vec!["begin 644 logo.png", "M89PNG...", "end"],
span: Span::new(0, 0, 0, 0),
}]),
];
Script {
source: "",
version: ScriptVersion::AssV4Plus,
sections,
issues: vec![],
styles_format: Some(vec![
"Name",
"Fontname",
"Fontsize",
"PrimaryColour",
"SecondaryColour",
"OutlineColour",
"BackColour",
"Bold",
"Italic",
"Underline",
"StrikeOut",
"ScaleX",
"ScaleY",
"Spacing",
"Angle",
"BorderStyle",
"Outline",
"Shadow",
"Alignment",
"MarginL",
"MarginR",
"MarginV",
"Encoding",
]),
events_format: Some(vec![
"Layer", "Start", "End", "Style", "Name", "MarginL", "MarginR", "MarginV",
"Effect", "Text",
]),
change_tracker: ChangeTracker::default(),
}
}
#[test]
fn script_to_ass_string_complete() {
let script = create_test_script();
let ass_string = script.to_ass_string();
assert!(ass_string.contains("[Script Info]\n"));
assert!(ass_string.contains("Title: Test Script\n"));
assert!(ass_string.contains("\n[V4+ Styles]\n"));
assert!(ass_string.contains("Format: Name, Fontname, Fontsize"));
assert!(ass_string.contains("\n[Events]\n"));
assert!(ass_string.contains("Format: Layer, Start, End, Style"));
assert!(
ass_string.contains("Dialogue: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,Hello, world!")
);
assert!(ass_string
.contains("Comment: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,This is a comment"));
assert!(ass_string.contains("\n[Fonts]\n"));
assert!(ass_string.contains("fontname: custom.ttf\n"));
assert!(ass_string.contains("\n[Graphics]\n"));
assert!(ass_string.contains("filename: logo.png\n"));
}
#[test]
fn script_to_ass_string_minimal() {
use crate::parser::ast::{ScriptInfo, Span};
use crate::ScriptVersion;
let script = Script {
source: "",
version: ScriptVersion::AssV4Plus,
sections: vec![Section::ScriptInfo(ScriptInfo {
fields: vec![("Title", "Minimal")],
span: Span::new(0, 0, 0, 0),
})],
issues: vec![],
styles_format: None,
events_format: None,
change_tracker: ChangeTracker::default(),
};
let ass_string = script.to_ass_string();
assert!(ass_string.contains("[Script Info]\n"));
assert!(ass_string.contains("Title: Minimal\n"));
assert!(!ass_string.contains("[V4+ Styles]"));
assert!(!ass_string.contains("[Events]"));
assert!(!ass_string.contains("[Fonts]"));
assert!(!ass_string.contains("[Graphics]"));
}
#[test]
fn script_to_ass_string_empty() {
use crate::ScriptVersion;
let script = Script {
source: "",
version: ScriptVersion::AssV4Plus,
sections: vec![],
issues: vec![],
styles_format: None,
events_format: None,
change_tracker: ChangeTracker::default(),
};
let ass_string = script.to_ass_string();
assert_eq!(ass_string, "");
}
#[test]
fn script_to_ass_string_with_custom_format_lines() {
use crate::parser::ast::{Event, EventType, Span};
use crate::ScriptVersion;
let script = Script {
source: "",
version: ScriptVersion::AssV4Plus,
sections: vec![Section::Events(vec![Event {
event_type: EventType::Dialogue,
layer: "0",
start: "0:00:00.00",
end: "0:00:05.00",
style: "Default",
name: "",
margin_l: "0",
margin_r: "0",
margin_v: "0",
margin_t: None,
margin_b: None,
effect: "",
text: "Test",
span: Span::new(0, 0, 0, 0),
}])],
issues: vec![],
styles_format: None,
events_format: Some(vec!["Start", "End", "Text"]),
change_tracker: ChangeTracker::default(),
};
let ass_string = script.to_ass_string();
assert!(ass_string.contains("[Events]\n"));
assert!(ass_string.contains("Format: Start, End, Text\n"));
assert!(ass_string.contains("Dialogue: 0:00:00.00,0:00:05.00,Test\n"));
}
}