use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::ngram::NgramSet;
use crate::preproc::{apply_aggressive, apply_normalizers};
#[cfg(doc)]
use crate::store::Store;
#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LicenseType {
Original,
Header,
Alternate,
}
impl fmt::Display for LicenseType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match *self {
LicenseType::Original => "original text",
LicenseType::Header => "license header",
LicenseType::Alternate => "alternate text",
}
)
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct MatchData<D> {
ngrams: NgramSet,
view: (usize, usize),
data: D,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TextData {
lines: Vec<String>,
text: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub struct NoData {}
impl MatchData<TextData> {
#[must_use]
pub fn new(text: &str) -> Self {
let normalized = apply_normalizers(text);
let normalized_joined = normalized.join("\n");
let processed = apply_aggressive(&normalized_joined);
let ngrams = NgramSet::from_str(&processed, 2);
MatchData {
ngrams,
view: (0, normalized.len()),
data: TextData {
lines: normalized,
text: processed,
},
}
}
#[must_use]
pub fn with_view(&self, start: usize, end: usize) -> Self {
let view = &self.data.lines[start..end];
let view_joined = view.join("\n");
let processed = apply_aggressive(&view_joined);
MatchData {
ngrams: NgramSet::from_str(&processed, 2),
view: (start, end),
data: TextData {
lines: self.data.lines.clone(),
text: processed,
},
}
}
#[must_use]
pub fn white_out(&self) -> Self {
let new_normalized: Vec<String> = self
.data
.lines
.iter()
.enumerate()
.map(|(i, line)| {
if i >= self.view.0 && i < self.view.1 {
String::new()
} else {
line.clone()
}
})
.collect();
let processed = apply_aggressive(&new_normalized.join("\n"));
MatchData {
ngrams: NgramSet::from_str(&processed, 2),
view: (0, new_normalized.len()),
data: TextData {
lines: new_normalized,
text: processed,
},
}
}
#[must_use]
pub fn lines(&self) -> &[String] {
&self.data.lines[self.view.0..self.view.1]
}
#[must_use]
pub fn text(&self) -> &str {
&self.data.text
}
#[must_use]
pub fn optimize_bounds<E>(&self, other: &MatchData<E>) -> (Self, f32) {
let view = self.view;
let (end_optimized, _) = self.search_optimize(&|end| self.with_view(view.0, end).match_score(other), &|end| {
self.with_view(view.0, end)
});
let new_end = end_optimized.view.1;
let (optimized, score) = end_optimized.search_optimize(
&|start| end_optimized.with_view(start, new_end).match_score(other),
&|start| end_optimized.with_view(start, new_end),
);
(optimized, score)
}
}
impl<D> MatchData<D> {
#[must_use]
pub fn without_text(self) -> MatchData<NoData> {
MatchData {
ngrams: self.ngrams,
view: (0, 0),
data: NoData {},
}
}
#[must_use]
pub const fn lines_view(&self) -> (usize, usize) {
self.view
}
#[must_use]
pub fn match_score<E>(&self, other: &MatchData<E>) -> f32 {
self.ngrams.dice(&other.ngrams)
}
#[must_use]
pub(crate) fn eq_data<E>(&self, other: &MatchData<E>) -> bool {
self.ngrams.eq(&other.ngrams)
}
#[must_use]
fn search_optimize(&self, score: &dyn Fn(usize) -> f32, value: &dyn Fn(usize) -> Self) -> (Self, f32) {
fn search(score: &mut dyn FnMut(usize) -> f32, left: usize, right: usize) -> (usize, f32) {
if right - left <= 3 {
return (left..=right)
.map(|x| (x, score(x)))
.fold((0usize, 0f32), |acc, x| if x.1 >= acc.1 { x } else { acc });
}
let low = (left * 2 + right) / 3;
let high = (left + right * 2) / 3;
let score_low = score(low);
let score_high = score(high);
if score_low > score_high {
search(score, left, high - 1)
} else {
search(score, low + 1, right)
}
}
let mut memo: HashMap<usize, f32> = HashMap::new();
let mut check_score = |index: usize| -> f32 { *memo.entry(index).or_insert_with(|| score(index)) };
let optimal = search(&mut check_score, self.view.0, self.view.1);
(value(optimal.0), optimal.1)
}
}
impl<'a> From<&'a str> for MatchData<TextData> {
fn from(text: &'a str) -> Self {
Self::new(text)
}
}
impl From<String> for MatchData<TextData> {
fn from(text: String) -> Self {
Self::new(&text)
}
}
#[cfg(test)]
#[expect(clippy::float_cmp, reason = "ignore float comparisons in tests")]
mod tests {
use super::*;
#[test]
fn optimize_bounds() {
let license_text = "this is a license text\nor it pretends to be one\nit's just a test";
let sample_text = "this is a license text\nor it pretends to be one\nit's just a test\nwords\n\nhere is some\ncode\nhello();\n\n//a comment too";
let license = MatchData::from(license_text).without_text();
let sample = MatchData::from(sample_text);
let (optimized, _) = sample.optimize_bounds(&license);
println!("{:?}", optimized.view);
println!("{:?}", optimized.data.lines);
assert_eq!((0, 3), optimized.view);
let sample_text = format!("{sample_text}\none more line");
let sample = MatchData::from(sample_text.as_str());
let (optimized, _) = sample.optimize_bounds(&license);
println!("{:?}", optimized.view);
println!("{:?}", optimized.data.lines);
assert_eq!((0, 3), optimized.view);
let sample_text = format!("some content\nat\n\nthe beginning\n{sample_text}");
let sample = MatchData::from(sample_text.as_str());
let (optimized, _) = sample.optimize_bounds(&license);
println!("{:?}", optimized.view);
println!("{:?}", optimized.data.lines);
assert!(
(4, 7) == optimized.view || (4, 8) == optimized.view,
"bounds are (4, 7) or (4, 8)"
);
}
#[test]
fn optimize_doesnt_grow_view() {
let sample_text = "0\n1\n2\naaa aaa\naaa\naaa\naaa\n7\n8";
let license_text = "aaa aaa aaa aaa aaa";
let sample = MatchData::from(sample_text);
let license = MatchData::from(license_text).without_text();
let (optimized, _) = sample.optimize_bounds(&license);
assert_eq!((3, 7), optimized.view);
let sample = sample.with_view(3, 7);
let (optimized, _) = sample.optimize_bounds(&license);
assert_eq!((3, 7), optimized.view);
let sample = sample.with_view(4, 6);
let (optimized, _) = sample.optimize_bounds(&license);
assert_eq!((4, 6), optimized.view);
let sample = sample.with_view(0, 9);
let (optimized, _) = sample.optimize_bounds(&license);
assert_eq!((3, 7), optimized.view);
}
#[test]
fn match_small() {
let a = MatchData::from("a b");
let b = MatchData::from("a\nlong\nlicense\nfile\n\n\n\n\nabcdefg");
let x = a.match_score(&b);
let y = b.match_score(&a);
assert_eq!(x, y);
}
#[test]
fn match_empty() {
let a = MatchData::from("");
let b = MatchData::from("a\nlong\nlicense\nfile\n\n\n\n\nabcdefg");
let x = a.match_score(&b);
let y = b.match_score(&a);
assert_eq!(x, y);
}
#[test]
fn view_and_white_out() {
let a = MatchData::from("aaa\nbbb\nccc\nddd");
assert_eq!("aaa bbb ccc ddd", a.data.text);
let b = a.with_view(1, 3);
assert_eq!(2, b.lines().len());
assert_eq!("bbb ccc", b.data.text);
let c = b.white_out();
assert_eq!("aaa ddd", c.data.text);
}
}