avatarr_parser/language/subtitle.rs
1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2// src/NzbDrone.Core/Parser/LanguageParser.cs (lines 217-335 + regex
3// declarations 32-34, 36).
4// src/NzbDrone.Core/Parser/Model/SubtitleTitleInfo.cs (return-type fields).
5//
6// Public entry points:
7// - [`parse_subtitle_language`] mirrors C# `ParseSubtitleLanguage`.
8// - [`parse_basic_subtitle`] mirrors C# `ParseBasicSubtitle`.
9// - [`parse_subtitle_language_information`] mirrors C# `ParseSubtitleLanguageInformation`.
10// - [`parse_language_tags`] mirrors C# `ParseLanguageTags`.
11//
12// **Plan-spec / Rust-regex divergences resolved in favour of C# semantics
13// (per standing rule #1):**
14//
15// 1. Rust's `regex` crate forbids duplicate named groups. C#
16// `SubtitleLanguageRegex` declares `(?<tags>...)` twice (once before
17// iso_code, once after) and relies on `match.Groups["tags"].Captures`
18// enumerating BOTH. The Rust port drops both `(?<tags>...)` captures
19// from the structural regex; tag-token positions are recovered by
20// walking outward from the iso_code position post-match, applying
21// the same vocabulary (`forced|foreign|default|cc|psdh|sdh`). The
22// walking algorithm is faithful to C#'s greedy `(?:[-_. ]<tag>)*` /
23// lazy `.+?` interaction: tags are only collected when they are
24// contiguous with the iso_code via tag-or-iso-only segments.
25//
26// 2. Same problem in `SubtitleLanguageTitleRegex`: the C# pattern names
27// `iso_code` twice (pre-title and post-title) AND `tags1`/`tags2`
28// repeat inside their respective `\.(...)*` clusters. The Rust port
29// captures only the structural anchor (the title group) and walks
30// the pre-title and post-title segment lists in code to enumerate
31// iso_codes + tags. This mirrors C#'s `Captures.Count` semantics
32// (the "exactly one iso_code" gate at line 267).
33//
34// 3. C# `SubtitleTitleRegex` (line 36) uses `(?<!\d+)\d{1,3}(?!\d+)`
35// for the copy-number tail. Rust regex supports neither lookahead
36// nor lookbehind; the port relaxes the regex to `\d{1,3}` and
37// rejects matches whose copy-number is adjacent to other digits.
38//
39// 4. C# uses `Path.GetFileNameWithoutExtension` to strip the final
40// extension before matching. We reproduce that with a string-based
41// helper that takes everything before the last `.` (or the whole
42// string if no `.`). This avoids `std::path::Path` rewrites of
43// forward/backward slashes that would conflict with C# `Path`
44// semantics on POSIX hosts.
45
46use crate::language::{Language, iso_languages};
47use once_cell::sync::Lazy;
48use regex::Regex;
49
50/// Tag vocabulary matching the alternation in `SubtitleLanguageRegex`
51/// and `SubtitleLanguageTitleRegex`. Order matters only for negative
52/// regression checks; lookups are case-insensitive in all consumers.
53const TAG_VOCAB: &[&str] = &["forced", "foreign", "default", "cc", "psdh", "sdh"];
54
55/// Outcome of [`parse_basic_subtitle`] / mirrors C# `SubtitleTitleInfo`
56/// minimum surface (the fields `ParseBasicSubtitle` actually populates).
57///
58/// `ParseBasicSubtitle` only sets `TitleFirst` (always `false`),
59/// `LanguageTags`, and `Language`. `RawTitle`, `Title`, `Copy` are
60/// left at their default values (`None`, `None`, `0`).
61#[derive(Debug, Clone, PartialEq, Eq, Default)]
62pub struct SubtitleTitleInfo {
63 /// Mirrors C# `LanguageTags`. Always lowercased per
64 /// `tag.Value.ToLower()` in `ParseSubtitleLanguageInformation`.
65 pub language_tags: Vec<String>,
66 /// Mirrors C# `Language`. `Language::Unknown` when the file
67 /// carried no resolvable language code.
68 pub language: Language,
69 /// Mirrors C# `RawTitle`. `None` when no title segment was parsed
70 /// (e.g. `ParseBasicSubtitle` short-circuit, or
71 /// `ParseSubtitleLanguageInformation` falling back to
72 /// `ParseBasicSubtitle`).
73 pub raw_title: Option<String>,
74 /// Mirrors C# `Title`. Either the bare `RawTitle` or the
75 /// `(?<title>.+)` capture from `SubtitleTitleRegex` once the
76 /// trailing copy-number is stripped.
77 pub title: Option<String>,
78 /// Mirrors C# `Copy`. Zero when no trailing copy-number suffix
79 /// was found.
80 pub copy: i32,
81 /// Mirrors C# `TitleFirst`. `true` when the parsed title segment
82 /// precedes any tag/iso_code segment in the filename (i.e. when
83 /// `tags1` is empty in C#'s regex).
84 pub title_first: bool,
85}
86
87/// Regex constants ported from `LanguageParser.cs:32-34, 36`. Each
88/// `Lazy<Regex>` corresponds to a `private static readonly Regex`
89/// in C#. Where the C# pattern uses a feature Rust regex lacks
90/// (duplicate named groups, lookarounds), the Rust regex is the
91/// structural superset and a sibling helper applies the missing
92/// constraint post-match. See the file header for the complete list.
93pub mod regexes {
94 use super::{Lazy, Regex};
95
96 /// `SubtitleLanguageRegex` from `LanguageParser.cs:32`.
97 ///
98 /// C# pattern (with two `(?<tags>...)` groups whose `Captures`
99 /// collection C# concatenates):
100 /// `.+?([-_. ](?<tags>forced|foreign|default|cc|psdh|sdh))*[-_. ](?<iso_code>[a-z]{2,3})([-_. ](?<tags>forced|foreign|default|cc|psdh|sdh))*$`
101 ///
102 /// Rust port drops the inner `(?<tags>...)` captures (so the regex
103 /// is just structural validation) and lets [`super::collect_tags_around`]
104 /// recover the tag tokens from positions adjacent to `iso_code`.
105 pub static SUBTITLE_LANGUAGE_REGEX: Lazy<Regex> = Lazy::new(|| {
106 Regex::new(
107 r"(?i)^.+?(?:[-_. ](?:forced|foreign|default|cc|psdh|sdh))*[-_. ](?P<iso_code>[a-z]{2,3})(?:[-_. ](?:forced|foreign|default|cc|psdh|sdh))*$",
108 )
109 .expect("SUBTITLE_LANGUAGE_REGEX must compile")
110 });
111
112 /// `SubtitleLanguageTitleRegex` from `LanguageParser.cs:34`.
113 ///
114 /// C# pattern (with duplicate `iso_code` groups):
115 /// `.+?(\.((?<tags1>forced|...)|(?<iso_code>[a-z]{2,3})))*[-_. ](?<title>[^.]*)(\.((?<tags2>forced|...)|(?<iso_code>[a-z]{2,3})))*$`
116 ///
117 /// Rust port retains the structure but drops the inner alternation
118 /// captures; consumers walk the segments to enumerate iso_codes
119 /// (gated to exactly one) and tags. The `title` group is the
120 /// only structural capture.
121 pub static SUBTITLE_LANGUAGE_TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
122 Regex::new(
123 r"(?i)^.+?(?:\.(?:forced|foreign|default|cc|psdh|sdh|[a-z]{2,3}))*[-_. ](?P<title>[^.]*)(?:\.(?:forced|foreign|default|cc|psdh|sdh|[a-z]{2,3}))*$",
124 )
125 .expect("SUBTITLE_LANGUAGE_TITLE_REGEX must compile")
126 });
127
128 /// `SubtitleTitleRegex` from `LanguageParser.cs:36`.
129 ///
130 /// C# pattern: `^((?<title>.+) - )?(?<copy>(?<!\d+)\d{1,3}(?!\d+))$`.
131 /// The lookarounds gate against multi-digit-adjacency (e.g. a
132 /// 4-digit year, where `\d{1,3}` would otherwise capture three of
133 /// the four digits). Rust regex supports neither lookahead nor
134 /// lookbehind; the port relaxes the regex and applies the gate
135 /// in [`super::is_valid_copy_match`].
136 pub static SUBTITLE_TITLE_REGEX: Lazy<Regex> = Lazy::new(|| {
137 Regex::new(r"^(?:(?P<title>.+) - )?(?P<copy>\d{1,3})$")
138 .expect("SUBTITLE_TITLE_REGEX must compile")
139 });
140}
141
142/// Public entry: mirror C# `LanguageParser.ParseSubtitleLanguage`
143/// (lines 217-250).
144///
145/// Algorithm:
146/// 1. `simple_filename = strip_last_extension(file_name)`.
147/// 2. Match `SUBTITLE_LANGUAGE_REGEX` against `simple_filename`.
148/// On success, look up `iso_code` via `iso_languages::find`; that
149/// maps to `Language::Unknown` if the code is unknown.
150/// 3. Otherwise, walk `Language::All` checking
151/// `simple_filename.ends_with(language.name())` case-insensitively.
152/// First hit wins.
153/// 4. Otherwise return `Language::Unknown`.
154pub fn parse_subtitle_language(file_name: &str) -> Language {
155 let simple = strip_last_extension(file_name);
156
157 if let Some(caps) = regexes::SUBTITLE_LANGUAGE_REGEX.captures(simple)
158 && let Some(iso) = caps.name("iso_code")
159 {
160 // C# `IsoLanguages.Find` is the same lookup wired in T10; a
161 // miss yields `Language::Unknown` (matching C#'s
162 // `isoLanguage?.Language ?? Language.Unknown`).
163 return iso_languages::find(&iso.as_str().to_ascii_lowercase())
164 .unwrap_or(Language::Unknown);
165 }
166
167 // Fallback: ends_with(language.name()) for every Language variant,
168 // case-insensitive. C# loops over `Language.All`; we mirror that
169 // ordering (C# sets `All` to the contiguous list plus `Original`).
170 for language in ALL_LANGUAGES_FOR_ENDS_WITH {
171 let name = language.name();
172 if ends_with_ignore_ascii_case(simple, name) {
173 return *language;
174 }
175 }
176
177 Language::Unknown
178}
179
180/// Public entry: mirror C# `LanguageParser.ParseBasicSubtitle`
181/// (lines 252-260).
182///
183/// Composes `parse_language_tags` and `parse_subtitle_language` into a
184/// minimal `SubtitleTitleInfo` (only `language_tags`, `language`, and
185/// `title_first = false` are populated; `raw_title`/`title`/`copy`
186/// stay at defaults).
187pub fn parse_basic_subtitle(file_name: &str) -> SubtitleTitleInfo {
188 SubtitleTitleInfo {
189 title_first: false,
190 language_tags: parse_language_tags(file_name),
191 language: parse_subtitle_language(file_name),
192 ..Default::default()
193 }
194}
195
196/// Public entry: mirror C# `LanguageParser.ParseSubtitleLanguageInformation`
197/// (lines 262-297).
198///
199/// Tries the richer `SUBTITLE_LANGUAGE_TITLE_REGEX` first. On match the
200/// `iso_code` count must be exactly one (C# line 267 gate). On failure
201/// or gate-violation, falls back to `parse_basic_subtitle`.
202pub fn parse_subtitle_language_information(file_name: &str) -> SubtitleTitleInfo {
203 let simple = strip_last_extension(file_name);
204
205 let Some(caps) = regexes::SUBTITLE_LANGUAGE_TITLE_REGEX.captures(simple) else {
206 return parse_basic_subtitle(file_name);
207 };
208
209 let title_match = match caps.name("title") {
210 Some(m) => m,
211 None => return parse_basic_subtitle(file_name),
212 };
213
214 // Walk the dot-prefixed segments BEFORE the title separator and
215 // AFTER the title to recover iso_codes and tags. The title's own
216 // separator is `[-_. ]` (any of dash/underscore/dot/space) but the
217 // surrounding segments are `\.<token>` (literal dot prefix).
218 let title_start = title_match.start();
219 let title_end = title_match.end();
220
221 // C# `[-_. ](?<title>...)` consumes one separator immediately
222 // before the title's first byte. Find its byte index so the
223 // pre-segments scan stops at the right place.
224 let title_sep_idx = title_start.saturating_sub(1);
225 let pre_section = &simple[..title_sep_idx];
226 let post_section = &simple[title_end..];
227
228 let (pre_iso_codes, pre_tags) = scan_dot_segments_from_right(pre_section);
229 let (post_iso_codes, post_tags) = scan_dot_segments_from_left(post_section);
230
231 let iso_count = pre_iso_codes.len() + post_iso_codes.len();
232 if iso_count != 1 {
233 return parse_basic_subtitle(file_name);
234 }
235
236 let iso_code = pre_iso_codes
237 .first()
238 .or_else(|| post_iso_codes.first())
239 .expect("iso_count==1 guarantees at least one iso_code");
240
241 let language = iso_languages::find(&iso_code.to_ascii_lowercase()).unwrap_or(Language::Unknown);
242
243 // C# `tags1.Captures.Union(tags2.Captures).Cast<Capture>()` uses
244 // `Capture`'s default reference-equality comparer (Capture has no
245 // `Equals`/`GetHashCode` override), so distinct Capture objects
246 // are NEVER deduped even when their `Value` matches. The Rust
247 // port mirrors that with a plain concatenate-and-lowercase. (No
248 // current test fixture exercises the dup case, but
249 // standing rule #1 says C# fidelity wins.)
250 let language_tags: Vec<String> = pre_tags
251 .iter()
252 .chain(post_tags.iter())
253 .filter(|s| !s.is_empty())
254 .map(|s| s.to_ascii_lowercase())
255 .collect();
256
257 // C# `RawTitle = matchTitle.Groups["title"].Value`. Always
258 // `Some(...)` (possibly empty string) when the outer regex
259 // matched; `[^.]*` always succeeds (zero or more), so
260 // `Group.Success` is true and `.Value` is at minimum `""`.
261 let raw_title = caps.name("title").map(|m| m.as_str().to_string());
262 // C# `TitleFirst = matchTitle.Groups["tags1"].Captures.Empty()` -- it
263 // checks tags1 only, NOT iso_code. A pre-iso segment (e.g.
264 // `.eng.testtitle.forced.ass`) leaves tags1 empty and so still has
265 // `TitleFirst = true` in C#.
266 let title_first = pre_tags.is_empty();
267
268 let mut info = SubtitleTitleInfo {
269 title_first,
270 language_tags,
271 raw_title,
272 language,
273 ..Default::default()
274 };
275 update_title_and_copy_from_title(&mut info);
276 info
277}
278
279/// Public entry: mirror C# `LanguageParser.ParseLanguageTags`
280/// (lines 318-335).
281///
282/// Returns the lowercased tag tokens captured around `iso_code` by
283/// `SUBTITLE_LANGUAGE_REGEX`. Returns an empty list when the regex
284/// does not match (matching C#'s empty `Captures` enumeration).
285pub fn parse_language_tags(file_name: &str) -> Vec<String> {
286 let simple = strip_last_extension(file_name);
287
288 let Some(caps) = regexes::SUBTITLE_LANGUAGE_REGEX.captures(simple) else {
289 return Vec::new();
290 };
291 let Some(iso) = caps.name("iso_code") else {
292 return Vec::new();
293 };
294
295 // The iso_code group span doesn't include its leading separator;
296 // `iso_start` points at the iso_code's first byte. Walk from
297 // `iso_start - 1` leftward (the separator chars are 1 byte each)
298 // collecting tag tokens, then from `iso.end()` rightward.
299 collect_tags_around(simple, iso.start(), iso.end())
300}
301
302/// Mirror C# `UpdateTitleAndCopyFromTitle` (lines 299-316). Operates
303/// in-place on the partially-filled `SubtitleTitleInfo`.
304///
305/// Matches `SUBTITLE_TITLE_REGEX` against `info.raw_title`. On match
306/// the optional `(?<title>.+)` capture (may be `None` when only a
307/// copy-number is present) becomes `info.title`, and `(?<copy>\d{1,3})`
308/// becomes `info.copy`. On no-match, `info.title = info.raw_title`
309/// and `info.copy = 0`.
310fn update_title_and_copy_from_title(info: &mut SubtitleTitleInfo) {
311 let Some(raw) = info.raw_title.as_deref() else {
312 info.title = None;
313 info.copy = 0;
314 return;
315 };
316
317 if let Some(caps) = regexes::SUBTITLE_TITLE_REGEX.captures(raw)
318 && let Some(copy) = caps.name("copy")
319 {
320 // Reapply the C# lookarounds: copy must NOT be adjacent
321 // to other digits inside `raw`. With the regex anchored
322 // start-to-end, "adjacency" can only mean: the digit run
323 // is longer than 3 (the regex would still match since
324 // `\d{1,3}` is greedy at end with $). Concretely, if
325 // RawTitle contains a 4+ digit run, the regex either
326 // fails (because part of the run lands inside `title`
327 // and breaks the ` - ` separator) or backtracks to a
328 // valid 1-3 digit copy with title preceding.
329 //
330 // For robustness, validate the C# lookaround logic
331 // explicitly: characters immediately before `copy.start()`
332 // and after `copy.end()` (within `raw`) must not be ASCII
333 // digits.
334 if !is_valid_copy_match(raw, copy.start(), copy.end()) {
335 info.title = Some(raw.to_string());
336 info.copy = 0;
337 return;
338 }
339 info.title = caps.name("title").map(|m| m.as_str().to_string());
340 info.copy = copy.as_str().parse().unwrap_or(0);
341 return;
342 }
343
344 info.title = Some(raw.to_string());
345 info.copy = 0;
346}
347
348/// Reapplies the C# `(?<!\d+)\d{1,3}(?!\d+)` lookarounds. Returns
349/// `true` if `raw[copy_start..copy_end]` is a valid copy-number
350/// span (no immediately adjacent digit on either side).
351fn is_valid_copy_match(raw: &str, copy_start: usize, copy_end: usize) -> bool {
352 let bytes = raw.as_bytes();
353 if copy_start > 0 && bytes[copy_start - 1].is_ascii_digit() {
354 return false;
355 }
356 if copy_end < bytes.len() && bytes[copy_end].is_ascii_digit() {
357 return false;
358 }
359 true
360}
361
362/// Strip the last `.<ext>` from a filename, mirroring
363/// `Path.GetFileNameWithoutExtension`. First strips any leading
364/// directory components (everything up to and including the final
365/// `/` or `\`), then strips the last `.<ext>`. Returns the input
366/// unchanged when no `.` is present; returns `""` for a leading-dot
367/// name like `.bashrc` (C# treats those as having an empty stem).
368fn strip_last_extension(file_name: &str) -> &str {
369 // Step 1: drop any leading directory portion. C# `Path.GetFileName`
370 // splits on either `/` or `\` (the latter for legacy Windows paths).
371 let bare = match file_name.rfind(['/', '\\']) {
372 Some(idx) => &file_name[idx + 1..],
373 None => file_name,
374 };
375 // Step 2: drop the last `.<ext>` if any.
376 match bare.rfind('.') {
377 None => bare,
378 Some(idx) => &bare[..idx],
379 }
380}
381
382/// Case-insensitive `ends_with` over ASCII bytes.
383///
384/// C# uses `StringComparison.OrdinalIgnoreCase` which is byte-equal
385/// modulo ASCII case. Language names are pure ASCII (e.g. "Portuguese
386/// (Brazil)") so a byte-level compare suffices.
387fn ends_with_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
388 if needle.len() > haystack.len() {
389 return false;
390 }
391 let tail = &haystack.as_bytes()[haystack.len() - needle.len()..];
392 tail.eq_ignore_ascii_case(needle.as_bytes())
393}
394
395/// Walk leftward from `iso_start - 1` (i.e. the iso_code's leading
396/// separator byte) collecting `[-_. ]<tag>` segments while each
397/// segment's token is in the tag vocabulary. Then walk rightward
398/// from `iso_end` collecting the same. Returns concatenated tag
399/// tokens lowercased, in left-to-right source order.
400///
401/// This recovers the `(?<tags>...)*` captures C# would have collected
402/// before and after iso_code, despite Rust regex's no-duplicate-name
403/// constraint. Faithfulness check: stops at the first non-tag-vocab
404/// segment because C#'s `(?:[-_. ]<tag>)*` would also stop the loop
405/// at that point (the tag alternation would fail, ending the `*`
406/// repetition).
407fn collect_tags_around(simple: &str, iso_start: usize, iso_end: usize) -> Vec<String> {
408 let mut left_tags = Vec::new();
409 let mut cursor = iso_start;
410 while cursor > 0 {
411 let Some((tag, new_cursor)) = pop_left_tag_segment(simple, cursor) else {
412 break;
413 };
414 left_tags.push(tag);
415 cursor = new_cursor;
416 }
417 left_tags.reverse();
418
419 let mut right_tags = Vec::new();
420 let mut cursor = iso_end;
421 while cursor < simple.len() {
422 let Some((tag, new_cursor)) = pop_right_tag_segment(simple, cursor) else {
423 break;
424 };
425 right_tags.push(tag);
426 cursor = new_cursor;
427 }
428
429 left_tags.append(&mut right_tags);
430 left_tags
431}
432
433/// If the bytes ending at `cursor` form `[-_. ]<tag>`, return
434/// `(tag.lowercased(), new_cursor)` where `new_cursor` points one
435/// byte past the separator (i.e. the position the next leftward step
436/// should start from). Returns `None` if the segment isn't a tag.
437fn pop_left_tag_segment(simple: &str, cursor: usize) -> Option<(String, usize)> {
438 // Look back for a separator. The C# regex uses `[-_. ]` as
439 // separator both before and after iso_code in
440 // `SubtitleLanguageRegex`.
441 let bytes = simple.as_bytes();
442 if cursor == 0 {
443 return None;
444 }
445 // `cursor` is currently the byte index of the iso_code's first
446 // byte (or the previously-popped tag's separator byte). The
447 // separator immediately before `cursor` is at `cursor - 1`.
448 let sep_idx = cursor.checked_sub(1)?;
449 let sep = bytes[sep_idx];
450 if !is_subtitle_separator(sep) {
451 return None;
452 }
453 // The token starts at the byte AFTER the previous separator (or 0)
454 // and runs up to `sep_idx`. Walk leftward from `sep_idx - 1` until
455 // we find another separator or reach the start.
456 let mut tok_start = sep_idx;
457 while tok_start > 0 && !is_subtitle_separator(bytes[tok_start - 1]) {
458 tok_start -= 1;
459 }
460 let token = &simple[tok_start..sep_idx];
461 if !is_tag_token(token) {
462 return None;
463 }
464 Some((token.to_ascii_lowercase(), tok_start))
465}
466
467/// Mirror of [`pop_left_tag_segment`] for rightward walks. Returns
468/// `(tag, new_cursor)` where `new_cursor` is the byte index one past
469/// the consumed token.
470fn pop_right_tag_segment(simple: &str, cursor: usize) -> Option<(String, usize)> {
471 let bytes = simple.as_bytes();
472 if cursor >= bytes.len() {
473 return None;
474 }
475 let sep = bytes[cursor];
476 if !is_subtitle_separator(sep) {
477 return None;
478 }
479 // Token starts after the separator, runs until next separator or
480 // end of string.
481 let tok_start = cursor + 1;
482 let mut tok_end = tok_start;
483 while tok_end < bytes.len() && !is_subtitle_separator(bytes[tok_end]) {
484 tok_end += 1;
485 }
486 let token = &simple[tok_start..tok_end];
487 if !is_tag_token(token) {
488 return None;
489 }
490 Some((token.to_ascii_lowercase(), tok_end))
491}
492
493/// Walk a pre-title section `\.<token>` rightward. Returns
494/// `(iso_codes, tags)` accumulated. The last segment ends at the
495/// section's end (the title separator is consumed by the caller).
496///
497/// Ordering: tokens appear left-to-right (which is also the order
498/// C# would enumerate `Captures`).
499fn scan_dot_segments_from_right(section: &str) -> (Vec<String>, Vec<String>) {
500 // Walk the section breaking on every `.` boundary.
501 let mut iso_codes = Vec::new();
502 let mut tags = Vec::new();
503 if section.is_empty() {
504 return (iso_codes, tags);
505 }
506 let bytes = section.as_bytes();
507
508 // Walk RIGHT-TO-LEFT collecting trailing `\.<token>` segments
509 // until a segment fails to match either tag vocab or 2-3 letter
510 // ISO. This mirrors C#'s greedy `(\.(...))*` cluster anchored
511 // against the title separator: the cluster stops at the first
512 // segment that doesn't fit either alternation.
513 let mut consumed: Vec<(String, bool)> = Vec::new();
514 let mut cursor = bytes.len();
515 while cursor > 0 {
516 // Find the rightmost `.` strictly inside [..cursor].
517 let Some(dot) = section[..cursor].rfind('.') else {
518 break;
519 };
520 let token = §ion[dot + 1..cursor];
521 if token.is_empty() {
522 break;
523 }
524 let is_iso = is_iso_code_token(token);
525 let is_tag = is_tag_token(token);
526 if !is_iso && !is_tag {
527 break;
528 }
529 // Tags take precedence over iso_code in C# (alternation order).
530 if is_tag {
531 consumed.push((token.to_ascii_lowercase(), false));
532 } else {
533 consumed.push((token.to_string(), true));
534 }
535 cursor = dot;
536 }
537 // We collected right-to-left; reverse for source order.
538 consumed.reverse();
539 for (tok, is_iso_flag) in consumed {
540 if is_iso_flag {
541 iso_codes.push(tok);
542 } else {
543 tags.push(tok);
544 }
545 }
546 (iso_codes, tags)
547}
548
549/// Walk a post-title section `\.<token>` left-to-right. Returns
550/// `(iso_codes, tags)` accumulated.
551fn scan_dot_segments_from_left(section: &str) -> (Vec<String>, Vec<String>) {
552 let mut iso_codes = Vec::new();
553 let mut tags = Vec::new();
554 if section.is_empty() {
555 return (iso_codes, tags);
556 }
557 if !section.starts_with('.') {
558 // C# regex requires `\.<token>`; without a leading dot the
559 // cluster matches zero times.
560 return (iso_codes, tags);
561 }
562 // Split on '.' and walk left-to-right. The leading empty part
563 // before the first `.` is dropped.
564 for token in section.split('.').skip(1) {
565 if token.is_empty() {
566 break;
567 }
568 let is_iso = is_iso_code_token(token);
569 let is_tag = is_tag_token(token);
570 if !is_iso && !is_tag {
571 break;
572 }
573 if is_tag {
574 tags.push(token.to_ascii_lowercase());
575 } else {
576 iso_codes.push(token.to_string());
577 }
578 }
579 (iso_codes, tags)
580}
581
582/// `[a-z]{2,3}` case-insensitive ASCII test mirroring the iso_code
583/// branch of the alternation. Pure-ASCII because the C# pattern is
584/// `[a-z]` not `[\p{L}]`.
585fn is_iso_code_token(token: &str) -> bool {
586 let n = token.len();
587 if n != 2 && n != 3 {
588 return false;
589 }
590 token.bytes().all(|b| b.is_ascii_alphabetic())
591}
592
593/// Tag vocabulary membership test (case-insensitive ASCII).
594fn is_tag_token(token: &str) -> bool {
595 TAG_VOCAB
596 .iter()
597 .any(|t| t.len() == token.len() && t.eq_ignore_ascii_case(token))
598}
599
600/// `[-_. ]` separator class matching the C# alternation. ASCII bytes
601/// only (matches the C# class verbatim).
602fn is_subtitle_separator(b: u8) -> bool {
603 matches!(b, b'-' | b'_' | b'.' | b' ')
604}
605
606/// `Language.All` ordered list for the `EndsWith` fallback in
607/// `parse_subtitle_language`. Mirrors C# `Language.All` ordering
608/// (the contiguous-ID list plus `Original` last). `Unknown` is
609/// excluded because the fallback should produce `Unknown` only via
610/// the final `else`.
611const ALL_LANGUAGES_FOR_ENDS_WITH: &[Language] = &[
612 Language::English,
613 Language::French,
614 Language::Spanish,
615 Language::German,
616 Language::Italian,
617 Language::Danish,
618 Language::Dutch,
619 Language::Japanese,
620 Language::Icelandic,
621 Language::Chinese,
622 Language::Russian,
623 Language::Polish,
624 Language::Vietnamese,
625 Language::Swedish,
626 Language::Norwegian,
627 Language::Finnish,
628 Language::Turkish,
629 Language::Portuguese,
630 Language::Flemish,
631 Language::Greek,
632 Language::Korean,
633 Language::Hungarian,
634 Language::Hebrew,
635 Language::Lithuanian,
636 Language::Czech,
637 Language::Arabic,
638 Language::Hindi,
639 Language::Bulgarian,
640 Language::Malayalam,
641 Language::Ukrainian,
642 Language::Slovak,
643 Language::Thai,
644 Language::PortugueseBrazil,
645 Language::SpanishLatino,
646 Language::Romanian,
647 Language::Latvian,
648 Language::Persian,
649 Language::Catalan,
650 Language::Croatian,
651 Language::Serbian,
652 Language::Bosnian,
653 Language::Estonian,
654 Language::Tamil,
655 Language::Indonesian,
656 Language::Macedonian,
657 Language::Slovenian,
658 Language::Original,
659];
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 // ----- Plan-required tests -------------------------------------------
666
667 #[test]
668 fn parse_subtitle_extracts_eng_iso_code() {
669 let lang = parse_subtitle_language("Show.S01E01.eng.srt");
670 assert_eq!(lang, Language::English);
671 }
672
673 #[test]
674 fn parse_subtitle_extracts_fre_iso_code() {
675 // "fre" -> "fra" via the B-to-T map wired in T10.
676 assert_eq!(
677 parse_subtitle_language("Show.S01E01.fre.srt"),
678 Language::French
679 );
680 }
681
682 #[test]
683 fn parse_basic_subtitle_returns_lang_and_tags() {
684 let r = parse_basic_subtitle("Show.S01E01.eng.forced.srt");
685 assert_eq!(r.language, Language::English);
686 assert!(r.language_tags.contains(&"forced".to_string()));
687 }
688
689 #[test]
690 fn parse_language_tags_extracts_sdh() {
691 let tags = parse_language_tags("Show.S01E01.eng.sdh.srt");
692 assert!(tags.contains(&"sdh".to_string()));
693 }
694
695 // ----- C# LanguageParserFixture parity -------------------------------
696
697 #[test]
698 fn fixture_subtitle_unknown_when_no_iso_code() {
699 // C# fixture line 12: `"Series Title - S01E01 - Pilot.sub"` -> Unknown.
700 assert_eq!(
701 parse_subtitle_language("Series Title - S01E01 - Pilot.sub"),
702 Language::Unknown
703 );
704 }
705
706 #[test]
707 fn fixture_subtitle_english_via_iso_codes_and_endswith() {
708 // C# fixture lines 19-29: every variant resolves to English.
709 let cases = [
710 "Series Title - S01E01 - Pilot.en.sub",
711 "Series Title - S01E01 - Pilot.EN.sub",
712 "Series Title - S01E01 - Pilot.eng.sub",
713 "Series Title - S01E01 - Pilot.ENG.sub",
714 "Series Title - S01E01 - Pilot.English.sub",
715 "Series Title - S01E01 - Pilot.english.sub",
716 "Series Title - S01E01 - Pilot.en.cc.sub",
717 "Series Title - S01E01 - Pilot.en.sdh.sub",
718 "Series Title - S01E01 - Pilot.en.forced.sub",
719 "Series Title - S01E01 - Pilot.en.sdh.forced.sub",
720 ];
721 for case in cases {
722 assert_eq!(
723 parse_subtitle_language(case),
724 Language::English,
725 "case = {case}"
726 );
727 }
728 }
729
730 #[test]
731 fn fixture_parse_subtitle_language_information_eng_with_default_forced() {
732 // C# fixture line 465.
733 let info = parse_subtitle_language_information(
734 "Name (2020) - S01E20 - [AAC 2.0].testtitle.default.eng.forced.ass",
735 );
736 assert_eq!(info.language, Language::English);
737 assert_eq!(
738 info.language_tags,
739 vec!["default".to_string(), "forced".to_string()]
740 );
741 assert_eq!(info.title.as_deref(), Some("testtitle"));
742 }
743
744 #[test]
745 fn fixture_parse_subtitle_language_information_fra_with_default_forced() {
746 // C# fixture line 471.
747 let info = parse_subtitle_language_information(
748 "Name (2020) - S01E20 - [AAC 2.0].testtitle.default.fra.forced.ass",
749 );
750 assert_eq!(info.language, Language::French);
751 assert_eq!(
752 info.language_tags,
753 vec!["default".to_string(), "forced".to_string()]
754 );
755 assert_eq!(info.title.as_deref(), Some("testtitle"));
756 }
757
758 #[test]
759 fn fixture_parse_subtitle_language_information_ru_dashed_title() {
760 // C# fixture line 477: `ru-something-else` -> language Russian,
761 // title `something-else`, tags empty.
762 let info = parse_subtitle_language_information(
763 "Name (2020) - S01E20 - [AAC 2.0].ru-something-else.srt",
764 );
765 assert_eq!(info.language, Language::Russian);
766 assert!(
767 info.language_tags.is_empty(),
768 "got {:?}",
769 info.language_tags
770 );
771 assert_eq!(info.title.as_deref(), Some("something-else"));
772 }
773
774 #[test]
775 fn fixture_parse_subtitle_language_information_full_subtitles_title() {
776 // C# fixture line 478.
777 let info = parse_subtitle_language_information(
778 "Name (2020) - S01E20 - [AAC 2.0].Full Subtitles.eng.ass",
779 );
780 assert_eq!(info.language, Language::English);
781 assert!(
782 info.language_tags.is_empty(),
783 "got {:?}",
784 info.language_tags
785 );
786 assert_eq!(info.title.as_deref(), Some("Full Subtitles"));
787 }
788
789 #[test]
790 fn fixture_parse_subtitle_language_information_dash_one_copy_strip() {
791 // C# fixture line 479: title "mytitle - 1" => after
792 // SubtitleTitleRegex => Title="mytitle", Copy=1.
793 let info = parse_subtitle_language_information(
794 "Name (2020) - S01E20 - [AAC 2.0].mytitle - 1.en.ass",
795 );
796 assert_eq!(info.language, Language::English);
797 assert_eq!(info.title.as_deref(), Some("mytitle"));
798 assert_eq!(info.copy, 1);
799 }
800
801 #[test]
802 fn fixture_parse_subtitle_language_information_space_one_no_strip() {
803 // C# fixture line 480: title "mytitle 1" => Title="mytitle 1",
804 // Copy=0 (no ` - ` separator before the digit).
805 let info = parse_subtitle_language_information(
806 "Name (2020) - S01E20 - [AAC 2.0].mytitle 1.en.ass",
807 );
808 assert_eq!(info.language, Language::English);
809 assert_eq!(info.title.as_deref(), Some("mytitle 1"));
810 assert_eq!(info.copy, 0);
811 }
812
813 #[test]
814 fn fixture_parse_subtitle_language_information_no_copy() {
815 // C# fixture line 481: title "mytitle" -> Title="mytitle", Copy=0.
816 let info =
817 parse_subtitle_language_information("Name (2020) - S01E20 - [AAC 2.0].mytitle.en.ass");
818 assert_eq!(info.language, Language::English);
819 assert_eq!(info.title.as_deref(), Some("mytitle"));
820 assert_eq!(info.copy, 0);
821 }
822
823 #[test]
824 fn fixture_parse_subtitle_language_information_no_iso_code_falls_back_to_basic() {
825 // C# fixture line 491-494: zero or two iso_codes => fallback
826 // to ParseBasicSubtitle => Language=Unknown, LanguageTags=empty,
827 // RawTitle=null.
828 let cases = [
829 "Name (2020) - S01E20 - [AAC 2.0].default.forced.ass",
830 "Name (2020) - S01E20 - [AAC 2.0].default.ass",
831 "Name (2020) - S01E20 - [AAC 2.0].ass",
832 "Name (2020) - S01E20 - [AAC 2.0].testtitle.ass",
833 ];
834 for case in cases {
835 let info = parse_subtitle_language_information(case);
836 assert_eq!(info.language, Language::Unknown, "case = {case}");
837 assert!(info.language_tags.is_empty(), "case = {case}");
838 assert!(info.raw_title.is_none(), "case = {case}");
839 }
840 }
841
842 // ----- Edge cases that pin our helper-function logic -------------------
843
844 #[test]
845 fn parse_subtitle_endswith_after_extension_strip() {
846 // C# `Path.GetFileNameWithoutExtension` strips one extension.
847 // `Show - Pilot.English.sub` -> simple `Show - Pilot.English`
848 // which neither matches `SUBTITLE_LANGUAGE_REGEX` (no `[a-z]{2,3}`
849 // tail) nor ends with a Language name except via a trailing
850 // language-name component. Fixture line 23 already covers the
851 // `.English.sub` -> English case via the EndsWith fallback.
852 // Pin the same path via `Pilot.German.sub` as a positive test
853 // and `Pilot.NotALanguage.sub` as a negative.
854 assert_eq!(
855 parse_subtitle_language("Show - Pilot.German.sub"),
856 Language::German
857 );
858 assert_eq!(
859 parse_subtitle_language("Show - Pilot.NotALanguage.sub"),
860 Language::Unknown
861 );
862 }
863
864 #[test]
865 fn parse_subtitle_endswith_portuguese_brazil() {
866 // `Language::PortugueseBrazil.name() == "Portuguese (Brazil)"`.
867 // The EndsWith fallback uses the literal Display name. Strip
868 // one extension first, so the test's simple filename ends in
869 // `Portuguese (Brazil)`.
870 assert_eq!(
871 parse_subtitle_language("Show - Pilot.Portuguese (Brazil).sub"),
872 Language::PortugueseBrazil
873 );
874 }
875
876 #[test]
877 fn parse_language_tags_empty_when_no_match() {
878 assert!(parse_language_tags("Pilot.sub").is_empty());
879 assert!(parse_language_tags("file_with_no_extension").is_empty());
880 }
881
882 #[test]
883 fn parse_language_tags_handles_pre_iso_tags() {
884 // `Pilot.forced.eng.srt` -> simple `Pilot.forced.eng`.
885 // Regex matches: pre-tag `forced`, iso `eng`, post-tags none.
886 let tags = parse_language_tags("Pilot.forced.eng.srt");
887 assert_eq!(tags, vec!["forced".to_string()]);
888 }
889
890 #[test]
891 fn parse_language_tags_handles_post_iso_tags() {
892 let tags = parse_language_tags("Pilot.eng.sdh.forced.srt");
893 assert_eq!(tags, vec!["sdh".to_string(), "forced".to_string()]);
894 }
895
896 #[test]
897 fn parse_language_tags_drops_non_vocab_words() {
898 // `Pilot.junk.eng.srt` -> simple `Pilot.junk.eng`. The C#
899 // regex would match `iso=eng` with `.+? = Pilot.junk`. Tags
900 // would be empty (junk is not a tag and gets absorbed by .+?).
901 let tags = parse_language_tags("Pilot.junk.eng.srt");
902 assert!(tags.is_empty(), "got {tags:?}");
903 }
904
905 #[test]
906 fn parse_basic_subtitle_title_first_is_false() {
907 // C# `ParseBasicSubtitle` always sets TitleFirst = false.
908 let r = parse_basic_subtitle("Show.S01E01.eng.srt");
909 assert!(!r.title_first);
910 }
911
912 #[test]
913 fn parse_subtitle_language_information_title_first_true_when_no_pre_segments() {
914 // `testtitle.eng.forced` -> title is the first segment after
915 // the title separator, no pre-iso/pre-tag => TitleFirst=true.
916 let info = parse_subtitle_language_information(
917 "Name (2020) - S01E20 - [AAC 2.0].testtitle.eng.forced.ass",
918 );
919 assert!(info.title_first, "got {info:?}");
920 }
921
922 #[test]
923 fn parse_subtitle_language_information_title_first_true_with_pre_iso_only() {
924 // `eng.testtitle.forced` -> pre-iso fires but tags1 stays
925 // empty. C# `TitleFirst = tags1.Captures.Empty()` is true.
926 let info = parse_subtitle_language_information(
927 "Name (2020) - S01E20 - [AAC 2.0].eng.testtitle.forced.ass",
928 );
929 assert!(info.title_first, "got {info:?}");
930 }
931
932 #[test]
933 fn parse_subtitle_language_information_title_first_false_with_pre_tag() {
934 // `default.eng.testtitle.forced` -> pre-tag fires (tags1 has
935 // `default`), TitleFirst=false.
936 let info = parse_subtitle_language_information(
937 "Name (2020) - S01E20 - [AAC 2.0].default.eng.testtitle.forced.ass",
938 );
939 assert!(!info.title_first, "got {info:?}");
940 }
941
942 #[test]
943 fn parse_subtitle_unknown_for_pure_garbage() {
944 assert_eq!(
945 parse_subtitle_language("garbage_no_match"),
946 Language::Unknown
947 );
948 }
949
950 #[test]
951 fn strip_last_extension_handles_empty_and_dotfiles() {
952 assert_eq!(strip_last_extension(""), "");
953 // C# `Path.GetFileNameWithoutExtension(".bashrc")` returns "":
954 // a leading-dot name has an empty stem.
955 assert_eq!(strip_last_extension(".bashrc"), "");
956 assert_eq!(strip_last_extension("a.b.c"), "a.b");
957 assert_eq!(strip_last_extension("foo"), "foo");
958 assert_eq!(strip_last_extension("foo.bar"), "foo");
959 }
960
961 #[test]
962 fn strip_last_extension_strips_directories() {
963 // C# `Path.GetFileNameWithoutExtension` first calls `GetFileName`
964 // (drops directory portion), then strips the last extension.
965 assert_eq!(strip_last_extension("/foo/bar.zip/baz.eng.srt"), "baz.eng");
966 assert_eq!(strip_last_extension("/tmp/Pilot.eng.srt"), "Pilot.eng");
967 assert_eq!(strip_last_extension(r"C:\tmp\Pilot.eng.srt"), "Pilot.eng");
968 }
969
970 #[test]
971 fn ends_with_ignore_ascii_case_basic() {
972 assert!(ends_with_ignore_ascii_case("FooEnglish", "english"));
973 assert!(ends_with_ignore_ascii_case("FooEnglish", "ENGLISH"));
974 assert!(!ends_with_ignore_ascii_case("Foo", "FooBar"));
975 }
976}