Skip to main content

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 = &section[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}