Skip to main content

fea_rs_ast/
contextual.rs

1use std::{fmt::Display, ops::Range};
2
3use fea_rs::{
4    Kind,
5    typed::{AstNode as _, GlyphOrClass, GposIgnore, GsubIgnore},
6};
7use smol_str::SmolStr;
8
9use crate::{
10    AsFea, GlyphContainer, LigatureSubstStatement, MultipleSubstStatement, SingleSubstStatement,
11    Statement,
12};
13
14pub(crate) fn backtrack(val: &fea_rs::Node) -> Vec<GlyphContainer> {
15    fea_rs::Node::iter_children(val)
16        .find(|c| c.kind() == Kind::BacktrackSequence)
17        .unwrap()
18        .as_node()
19        .unwrap()
20        .iter_children()
21        .filter_map(GlyphOrClass::cast)
22        .map(|goc| goc.into())
23        .collect()
24}
25pub(crate) fn lookahead(val: &fea_rs::Node) -> Vec<GlyphContainer> {
26    fea_rs::Node::iter_children(val)
27        .find(|c| c.kind() == Kind::LookaheadSequence)
28        .unwrap()
29        .as_node()
30        .unwrap()
31        .iter_children()
32        .take_while(|c| c.kind() != Kind::InlineSubNode)
33        .filter_map(GlyphOrClass::cast)
34        .map(|goc| goc.into())
35        .collect()
36}
37
38pub(crate) fn context_glyphs(val: &fea_rs::Node) -> Vec<GlyphContainer> {
39    let glyphnodes = fea_rs::Node::iter_children(val)
40        .find(|c| c.kind() == Kind::ContextSequence)
41        .unwrap()
42        .as_node()
43        .unwrap()
44        .iter_children()
45        .filter(|c| c.kind() == Kind::ContextGlyphNode)
46        .collect::<Vec<_>>();
47    glyphnodes
48        .iter()
49        .flat_map(|gn| {
50            gn.as_node()
51                .unwrap()
52                .iter_children()
53                .filter_map(GlyphOrClass::cast)
54        })
55        .map(|goc| goc.into())
56        .collect()
57}
58
59/// A chained contextual substitution statement, either GSUB or GPOS.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ChainedContextStatement<T: SubOrPos> {
62    /// The location of the statement in the source FEA.
63    pub location: Range<usize>,
64    /// The prefix (backtrack) glyphs
65    pub prefix: Vec<GlyphContainer>,
66    /// The suffix (lookahead) glyphs
67    pub suffix: Vec<GlyphContainer>,
68    /// Input glyphs
69    pub glyphs: Vec<GlyphContainer>,
70    /// Lookups to apply at each glyph position
71    pub lookups: Vec<Vec<SmolStr>>,
72    sub_or_pos: T,
73}
74
75impl<T: SubOrPos> ChainedContextStatement<T> {
76    /// Create a new chained contextual statement.
77    ///
78    /// ``prefix``, ``glyphs``, and ``suffix`` should be lists of
79    /// `glyph-containing objects`_ .
80    ///
81    /// ``lookups`` should be a list of elements representing what lookups
82    /// to apply at each glyph position. Each element should be a
83    /// :class:`LookupBlock` to apply a single chaining lookup at the given
84    /// position, a list of :class:`LookupBlock`\ s to apply multiple
85    /// lookups, or ``None`` to apply no lookup. The length of the outer
86    /// list should equal the length of ``glyphs``; the inner lists can be
87    /// of variable length
88    pub fn new(
89        glyphs: Vec<GlyphContainer>,
90        prefix: Vec<GlyphContainer>,
91        suffix: Vec<GlyphContainer>,
92        lookups: Vec<Vec<SmolStr>>,
93        location: Range<usize>,
94        sub_or_pos: T,
95    ) -> Self {
96        Self {
97            prefix,
98            suffix,
99            glyphs,
100            lookups,
101            location,
102            sub_or_pos,
103        }
104    }
105}
106
107impl<T: SubOrPos> PotentiallyContextualStatement for ChainedContextStatement<T> {
108    fn is_contextual(&self) -> bool {
109        true
110    }
111    fn prefix(&self) -> &[GlyphContainer] {
112        &self.prefix
113    }
114    fn suffix(&self) -> &[GlyphContainer] {
115        &self.suffix
116    }
117    fn force_chain(&self) -> bool {
118        true
119    }
120    fn format_begin(&self, _indent: &str) -> String {
121        format!("{} ", self.sub_or_pos)
122    }
123    fn format_contextual_parts(&self, indent: &str) -> Vec<String> {
124        let mut parts = Vec::new();
125        for (i, g) in self.glyphs.iter().enumerate() {
126            let mut s = format!("{}'", g.as_fea(indent));
127            if !self.lookups[i].is_empty() {
128                for lu in &self.lookups[i] {
129                    s.push_str(&format!(" lookup {}", lu));
130                }
131            }
132            parts.push(s);
133        }
134        parts
135    }
136    fn format_noncontextual_parts(&self, _indent: &str) -> Vec<String> {
137        unreachable!()
138    }
139}
140
141impl From<fea_rs::typed::Gsub6> for Statement {
142    fn from(val: fea_rs::typed::Gsub6) -> Self {
143        // There are four possible forms here:
144        // If it has an InlineSubNode child, it's a contextual form of one of
145        // the other gsub types. If not, and if there is a LookupRefNode, we return it as a normal ChainedContextualSubstStatement.
146        // To distinguish, we count the context and target glyphs.
147        // We need to find a LookupRefNode within the ContextGlyphNode within ContextSequence
148        let prefix = backtrack(val.node());
149        let suffix = lookahead(val.node());
150        let context_glyph_nodes = fea_rs::Node::iter_children(val.node())
151            .find(|c| c.kind() == Kind::ContextSequence)
152            .unwrap()
153            .as_node()
154            .unwrap()
155            .iter_children()
156            .filter(|c| c.kind() == Kind::ContextGlyphNode)
157            .collect::<Vec<_>>(); // Safe?
158
159        if let Some((context_glyphs, lookups)) = check_for_simple_contextual(&context_glyph_nodes) {
160            return Statement::ChainedContextSubst(ChainedContextStatement::new(
161                context_glyphs,
162                prefix,
163                suffix,
164                lookups,
165                val.node().range(),
166                Subst,
167            ));
168        }
169        // I'm assuming there's an InlineSubNode here, let's find it.
170        let Some(inline_sub) = val
171            .node()
172            .iter_children()
173            .find_map(fea_rs::typed::InlineSubRule::cast)
174        else {
175            panic!(
176                "No LookRefNode or InlineSubNode found in Gsub6, can't get here, fea-rs has failed me: {}",
177                val.node()
178                    .iter_tokens()
179                    .map(|t| t.text.clone())
180                    .collect::<Vec<_>>()
181                    .join("")
182            );
183        };
184        let target_glyphs = inline_sub
185            .node()
186            .iter_children()
187            .filter_map(GlyphOrClass::cast)
188            .map(|goc| goc.into())
189            .collect::<Vec<_>>();
190        let mut context_glyphs = context_glyphs(val.node());
191        if target_glyphs.len() > 1 {
192            return Statement::MultipleSubst(MultipleSubstStatement {
193                prefix,
194                suffix,
195                glyph: context_glyphs.remove(0),
196                replacement: target_glyphs,
197                location: val.node().range(),
198                force_chain: true,
199            });
200        }
201        if context_glyphs.len() == 1 && target_glyphs.len() == 1 {
202            return Statement::SingleSubst(SingleSubstStatement {
203                prefix,
204                suffix,
205                glyphs: context_glyphs,
206                replacement: target_glyphs,
207                location: val.node().range(),
208                force_chain: true,
209            });
210        }
211        if context_glyphs.len() > 1 && target_glyphs.len() == 1 {
212            // It's a LigatureSubst, we don't support contextual AlternateSubst
213            return Statement::LigatureSubst(LigatureSubstStatement {
214                prefix,
215                suffix,
216                glyphs: context_glyphs,
217                replacement: target_glyphs[0].clone(),
218                location: val.node().range(),
219                force_chain: true,
220            });
221        }
222        panic!("Don't know what this GSUB6 is supposed to be!")
223    }
224}
225
226fn check_for_simple_contextual(
227    context_glyph_nodes: &[&fea_rs::NodeOrToken],
228) -> Option<(Vec<GlyphContainer>, Vec<Vec<SmolStr>>)> {
229    // Do we see any LookupRefNode children within the context glyph nodes?
230    if context_glyph_nodes.iter().any(|cgn| {
231        cgn.as_node()
232            .unwrap()
233            .iter_children()
234            .any(|child| child.kind() == Kind::LookupRefNode)
235    }) {
236        // Within context_glyph_node we want to see a sequence of GlyphOrClass nodes,
237        // each of which may have zero or more LookupRefNode children.
238        let mut context_glyphs = Vec::new();
239        let mut lookups = Vec::new();
240        for context_glyph_node in context_glyph_nodes.iter() {
241            let glyph_node = context_glyph_node.as_node().unwrap();
242            for node in glyph_node.iter_children() {
243                if let Some(goc) = GlyphOrClass::cast(node) {
244                    context_glyphs.push(goc.into());
245                    lookups.push(vec![]);
246                } else if let Some(lookup_ref) = fea_rs::typed::LookupRef::cast(node)
247                    && let Some(last) = lookups.last_mut()
248                {
249                    last.push(SmolStr::new(
250                        &lookup_ref
251                            .node()
252                            .iter_tokens()
253                            .find(|t| t.kind == Kind::Ident)
254                            .unwrap()
255                            .text,
256                    ));
257                }
258            }
259        }
260        return Some((context_glyphs, lookups));
261    }
262    None
263}
264
265impl From<fea_rs::typed::Gpos8> for Statement {
266    fn from(val: fea_rs::typed::Gpos8) -> Self {
267        let prefix = backtrack(val.node());
268        let suffix = lookahead(val.node());
269        let context_glyph_nodes = fea_rs::Node::iter_children(val.node())
270            .find(|c| c.kind() == Kind::ContextSequence)
271            .unwrap()
272            .as_node()
273            .unwrap()
274            .iter_children()
275            .filter(|c| c.kind() == Kind::ContextGlyphNode)
276            .collect::<Vec<_>>(); // Safe?
277        if let Some((context_glyphs, lookups)) = check_for_simple_contextual(&context_glyph_nodes) {
278            return Statement::ChainedContextPos(ChainedContextStatement::new(
279                context_glyphs,
280                prefix,
281                suffix,
282                lookups,
283                val.node().range(),
284                Pos,
285            ));
286        }
287        // Each ContextGlyphNode should have a single GlyphOrClass child and a ValueRecord child
288        let mut context_glyphs = Vec::new();
289        for context_glyph_node in context_glyph_nodes.iter() {
290            let glyph_node = context_glyph_node.as_node().unwrap();
291            let glyph = glyph_node.iter_children().find_map(GlyphOrClass::cast);
292            let value_record_node = glyph_node
293                .iter_children()
294                .find_map(fea_rs::typed::ValueRecord::cast);
295            if let Some(goc) = glyph {
296                context_glyphs.push((goc.into(), value_record_node.map(|vr| vr.into())));
297            }
298        }
299        Statement::SinglePos(crate::gpos::SinglePosStatement::new(
300            prefix,
301            suffix,
302            context_glyphs,
303            true,
304            val.node().range(),
305        ))
306    }
307}
308
309/// Type marker that the statement is a positioning statement
310#[derive(Debug, Clone, PartialEq, Eq, Copy)]
311pub struct Pos;
312
313/// Type marker that the statement is a substitution statement
314#[derive(Debug, Clone, PartialEq, Eq, Copy)]
315pub struct Subst;
316/// A trait implemented by both Pos and Subst to allow generic handling
317pub trait SubOrPos: Display + Copy {}
318impl SubOrPos for Pos {}
319impl SubOrPos for Subst {}
320impl Display for Pos {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        write!(f, "pos")
323    }
324}
325impl Display for Subst {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        write!(f, "sub")
328    }
329}
330
331/// Either a GPOS or GSUB ignore statement.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct IgnoreStatement<T: SubOrPos> {
334    /// The location of the statement in the source FEA.
335    pub location: Range<usize>,
336    /// The chain contexts: a list of (prefix, glyphs, suffix) tuples.
337    pub chain_contexts: Vec<(
338        Vec<GlyphContainer>,
339        Vec<GlyphContainer>,
340        Vec<GlyphContainer>,
341    )>,
342    sub_or_pos: T,
343}
344
345impl<T: SubOrPos> IgnoreStatement<T> {
346    /// Creates a new IgnoreStatement.
347    pub fn new(
348        chain_contexts: Vec<(
349            Vec<GlyphContainer>,
350            Vec<GlyphContainer>,
351            Vec<GlyphContainer>,
352        )>,
353        location: Range<usize>,
354        sub_or_pos: T,
355    ) -> Self {
356        Self {
357            chain_contexts,
358            location,
359            sub_or_pos,
360        }
361    }
362}
363
364impl<T: SubOrPos> AsFea for IgnoreStatement<T> {
365    fn as_fea(&self, indent: &str) -> String {
366        let mut res = String::new();
367        res.push_str(&format!("ignore {} ", self.sub_or_pos));
368        let contexts_str: Vec<String> = self
369            .chain_contexts
370            .iter()
371            .map(|(prefix, glyphs, suffix)| {
372                let mut s = String::new();
373                if !prefix.is_empty() {
374                    let prefix_str: Vec<String> = prefix.iter().map(|g| g.as_fea(indent)).collect();
375                    s.push_str(&prefix_str.join(" ").to_string());
376                    s.push(' ');
377                }
378                let glyphs_str: Vec<String> =
379                    glyphs.iter().map(|g| g.as_fea(indent) + "'").collect();
380                s.push_str(&glyphs_str.join(" "));
381                if !suffix.is_empty() {
382                    s.push(' ');
383                    let suffix_str: Vec<String> = suffix.iter().map(|g| g.as_fea(indent)).collect();
384                    s.push_str(&suffix_str.join(" "));
385                }
386                s
387            })
388            .collect();
389        res.push_str(&contexts_str.join(", "));
390        res.push(';');
391        res
392    }
393}
394
395impl From<GsubIgnore> for IgnoreStatement<Subst> {
396    fn from(val: GsubIgnore) -> Self {
397        let mut chain_contexts = Vec::new();
398        for context in val.node().iter_children() {
399            if let Some(chain_context) = fea_rs::typed::IgnoreRule::cast(context) {
400                let prefix = backtrack(chain_context.node());
401                let suffix = lookahead(chain_context.node());
402                let glyphs = context_glyphs(chain_context.node());
403                chain_contexts.push((prefix, glyphs, suffix));
404            }
405        }
406        IgnoreStatement {
407            chain_contexts,
408            location: val.node().range(),
409            sub_or_pos: Subst,
410        }
411    }
412}
413
414impl From<GposIgnore> for IgnoreStatement<Pos> {
415    fn from(val: GposIgnore) -> Self {
416        let mut chain_contexts = Vec::new();
417        for context in val.node().iter_children() {
418            if let Some(chain_context) = fea_rs::typed::IgnoreRule::cast(context) {
419                let prefix = backtrack(chain_context.node());
420                let suffix = lookahead(chain_context.node());
421                let glyphs = context_glyphs(chain_context.node());
422                chain_contexts.push((prefix, glyphs, suffix));
423            }
424        }
425        IgnoreStatement {
426            chain_contexts,
427            location: val.node().range(),
428            sub_or_pos: Pos,
429        }
430    }
431}
432
433// There's a parallelism in a few statement types that might have a
434// prefix or a suffix, and the string joining for the FEA generation
435// is always a bit fiddly.
436// Put it here once and get it right for all.
437pub(crate) trait PotentiallyContextualStatement {
438    fn prefix(&self) -> &[GlyphContainer];
439    fn suffix(&self) -> &[GlyphContainer];
440    fn force_chain(&self) -> bool;
441    fn format_begin(&self, indent: &str) -> String;
442    fn format_contextual_parts(&self, indent: &str) -> Vec<String>;
443    fn format_noncontextual_parts(&self, indent: &str) -> Vec<String>;
444    fn format_end(&self, _indent: &str) -> String {
445        "".to_string()
446    }
447
448    fn is_contextual(&self) -> bool {
449        !self.prefix().is_empty() || !self.suffix().is_empty() || self.force_chain()
450    }
451}
452
453impl<T: PotentiallyContextualStatement> AsFea for T {
454    fn as_fea(&self, indent: &str) -> String {
455        let mut res = String::new();
456        res.push_str(&self.format_begin(indent));
457        if self.is_contextual() {
458            if !self.prefix().is_empty() {
459                let prefix_str: Vec<String> = self.prefix().iter().map(|g| g.as_fea("")).collect();
460                res.push_str(&prefix_str.join(" ").to_string());
461                res.push(' ');
462            }
463            res.push_str(&self.format_contextual_parts(indent).join(" "));
464            if !self.suffix().is_empty() {
465                let suffix_str: Vec<String> = self.suffix().iter().map(|g| g.as_fea("")).collect();
466                res.push_str(&format!(" {}", suffix_str.join(" ")));
467            }
468        } else {
469            res.push_str(self.format_noncontextual_parts(indent).join(" ").as_str());
470        }
471        res.push_str(&self.format_end(indent));
472        res.push(';');
473        res
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use crate::{GlyphClass, GlyphName};
480
481    use super::*;
482
483    #[test]
484    fn test_generate_chain_subst() {
485        let chain_subst = ChainedContextStatement::new(
486            vec![
487                GlyphContainer::GlyphName(GlyphName::new("x")),
488                GlyphContainer::GlyphName(GlyphName::new("y")),
489                GlyphContainer::GlyphName(GlyphName::new("z")),
490            ],
491            vec![GlyphContainer::GlyphClass(GlyphClass::new(
492                vec![
493                    GlyphContainer::GlyphName(GlyphName::new("a.smcp")),
494                    GlyphContainer::GlyphName(GlyphName::new("b.smcp")),
495                ],
496                0..0,
497            ))],
498            vec![],
499            vec![
500                vec![SmolStr::new("lookup1")],
501                vec![],
502                vec![SmolStr::new("lookup2"), SmolStr::new("lookup3")],
503            ],
504            0..0,
505            Subst,
506        );
507        assert_eq!(
508            chain_subst.as_fea(""),
509            "sub [a.smcp b.smcp] x' lookup lookup1 y' z' lookup lookup2 lookup lookup3;"
510        );
511    }
512
513    #[test]
514    fn chain_context_subst_from_gsub6() {
515        let fea =
516            r#"feature foo { sub x [a b] c' lookup test d' e' lookup bar lookup quux f; } foo;"#;
517        let (parsed, _) = fea_rs::parse::parse_string(fea);
518        let gsub6 = parsed
519            .root()
520            .iter_children()
521            .find_map(fea_rs::typed::Feature::cast)
522            .and_then(|feature| {
523                feature
524                    .node()
525                    .iter_children()
526                    .find_map(fea_rs::typed::Gsub6::cast)
527            })
528            .unwrap();
529        let statement = Statement::from(gsub6);
530        let Statement::ChainedContextSubst(chain_subst) = statement else {
531            panic!("Expected ChainedContextSubstStatement, got {:?}", statement);
532        };
533        assert_eq!(chain_subst.prefix.len(), 2);
534        assert_eq!(chain_subst.suffix.len(), 1);
535        assert_eq!(chain_subst.glyphs.len(), 3);
536        assert_eq!(
537            chain_subst.lookups,
538            vec![
539                vec![SmolStr::new("test")],
540                vec![],
541                vec![SmolStr::new("bar"), SmolStr::new("quux")]
542            ]
543        );
544    }
545
546    #[test]
547    fn chain_context_subst_round_trip() {
548        let fea =
549            r#"feature foo { sub [a b] x' lookup test y' z' lookup bar lookup quux f; } foo;"#;
550        let (parsed, _) = fea_rs::parse::parse_string(fea);
551        let gsub6 = parsed
552            .root()
553            .iter_children()
554            .find_map(fea_rs::typed::Feature::cast)
555            .and_then(|feature| {
556                feature
557                    .node()
558                    .iter_children()
559                    .find_map(fea_rs::typed::Gsub6::cast)
560            })
561            .unwrap();
562        let Statement::ChainedContextSubst(chain_subst) = Statement::from(gsub6) else {
563            panic!("Expected ChainedContextSubstStatement");
564        };
565        let fea_generated = chain_subst.as_fea("");
566        assert_eq!(
567            fea_generated,
568            "sub [a b] x' lookup test y' z' lookup bar lookup quux f;"
569        );
570    }
571
572    #[test]
573    fn generate_ignore_subst() {
574        let ignore_subst = IgnoreStatement::new(
575            vec![
576                (
577                    vec![GlyphContainer::GlyphName(GlyphName::new("a"))],
578                    vec![GlyphContainer::GlyphName(GlyphName::new("x"))],
579                    vec![GlyphContainer::GlyphName(GlyphName::new("b"))],
580                ),
581                (
582                    vec![],
583                    vec![GlyphContainer::GlyphName(GlyphName::new("y"))],
584                    vec![],
585                ),
586            ],
587            0..0,
588            Subst,
589        );
590        assert_eq!(ignore_subst.as_fea(""), "ignore sub a x' b, y';");
591    }
592
593    #[test]
594    fn test_roundtrip_ignore_subst() {
595        let fea = "feature foo { ignore sub a x' b, y'; } foo;";
596        let (parsed, _) = fea_rs::parse::parse_string(fea);
597        let gsub_ignore = parsed
598            .root()
599            .iter_children()
600            .find_map(fea_rs::typed::Feature::cast)
601            .and_then(|feature| {
602                feature
603                    .node()
604                    .iter_children()
605                    .find_map(fea_rs::typed::GsubIgnore::cast)
606            })
607            .unwrap();
608        let ignore_subst = IgnoreStatement::<Subst>::from(gsub_ignore);
609        assert_eq!(ignore_subst.as_fea(""), "ignore sub a x' b, y';");
610    }
611
612    #[test]
613    fn test_roundtrip_gsub1_contextual() {
614        let fea = "feature smcp { sub x a' by a.smcp; } smcp;";
615        let (parsed, _) = fea_rs::parse::parse_string(fea);
616        let gsub6 = parsed
617            .root()
618            .iter_children()
619            .find_map(fea_rs::typed::Feature::cast)
620            .and_then(|feature| {
621                feature
622                    .node()
623                    .iter_children()
624                    .find_map(fea_rs::typed::Gsub6::cast)
625            })
626            .unwrap();
627        let single_subst = Statement::from(gsub6);
628        assert!(
629            matches!(
630                single_subst,
631                Statement::SingleSubst(SingleSubstStatement { .. })
632            ),
633            "Expected SingleSubstStatement, got {:?}",
634            single_subst
635        );
636        assert_eq!(single_subst.as_fea(""), "sub x a' by a.smcp;");
637    }
638
639    #[test]
640    fn test_roundtrip_gsub2_contextual() {
641        let fea = "feature smcp { sub x a' by a b; } smcp;";
642        let (parsed, _) = fea_rs::parse::parse_string(fea);
643        let gsub6 = parsed
644            .root()
645            .iter_children()
646            .find_map(fea_rs::typed::Feature::cast)
647            .and_then(|feature| {
648                feature
649                    .node()
650                    .iter_children()
651                    .find_map(fea_rs::typed::Gsub6::cast)
652            })
653            .unwrap();
654        let mult_subst = Statement::from(gsub6);
655        assert!(
656            matches!(
657                mult_subst,
658                Statement::MultipleSubst(MultipleSubstStatement { .. })
659            ),
660            "Expected MultipleSubstStatement, got {:?}",
661            mult_subst
662        );
663        assert_eq!(mult_subst.as_fea(""), "sub x a' by a b;");
664    }
665
666    #[test]
667    fn test_roundtrip_gsub4_contextual() {
668        let fea = "feature smcp { sub x a' b' by a; } smcp;";
669        let (parsed, _) = fea_rs::parse::parse_string(fea);
670        let gsub6 = parsed
671            .root()
672            .iter_children()
673            .find_map(fea_rs::typed::Feature::cast)
674            .and_then(|feature| {
675                feature
676                    .node()
677                    .iter_children()
678                    .find_map(fea_rs::typed::Gsub6::cast)
679            })
680            .unwrap();
681        let liga_subst = Statement::from(gsub6);
682        assert!(
683            matches!(
684                liga_subst,
685                Statement::LigatureSubst(LigatureSubstStatement { .. })
686            ),
687            "Expected LigatureSubstStatement, got {:?}",
688            liga_subst
689        );
690        assert_eq!(
691            liga_subst.as_fea(""),
692            "sub x a' b' by a;",
693            "{:#?}",
694            liga_subst
695        );
696    }
697}