scallion 0.1.0-rc.2

Library for identification of license texts based on the SPDX license list
Documentation
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;

/// The type of a license entry (typically in a [`Store`]).
#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LicenseType {
    /// The canonical text of the license.
    Original,
    /// A license header. There may be more than one in a [`Store`].
    Header,
    /// An alternate form of a license. This is intended to be used for
    /// alternate _formats_ of a license, not for variants where the text has
    /// different meaning. Not currently used in scallion's SPDX dataset.
    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",
            }
        )
    }
}

/// A structure representing compiled text/matching data.
///
/// This is the key structure used to compare two texts against one another. It
/// handles pre-processing the text to n-grams, scoring, and optimizing the
/// result to try to identify specific details about a match.
///
/// # Examples
///
/// Basic scoring of two texts:
///
/// ```
/// use scallion::MatchData;
///
/// let license = MatchData::from("My First License");
/// let sample = MatchData::from("copyright 20xx me irl\n\n //  my   first license");
/// assert_eq!(sample.match_score(&license), 1.0);
/// ```
///
/// The above example is a perfect match, as identifiable copyright statements
/// are stripped out during pre-processing.
///
/// Building on that, [`MatchData`] is able to tell you _where_ in the text a
/// license is located:
///
/// ```
/// # use std::error::Error;
/// # use scallion::MatchData;
/// # fn main() -> Result<(), Box<dyn Error>> {
/// # let license = MatchData::from("My First License");
/// let sample = MatchData::from("copyright 20xx me irl\n// My First License\nfn hello() {\n ...");
/// let (optimized, score) = sample.optimize_bounds(&license);
/// assert_eq!((1, 2), optimized.lines_view());
/// assert!(score > 0.99f32, "license within text matches");
/// # Ok(())
/// # }
/// ```
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct MatchData {
    ngrams: NgramSet<2>,
    view: (usize, usize),
    // normalized lines
    lines: Vec<String>,
    // preprocessed text
    text: String,
}

impl MatchData {
    /// Create a new [`MatchData`] structure from a string.
    ///
    /// The given text will be normalized, then smashed down into n-grams for
    /// matching. By default, the normalized text is stored inside the
    /// structure for future diagnostics. This is necessary for optimizing a
    /// match and for diffing against other texts. If you don't want this extra
    /// data, you can call `without_text` throw it out. Generally, as a user of
    /// this library you want to keep the text data, but scallion will throw it
    /// away in its own [`Store`] as it's not needed.
    #[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::<2>::from_str(&processed);

