1use std::fmt;
4use std::str::FromStr;
5
6use crate::error::{ParseError, ParseErrorKind};
7
8pub const UNITS_PER_SECOND: u64 = 10_000_000;
10
11#[derive(Debug, Clone, PartialEq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct Label {
16 #[cfg_attr(
18 feature = "serde",
19 serde(default, skip_serializing_if = "Option::is_none")
20 )]
21 pub start: Option<u64>,
22 #[cfg_attr(
24 feature = "serde",
25 serde(default, skip_serializing_if = "Option::is_none")
26 )]
27 pub end: Option<u64>,
28 pub text: String,
30 #[cfg_attr(
32 feature = "serde",
33 serde(default, skip_serializing_if = "Option::is_none")
34 )]
35 pub score: Option<f64>,
36}
37
38impl Label {
39 pub fn new(start: u64, end: u64, text: impl Into<String>) -> Self {
41 Label {
42 start: Some(start),
43 end: Some(end),
44 text: text.into(),
45 score: None,
46 }
47 }
48
49 pub fn from_secs(start: f64, end: f64, text: impl Into<String>) -> Self {
51 Label::new(secs_to_units(start), secs_to_units(end), text)
52 }
53
54 pub fn start_secs(&self) -> Option<f64> {
56 self.start.map(units_to_secs)
57 }
58
59 pub fn end_secs(&self) -> Option<f64> {
61 self.end.map(units_to_secs)
62 }
63
64 pub fn duration(&self) -> Option<u64> {
66 match (self.start, self.end) {
67 (Some(s), Some(e)) => Some(e.saturating_sub(s)),
68 _ => None,
69 }
70 }
71
72 pub fn duration_secs(&self) -> Option<f64> {
74 self.duration().map(units_to_secs)
75 }
76
77 pub fn contains(&self, time: u64) -> bool {
79 matches!((self.start, self.end), (Some(s), Some(e)) if s <= time && time < e)
80 }
81
82 pub(crate) fn parse_line(line: &str, line_no: usize) -> Result<Self, ParseError> {
84 let tokens = tokenize(line, line_no)?;
85 if tokens.is_empty() {
86 return Err(invalid_label(line_no, "label line is empty"));
87 }
88
89 let mut idx = 0;
90 let mut start = None;
91 let mut end = None;
92 if tokens.len() >= 3 && tokens[0].quoted && tokens[0].value.is_empty() && !tokens[1].quoted
93 {
94 if let Some(t) = parse_time(&tokens[1].value, line_no)? {
95 end = Some(t);
96 idx = 2;
97 }
98 }
99 while idx < tokens.len() - 1 && idx < 2 && !tokens[idx].quoted {
102 match parse_time(&tokens[idx].value, line_no)? {
103 Some(t) if idx == 0 => start = Some(t),
104 Some(t) => end = Some(t),
105 None => break,
106 }
107 idx += 1;
108 }
109
110 if let (Some(s), Some(e)) = (start, end) {
111 if s > e {
112 return Err(ParseError {
113 line: line_no,
114 kind: ParseErrorKind::StartAfterEnd { start: s, end: e },
115 });
116 }
117 }
118
119 let rest = &tokens[idx..];
120 let (text_tokens, score) = match rest.split_last() {
121 Some((last, init)) if !init.is_empty() && !last.quoted => {
123 match last.value.parse::<f64>() {
124 Ok(score) if score.is_finite() => (init, Some(score)),
125 Err(_) => (rest, None),
126 _ => (rest, None),
127 }
128 }
129 _ => (rest, None),
130 };
131
132 Ok(Label {
133 start,
134 end,
135 text: text_tokens
136 .iter()
137 .map(|token| token.value.as_str())
138 .collect::<Vec<_>>()
139 .join(" "),
140 score,
141 })
142 }
143}
144
145impl fmt::Display for Label {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 if let Some(s) = self.start {
148 write!(f, "{s} ")?;
149 } else if let Some(e) = self.end {
150 write!(f, "\"\" {e} ")?;
151 }
152 if self.start.is_some() {
153 if let Some(e) = self.end {
154 write!(f, "{e} ")?;
155 }
156 }
157 write_text(f, &self.text)?;
158 if let Some(score) = self.score {
159 write!(f, " {score}")?;
160 }
161 Ok(())
162 }
163}
164
165impl FromStr for Label {
166 type Err = ParseError;
167
168 fn from_str(s: &str) -> Result<Self, Self::Err> {
169 let line = s.trim_end_matches(['\r', '\n']);
170 if line.contains('\r') || line.contains('\n') {
171 return Err(invalid_label(1, "a label must contain exactly one line"));
172 }
173 Label::parse_line(line, 1)
174 }
175}
176
177pub fn units_to_secs(units: u64) -> f64 {
179 units as f64 / UNITS_PER_SECOND as f64
180}
181
182pub fn secs_to_units(secs: f64) -> u64 {
184 (secs * UNITS_PER_SECOND as f64).round().max(0.0) as u64
185}
186
187fn parse_time(token: &str, line_no: usize) -> Result<Option<u64>, ParseError> {
190 match token.parse::<u64>() {
191 Ok(t) => Ok(Some(t)),
192 Err(_) if token.bytes().all(|b| b.is_ascii_digit()) => Err(ParseError {
193 line: line_no,
194 kind: ParseErrorKind::InvalidTime(token.to_string()),
195 }),
196 Err(_) => Ok(None),
197 }
198}
199
200#[derive(Debug)]
201struct Token {
202 value: String,
203 quoted: bool,
204}
205
206fn tokenize(line: &str, line_no: usize) -> Result<Vec<Token>, ParseError> {
207 let mut tokens = Vec::new();
208 let mut chars = line.chars().peekable();
209
210 while chars.peek().is_some() {
211 while chars.peek().is_some_and(|c| c.is_whitespace()) {
212 chars.next();
213 }
214 if chars.peek().is_none() {
215 break;
216 }
217
218 let mut value = String::new();
219 let mut quoted = false;
220 while let Some(&c) = chars.peek() {
221 if c.is_whitespace() {
222 break;
223 }
224 chars.next();
225 if c != '"' {
226 value.push(c);
227 continue;
228 }
229
230 quoted = true;
231 let mut closed = false;
232 while let Some(c) = chars.next() {
233 match c {
234 '"' => {
235 closed = true;
236 break;
237 }
238 '\\' => match chars.next() {
239 Some('n') => value.push('\n'),
240 Some('r') => value.push('\r'),
241 Some('t') => value.push('\t'),
242 Some('\\') => value.push('\\'),
243 Some('"') => value.push('"'),
244 Some(other) => {
245 return Err(invalid_label(
246 line_no,
247 format!("unsupported escape sequence `\\{other}`"),
248 ));
249 }
250 None => return Err(invalid_label(line_no, "unterminated escape sequence")),
251 },
252 other => value.push(other),
253 }
254 }
255 if !closed {
256 return Err(invalid_label(line_no, "unterminated quoted label text"));
257 }
258 }
259 tokens.push(Token { value, quoted });
260 }
261
262 Ok(tokens)
263}
264
265fn invalid_label(line: usize, message: impl Into<String>) -> ParseError {
266 ParseError {
267 line,
268 kind: ParseErrorKind::InvalidLabel(message.into()),
269 }
270}
271
272fn write_text(f: &mut fmt::Formatter<'_>, text: &str) -> fmt::Result {
273 let needs_quotes = text.is_empty()
274 || text
275 .chars()
276 .any(|c| c.is_whitespace() || c == '"' || c == '\\')
277 || text.parse::<u64>().is_ok()
278 || text.parse::<f64>().is_ok();
279 if !needs_quotes {
280 return f.write_str(text);
281 }
282
283 f.write_str("\"")?;
284 for c in text.chars() {
285 match c {
286 '\n' => f.write_str("\\n")?,
287 '\r' => f.write_str("\\r")?,
288 '\t' => f.write_str("\\t")?,
289 '\\' => f.write_str("\\\\")?,
290 '"' => f.write_str("\\\"")?,
291 other => write!(f, "{other}")?,
292 }
293 }
294 f.write_str("\"")
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn parses_full_line() {
303 let l: Label = "0 23823130 pau".parse().unwrap();
304 assert_eq!(l, Label::new(0, 23823130, "pau"));
305 }
306
307 #[test]
308 fn parses_score() {
309 let l: Label = "0 100 a -42.5".parse().unwrap();
310 assert_eq!(l.score, Some(-42.5));
311 assert_eq!(l.text, "a");
312 }
313
314 #[test]
315 fn parses_label_only() {
316 let l: Label = "sil".parse().unwrap();
317 assert_eq!((l.start, l.end), (None, None));
318 assert_eq!(l.text, "sil");
319 }
320
321 #[test]
322 fn parses_single_time() {
323 let l: Label = "100 sil".parse().unwrap();
324 assert_eq!((l.start, l.end), (Some(100), None));
325 }
326
327 #[test]
328 fn numeric_only_line_is_a_label() {
329 let l: Label = "100 200".parse().unwrap();
331 assert_eq!((l.start, l.end), (Some(100), None));
332 assert_eq!(l.text, "200");
333 }
334
335 #[test]
336 fn rejects_start_after_end() {
337 let err = "200 100 a".parse::<Label>().unwrap_err();
338 assert_eq!(
339 err.kind,
340 ParseErrorKind::StartAfterEnd {
341 start: 200,
342 end: 100
343 }
344 );
345 }
346
347 #[test]
348 fn rejects_overflowing_time() {
349 let err = "99999999999999999999999 100 a"
350 .parse::<Label>()
351 .unwrap_err();
352 assert!(matches!(err.kind, ParseErrorKind::InvalidTime(_)));
353 }
354
355 #[test]
356 fn display_round_trips() {
357 for line in ["0 23823130 pau", "100 sil", "sil", "0 100 a -42.5"] {
358 let l: Label = line.parse().unwrap();
359 assert_eq!(l.to_string(), line);
360 }
361 }
362
363 #[test]
364 fn display_round_trips_ambiguous_text_and_end_only_labels() {
365 let labels = [
366 Label::new(0, 100, "word 123"),
367 Label {
368 start: None,
369 end: Some(100),
370 text: "word".into(),
371 score: None,
372 },
373 Label {
374 start: None,
375 end: None,
376 text: "a\nquoted \"label\"".into(),
377 score: None,
378 },
379 ];
380
381 for label in labels {
382 assert_eq!(label.to_string().parse::<Label>().unwrap(), label);
383 }
384 }
385
386 #[test]
387 fn rejects_empty_and_multiline_single_labels() {
388 let empty = " ".parse::<Label>().unwrap_err();
389 assert!(matches!(empty.kind, ParseErrorKind::InvalidLabel(_)));
390
391 let multiline = "0 10 a\n10 20 b".parse::<Label>().unwrap_err();
392 assert!(matches!(multiline.kind, ParseErrorKind::InvalidLabel(_)));
393 }
394
395 #[test]
396 fn seconds_conversion() {
397 let l = Label::from_secs(1.0, 2.5, "a");
398 assert_eq!(l.start, Some(10_000_000));
399 assert_eq!(l.end, Some(25_000_000));
400 assert_eq!(l.duration_secs(), Some(1.5));
401 assert!(l.contains(15_000_000));
402 assert!(!l.contains(25_000_000));
403 }
404}