ad_astra/analysis/closeness.rs
1////////////////////////////////////////////////////////////////////////////////
2// This file is part of "Ad Astra", an embeddable scripting programming //
3// language platform. //
4// //
5// This work is proprietary software with source-available code. //
6// //
7// To copy, use, distribute, or contribute to this work, you must agree to //
8// the terms of the General License Agreement: //
9// //
10// https://github.com/Eliah-Lakhin/ad-astra/blob/master/EULA.md //
11// //
12// The agreement grants a Basic Commercial License, allowing you to use //
13// this work in non-commercial and limited commercial products with a total //
14// gross revenue cap. To remove this commercial limit for one of your //
15// products, you must acquire a Full Commercial License. //
16// //
17// If you contribute to the source code, documentation, or related materials, //
18// you must grant me an exclusive license to these contributions. //
19// Contributions are governed by the "Contributions" section of the General //
20// License Agreement. //
21// //
22// Copying the work in parts is strictly forbidden, except as permitted //
23// under the General License Agreement. //
24// //
25// If you do not or cannot agree to the terms of this Agreement, //
26// do not use this work. //
27// //
28// This work is provided "as is", without any warranties, express or implied, //
29// except where such disclaimers are legally invalid. //
30// //
31// Copyright (c) 2024 Ilya Lakhin (Илья Александрович Лахин). //
32// All rights reserved. //
33////////////////////////////////////////////////////////////////////////////////
34
35use std::{
36 cmp::Ordering,
37 fmt::{Debug, Display, Formatter},
38 hash::{Hash, Hasher},
39};
40
41use strsim::normalized_damerau_levenshtein;
42
43const EPSILON: f32 = 0.0001;
44
45/// A score representing the distance between two strings.
46///
47/// The score is measured in terms of percentage with fractional precision.
48///
49/// "100%" indicates that the estimated string exactly matches the pattern
50/// string, while "0%" indicates they are completely distinct.
51///
52/// The Debug and Display implementations of this object round the underlying
53/// percentage to the nearest integer. The default value is "0%".
54///
55/// The [StringEstimation::estimate] function estimates the distance
56/// between two strings and returns a `Closeness` value.
57#[repr(transparent)]
58#[derive(Clone, Copy)]
59pub struct Closeness(f32);
60
61impl Debug for Closeness {
62 #[inline(always)]
63 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
64 Display::fmt(self, formatter)
65 }
66}
67
68impl Display for Closeness {
69 #[inline(always)]
70 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
71 formatter.write_fmt(format_args!("{}%", self.percents()))
72 }
73}
74
75impl PartialEq for Closeness {
76 #[inline(always)]
77 fn eq(&self, other: &Self) -> bool {
78 self.normalized().eq(&other.normalized())
79 }
80}
81
82impl Eq for Closeness {}
83
84impl PartialOrd for Closeness {
85 #[inline(always)]
86 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
87 Some(self.cmp(other))
88 }
89}
90
91impl Ord for Closeness {
92 #[inline]
93 fn cmp(&self, other: &Self) -> Ordering {
94 self.normalized().cmp(&other.normalized())
95 }
96}
97
98impl Hash for Closeness {
99 #[inline(always)]
100 fn hash<H: Hasher>(&self, state: &mut H) {
101 self.normalized().hash(state)
102 }
103}
104
105impl Default for Closeness {
106 #[inline(always)]
107 fn default() -> Self {
108 Self::zero()
109 }
110}
111
112impl Closeness {
113 /// Returns a "0%" closeness value.
114 #[inline(always)]
115 pub const fn zero() -> Self {
116 Self(0.0)
117 }
118
119 /// Returns a "50%" closeness value.
120 #[inline(always)]
121 pub const fn half() -> Self {
122 Self(0.5)
123 }
124
125 /// Returns a "100%" closeness value.
126 #[inline(always)]
127 pub const fn one() -> Self {
128 Self(1.0)
129 }
130
131 /// Returns the underlying percentage value rounded up to the nearest
132 /// integer.
133 #[inline(always)]
134 pub fn percents(self) -> u16 {
135 ((self.0 * 1000.0).round() / 10.0) as u16
136 }
137
138 #[inline(always)]
139 fn normalized(self) -> u32 {
140 (self.0 / EPSILON) as u32
141 }
142}
143
144/// An extension trait for strings that estimates the distance between two
145/// strings.
146pub trait StringEstimation {
147 /// Estimates the similarity between two strings.
148 ///
149 /// The returned [Closeness] object represents the similarity between
150 /// the provided string and the specified `pattern` in terms of percentage,
151 /// with fractional precision. A value of "100%" ([Closeness::one]) means
152 /// that the string fully matches the pattern.
153 ///
154 /// ```rust
155 /// use ad_astra::analysis::{Closeness, StringEstimation};
156 ///
157 /// assert_eq!("foo".estimate("foo"), Closeness::one());
158 /// assert_eq!("foo".estimate("aaa"), Closeness::zero());
159 ///
160 /// println!("{}", "foo".estimate("Foo")); // ~ 66%
161 /// println!("{}", "Bra".estimate("bar")); // ~ 33%
162 /// ```
163 fn estimate(&self, pattern: impl AsRef<str>) -> Closeness;
164}
165
166impl<S: AsRef<str>> StringEstimation for S {
167 fn estimate(&self, pattern: impl AsRef<str>) -> Closeness {
168 let this = self.as_ref();
169 let pattern = pattern.as_ref();
170
171 let closeness = normalized_damerau_levenshtein(pattern, this);
172
173 Closeness((closeness as f32 / EPSILON) as usize as f32 * EPSILON)
174 }
175}