        MatchData {
            ngrams,
            view: (0, normalized.len()),
            lines: normalized,
            text: processed,
        }
    }

    /// Get the bounds of the active line view.
    ///
    /// This represents the "active" region of lines that matches are generated
    /// from. The bounds are a 0-indexed `(start, end)` tuple, with inclusive
    /// start and exclusive end indicies. See [`MatchData::optimize_bounds`].
    ///
    /// This is largely for informational purposes; other methods in
    /// [`MatchData`], such as [`MatchData::lines`] and [`MatchData::match_score`],
    /// will already account for the line range.
    /// However, it's useful to call it after running [`MatchData::optimize_bounds`]
    /// to discover where the input text was discovered.
    #[must_use]
    pub const fn lines_view(&self) -> (usize, usize) {
        self.view
    }

    /// Clone this [`MatchData`], creating a copy with the given view.
    ///
    /// This will re-generate match data for the given view. It's used in
    /// `optimize_bounds` to shrink/expand the view of the text to discover
    /// bounds.
    ///
    /// Other methods on [`MatchData`] respect this boundary, so it's not needed
    /// outside this struct.
    #[must_use]
    pub fn with_view(&self, start: usize, end: usize) -> Self {
        let view = &self.lines[start..end];
        let view_joined = view.join("\n");
        let processed = apply_aggressive(&view_joined);
        MatchData {
            ngrams: NgramSet::<2>::from_str(&processed),
            view: (start, end),
            lines: self.lines.clone(),
            text: processed,
        }
    }

    /// "Erase" the current lines in view and restore the view to its original
    /// bounds.
    ///
    /// For example, consider a file with two licenses in it. One was identified
    /// (and located) with [`MatchData::optimize_bounds`].
    ///
    /// Now you want to find the other: white-out the matched lines, and re-run
    /// the overall search to find a new high score.
    #[must_use]
    pub fn white_out(&self) -> Self {
        let new_normalized: Vec<String> = self
            .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::<2>::from_str(&processed),
            view: (0, new_normalized.len()),
            lines: new_normalized,
            text: processed,
        }
    }

    /// Get a slice of the normalized lines in this [`MatchData`].
    #[must_use]
    pub fn lines(&self) -> &[String] {
        &self.lines[self.view.0..self.view.1]
    }

    /// Get a reference to the processed text in this [`MatchData`].
    #[must_use]
    pub fn text(&self) -> &str {
        &self.text
    }

    /// Compare this [`MatchData`] with another, returning a similarity score.
    ///
    /// This is what's used during analysis to rank licenses.
    #[must_use]
    pub fn match_score(&self, other: &MatchData) -> f32 {
        self.ngrams.dice(&other.ngrams)
    }

    #[must_use]
    pub(crate) fn eq_data(&self, other: &MatchData) -> bool {
        self.ngrams.eq(&other.ngrams)
    }

    /// Attempt to optimize a known match to locate possible line ranges.
    ///
    /// Returns a new [`MatchData`] struct and a score. The returned struct is a
    /// clone of `self`, with its view set to the best match against `other`.
    ///
    /// This will respect any views set on the [`MatchData`] (an optimized result
    /// won't go outside the original view).
    ///
    /// Note that this won't be 100% optimal if there are blank lines
    /// surrounding the actual match, since successive blank lines in a range
    /// will likely have the same score.
    ///
    /// You should check the value of [`MatchData::lines_view`] on the returned
    /// struct to find the line ranges.
    #[must_use]
    pub fn optimize_bounds(&self, other: &MatchData) -> (Self, f32) {
        let view = self.view;

        // optimize the ending bounds of the text match
        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;

        // then optimize the starting bounds
        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)
    }

    #[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 {
                // find the index of the highest score in the remaining items
                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)
            }
        }

        // cache score checks, since they're kinda expensive
        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 {
    fn from(text: &'a str) -> Self {
        Self::new(text)
    }
}

impl From<String> for MatchData {
    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);
        let sample = MatchData::from(sample_text);

        let (optimized, _) = sample.optimize_bounds(&license);
        println!("{:?}", optimized.view);
        println!("{:?}", optimized.lines);
        assert_eq!((0, 3), optimized.view);

        // add more to the string, try again (avoid int trunc screwups)
        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.lines);
        assert_eq!((0, 3), optimized.view);

        // add to the beginning too
        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.lines);
        // end bounds at 7 and 8 have the same score, since they're empty lines (not
        // counted). scallion is not smart enough to trim this as close as it
        // can.
        assert!(
            (4, 7) == optimized.view || (4, 8) == optimized.view,
            "bounds are (4, 7) or (4, 8)"
        );
    }

    // if a view is set on the text data, optimize_bounds must not find text
    // outside of that range
    #[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);

        // sanity: the optimized bounds should be at (3, 7)
        let (optimized, _) = sample.optimize_bounds(&license);
        assert_eq!((3, 7), optimized.view);

        // this should still work
        let sample = sample.with_view(3, 7);
        let (optimized, _) = sample.optimize_bounds(&license);
        assert_eq!((3, 7), optimized.view);

        // but if we shrink the view further, it shouldn't be outside that range
        let sample = sample.with_view(4, 6);
        let (optimized, _) = sample.optimize_bounds(&license);
        assert_eq!((4, 6), optimized.view);

        // restoring the view should still be OK too
        let sample = sample.with_view(0, 9);
        let (optimized, _) = sample.optimize_bounds(&license);
        assert_eq!((3, 7), optimized.view);
    }

    // ensure we don't choke on small `MatchData` matches
    #[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);
    }

    // don't choke on empty `MatchData` either
    #[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.text);

        let b = a.with_view(1, 3);
        assert_eq!(2, b.lines().len());
        assert_eq!("bbb ccc", b.text);

        let c = b.white_out();
        assert_eq!("aaa ddd", c.text);
    }
}