use crate::context::{match_known_at, Ctx};
use crate::options::SplitAndMode;
use crate::scanner::{render_skel, slice_skel, trim_skel, Skel};
use crate::tables::is_unknown_token;
use crate::types::{ArtistRole, ArtistSource};
use crate::words::{
at, before, chars, collapse_chars, collapse_spaces, in_vocab, is_all_digits, is_digit,
is_whitespace, js_trim_end, string, sub, tail, word_key, word_matches, year_of,
};
#[derive(Debug, Clone)]
pub(crate) struct Credit {
pub name: String,
pub role: ArtistRole,
pub joiner: Option<String>,
pub source: ArtistSource,
pub pos: i64,
pub list: usize,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PositionedYear {
pub year: u32,
pub pos: i64,
}
#[derive(Debug, Clone)]
pub(crate) struct ListOptions {
pub role: ArtistRole,
pub source: ArtistSource,
pub lead: Option<String>,
pub split_with: bool,
pub feat_markers_join: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ListResult {
pub credits: Vec<Credit>,
pub unknown: bool,
pub years: Vec<PositionedYear>,
}
struct JoinerHit {
start: usize,
end: usize,
raw: String,
}
const AND_LIKE: [&str; 3] = ["&", "and", "+"];
fn word_at(text: &[char], start: usize) -> usize {
let mut end = start;
while end < text.len() && !is_whitespace(at(text, end)) {
end += 1;
}
end
}
fn next_word(text: &[char], from: usize) -> String {
let mut a = from;
while a < text.len() && is_whitespace(at(text, a)) {
a += 1;
}
let mut b = a;
while b < text.len() && !is_whitespace(at(text, b)) && !matches!(at(text, b), Some(',' | ';')) {
b += 1;
}
string(sub(text, a, b))
}
fn find_joiner(
text: &[char],
from: usize,
split_at_comma: bool,
opts: &ListOptions,
ctx: &Ctx<'_>,
) -> Option<JoinerHit> {
let mut p = from;
while let Some(ch) = at(text, p) {
if ch == ',' || ch == ';' {
if ch == ',' && is_digit(before(text, p)) && is_digit(at(text, p + 1)) {
p += 1;
continue; }
return Some(JoinerHit { start: p, end: p + 1, raw: ch.to_string() });
}
if p == 0 || before(text, p) != Some(' ') || is_whitespace(Some(ch)) {
p += 1;
continue;
}
let end = word_at(text, p);
if end >= text.len() || at(text, end) != Some(' ') {
p += 1;
continue; }
let word = string(sub(text, p, end));
if spaced_joiner(&word, text, end, split_at_comma, opts, ctx) {
return Some(JoinerHit { start: p, end, raw: word });
}
p += 1;
}
None
}
fn spaced_joiner(
word: &str,
text: &[char],
end: usize,
split_at_comma: bool,
opts: &ListOptions,
ctx: &Ctx<'_>,
) -> bool {
if opts.feat_markers_join && ctx.t.feat_markers.match_at(&[word], 0).is_some() {
return true;
}
for j in &ctx.t.spaced_joiners {
let matches = if j.case_sensitive { word == j.raw } else { word_matches(word, j.raw) };
if !matches {
continue;
}
if j.raw == "with" && !opts.split_with {
return false;
}
if j.is_and {
if ctx.split_and == SplitAndMode::Never {
return false;
}
if ctx.split_and == SplitAndMode::Auto && !split_at_comma {
return false;
}
}
if AND_LIKE.contains(&j.raw) && in_vocab(&next_word(text, end), &ctx.t.no_split_before) {
return false;
}
return true;
}
false
}
pub(crate) fn clean_name(raw: &str) -> String {
let mut name = chars(&collapse_spaces(raw));
if let (Some(&first), Some(&last)) = (name.first(), name.last()) {
if name.len() >= 2 && (first == '"' || first == '\'') && last == first {
name = chars(&collapse_chars(sub(&name, 1, name.len() - 1)));
}
}
let mut name: String = string(&name);
while name.ends_with(',') || name.ends_with(';') {
name.pop();
name.truncate(js_trim_end(&name).len());
}
if let Some(space) = name.find(' ') {
let head = name.get(..space).unwrap_or("");
if space > 0 && word_key(head) == "by" {
name = name.get(space + 1..).unwrap_or("").to_string();
}
}
collapse_spaces(&name)
}
struct RawName {
name: String,
joiner: Option<String>,
pos: i64,
}
fn unwrap_list(list: &Skel) -> Skel {
let t = trim_skel(list);
let n = t.text.len();
let Some(q) = at(&t.text, 0) else { return t };
if n < 2 || (q != '"' && q != '\'') || at(&t.text, n - 1) != Some(q) {
return t;
}
let next = tail(&t.text, 1).iter().position(|&c| c == q).map(|i| i + 1);
if next != Some(n - 1) {
return t;
}
trim_skel(&slice_skel(&t, 1, n - 1))
}
pub(crate) fn split_credits(input: &Skel, opts: &ListOptions, ctx: &mut Ctx<'_>) -> ListResult {
let list = unwrap_list(input); let text = &list.text;
let mut raw: Vec<RawName> = Vec::new();
let mut joiner: Option<String> = opts.lead.clone();
let mut split_at_comma = false;
let mut start = 0usize;
while start <= text.len() {
while start < text.len() && is_whitespace(at(text, start)) {
start += 1;
}
let known = match_known_at(text, start, ctx, Some('-')); let hit = find_joiner(text, start + known, split_at_comma, opts, ctx);
let end = hit.as_ref().map_or(text.len(), |h| h.start);
raw.push(RawName {
name: clean_name(&render_skel(&slice_skel(&list, start, end))),
joiner: joiner.take(),
pos: list.pos.get(start).or(list.pos.last()).copied().unwrap_or(0),
});
let Some(hit) = hit else { break };
if hit.raw == "," || hit.raw == ";" {
split_at_comma = true; }
start = hit.end;
joiner = Some(hit.raw);
}
finish_names(raw, opts, ctx)
}
fn finish_names(raw: Vec<RawName>, opts: &ListOptions, ctx: &mut Ctx<'_>) -> ListResult {
let named: Vec<RawName> = raw.into_iter().filter(|r| !r.name.is_empty()).collect();
let count = named.len();
let mut unknown = false;
let mut years: Vec<PositionedYear> = Vec::new();
let mut kept: Vec<RawName> = Vec::new();
for r in named {
if is_unknown_token(&r.name, ctx.t) {
unknown = true;
continue;
}
let year = if is_all_digits(&r.name) { year_of(&r.name) } else { None };
match year {
Some(year) if count > 1 => years.push(PositionedYear { year, pos: r.pos }),
_ => kept.push(r),
}
}
let list = ctx.list_seq;
ctx.list_seq += 1;
let credits = kept
.into_iter()
.enumerate()
.map(|(i, r)| Credit {
name: r.name,
role: opts.role,
joiner: if i == 0 { opts.lead.clone() } else { r.joiner },
source: opts.source,
pos: r.pos,
list,
})
.collect();
ListResult { credits, unknown, years }
}