Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.39% covered (danger)
21.39%
43 / 201
18.75% covered (danger)
18.75%
3 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
GenReplFst
21.39% covered (danger)
21.39%
43 / 201
18.75% covered (danger)
18.75%
3 / 16
2117.15
0.00% covered (danger)
0.00%
0 / 1
 addAlphabet
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 addEntry
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
72
 nextUtf8State
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 utf8alphabet
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
 flagDiacritic
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 addFdEdge
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addFdEdgePair
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 buildState
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
380
 emit
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 byteToHex
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 stringToTokens
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 tokensToString
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 applyDown
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 applyUp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
5
 writeATT
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\LangConv\Construct;
5
6use Wikimedia\Assert\Assert;
7
8/**
9 * GENerate a REPLacement string FST.
10 *
11 * Create an FST from a replacement string array (aka, as would be provided
12 * to `str_tr` or `ReplacementArray` in mediawiki core).
13 */
14class GenReplFst {
15    // This can be anything, as long as it is longer than 1 character
16    // (so it doesn't conflict w/ any of the single-character keys)
17    private const END_OF_STRING = '*END*';
18    // correlation of tree nodes to state machine states
19    private const STATE = '*STATE*';
20    // UTF-8 decode state: 0=first byte, 1/2/3=bytes remaining in char
21    private const UTF8STATE = '*UTF8STATE*';
22    // Character index of this tree node
23    private const INDEX = '*INDEX*';
24
25    /** @var array<int|string,int|string|array> */
26    private $prefixTree = [];
27    /** @var MutableFST */
28    private $fst;
29    /** @var array */
30    private $alphabet;
31    /**
32     * Prefix to use on flag diacritics (so they are unique to this FST).
33     * @var string
34     */
35    private $fdPrefix;
36    /**
37     * How many flag diacritic features are needed.  At most one less than
38     * the longest string, but can be shorter.
39     * @var int
40     */
41    private $maxLookahead;
42    /**
43     * Cache of shift machines.
44     * @var array
45     */
46    private $shiftCache = [];
47
48    /**
49     * Add each letter in the given word to our alphabet.
50     * @param array &$alphabet
51     * @param string $word
52     */
53    private static function addAlphabet( array &$alphabet, string $word ): void {
54        for ( $i = 0; $i < strlen( $word ); $i++ ) {
55            $alphabet[ord( $word[$i] )] = true;
56        }
57    }
58
59    /**
60     * Add the given string (the substring of $from starting from $index) to
61     * the prefix tree $tree, along with the conversion output $to.
62     * @param array<int|string,string|array> &$tree
63     * @param string $from
64     * @param int $index
65     * @param int $utf8state 0=first byte, 1/2/3 = bytes remaining in char
66     * @param string $to
67     * @param bool $suppressUtf8Checks Whether to suppress the checks that the
68     *   $from string is complete utf8.
69     */
70    private static function addEntry(
71        array &$tree, string $from, int $index, int $utf8state, string $to,
72        bool $suppressUtf8Checks = false
73    ): void {
74        $c = ord( $from[$index] );
75        if ( !isset( $tree[$c] ) ) {
76            $tree[$c] = [];
77        }
78        if ( !isset( $tree[self::UTF8STATE] ) ) {
79            $tree[self::UTF8STATE] = $utf8state;
80        }
81        Assert::invariant(
82            isset( $tree[self::UTF8STATE] ) && $tree[self::UTF8STATE] === $utf8state,
83            "Should never happen"
84        );
85        if ( !isset( $tree[self::INDEX] ) ) {
86            $tree[self::INDEX] = $index;
87        }
88        Assert::invariant(
89            isset( $tree[self::INDEX] ) && $tree[self::INDEX] === $index,
90            "Should never happen"
91        );
92        $nextUtf8State = self::nextUtf8State( $utf8state, $c );
93        $nextIndex = $index + 1;
94        if ( $nextIndex < strlen( $from ) ) {
95            self::addEntry( $tree[$c], $from, $nextIndex, $nextUtf8State, $to );
96        } else {
97            if ( !$suppressUtf8Checks ) {
98                Assert::invariant( $nextUtf8State === 0, "Bad UTF-8 in input" );
99            }
100            $tree[$c][self::UTF8STATE] = $nextUtf8State;
101            $tree[$c][self::INDEX] = $nextIndex;
102            $tree[$c][self::END_OF_STRING] = $to;
103        }
104    }
105
106    /**
107     * Return the next UTF-8 state, given the current state and the
108     * current character.
109     * @param int $utf8state 0=first byte, 1/2/3 = bytes remaining in char
110     * @param int $c The current character
111     * @return int The next UTF-8 state
112     */
113    private static function nextUtf8State( int $utf8state, int $c ): int {
114        if ( $utf8state === 0 ) {
115            if ( $c <= 0x7F ) {
116                return 0;
117            } elseif ( $c <= 0xDF ) {
118                Assert::invariant( $c >= 0xC0, "Bad UTF-8 in input" );
119                return 1;
120            } elseif ( $c <= 0xEF ) {
121                Assert::invariant( $c >= 0xE0, "Bad UTF-8 in input" );
122                return 2;
123            } elseif ( $c <= 0xF7 ) {
124                Assert::invariant( $c >= 0xF0, "Bad UTF-8 in input" );
125                return 3;
126            }
127        } else {
128            return $utf8state - 1;
129        }
130        // @phan-suppress-next-line PhanImpossibleCondition
131        Assert::invariant( false, "Bad UTF-8 in input" );
132    }
133
134    /**
135     * Return the subset of the given alphabet appropriate for the current
136     * UTF-8 state.
137     * @param int $utf8state 0=first byte, 1/2/3 = bytes remaining in char
138     * @return \Generator<int>
139     */
140    private function utf8alphabet( int $utf8state ) {
141        foreach ( $this->alphabet as $c ) {
142            if ( $c >= 0x80 && $c <= 0xBF ) {
143                // UTF-8 continuation character
144                if ( $utf8state !== 0 ) {
145                    yield $c;
146                }
147            } else {
148                if ( $utf8state === 0 ) {
149                    yield $c;
150                }
151            }
152        }
153    }
154
155    /**
156     * Return the name of a flag diacritic for matching character at a
157     * specified offset.
158     * @param string $type Flag diacritic operator: P/R/D/C/U
159     * @param int $offset Identifies the feature
160     * @param int|null $value The value to set/test (optional)
161     * @return string The symbol name for the given flag diacritic operation
162     */
163    private function flagDiacritic(
164        string $type, int $offset, ?int $value = null
165    ): string {
166        $str = "@$type." . $this->fdPrefix . "char$offset";
167        if ( $value !== null ) {
168            $str .= ".";
169            if ( $value >= 0 ) {
170                $str .= self::byteToHex( $value );
171            } else {
172                $str .= "UNK";
173            }
174        }
175        return $str . "@";
176    }
177
178    /**
179     * Add a flag diacritic edge between $from and $to.  By convention the
180     * flag diacritic is on both the upper and lower slots of the edge.
181     * @param State $from The source of the new edge
182     * @param State $to The destination of the new edge
183     * @param string $type The flag diacritic operator
184     * @param int $offset The flag diacritic feature
185     * @param int|null $value The flag diacritic value (optional)
186     */
187    private function addFdEdge(
188        State $from, State $to, string $type, int $offset, ?int $value = null
189    ): void {
190        $fdName = $this->flagDiacritic( $type, $offset, $value );
191        $from->addEdge( $fdName, $fdName, $to );
192    }
193
194    /**
195     * Add two flag diacritic edge between $from and $to.  By convention the
196     * flag diacritics are on both the upper and lower slots of the edge.
197     * @param State $from The source of the new edges
198     * @param State $to The destination of the new edges
199     * @param string $type1 The first flag diacritic operator
200     * @param int $offset1 The first flag diacritic feature
201     * @param int|null $value1 The first flag diacritic value (optional)
202     * @param string $type2 The second flag diacritic operator
203     * @param int $offset2 The second flag diacritic feature
204     * @param int|null $value2 The second flag diacritic value (optional)
205     */
206    private function addFdEdgePair(
207        State $from, State $to,
208        string $type1, int $offset1, ?int $value1,
209        string $type2, int $offset2, ?int $value2
210    ): void {
211        $n = $this->fst->newState();
212        $this->addFdEdge( $from, $n, $type1, $offset1, $value1 );
213        $this->addFdEdge( $n, $to, $type2, $offset2, $value2 );
214    }
215
216    /**
217     * Add edges from state $from corresponding to the prefix tree $tree,
218     * given the $lastMatch and the characters seen since then, $seen.
219     * @param State $from
220     * @param array &$tree
221     * @param ?string $lastMatch (null if we haven't seen a match)
222     * @param string $seen characters seen since last match
223     */
224    private function buildState( State $from, array &$tree, ?string $lastMatch, string $seen ): void {
225        $tree[self::STATE] = $from;
226        $index = $tree[self::INDEX];
227        $utf8state = $tree[self::UTF8STATE];
228        if ( $index < $this->maxLookahead ) {
229            $noBufState = $this->fst->newState();
230            $this->addFdEdge( $from, $noBufState, 'D', $index );
231        } else {
232            $noBufState = $from;
233        }
234        $noMatchState = $this->fst->newState();
235
236        if ( isset( $tree[self::END_OF_STRING] ) ) {
237            $lastMatch = $tree[self::END_OF_STRING];
238            $seen = '';
239        }
240        foreach ( $this->utf8alphabet( $utf8state ) as $c ) {
241            if ( isset( $tree[$c] ) ) {
242                $nextSeen = $seen . chr( $c );
243                $n = $this->fst->newState();
244                $noBufState->addEdge( self::byteToHex( $c ), MutableFST::EPSILON, $n );
245                if ( $index < $this->maxLookahead ) {
246                    $this->addFdEdge( $from, $n, 'R', $index, $c );
247                }
248                $nn = $this->fst->newState();
249                $this->addFdEdge( $n, $nn, 'P', strlen( $seen ), $c );
250                $this->buildState( $nn, $tree[$c], $lastMatch, $nextSeen );
251            } else {
252                Assert::invariant( $index !== 0,
253                                 "fake single-char matches should always exist" );
254
255                $n = $this->fst->newState();
256                $noBufState->addEdge( self::byteToHex( $c ), MutableFST::EPSILON, $n );
257                if ( $index < $this->maxLookahead ) {
258                    $this->addFdEdge( $from, $n, 'R', $index, $c );
259                }
260                Assert::invariant(
261                    strlen( $seen ) < $this->maxLookahead,
262                    "Max lookahead should always account for seen"
263                );
264                $this->addFdEdge( $n, $noMatchState, 'P', strlen( $seen ), $c );
265            }
266        }
267        // our first state must echo all continuation characters, since
268        // the anythingState transitions there and we don't know what
269        // utf8 state IDENTITY will leave us in. (The characters not in
270        // our alphabet could consist of 1-/2-/3-/4-byte sequences.)
271        if ( $index === 0 ) {
272            foreach ( self::utf8alphabet( 1/*continuation chars*/ ) as $c ) {
273                $nextSeen = $seen . chr( $c );
274                $n = $this->fst->newState();
275                $noBufState->addEdge( self::byteToHex( $c ), MutableFST::EPSILON, $n );
276                if ( $index < $this->maxLookahead ) {
277                    $this->addFdEdge( $from, $n, 'R', $index, $c );
278                }
279                $fakeTree = [];
280                $fakeTree[self::UTF8STATE] = 1;
281                $fakeTree[self::INDEX] = $index + 1;
282                $fakeTree[self::END_OF_STRING] = chr( $c );
283                $this->buildState( $n, $fakeTree, $lastMatch, $nextSeen );
284            }
285        }
286        // "anything else"
287        $anythingElse = $this->fst->newState();
288        $noBufState->addEdge( MutableFST::EPSILON, MutableFST::EPSILON, $anythingElse );
289        if ( $index !== 0 ) {
290            $this->addFdEdge( $from, $anythingElse, 'R', $index, -1 );
291        }
292        $this->addFdEdge( $anythingElse, $noMatchState, 'P', strlen( $seen ), -1 );
293        // Emit the last match
294        if ( $lastMatch !== null ) {
295            $noMatchState = $this->emit( $noMatchState, MutableFST::EPSILON, $lastMatch );
296        }
297        // Shift over queued input
298        for ( $i = $index + 1; true; $i++ ) {
299            $j = strlen( $seen ) + ( $i - $index );
300            $key = $i . ':' . $j;
301            if ( isset( $this->shiftCache[$key] ) ) {
302                $noMatchState->addEdge( MutableFST::EPSILON, MutableFST::EPSILON, $this->shiftCache[$key] );
303                return;
304            }
305            $this->shiftCache[$key] = $noMatchState;
306            if ( $i < $this->maxLookahead ) {
307                $n = $this->fst->newState();
308                $this->addFdEdge( $noMatchState, $n, 'D', $i );
309            } else {
310                $n = $noMatchState;
311            }
312            for ( $k = $j; $k < $i && $k < $this->maxLookahead; $k++ ) {
313                $nn = $this->fst->newState();
314                $this->addFdEdge( $n, $nn, 'C', $k );
315                $n = $nn;
316            }
317            $n->addEdge( MutableFST::EPSILON, MutableFST::EPSILON, $this->fst->getStartState() );
318            if ( !( $i < $this->maxLookahead ) ) {
319                break;
320            }
321            $n = $this->fst->newState();
322            $this->addFdEdgePair( $noMatchState, $n, 'R', $i, -1, 'P', $j, -1 );
323            foreach ( $this->alphabet as $c ) {
324                $this->addFdEdgePair( $noMatchState, $n, 'R', $i, $c, 'P', $j, $c );
325            }
326            $noMatchState = $n;
327        }
328    }
329
330    /**
331     * Chain states together from $fromState to emit $emitStr.
332     * @param State $fromState
333     * @param string $fromChar
334     * @param string $emitStr
335     * @return State the resulting state (after the string has been emitted)
336     */
337    private function emit( State $fromState, string $fromChar, string $emitStr ): State {
338        if ( strlen( $emitStr ) === 0 && $fromChar !== MutableFST::EPSILON ) {
339            $n = $this->fst->newState();
340            $fromState->addEdge( $fromChar, MutableFST::EPSILON, $n );
341            return $n;
342        }
343        for ( $i = 0; $i < strlen( $emitStr ); $i++ ) {
344            $c = ord( $emitStr[$i] );
345            $n = $this->fst->newState();
346            $fromState->addEdge( $fromChar, self::byteToHex( $c ), $n );
347            $fromState = $n;
348            $fromChar = MutableFST::EPSILON;
349        }
350        return $fromState;
351    }
352
353    /**
354     * Private helper function: convert a numeric byte to the string
355     * token we use in the FST.
356     * @param int $byte
357     * @return string Token
358     */
359    private static function byteToHex( int $byte ): string {
360        $s = strtoupper( dechex( $byte ) );
361        while ( strlen( $s ) < 2 ) {
362            $s = "0$s";
363        }
364        return $s;
365    }
366
367    /**
368     * Private helper function: convert a UTF-8 string byte-by-byte into
369     * an array of tokens.
370     * @param string $s
371     * @return string[]
372     */
373    private static function stringToTokens( string $s ): array {
374        $toks = [];
375        for ( $i = 0; $i < strlen( $s ); $i++ ) {
376            $toks[] = self::byteToHex( ord( $s[$i] ) );
377        }
378        return $toks;
379    }
380
381    /**
382     * Private helper function: convert an array of tokens into
383     * a UTF-8 string.
384     * @param string[] $toks
385     * @return string
386     */
387    private static function tokensToString( array $toks ): string {
388        $s = '';
389        foreach ( $toks as $token ) {
390            if ( strlen( $token ) === 2 ) {
391                $s .= chr( hexdec( $token ) );
392            } else {
393                // shouldn't happen, but handy for debugging if it does
394                $s .= $token;
395            }
396        }
397        return $s;
398    }
399
400    /**
401     * For testing: apply the resulting FST to the given input string.
402     * @param string $input
403     * @return string[] The possible outputs.
404     */
405    public function applyDown( string $input ): array {
406        // convert input to byte tokens
407        $result = $this->fst->applyDown( self::stringToTokens( $input ) );
408        return array_map( function ( $toks ) {
409            return self::tokensToString( $toks );
410        }, $result );
411    }
412
413    /**
414     * For testing: run the resulting FST "in reverse" against the given
415     * input string.
416     * @param string $input
417     * @return string[] The possible outputs.
418     */
419    public function applyUp( string $input ): array {
420        // convert input to byte tokens
421        $result = $this->fst->applyUp( self::stringToTokens( $input ) );
422        return array_map( function ( $toks ) {
423            return self::tokensToString( $toks );
424        }, $result );
425    }
426
427    /**
428     * Convert the given $replacementTable (strtr-style) to an FST.
429     * @param string $name
430     * @param array<string,string> $replacementTable
431     * @param string $fdPrefix Flag diacritic feature prefix, for uniqueness
432     */
433    public function __construct(
434        string $name, array $replacementTable, string $fdPrefix = ''
435    ) {
436        $this->fdPrefix = $fdPrefix;
437        $alphabet = [];
438        $longestWord = 0;
439        foreach ( $replacementTable as $from => $to ) {
440            $longestWord = max( $longestWord, strlen( $from ) );
441            self::addAlphabet( $alphabet, $from );
442            self::addAlphabet( $alphabet, $to );
443            self::addEntry( $this->prefixTree, $from, 0, 0, $to );
444        }
445        // fake one character matches!
446        foreach ( $alphabet as $sym => $value ) {
447            if ( $sym < 0x80 || $sym > 0xBF ) { // not continuation chars
448                self::addEntry( $this->prefixTree, chr( $sym ), 0, 0, chr( $sym ), true );
449            }
450        }
451        $this->maxLookahead = $longestWord - 1; // XXX could be shorter
452        $this->alphabet = array_keys( $alphabet );
453        sort( $this->alphabet, SORT_NUMERIC );
454        // ok, now we're ready to emit the FST
455        $this->fst = new MutableFST( array_map( function ( $n ) {
456            return self::byteToHex( $n );
457        }, $this->alphabet ) );
458        $anythingState = $this->fst->newState();
459        $anything2State = $this->fst->newState();
460        $anythingState->addEdge(
461            MutableFST::UNKNOWN, MutableFST::IDENTITY,
462            $anything2State
463        );
464        $this->addFdEdge( $this->fst->getStartState(), $anythingState, 'R', 0, -1 );
465        $this->addFdEdge( $anything2State, $this->fst->getStartState(), 'C', 0 );
466        // The anything state could also be the end of the string
467        // (which for modelling purposes we can think of as a special
468        // "EOF" token not in the alphabet)
469        // Important that there are no outgoing edges from the $endState!
470        $endState = $this->fst->newState();
471        $endState->isFinal = true;
472        $anythingState->addEdge(
473            MutableFST::EPSILON, MutableFST::EPSILON,
474            $endState
475        );
476
477        // Create states corresponding to prefix tree nodes
478        $this->buildState(
479            $this->fst->getStartState(), $this->prefixTree, null, ''
480        );
481        // ok, done!
482        $this->fst->optimize();
483    }
484
485    /**
486     * Write the FST to the given file handle in AT&T format.
487     * @param resource $handle
488     */
489    public function writeATT( $handle ): void {
490        $this->fst->writeATT( $handle );
491    }
492
493}