Version: 0.9.1 Last Updated: 2025-11-12
Restricted substitutions allow you to define custom character equivalences for fuzzy string matching. Instead of treating all character substitutions equally (as standard Levenshtein distance does), you can specify which substitutions should be treated as zero-cost (free).
In standard Levenshtein distance:
1 (c→k substitution costs 1)1 (a→u substitution costs 1)With restricted substitutions:
0 (c↔k is free)1 (a→u costs 1)The Unrestricted policy (default behavior) is a zero-sized type that adds zero overhead:
0 bytes0 (compiler optimizes away all checks)Only when you use Restricted policy do you pay for the feature (1–5% overhead).
Add to your Cargo.toml:
[dependencies]
liblevenshtein = "0.9.1"
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Restricted, Algorithm};
// 1. Create a dictionary
let dict = DoubleArrayTrie::from_terms(vec!["cat", "phone"]);
// 2. Define substitutions
let mut substitutions = SubstitutionSet::new();
substitutions.allow('c', 'k'); // c → k
substitutions.allow('k', 'c'); // k → c (symmetric)
// 3. Create policy
let policy = Restricted::new(&substitutions);
// 4. Create transducer with policy
let transducer = Transducer::with_policy(dict, Algorithm::Standard, policy);
// 5. Query with substitutions
let results: Vec<String> = transducer.query("kat", 0).collect();
assert!(results.contains(&"cat".to_string())); // "kat" matches "cat" with distance 0!
Problem: Users search using phonetic spellings ("fone" for "phone", "senter" for "center").
Solution: Use phonetic equivalences.
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Algorithm};
// Use preset phonetic substitutions
let phonetic = SubstitutionSet::phonetic_basic();
let dict = DoubleArrayTrie::from_terms(vec!["phone", "center", "dogs"]);
let transducer = Transducer::with_substitutions(dict, Algorithm::Standard, phonetic);
// Phonetic queries match correctly
assert_eq!(transducer.query("fone", 1).count(), 1); // matches "phone"
assert_eq!(transducer.query("senter", 1).count(), 1); // matches "center"
assert_eq!(transducer.query("dogz", 1).count(), 1); // matches "dogs"
Included Equivalences:
f ↔ ph (phone/fone)c ↔ k (cat/kat)c ↔ s (cent/sent)s ↔ z (dogs/dogz)Problem: Users make typos by hitting adjacent keys ("qwerty" → "awerty").
Solution: Use keyboard proximity substitutions.
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Algorithm};
// Use QWERTY keyboard proximity
let keyboard = SubstitutionSet::keyboard_qwerty();
let dict = DoubleArrayTrie::from_terms(vec!["password", "hello"]);
let transducer = Transducer::with_substitutions(dict, Algorithm::Standard, keyboard);
// Adjacent key typos are caught
assert!(transducer.query("passwird", 1).any(|s| s == "password")); // o→i adjacent
assert!(transducer.query("hwllo", 1).any(|s| s == "hello")); // e→w adjacent
Included Adjacencies:
Problem: Users search without accents ("cafe" should match "café").
Solution: Use SubstitutionSetChar for Unicode support.
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSetChar, RestrictedChar, Algorithm};
// Use preset diacritic equivalences
let diacritics = SubstitutionSetChar::diacritics_latin();
let dict = DoubleArrayTrieChar::from_terms(vec!["café", "niño", "über"]);
let policy = RestrictedChar::new(&diacritics);
let transducer = Transducer::with_policy(dict, Algorithm::Standard, policy);
// Diacritic variants match with distance 0
let results: Vec<String> = transducer.query("cafe", 0).collect();
assert!(results.contains(&"café".to_string()));
let results: Vec<String> = transducer.query("nino", 0).collect();
assert!(results.contains(&"niño".to_string()));
Included Diacritics:
Problem: OCR systems confuse visually similar characters (0/O, 1/I/l).
Solution: Use OCR-friendly substitutions.
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Algorithm};
// Use OCR confusion pairs
let ocr = SubstitutionSet::ocr_friendly();
let dict = DoubleArrayTrie::from_terms(vec!["HELLO", "WORLD"]);
let transducer = Transducer::with_substitutions(dict, Algorithm::Standard, ocr);
// OCR confusions are tolerated
assert!(transducer.query("HELL0", 0).any(|s| s == "HELLO")); // O→0
assert!(transducer.query("W0RLD", 0).any(|s| s == "WORLD")); // O→0
Included Confusions:
Problem: Normalize leetspeak variations ("l33t" → "leet").
Solution: Use leetspeak substitutions.
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Algorithm};
// Use leetspeak equivalences
let leet = SubstitutionSet::leet_speak();
let dict = DoubleArrayTrie::from_terms(vec!["leet", "hacker", "root"]);
let transducer = Transducer::with_substitutions(dict, Algorithm::Standard, leet);
// Leetspeak variants match
assert!(transducer.query("l33t", 0).any(|s| s == "leet")); // 3→e
assert!(transducer.query("h4ck3r", 1).any(|s| s == "hacker")); // 4→a, 3→e
assert!(transducer.query("r00t", 0).any(|s| s == "root")); // 0→o
Included Substitutions:
Build your own substitution set from scratch:
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Restricted, Algorithm};
// Build custom substitution set
let mut custom = SubstitutionSet::new();
// Add bidirectional equivalences
custom.allow('v', 'w'); custom.allow('w', 'v'); // v ↔ w
custom.allow('i', 'y'); custom.allow('y', 'i'); // i ↔ y
custom.allow('ph', 'f'); custom.allow('f', 'p'); // f ↔ ph (multi-char handled as 'p')
let dict = DoubleArrayTrie::from_terms(vec!["very", "happy"]);
let policy = Restricted::new(&custom);
let transducer = Transducer::with_policy(dict, Algorithm::Standard, policy);
// Custom equivalences work
assert!(transducer.query("wery", 0).any(|s| s == "very")); // v↔w
assert!(transducer.query("happi", 0).any(|s| s == "happy")); // y↔i
Combine multiple preset sets:
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Algorithm};
// Start with phonetic base
let mut combined = SubstitutionSet::phonetic_basic();
// Add keyboard typos
let keyboard = SubstitutionSet::keyboard_qwerty();
for &(a, b) in &[('q', 'w'), ('w', 'q'), ('a', 's'), ('s', 'a')] {
combined.allow(a as char, b as char);
}
// Add leetspeak
combined.allow('3', 'e');
combined.allow('e', '3');
combined.allow('0', 'o');
combined.allow('o', '0');
let dict = DoubleArrayTrie::from_terms(vec!["hello", "phone"]);
let transducer = Transducer::with_substitutions(dict, Algorithm::Standard, combined);
// All substitution types work
assert!(transducer.query("h3llo", 1).any(|s| s == "hello")); // leetspeak
assert!(transducer.query("fone", 1).any(|s| s == "phone")); // phonetic
For large substitution sets, pre-allocate capacity:
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSet, Algorithm};
// Pre-allocate for 200 substitution pairs
let mut large_set = SubstitutionSet::with_capacity(200);
// Add many pairs without reallocations
for i in 0..26 {
let lower = (b'a' + i) as char;
let upper = (b'A' + i) as char;
large_set.allow(lower, upper);
large_set.allow(upper, lower);
}
assert_eq!(large_set.len(), 52); // 26 pairs × 2 directions
Match Greek text case-insensitively:
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSetChar, RestrictedChar, Algorithm};
// Greek case-insensitive matching
let greek = SubstitutionSetChar::greek_case_insensitive();
let dict = DoubleArrayTrieChar::from_terms(vec!["Αθήνα", "Ελλάδα"]); // Athens, Greece
let policy = RestrictedChar::new(&greek);
let transducer = Transducer::with_policy(dict, Algorithm::Standard, policy);
// Case variants match with distance 0
assert!(transducer.query("αθήνα", 0).any(|s| s == "Αθήνα")); // lowercase matches
assert!(transducer.query("ΕΛΛΆΔΑ", 0).any(|s| s == "Ελλάδα")); // uppercase matches
Allow hiragana↔katakana equivalence:
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSetChar, RestrictedChar, Algorithm};
// Japanese script equivalences
let japanese = SubstitutionSetChar::japanese_hiragana_katakana();
let dict = DoubleArrayTrieChar::from_terms(vec!["カタカナ", "ひらがな"]);
let policy = RestrictedChar::new(&japanese);
let transducer = Transducer::with_policy(dict, Algorithm::Standard, policy);
// Hiragana queries match katakana terms
// (In real use, you'd have mixed-script dictionary entries)
Handle multiple languages with Unicode:
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::{SubstitutionSetChar, RestrictedChar, Algorithm};
// Combine multiple language equivalences
let mut multilang = SubstitutionSetChar::new();
// Add Latin diacritics
let latin = SubstitutionSetChar::diacritics_latin();
for i in 0..latin.len() {
// In real code, you'd iterate over pairs
}
// Add Greek case-insensitive
let greek = SubstitutionSetChar::greek_case_insensitive();
// (Similarly merge)
// Add Cyrillic case-insensitive
let cyrillic = SubstitutionSetChar::cyrillic_case_insensitive();
// (Similarly merge)
// Now supports French, Greek, and Russian simultaneously
Implement your own substitution policy:
use liblevenshtein::transducer::SubstitutionPolicy;
/// Custom policy: allow vowel substitutions only
#[derive(Copy, Clone, Debug)]
struct VowelPolicy;
impl SubstitutionPolicy for VowelPolicy {
fn is_allowed(&self, dict_char: u8, query_char: u8) -> bool {
const VOWELS: &[u8] = b"aeiouAEIOU";
// Allow exact match or both are vowels
dict_char == query_char ||
(VOWELS.contains(&dict_char) && VOWELS.contains(&query_char))
}
}
// Use custom policy
// let transducer = Transducer::with_policy(dict, Algorithm::Standard, VowelPolicy);
| Policy Type | Size | Runtime Overhead | Use Case |
|---|---|---|---|
Unrestricted | 0 bytes | 0% (zero-cost) | Default, no substitutions |
Restricted | 8 bytes (ref) | 1-5% (HashSet lookup) | Custom substitutions |
Use Unrestricted when possible: If you don't need substitutions, use the default:
let transducer = Transducer::new(dict, Algorithm::Standard); // Zero overhead
Pre-allocate substitution sets: Use with_capacity() for large sets:
let mut set = SubstitutionSet::with_capacity(100); // Avoids reallocations
Reuse policies: Create policy once, use multiple times:
let policy = Restricted::new(&substitutions);
let trans1 = Transducer::with_policy(dict1, Algorithm::Standard, policy);
let trans2 = Transducer::with_policy(dict2, Algorithm::Standard, policy);
Benchmark your use case: Overhead varies by:
use std::mem::size_of;
use liblevenshtein::transducer::{Unrestricted, Restricted, SubstitutionSet};
// Zero-sized type
assert_eq!(size_of::<Unrestricted>(), 0);
// Restricted is just a reference
assert_eq!(size_of::<Restricted>(), 8); // 64-bit pointer
// SubstitutionSet scales with number of pairs
let small = SubstitutionSet::new();
let large = SubstitutionSet::phonetic_basic();
println!("Small set: {} bytes (base)", size_of_val(&small));
println!("Large set: {} pairs = ~{} bytes", large.len(), 48 + large.len() * 24);
If you want bidirectional equivalence, add both directions:
let mut set = SubstitutionSet::new();
// ❌ WRONG: Only c→k works
set.allow('c', 'k');
// ✅ CORRECT: Both c→k and k→c work
set.allow('c', 'k');
set.allow('k', 'c');
Don't reinvent the wheel:
// ❌ TEDIOUS: Manual construction
let mut phonetic = SubstitutionSet::new();
phonetic.allow('f', 'p'); phonetic.allow('p', 'f');
phonetic.allow('c', 'k'); phonetic.allow('k', 'c');
// ... many more
// ✅ BETTER: Use preset
let phonetic = SubstitutionSet::phonetic_basic();
Explain why you chose specific equivalences:
// Medical terminology: common abbreviations and variants
let mut medical = SubstitutionSet::new();
medical.allow('x', 'c'); medical.allow('c', 'x'); // "excision" ↔ "exsision"
medical.allow('ph', 'f'); medical.allow('f', 'p'); // "pharmacy" ↔ "farmacy"
// Document: Based on common medical spelling variants
Verify expected behavior:
#[test]
fn test_phonetic_matching() {
let phonetic = SubstitutionSet::phonetic_basic();
let dict = DoubleArrayTrie::from_terms(vec!["phone", "cat"]);
let transducer = Transducer::with_substitutions(dict, Algorithm::Standard, phonetic);
// Positive cases
assert!(transducer.query("fone", 1).any(|s| s == "phone"));
assert!(transducer.query("kat", 1).any(|s| s == "cat"));
// Negative cases (ensure we don't over-match)
assert!(!transducer.query("xyz", 1).any(|s| s == "phone"));
}
Substitutions reduce effective distance, so adjust max distance:
// With substitutions, distance 0 may match more
let results_d0 = transducer.query("kat", 0).collect::<Vec<_>>();
// Without substitutions, you'd need distance 1
let results_d1 = transducer_no_subs.query("kat", 1).collect::<Vec<_>>();
SubstitutionSet and SubstitutionSetChar?A: SubstitutionSet works with ASCII bytes (u8, 0–127) and is used with DoubleArrayTrie. SubstitutionSetChar works with full Unicode characters (char, U+0000–U+10FFFF) and is used with character-level dictionaries.
// Byte-level (ASCII only)
let mut ascii_set = SubstitutionSet::new();
ascii_set.allow('a', 'b'); // Works
ascii_set.allow('é', 'e'); // Silently ignored (non-ASCII)
// Character-level (full Unicode)
let mut unicode_set = SubstitutionSetChar::new();
unicode_set.allow('a', 'b'); // Works
unicode_set.allow('é', 'e'); // Works! ✅
unicode_set.allow('你', '好'); // Works! ✅
A: Add both directions explicitly:
let mut set = SubstitutionSet::new();
set.allow('c', 'k'); // dict 'c' matches query 'k'
set.allow('k', 'c'); // dict 'k' matches query 'c'
A: No, a transducer has exactly one policy. Instead, merge substitution sets:
let mut combined = SubstitutionSet::phonetic_basic();
// Add more pairs from other sets
combined.allow('0', 'O');
combined.allow('O', '0');
A:
0% overhead (zero-cost abstraction)1–5% overhead (HashSet lookup on mismatches only)The overhead is minimal because:
A: Yes! Policies work with all algorithm variants:
// Substitutions + Transposition
let transducer = Transducer::with_policy(
dict,
Algorithm::Transposition,
policy
);
// Substitutions + MergeAndSplit
let transducer = Transducer::with_policy(
dict,
Algorithm::MergeAndSplit,
policy
);
A: Currently, substitutions are single-character only. Multi-character support is planned for future releases. As a workaround:
// Workaround: Treat "ph" as 'p' in context
let mut set = SubstitutionSet::new();
set.allow('f', 'p'); // Partial match for "ph" → "f"
// Note: This doesn't fully solve the problem yet
A: SubstitutionSet is mutable, but Restricted policy holds a reference. To update:
let mut set = SubstitutionSet::new();
set.allow('a', 'b');
// Create transducer
let policy = Restricted::new(&set);
let transducer = Transducer::with_policy(dict, Algorithm::Standard, policy);
// ❌ Can't update set while policy/transducer exist (borrow checker)
// set.allow('c', 'd'); // Error: already borrowed
// ✅ Solution: Create new transducer with updated set
let mut new_set = set.clone();
new_set.allow('c', 'd');
let new_policy = Restricted::new(&new_set);
let new_transducer = Transducer::with_policy(dict, Algorithm::Standard, new_policy);
Box::leak() in with_substitutions()?A: To extend the lifetime to 'static, allowing the returned transducer to own its policy. This is a convenience tradeoff:
// Convenience method (leaks memory, but easy to use)
let transducer = Transducer::with_substitutions(dict, Algorithm::Standard, set);
// Explicit lifetime management (no leak, but requires reference)
let policy = Restricted::new(&set);
let transducer = Transducer::with_policy(dict, Algorithm::Standard, policy);
// Note: 'set' must outlive 'transducer'
For long-lived transducers, the leak is acceptable. For short-lived ones, use with_policy() directly.
Restricted substitutions provide powerful, flexible, and zero-cost (when unused) character equivalence matching:
✅ Use SubstitutionSet presets for common scenarios (phonetic, keyboard, leetspeak, OCR)
✅ Use SubstitutionSetChar for Unicode (diacritics, Greek, Cyrillic, Japanese)
✅ Zero overhead when disabled (default Unrestricted policy)
✅ 1–5% overhead when enabled (fast HashSet lookups)
✅ Fully backward compatible (existing code works unchanged)
Next Steps:
Author: liblevenshtein-rust contributors License: MIT OR Apache-2.0 Repository: https://github.com/dylon/liblevenshtein-rust
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |