Skip to main content

kashida/
pattern.rs

1//! The pattern language: IR types and the compiler.
2
3use crate::error::{CompileError, CompileErrorKind};
4use crate::grapheme::{is_joining_type, KASHIDA};
5use crate::rasm::resolve_group_name;
6use icu_properties::props::{JoiningGroup, JoiningType};
7use icu_properties::CodePointMapData;
8
9#[derive(Clone, Debug)]
10pub(crate) enum Token {
11    Group(JoiningGroup),      // @Name, positional rasm by group
12    ExactGroup(JoiningGroup), // =Name, that Joining_Group alone, no folding
13    GroupSet(Vec<Token>),     // {…}, any of its members
14    NotGroupSet(Vec<Token>),  // ^{…}, none of its members
15    Literal(u32),             // a letter, exact codepoint
16    Any,                      // * wildcard
17}
18
19// `base` at the guard's floor length, stepping down 1 per extra letter to
20// `min`, then holding (`min == base` is constant).
21#[derive(Clone, Copy, Debug)]
22pub(crate) enum Weight {
23    Priority { base: u8, min: u8 },
24    Suppress,
25}
26
27#[derive(Clone, Copy, Debug)]
28pub(crate) enum LengthGuard {
29    Exact(usize),
30    Min(usize),
31    Range { lo: usize, hi: usize },
32}
33
34#[derive(Clone, Debug)]
35pub(crate) struct CompiledPattern {
36    pub(crate) guard: Option<LengthGuard>,
37    pub(crate) tokens: Vec<Token>,
38    // weights[k] is the contribution at the gap before token k; index
39    // tokens.len() is the gap after the last token.
40    pub(crate) weights: Vec<Option<Weight>>,
41    pub(crate) leading_boundary: bool,
42    pub(crate) trailing_boundary: bool,
43}
44
45/// A compiled set of kashida insertion patterns.
46#[derive(Clone, Debug)]
47pub struct PatternSet {
48    pub(crate) patterns: Vec<CompiledPattern>,
49}
50
51fn strip_comment(raw: &str) -> String {
52    let body = match raw.find('#') {
53        Some(hash) => &raw[..hash],
54        None => raw,
55    };
56    body.trim().to_string()
57}
58
59// letter ::= a codepoint with a joining Joining_Type
60fn is_letter(ch: char) -> bool {
61    is_joining_type(CodePointMapData::<JoiningType>::new().get(ch))
62}
63
64// An `@` reference folds positionally through the rasm classes. A group in
65// none of them just matches itself alone. An `=` reference matches its
66// Joining_Group alone in any position. Under either prefix, `Tatweel` names
67// U+0640 ARABIC TATWEEL itself.
68fn resolve_reference(name: &str) -> Result<Token, CompileErrorKind> {
69    if name.strip_prefix(['@', '=']) == Some("Tatweel") {
70        return Ok(Token::Literal(KASHIDA as u32));
71    }
72    let group = resolve_group_name(name)?;
73    if name.starts_with('@') {
74        Ok(Token::Group(group))
75    } else {
76        Ok(Token::ExactGroup(group))
77    }
78}
79
80fn set_weight(
81    weights: &mut Vec<Option<Weight>>,
82    k: usize,
83    weight: Weight,
84) -> Result<(), CompileErrorKind> {
85    if k >= weights.len() {
86        weights.resize(k + 1, None);
87    }
88    // Two weights in one gap make no sense.
89    if weights[k].is_some() {
90        return Err(CompileErrorKind::ConflictingWeights);
91    }
92    weights[k] = Some(weight);
93    Ok(())
94}
95
96// A recursive-descent parser over one comment-stripped line:
97//
98//   pattern   ::= guard? element+
99//   guard     ::= "[" bound ("+" | "-" bound)? "]"
100//   element   ::= token | weight | "."
101//   token     ::= reference | set | "^" (set | reference) | letter | "*"
102//   set       ::= "{" member+ "}"
103//   member    ::= reference | letter
104//   reference ::= ("@" | "=") name
105//   weight    ::= digit ("\" digit)? | "!"
106struct Parser<'a> {
107    chars: &'a [char],
108    pos: usize,
109}
110
111impl Parser<'_> {
112    fn peek(&self) -> Option<char> {
113        self.chars.get(self.pos).copied()
114    }
115
116    fn eat(&mut self, expected: char) -> bool {
117        if self.peek() == Some(expected) {
118            self.pos += 1;
119            true
120        } else {
121            false
122        }
123    }
124
125    fn digit(&mut self) -> Option<u8> {
126        let digit = self.peek()?.to_digit(10)?;
127        self.pos += 1;
128        Some(digit as u8)
129    }
130
131    fn skip_whitespace(&mut self) {
132        while matches!(self.peek(), Some(' ' | '\t')) {
133            self.pos += 1;
134        }
135    }
136
137    // pattern ::= guard? element+
138    fn pattern(&mut self) -> Result<CompiledPattern, CompileErrorKind> {
139        let guard = if self.peek() == Some('[') {
140            Some(self.guard()?)
141        } else {
142            None
143        };
144
145        let mut tokens: Vec<Token> = Vec::new();
146        let mut weights: Vec<Option<Weight>> = Vec::new();
147        let mut leading_boundary = false;
148        let mut trailing_boundary = false;
149
150        // element ::= token | weight | "."
151        loop {
152            self.skip_whitespace();
153            let Some(ch) = self.peek() else { break };
154            if ch == '.' {
155                self.pos += 1;
156                if tokens.is_empty() {
157                    leading_boundary = true;
158                } else {
159                    trailing_boundary = true;
160                }
161                continue;
162            }
163            if trailing_boundary {
164                return Err(CompileErrorKind::TokenAfterTrailingBoundary);
165            }
166            if ch.is_ascii_digit() || ch == '!' || ch == '\\' {
167                set_weight(&mut weights, tokens.len(), self.weight()?)?;
168                continue;
169            }
170            tokens.push(self.token(ch)?);
171        }
172
173        if tokens.is_empty() {
174            return Err(CompileErrorKind::NoLetters);
175        }
176        weights.resize(tokens.len() + 1, None);
177        // A weight in the gap between a token and a `.` can never land on a
178        // junction: no junction exists at a run's edge.
179        if (leading_boundary && weights[0].is_some())
180            || (trailing_boundary && weights[tokens.len()].is_some())
181        {
182            return Err(CompileErrorKind::WeightOutsideRun);
183        }
184        Ok(CompiledPattern {
185            guard,
186            tokens,
187            weights,
188            leading_boundary,
189            trailing_boundary,
190        })
191    }
192
193    // guard ::= "[" bound ("+" | "-" bound)? "]"
194    fn guard(&mut self) -> Result<LengthGuard, CompileErrorKind> {
195        self.pos += 1; // the `[`
196        let start = self.pos;
197        while self.peek().is_some_and(|c| c != ']') {
198            self.pos += 1;
199        }
200        if !self.eat(']') {
201            return Err(CompileErrorKind::UnterminatedLengthGuard);
202        }
203        let body: String = self.chars[start..self.pos - 1].iter().collect();
204        let trimmed = body.trim();
205        let invalid = || CompileErrorKind::InvalidLengthGuard(body.clone());
206        // bound ::= digit+.
207        let bound = |s: &str| -> Result<usize, CompileErrorKind> {
208            if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
209                return Err(invalid());
210            }
211            s.parse::<usize>().map_err(|_| invalid())
212        };
213        let guard = if let Some(stripped) = trimmed.strip_suffix('+') {
214            LengthGuard::Min(bound(stripped)?)
215        } else if let Some(dash) = trimmed.find('-').filter(|&dash| dash > 0) {
216            LengthGuard::Range {
217                lo: bound(&trimmed[..dash])?,
218                hi: bound(&trimmed[dash + 1..])?,
219            }
220        } else {
221            LengthGuard::Exact(bound(trimmed)?)
222        };
223        // Reject guards no run can satisfy: a junction needs two letters, and
224        // a range must not be empty.
225        let bounds_ok = match guard {
226            LengthGuard::Exact(n) | LengthGuard::Min(n) => n >= 2,
227            LengthGuard::Range { lo, hi } => lo >= 2 && lo <= hi,
228        };
229        if bounds_ok {
230            Ok(guard)
231        } else {
232            Err(invalid())
233        }
234    }
235
236    // weight ::= digit ("\" digit)? | "!"
237    fn weight(&mut self) -> Result<Weight, CompileErrorKind> {
238        if self.eat('!') {
239            return Ok(Weight::Suppress);
240        }
241        let Some(base) = self.digit() else {
242            // Only a lone `\` lands here.
243            return Err(CompileErrorKind::BackslashWithoutDigit);
244        };
245        let mut min = base;
246        if self.eat('\\') {
247            // The priority drops from the first digit down to the second as
248            // the run grows.
249            match self.digit() {
250                Some(end) => {
251                    min = end;
252                    if min > base {
253                        return Err(CompileErrorKind::IncreasingPriority { base, min });
254                    }
255                }
256                None => return Err(CompileErrorKind::ExpectedDigitAfterBackslash),
257            }
258        }
259        Ok(Weight::Priority { base, min })
260    }
261
262    // token ::= reference | set | "^" (set | reference) | letter | "*"
263    fn token(&mut self, ch: char) -> Result<Token, CompileErrorKind> {
264        match ch {
265            '*' => {
266                self.pos += 1;
267                Ok(Token::Any)
268            }
269            '{' => Ok(Token::GroupSet(self.set()?)),
270            '^' => {
271                self.pos += 1;
272                match self.peek() {
273                    Some('{') => Ok(Token::NotGroupSet(self.set()?)),
274                    Some('@' | '=') => Ok(Token::NotGroupSet(vec![self.reference()?])),
275                    _ => Err(CompileErrorKind::CaretNotFollowed),
276                }
277            }
278            '@' | '=' => self.reference(),
279            _ => {
280                self.pos += 1;
281                // A letter stands for itself alone. Anything else is an error,
282                // not a token.
283                if is_letter(ch) {
284                    Ok(Token::Literal(ch as u32))
285                } else {
286                    Err(CompileErrorKind::StrayCharacter(ch))
287                }
288            }
289        }
290    }
291
292    // set ::= "{" member+ "}"
293    // member ::= reference | letter
294    fn set(&mut self) -> Result<Vec<Token>, CompileErrorKind> {
295        self.pos += 1; // the `{`
296        let start = self.pos;
297        while self.peek().is_some_and(|c| c != '}') {
298            self.pos += 1;
299        }
300        if !self.eat('}') {
301            return Err(CompileErrorKind::UnterminatedGroupSet);
302        }
303        let body: String = self.chars[start..self.pos - 1].iter().collect();
304        if body.trim().is_empty() {
305            return Err(CompileErrorKind::EmptyGroupSet);
306        }
307        let mut members = Vec::new();
308        for part in body.split_whitespace() {
309            if part.starts_with('@') || part.starts_with('=') {
310                members.push(resolve_reference(part)?);
311            } else {
312                for ch in part.chars() {
313                    if is_letter(ch) {
314                        members.push(Token::Literal(ch as u32));
315                    } else {
316                        return Err(CompileErrorKind::StrayCharacter(ch));
317                    }
318                }
319            }
320        }
321        Ok(members)
322    }
323
324    // reference ::= ("@" | "=") name
325    // name ::= (ALPHA | "_")+
326    fn reference(&mut self) -> Result<Token, CompileErrorKind> {
327        let mut name = String::from(self.chars[self.pos]); // the `@` or `=`
328        self.pos += 1;
329        while let Some(c) = self.peek() {
330            if c.is_ascii_alphabetic() || c == '_' {
331                name.push(c);
332                self.pos += 1;
333            } else {
334                break;
335            }
336        }
337        if name.len() == 1 {
338            return Err(CompileErrorKind::EmptyGroupName);
339        }
340        resolve_reference(&name)
341    }
342}
343
344// line ::= pattern? comment?
345fn parse_line(raw: &str) -> Result<Option<CompiledPattern>, CompileErrorKind> {
346    let line = strip_comment(raw);
347    if line.is_empty() {
348        return Ok(None);
349    }
350    let chars: Vec<char> = line.chars().collect();
351    Parser {
352        chars: &chars,
353        pos: 0,
354    }
355    .pattern()
356    .map(Some)
357}
358
359/// Compiles pattern text into a [`PatternSet`].
360pub fn compile_pattern_text(text: &str) -> Result<PatternSet, CompileError> {
361    let mut patterns = Vec::new();
362    for (index, raw) in text.split('\n').enumerate() {
363        let raw = raw.strip_suffix('\r').unwrap_or(raw);
364        let context = |kind| CompileError {
365            kind,
366            line_number: index + 1,
367        };
368        if let Some(pattern) = parse_line(raw).map_err(context)? {
369            patterns.push(pattern);
370        }
371    }
372    Ok(PatternSet { patterns })
373}