Skip to main content

fea_rs_ast/
lib.rs

1#![deny(missing_docs)]
2//! # fea-rs-ast
3//!
4//! A Rust port of Python's [`fontTools.feaLib.ast`](https://fonttools.readthedocs.io/en/latest/feaLib/ast.html)
5//! library, providing a fontTools-compatible AST (Abstract Syntax Tree) for OpenType Feature Files.
6//!
7//! This crate builds on top of the [`fea-rs`](https://github.com/googlefonts/fontc/tree/main/fea-rs)
8//! parser, providing a higher-level, more ergonomic interface that matches the familiar fontTools API
9//! while leveraging Rust's type safety and performance.
10//!
11//! ## Overview
12//!
13//! OpenType Feature Files (.fea) define advanced typographic features for fonts using a
14//! domain-specific language. This crate provides:
15//!
16//! - **Parsing**: Load and parse feature files into a structured AST using `fea-rs`.
17//! - **Construction**: Programmatically build feature file structures
18//! - **Serialization**: Convert AST back to valid feature file syntax via the [`AsFea`] trait
19//! - **Transformation**: Modify AST using the visitor pattern
20//!
21//! ## Architecture
22//!
23//! The crate provides two main statement enums:
24//!
25//! - [`Statement`]: All possible statements in a feature file, regardless of context
26//! - [`ToplevelItem`]: Only statements valid at the top level of a feature file
27//!
28//! Both implement the [`AsFea`] trait for serialization back to .fea syntax.
29//!
30//! ## Examples
31//!
32//! ### Loading an Existing Feature File
33//!
34//! Parse a feature file from a string:
35//!
36//! ```rust
37//! use fea_rs_ast::{FeatureFile, AsFea};
38//!
39//! let fea_code = r#"
40//!     languagesystem DFLT dflt;
41//!     
42//!     feature smcp {
43//!         sub a by a.smcp;
44//!         sub b by b.smcp;
45//!     } smcp;
46//! "#;
47//!
48//! // Simple parsing without glyph name resolution
49//! let feature_file = FeatureFile::try_from(fea_code).unwrap();
50//!
51//! // Or with full resolution support
52//! let feature_file = FeatureFile::new_from_fea(
53//!     fea_code,
54//!     Some(&["a", "a.smcp", "b", "b.smcp"]), // Glyph names
55//!     None::<&str>, // Project root for includes
56//! ).unwrap();
57//!
58//! // Serialize back to .fea syntax
59//! let output = feature_file.as_fea("");
60//! println!("{}", output);
61//! ```
62//!
63//! ### Constructing New Statements
64//!
65//! Build feature file structures programmatically:
66//!
67//! ```rust
68//! use fea_rs_ast::*;
69//!
70//! // Create a glyph class definition
71//! let lowercase = GlyphClassDefinition::new(
72//!     "lowercase".to_string(),
73//!     GlyphClass::new(vec![
74//!         GlyphContainer::GlyphName(GlyphName::new("a")),
75//!         GlyphContainer::GlyphName(GlyphName::new("b")),
76//!         GlyphContainer::GlyphName(GlyphName::new("c")),
77//!     ], 0..0),
78//!     0..0, // location range
79//! );
80//!
81//! // Create a single substitution statement
82//! let subst = SingleSubstStatement::new(
83//!     vec![GlyphContainer::GlyphName(GlyphName::new("a"))],
84//!     vec![GlyphContainer::GlyphName(GlyphName::new("a.smcp"))],
85//!     vec![], // prefix
86//!     vec![], // suffix
87//!     0..0,   // location
88//!     false,  // force_chain
89//! );
90//!
91//! // Create a feature block
92//! let feature = FeatureBlock::new(
93//!     "smcp".into(),
94//!     vec![Statement::SingleSubst(subst)],
95//!     false, // use_extension
96//!     0..0,  // location
97//! );
98//!
99//! // Build the complete feature file
100//! let feature_file = FeatureFile::new(vec![
101//!     ToplevelItem::GlyphClassDefinition(lowercase),
102//!     ToplevelItem::Feature(feature),
103//! ]);
104//!
105//! // Serialize to .fea syntax
106//! let output = feature_file.as_fea("");
107//! assert!(output.contains("@lowercase = [a b c];"));
108//! assert!(output.contains("feature smcp"));
109//! assert!(output.contains("sub a by a.smcp;"));
110//! ```
111//!
112//! ### Using the Visitor Pattern
113//!
114//! Transform AST structures by implementing the [`LayoutVisitor`] trait:
115//!
116//! ```rust
117//! use fea_rs_ast::*;
118//!
119//! // Create a visitor that renames all features
120//! struct FeatureRenamer {
121//!     old_name: String,
122//!     new_name: String,
123//! }
124//!
125//! impl LayoutVisitor for FeatureRenamer {
126//!     fn visit_statement(&mut self, statement: &mut Statement) -> bool {
127//!         match statement {
128//!             Statement::FeatureBlock(feature) => {
129//!                 if feature.name == self.old_name.as_str() {
130//!                     feature.name = self.new_name.as_str().into();
131//!                 }
132//!             }
133//!             _ => {}
134//!         }
135//!         true // Continue visiting
136//!     }
137//! }
138//!
139//! // Use the visitor
140//! let fea_code = r#"
141//!     feature liga {
142//!         sub f i by fi;
143//!     } liga;
144//! "#;
145//!
146//! let mut feature_file = FeatureFile::try_from(fea_code).unwrap();
147//! let mut visitor = FeatureRenamer {
148//!     old_name: "liga".to_string(),
149//!     new_name: "dlig".to_string(),
150//! };
151//!
152//! visitor.visit(&mut feature_file).unwrap();
153//!
154//! let output = feature_file.as_fea("");
155//! assert!(output.contains("feature dlig"));
156//! ```
157//!
158//! ### More Complex Visitor: Glyph Name Substitution
159//!
160//! ```rust
161//! use fea_rs_ast::*;
162//! use std::collections::HashMap;
163//!
164//! // Visitor that replaces glyph names throughout the AST
165//! struct GlyphNameReplacer {
166//!     replacements: HashMap<String, String>,
167//! }
168//!
169//! impl LayoutVisitor for GlyphNameReplacer {
170//!     fn visit_statement(&mut self, statement: &mut Statement) -> bool {
171//!         // Replace glyph names in various statement types
172//!         match statement {
173//!             Statement::SingleSubst(subst) => {
174//!                 for container in &mut subst.glyphs {
175//!                     self.replace_in_container(container);
176//!                 }
177//!                 for container in &mut subst.replacement {
178//!                     self.replace_in_container(container);
179//!                 }
180//!             }
181//!             Statement::GlyphClassDefinition(gcd) => {
182//!                 for container in &mut gcd.glyphs.glyphs {
183//!                     self.replace_in_container(container);
184//!                 }
185//!             }
186//!             _ => {}
187//!         }
188//!         true
189//!     }
190//! }
191//!
192//! impl GlyphNameReplacer {
193//!     fn replace_in_container(&self, container: &mut GlyphContainer) {
194//!         match container {
195//!             GlyphContainer::GlyphName(gn) => {
196//!                 if let Some(new_name) = self.replacements.get(gn.name.as_str()) {
197//!                     gn.name = new_name.as_str().into();
198//!                 }
199//!             }
200//!             GlyphContainer::GlyphClass(gc) => {
201//!                 for glyph_container in &mut gc.glyphs {
202//!                     self.replace_in_container(glyph_container);
203//!                 }
204//!             }
205//!             _ => {}
206//!         }
207//!     }
208//! }
209//! ```
210//!
211//! ## Feature Coverage
212//!
213//! This crate supports most OpenType feature file constructs:
214//!
215//! - **GSUB**: Single, Multiple, Alternate, Ligature, Contextual, and Reverse Chaining substitutions
216//! - **GPOS**: Single, Pair, Cursive, Mark-to-Base, Mark-to-Ligature, and Mark-to-Mark positioning
217//! - **Tables**: GDEF, BASE, head, hhea, name, OS/2, STAT, vhea
218//! - **Contextual Rules**: Chaining context and ignore statements
219//! - **Variable Fonts**: Conditionsets and variation blocks
220//! - **Lookups**: Lookup blocks with flags and references
221//! - **Features**: Feature blocks with useExtension
222//!
223//! Features which fea-rs parses which this crate does not currently support:
224//!
225//! - Glyphs number variables in value records
226//! - CID-keyed glyph names
227//!
228//! ## Compatibility
229//!
230//! The API closely mirrors fontTools' Python API where practical, making it easier to port
231//! existing Python code to Rust. Key differences:
232//!
233//! - Rust's type system provides compile-time guarantees about statement validity
234//! - The [`Statement`] enum distinguishes between all possible statements
235//! - The [`ToplevelItem`] enum ensures only valid top-level constructs
236//! - Location tracking uses byte ranges (`Range<usize>`) instead of line/column numbers
237//!
238//! ## Re-exports
239//!
240//! This crate re-exports the underlying [`fea_rs`] parser for advanced use cases where
241//! direct access to the parse tree is needed.
242
243use std::{
244    ops::Range,
245    path::{Path, PathBuf},
246    sync::Arc,
247};
248mod base;
249mod contextual;
250mod dummyresolver;
251mod error;
252mod gdef;
253mod glyphcontainers;
254mod gpos;
255mod gsub;
256mod miscellenea;
257mod name;
258mod os2;
259mod stat;
260mod tables;
261mod values;
262mod visitor;
263pub use contextual::*;
264pub use error::Error;
265pub use fea_rs;
266use fea_rs::{GlyphMap, NodeOrToken, ParseTree, parse::FileSystemResolver, typed::AstNode as _};
267pub use gdef::*;
268pub use glyphcontainers::*;
269pub use gpos::*;
270pub use gsub::*;
271pub use miscellenea::*;
272pub use name::*;
273use smol_str::SmolStr;
274pub use tables::*;
275pub use values::*;
276pub use visitor::LayoutVisitor;
277
278use crate::{base::Base, os2::Os2};
279
280pub(crate) const SHIFT: &str = "    ";
281
282/// Trait for converting AST nodes back to feature file syntax.
283pub trait AsFea {
284    /// Convert the AST node to feature file syntax with the given indentation.
285    fn as_fea(&self, indent: &str) -> String;
286}
287
288// All possible statements in a feature file need to go
289// here, regardless of context, because we need to be able to
290// treat them as a heterogeneous collection when we do visiting etc.
291// We split them up by context in later enums.
292/// An AST node representing a single statement in a feature file.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum Statement {
295    // GSUB statements
296    /// A single substitution (GSUB type 1) statement: `sub a by b;`
297    SingleSubst(SingleSubstStatement),
298    /// A multiple substitution (GSUB type 2) statement: `sub a by b c;`
299    MultipleSubst(MultipleSubstStatement),
300    /// An alternate substitution (GSUB type 3) statement: `sub a from [b c d];`
301    AlternateSubst(AlternateSubstStatement),
302    /// A ligature substitution (GSUB type 4) statement: `sub a b by c;`
303    LigatureSubst(LigatureSubstStatement),
304    /// A reverse chaining contextual single substitution (GSUB type 8) statement
305    ReverseChainSubst(ReverseChainSingleSubstStatement),
306    /// A chaining contextual substitution (GSUB type 6) statement: `sub a' lookup foo b;`
307    ChainedContextSubst(ChainedContextStatement<Subst>),
308    /// An ignore substitution rule: `ignore sub a b;`
309    IgnoreSubst(IgnoreStatement<Subst>),
310    // GPOS
311    /// A single adjustment positioning (GPOS type 1) statement: `pos a <10 0 20 0>;`
312    SinglePos(SinglePosStatement),
313    /// A pair adjustment positioning (GPOS type 2) statement: `pos a b <10 0 20 0>;`
314    PairPos(PairPosStatement),
315    /// A cursive attachment positioning (GPOS type 3) statement
316    CursivePos(CursivePosStatement),
317    /// A mark-to-base attachment positioning (GPOS type 4) statement
318    MarkBasePos(MarkBasePosStatement),
319    /// A mark-to-ligature attachment positioning (GPOS type 5) statement
320    MarkLigPos(MarkLigPosStatement),
321    /// A mark-to-mark attachment positioning (GPOS type 6) statement
322    MarkMarkPos(MarkMarkPosStatement),
323    /// A chaining contextual positioning (GPOS type 8) statement: `pos a' lookup foo b;`
324    ChainedContextPos(ChainedContextStatement<Pos>),
325    /// An ignore positioning rule: `ignore pos a b;`
326    IgnorePos(IgnoreStatement<Pos>),
327    // Miscellenea
328    /// An anchor definition: `anchorDef 100 200 contourpoint 5 MyAnchor;`
329    AnchorDefinition(AnchorDefinition),
330    /// A mark class definition: `markClass a <anchor 100 200> @TOP_MARKS;`
331    MarkClassDefinition(MarkClassDefinition),
332    /// A comment in the feature file: `# This is a comment`
333    Comment(Comment),
334    /// A feature name statement within a `featureNames` block
335    FeatureNameStatement(NameRecord),
336    /// A font revision statement: `FontRevision 1.000;`
337    FontRevision(FontRevisionStatement),
338    /// A feature reference statement: `feature liga;`
339    FeatureReference(FeatureReferenceStatement),
340    /// A glyph class definition: `@lowercase = [a b c];`
341    GlyphClassDefinition(GlyphClassDefinition),
342    /// A language statement: `language dflt;`
343    Language(LanguageStatement),
344    /// A language system statement: `languagesystem DFLT dflt;`
345    LanguageSystem(LanguageSystemStatement),
346    /// A lookup flag statement: `lookupflag RightToLeft;`
347    LookupFlag(LookupFlagStatement),
348    /// A lookup reference statement: `lookup MyLookup;`
349    LookupReference(LookupReferenceStatement),
350    /// Size feature parameters: `parameters 10.0 0;`
351    SizeParameters(SizeParameters),
352    /// A size menu name statement: `sizemenuname 3 1 0x409 "Small";`
353    SizeMenuName(NameRecord),
354    /// A subtable statement: `subtable;`
355    Subtable(SubtableStatement),
356    /// A script statement: `script latn;`
357    Script(ScriptStatement),
358    /// A value record definition: `valueRecordDef 10 MyValue;`
359    ValueRecordDefinition(ValueRecordDefinition),
360    /// A condition set for variable fonts: `conditionset heavy { wght 700 900; } heavy;`
361    ConditionSet(ConditionSet),
362    /// A variation block for variable fonts: `variation rvrn heavy { ... } rvrn;`
363    VariationBlock(VariationBlock),
364    // Tables and blocks
365    /// A BASE table definition: `table BASE { ... } BASE;`
366    Base(Table<Base>),
367    /// A GDEF table definition: `table GDEF { ... } GDEF;`
368    Gdef(Table<Gdef>),
369    /// A head table definition: `table head { ... } head;`
370    Head(Table<Head>),
371    /// An hhea table definition: `table hhea { ... } hhea;`
372    Hhea(Table<Hhea>),
373    /// A name table definition: `table name { ... } name;`
374    Name(Table<Name>),
375    /// An OS/2 table definition: `table OS/2 { ... } OS/2;`
376    Os2(Table<Os2>),
377    /// A STAT table definition: `table STAT { ... } STAT;`
378    Stat(Table<Stat>),
379    /// A vhea table definition: `table vhea { ... } vhea;`
380    Vhea(Table<Vhea>),
381    /// A feature block: `feature liga { ... } liga;`
382    FeatureBlock(FeatureBlock),
383    /// A lookup block: `lookup MyLookup { ... } MyLookup;`
384    LookupBlock(LookupBlock),
385    /// A nested block (e.g., `featureNames { ... };`)
386    NestedBlock(NestedBlock),
387    // GDEF-related statements
388    /// A GDEF Attach statement: `Attach a 1 2 3;`
389    GdefAttach(AttachStatement),
390    /// A GDEF GlyphClassDef statement: `GlyphClassDef [a b], [c d], , [e f];`
391    GdefClassDef(GlyphClassDefStatement),
392    /// A GDEF LigatureCaretByIndex statement: `LigatureCaret a 1 2;`
393    GdefLigatureCaretByIndex(LigatureCaretByIndexStatement),
394    /// A GDEF LigatureCaretByPos statement: `LigatureCaretByPos a 100 200;`
395    GdefLigatureCaretByPos(LigatureCaretByPosStatement),
396}
397impl AsFea for Statement {
398    fn as_fea(&self, indent: &str) -> String {
399        match self {
400            // GSUB
401            Statement::SingleSubst(ss) => ss.as_fea(indent),
402            Statement::MultipleSubst(ms) => ms.as_fea(indent),
403            Statement::AlternateSubst(alt) => alt.as_fea(indent),
404            Statement::LigatureSubst(ls) => ls.as_fea(indent),
405            Statement::ChainedContextSubst(ccs) => ccs.as_fea(indent),
406            Statement::IgnoreSubst(is) => is.as_fea(indent),
407            Statement::ReverseChainSubst(rss) => rss.as_fea(indent),
408            // GPOS
409            Statement::SinglePos(sp) => sp.as_fea(indent),
410            Statement::PairPos(pp) => pp.as_fea(indent),
411            Statement::CursivePos(cp) => cp.as_fea(indent),
412            Statement::MarkBasePos(mbp) => mbp.as_fea(indent),
413            Statement::MarkLigPos(mlp) => mlp.as_fea(indent),
414            Statement::MarkMarkPos(mmp) => mmp.as_fea(indent),
415            Statement::ChainedContextPos(ccs) => ccs.as_fea(indent),
416            Statement::IgnorePos(ip) => ip.as_fea(indent),
417            // Miscellenea
418            Statement::AnchorDefinition(ad) => ad.as_fea(indent),
419            Statement::Comment(c) => c.as_fea(indent),
420            Statement::FeatureReference(fr) => fr.as_fea(indent),
421            Statement::FeatureNameStatement(fr) => fr.as_fea(indent),
422            Statement::FontRevision(fr) => fr.as_fea(indent),
423            Statement::GlyphClassDefinition(gcd) => gcd.as_fea(indent),
424            Statement::Language(ls) => ls.as_fea(indent),
425            Statement::LanguageSystem(ls) => ls.as_fea(indent),
426            Statement::LookupFlag(lf) => lf.as_fea(indent),
427            Statement::LookupReference(lr) => lr.as_fea(indent),
428            Statement::MarkClassDefinition(mc) => mc.as_fea(indent),
429            Statement::Script(sc) => sc.as_fea(indent),
430            Statement::SizeMenuName(sm) => sm.as_fea(indent),
431            Statement::SizeParameters(sp) => sp.as_fea(indent),
432            Statement::Subtable(st) => st.as_fea(indent),
433            Statement::ValueRecordDefinition(vrd) => vrd.as_fea(indent),
434            Statement::ConditionSet(cs) => cs.as_fea(indent),
435            Statement::VariationBlock(vb) => vb.as_fea(indent),
436            // GDEF-related statements
437            Statement::GdefAttach(at) => at.as_fea(indent),
438            Statement::GdefClassDef(gcd) => gcd.as_fea(indent),
439            Statement::GdefLigatureCaretByIndex(lc) => lc.as_fea(indent),
440            Statement::GdefLigatureCaretByPos(lc) => lc.as_fea(indent),
441            // Tables and blocks
442            Statement::Base(base) => base.as_fea(indent),
443            Statement::Gdef(gdef) => gdef.as_fea(indent),
444            Statement::Head(head) => head.as_fea(indent),
445            Statement::Hhea(hhea) => hhea.as_fea(indent),
446            Statement::Name(name) => name.as_fea(indent),
447            Statement::Os2(os2) => os2.as_fea(indent),
448            Statement::Stat(stat) => stat.as_fea(indent),
449            Statement::Vhea(vhea) => vhea.as_fea(indent),
450            Statement::FeatureBlock(fb) => fb.as_fea(indent),
451            Statement::LookupBlock(lb) => lb.as_fea(indent),
452            Statement::NestedBlock(nb) => nb.as_fea(indent),
453        }
454    }
455}
456
457fn to_statement(child: &NodeOrToken) -> Option<Statement> {
458    if child.kind() == fea_rs::Kind::Comment {
459        return Some(Statement::Comment(Comment::from(
460            child.token_text().unwrap(),
461        )));
462    } else if child.kind() == fea_rs::Kind::SubtableNode {
463        return Some(Statement::Subtable(SubtableStatement::new()));
464    }
465    #[allow(clippy::manual_map)]
466    // GSUB
467    if let Some(gsub1) = fea_rs::typed::Gsub1::cast(child) {
468        Some(Statement::SingleSubst(gsub1.into()))
469    } else if let Some(gsub2) = fea_rs::typed::Gsub2::cast(child) {
470        Some(Statement::MultipleSubst(gsub2.into()))
471    } else if let Some(gsub3) = fea_rs::typed::Gsub3::cast(child) {
472        Some(Statement::AlternateSubst(gsub3.into()))
473    } else if let Some(gsub4) = fea_rs::typed::Gsub4::cast(child) {
474        Some(Statement::LigatureSubst(gsub4.into()))
475    } else if let Some(gsub6) = fea_rs::typed::Gsub6::cast(child) {
476        Some(gsub6.into())
477    } else if let Some(rss) = fea_rs::typed::Gsub8::cast(child) {
478        Some(Statement::ReverseChainSubst(rss.into()))
479    } else if let Some(gsig) = fea_rs::typed::GsubIgnore::cast(child) {
480        Some(Statement::IgnoreSubst(gsig.into()))
481        // GPOS
482    } else if let Some(gpos1) = fea_rs::typed::Gpos1::cast(child) {
483        Some(Statement::SinglePos(gpos1.into()))
484    } else if let Some(gpos2) = fea_rs::typed::Gpos2::cast(child) {
485        Some(Statement::PairPos(gpos2.into()))
486    } else if let Some(gpos3) = fea_rs::typed::Gpos3::cast(child) {
487        Some(Statement::CursivePos(gpos3.into()))
488    } else if let Some(gpos4) = fea_rs::typed::Gpos4::cast(child) {
489        Some(Statement::MarkBasePos(gpos4.into()))
490    } else if let Some(gpos5) = fea_rs::typed::Gpos5::cast(child) {
491        Some(Statement::MarkLigPos(gpos5.into()))
492    } else if let Some(gpos6) = fea_rs::typed::Gpos6::cast(child) {
493        Some(Statement::MarkMarkPos(gpos6.into()))
494    } else if let Some(gpos8) = fea_rs::typed::Gpos8::cast(child) {
495        Some(gpos8.into())
496    } else if let Some(gpig) = fea_rs::typed::GposIgnore::cast(child) {
497        Some(Statement::IgnorePos(gpig.into()))
498    // Miscellenea
499    } else if let Some(ad) = fea_rs::typed::AnchorDef::cast(child) {
500        Some(Statement::AnchorDefinition(ad.into()))
501    } else if let Some(at) = fea_rs::typed::GdefAttach::cast(child) {
502        Some(Statement::GdefAttach(at.into()))
503    } else if let Some(gcd) = fea_rs::typed::GdefClassDef::cast(child) {
504        Some(Statement::GdefClassDef(gcd.into()))
505    } else if let Some(lc) = fea_rs::typed::GdefLigatureCaret::cast(child) {
506        // Check if it's by position or by index based on the first keyword
507        let is_by_pos = lc
508            .iter()
509            .next()
510            .map(|t| t.kind() == fea_rs::Kind::LigatureCaretByPosKw)
511            .unwrap_or(false);
512        if is_by_pos {
513            Some(Statement::GdefLigatureCaretByPos(lc.into()))
514        } else {
515            Some(Statement::GdefLigatureCaretByIndex(lc.into()))
516        }
517    } else if let Some(fr) = fea_rs::typed::FeatureRef::cast(child) {
518        Some(Statement::FeatureReference(fr.into()))
519    } else if let Some(fr) = fea_rs::typed::HeadFontRevision::cast(child) {
520        Some(Statement::FontRevision(fr.into()))
521    } else if let Some(gcd) = fea_rs::typed::GlyphClassDef::cast(child) {
522        Some(Statement::GlyphClassDefinition(gcd.into()))
523    } else if let Some(lang) = fea_rs::typed::Language::cast(child) {
524        Some(Statement::Language(lang.into()))
525    } else if let Some(langsys) = fea_rs::typed::LanguageSystem::cast(child) {
526        Some(Statement::LanguageSystem(langsys.into()))
527    } else if let Some(lookupflag) = fea_rs::typed::LookupFlag::cast(child) {
528        Some(Statement::LookupFlag(lookupflag.into()))
529    } else if let Some(lookupref) = fea_rs::typed::LookupRef::cast(child) {
530        Some(Statement::LookupReference(lookupref.into()))
531    } else if let Some(mcd) = fea_rs::typed::MarkClassDef::cast(child) {
532        Some(Statement::MarkClassDefinition(mcd.into()))
533    } else if let Some(script) = fea_rs::typed::Script::cast(child) {
534        Some(Statement::Script(script.into()))
535    } else if let Some(menuname) = fea_rs::typed::SizeMenuName::cast(child) {
536        Some(Statement::SizeMenuName(menuname.into()))
537    } else if let Some(sizeparams) = fea_rs::typed::Parameters::cast(child) {
538        Some(Statement::SizeParameters(sizeparams.into()))
539    } else if let Some(featurenames) = fea_rs::typed::FeatureNames::cast(child) {
540        Some(Statement::NestedBlock(featurenames.into()))
541    // Doesn't exist in fea_rs AST!
542    // } else if let Some(subtable) = fea_rs::typed::Subtable::cast(child) {
543    //     Some(Statement::Subtable(SubtableStatement::new()))
544    } else if let Some(vrd) = fea_rs::typed::ValueRecordDef::cast(child) {
545        Some(Statement::ValueRecordDefinition(vrd.into()))
546    } else if let Some(cs) = fea_rs::typed::ConditionSet::cast(child) {
547        Some(Statement::ConditionSet(cs.into()))
548    } else if let Some(fv) = fea_rs::typed::FeatureVariation::cast(child) {
549        Some(Statement::VariationBlock(fv.into()))
550    // Lookup blocks can exist within features
551    } else if let Some(lookup) = fea_rs::typed::LookupBlock::cast(child) {
552        Some(Statement::LookupBlock(lookup.into()))
553    } else {
554        None
555    }
556}
557
558/// A named feature block. (`feature foo { ... } foo;`)
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct FeatureBlock {
561    /// The name of the feature (also called the tag)
562    pub name: SmolStr,
563    /// The statements in the feature block
564    pub statements: Vec<Statement>,
565    /// Whether the feature uses `useExtension`
566    pub use_extension: bool,
567    /// The position of the feature block in the source
568    pub pos: Range<usize>,
569}
570
571impl FeatureBlock {
572    /// Creates a new FeatureBlock.
573    pub fn new(
574        name: SmolStr,
575        statements: Vec<Statement>,
576        use_extension: bool,
577        pos: Range<usize>,
578    ) -> Self {
579        Self {
580            name,
581            statements,
582            use_extension,
583            pos,
584        }
585    }
586}
587
588impl AsFea for FeatureBlock {
589    fn as_fea(&self, indent: &str) -> String {
590        let mut res = String::new();
591        res.push_str(&format!("{}feature {} {{\n", indent, self.name));
592        let mid_indent = indent.to_string() + SHIFT;
593        res.push_str(&format!(
594            "{}\n",
595            self.statements
596                .iter()
597                .map(|s| s.as_fea(&mid_indent))
598                .collect::<Vec<_>>()
599                .join(&format!("\n{mid_indent}"))
600        ));
601        res.push_str(&format!("{}}} {};", indent, self.name));
602        res
603    }
604}
605
606impl From<fea_rs::typed::Feature> for FeatureBlock {
607    fn from(val: fea_rs::typed::Feature) -> Self {
608        let statements: Vec<Statement> = val
609            .node()
610            .iter_children()
611            .filter_map(to_statement)
612            .collect();
613        FeatureBlock {
614            name: SmolStr::new(&val.tag().token().text),
615            use_extension: val.iter().any(|t| t.kind() == fea_rs::Kind::UseExtensionKw),
616            statements,
617            pos: val.node().range(),
618        }
619    }
620}
621
622/// A named lookup block. (`lookup foo { ... } foo;`)
623#[derive(Debug, Clone, PartialEq, Eq)]
624pub struct LookupBlock {
625    /// The name of the lookup
626    pub name: SmolStr,
627    /// The statements in the lookup block
628    pub statements: Vec<Statement>,
629    /// Whether the lookup should be placed in a separate extension subtable
630    pub use_extension: bool,
631    /// The position of the lookup block in the source
632    pub pos: Range<usize>,
633}
634
635impl LookupBlock {
636    /// Creates a new LookupBlock.
637    pub fn new(
638        name: SmolStr,
639        statements: Vec<Statement>,
640        use_extension: bool,
641        pos: Range<usize>,
642    ) -> Self {
643        Self {
644            name,
645            statements,
646            use_extension,
647            pos,
648        }
649    }
650}
651
652impl AsFea for LookupBlock {
653    fn as_fea(&self, indent: &str) -> String {
654        let mut res = String::new();
655        res.push_str(&format!("{}lookup {} {{\n", indent, self.name));
656        let mid_indent = indent.to_string() + SHIFT;
657        res.push_str(&format!(
658            "{mid_indent}{}\n",
659            self.statements
660                .iter()
661                .map(|s| s.as_fea(&mid_indent))
662                .collect::<Vec<_>>()
663                .join(&format!("\n{mid_indent}"))
664        ));
665        res.push_str(&format!("{}}} {};", indent, self.name));
666        res
667    }
668}
669
670impl From<fea_rs::typed::LookupBlock> for LookupBlock {
671    fn from(val: fea_rs::typed::LookupBlock) -> Self {
672        let statements: Vec<Statement> = val
673            .node()
674            .iter_children()
675            .filter_map(to_statement)
676            .collect();
677        let label = val
678            .iter()
679            .find(|t| t.kind() == fea_rs::Kind::Label)
680            .unwrap();
681        LookupBlock {
682            name: SmolStr::from(label.as_token().unwrap().text.as_str()),
683            use_extension: val.iter().any(|t| t.kind() == fea_rs::Kind::UseExtensionKw),
684            statements,
685            pos: val.node().range(),
686        }
687    }
688}
689
690/// A nested block containing statements (e.g., `featureNames { ... };`)
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct NestedBlock {
693    /// The tag identifying the block type
694    pub tag: SmolStr,
695    /// The statements contained in the block
696    pub statements: Vec<Statement>,
697    /// The position of the block in the source
698    pub pos: Range<usize>,
699}
700
701impl AsFea for NestedBlock {
702    fn as_fea(&self, indent: &str) -> String {
703        let mut res = String::new();
704        res.push_str(&format!("{}{} {{\n", indent, self.tag));
705        let mid_indent = indent.to_string() + SHIFT;
706        res.push_str(&format!(
707            "{mid_indent}{}\n",
708            self.statements
709                .iter()
710                .map(|s| s.as_fea(&mid_indent))
711                .collect::<Vec<_>>()
712                .join(&format!("\n{mid_indent}"))
713        ));
714        res.push_str(&format!("{}}};\n", indent));
715        res
716    }
717}
718
719impl From<fea_rs::typed::FeatureNames> for NestedBlock {
720    fn from(val: fea_rs::typed::FeatureNames) -> Self {
721        #[allow(clippy::manual_map)]
722        let statements: Vec<Statement> = val
723            .node()
724            .iter_children()
725            .filter_map(|child| {
726                // Preserve comments
727                if child.kind() == fea_rs::Kind::Comment {
728                    return Some(Statement::Comment(Comment::from(
729                        child.token_text().unwrap(),
730                    )));
731                }
732                if let Some(name_spec) = fea_rs::typed::NameSpec::cast(child) {
733                    let (platform_id, plat_enc_id, lang_id, string) = parse_namespec(name_spec);
734                    Some(Statement::FeatureNameStatement(NameRecord {
735                        platform_id,
736                        plat_enc_id,
737                        lang_id,
738                        string,
739                        kind: NameRecordKind::FeatureName,
740                        location: child.range(),
741                    }))
742                } else {
743                    None
744                }
745            })
746            .collect();
747        NestedBlock {
748            tag: SmolStr::new("featureNames"),
749            statements,
750            pos: val.node().range(),
751        }
752    }
753}
754
755/// Statements that can appear at the top level of a feature file.
756///
757/// This is a subset of [`Statement`] containing only constructs that are
758/// valid at the top level, excluding statements that can only appear within
759/// features, lookups, or table definitions.
760#[derive(Debug, Clone, PartialEq, Eq)]
761pub enum ToplevelItem {
762    /// A glyph class definition: `@lowercase = [a b c];`
763    GlyphClassDefinition(GlyphClassDefinition),
764    /// A mark class definition: `markClass a <anchor 100 200> @TOP_MARKS;`
765    MarkClassDefinition(MarkClassDefinition),
766    /// A language system statement: `languagesystem DFLT dflt;`
767    LanguageSystem(LanguageSystemStatement),
768    // Include(IncludeStatement),
769    /// A feature block: `feature liga { ... } liga;`
770    Feature(FeatureBlock),
771    /// A lookup block: `lookup MyLookup { ... } MyLookup;`
772    Lookup(LookupBlock),
773    /// A comment in the feature file: `# This is a comment`
774    Comment(Comment),
775    /// An anchor definition: `anchorDef 100 200 contourpoint 5 MyAnchor;`
776    AnchorDefinition(AnchorDefinition),
777    /// A value record definition: `valueRecordDef 10 MyValue;`
778    ValueRecordDefinition(ValueRecordDefinition),
779    /// A condition set for variable fonts: `conditionset heavy { wght 700 900; } heavy;`
780    ConditionSet(ConditionSet),
781    /// A variation block for variable fonts: `variation rvrn heavy { ... } rvrn;`
782    VariationBlock(VariationBlock),
783    // Tables
784    /// A BASE table definition: `table BASE { ... } BASE;`
785    Base(Table<Base>),
786    /// A GDEF table definition: `table GDEF { ... } GDEF;`
787    Gdef(Table<Gdef>),
788    /// A head table definition: `table head { ... } head;`
789    Head(Table<Head>),
790    /// An hhea table definition: `table hhea { ... } hhea;`
791    Hhea(Table<Hhea>),
792    /// A name table definition: `table name { ... } name;`
793    Name(Table<Name>),
794    /// An OS/2 table definition: `table OS/2 { ... } OS/2;`
795    Os2(Table<Os2>),
796    /// A STAT table definition: `table STAT { ... } STAT;`
797    Stat(Table<Stat>),
798    /// A vhea table definition: `table vhea { ... } vhea;`
799    Vhea(Table<Vhea>),
800}
801impl From<ToplevelItem> for Statement {
802    fn from(val: ToplevelItem) -> Self {
803        match val {
804            ToplevelItem::GlyphClassDefinition(gcd) => Statement::GlyphClassDefinition(gcd),
805            ToplevelItem::MarkClassDefinition(gcd) => Statement::MarkClassDefinition(gcd),
806
807            ToplevelItem::LanguageSystem(ls) => Statement::LanguageSystem(ls),
808            ToplevelItem::Feature(fb) => Statement::FeatureBlock(fb),
809            ToplevelItem::Lookup(lb) => Statement::LookupBlock(lb),
810            ToplevelItem::Comment(cmt) => Statement::Comment(cmt),
811            ToplevelItem::AnchorDefinition(ad) => Statement::AnchorDefinition(ad),
812            ToplevelItem::ValueRecordDefinition(vrd) => Statement::ValueRecordDefinition(vrd),
813            ToplevelItem::ConditionSet(cs) => Statement::ConditionSet(cs),
814            ToplevelItem::VariationBlock(vb) => Statement::VariationBlock(vb),
815            ToplevelItem::Base(base) => Statement::Base(base),
816            ToplevelItem::Gdef(gdef) => Statement::Gdef(gdef),
817            ToplevelItem::Head(head) => Statement::Head(head),
818            ToplevelItem::Hhea(hhea) => Statement::Hhea(hhea),
819            ToplevelItem::Name(name) => Statement::Name(name),
820            ToplevelItem::Os2(os2) => Statement::Os2(os2),
821            ToplevelItem::Stat(stat) => Statement::Stat(stat),
822            ToplevelItem::Vhea(vhea) => Statement::Vhea(vhea),
823        }
824    }
825}
826impl TryFrom<Statement> for ToplevelItem {
827    type Error = crate::Error;
828
829    fn try_from(value: Statement) -> Result<Self, Self::Error> {
830        match value {
831            Statement::GlyphClassDefinition(gcd) => Ok(ToplevelItem::GlyphClassDefinition(gcd)),
832            Statement::MarkClassDefinition(mcd) => Ok(ToplevelItem::MarkClassDefinition(mcd)),
833            Statement::LanguageSystem(ls) => Ok(ToplevelItem::LanguageSystem(ls)),
834            Statement::FeatureBlock(fb) => Ok(ToplevelItem::Feature(fb)),
835            Statement::LookupBlock(lb) => Ok(ToplevelItem::Lookup(lb)),
836            Statement::Comment(cmt) => Ok(ToplevelItem::Comment(cmt)),
837            Statement::AnchorDefinition(ad) => Ok(ToplevelItem::AnchorDefinition(ad)),
838            Statement::ValueRecordDefinition(vrd) => Ok(ToplevelItem::ValueRecordDefinition(vrd)),
839            Statement::ConditionSet(cs) => Ok(ToplevelItem::ConditionSet(cs)),
840            Statement::VariationBlock(vb) => Ok(ToplevelItem::VariationBlock(vb)),
841            Statement::Base(base) => Ok(ToplevelItem::Base(base)),
842            Statement::Gdef(gdef) => Ok(ToplevelItem::Gdef(gdef)),
843            Statement::Head(head) => Ok(ToplevelItem::Head(head)),
844            Statement::Hhea(hhea) => Ok(ToplevelItem::Hhea(hhea)),
845            Statement::Name(name) => Ok(ToplevelItem::Name(name)),
846            Statement::Os2(os2) => Ok(ToplevelItem::Os2(os2)),
847            Statement::Stat(stat) => Ok(ToplevelItem::Stat(stat)),
848            Statement::Vhea(vhea) => Ok(ToplevelItem::Vhea(vhea)),
849            _ => Err(crate::Error::CannotConvert),
850        }
851    }
852}
853
854impl AsFea for ToplevelItem {
855    fn as_fea(&self, indent: &str) -> String {
856        match self {
857            ToplevelItem::GlyphClassDefinition(gcd) => gcd.as_fea(indent),
858            ToplevelItem::MarkClassDefinition(mcd) => mcd.as_fea(indent),
859            ToplevelItem::LanguageSystem(ls) => ls.as_fea(indent),
860            ToplevelItem::Feature(fb) => fb.as_fea(indent),
861            ToplevelItem::Lookup(lb) => lb.as_fea(indent),
862            ToplevelItem::Comment(cmt) => cmt.as_fea(indent),
863            ToplevelItem::AnchorDefinition(ad) => ad.as_fea(indent),
864            ToplevelItem::ValueRecordDefinition(vrd) => vrd.as_fea(indent),
865            ToplevelItem::ConditionSet(cs) => cs.as_fea(indent),
866            ToplevelItem::VariationBlock(vb) => vb.as_fea(indent),
867            ToplevelItem::Base(base) => base.as_fea(indent),
868            ToplevelItem::Gdef(gdef) => gdef.as_fea(indent),
869            ToplevelItem::Head(head) => head.as_fea(indent),
870            ToplevelItem::Hhea(hhea) => hhea.as_fea(indent),
871            ToplevelItem::Name(name) => name.as_fea(indent),
872            ToplevelItem::Os2(os2) => os2.as_fea(indent),
873            ToplevelItem::Stat(stat) => stat.as_fea(indent),
874            ToplevelItem::Vhea(vhea) => vhea.as_fea(indent),
875        }
876    }
877}
878#[allow(clippy::manual_map)]
879fn to_toplevel_item(child: &NodeOrToken) -> Option<ToplevelItem> {
880    if child.kind() == fea_rs::Kind::Comment {
881        Some(ToplevelItem::Comment(Comment::from(
882            child.token_text().unwrap(),
883        )))
884    } else if let Some(gcd) = fea_rs::typed::GlyphClassDef::cast(child) {
885        Some(ToplevelItem::GlyphClassDefinition(gcd.into()))
886    } else if let Some(mcd) = fea_rs::typed::MarkClassDef::cast(child) {
887        Some(ToplevelItem::MarkClassDefinition(mcd.into()))
888    } else if let Some(langsys) = fea_rs::typed::LanguageSystem::cast(child) {
889        Some(ToplevelItem::LanguageSystem(langsys.into()))
890    } else if let Some(feature) = fea_rs::typed::Feature::cast(child) {
891        Some(ToplevelItem::Feature(feature.into()))
892    } else if let Some(lookup) = fea_rs::typed::LookupBlock::cast(child) {
893        Some(ToplevelItem::Lookup(lookup.into()))
894    } else if let Some(ad) = fea_rs::typed::AnchorDef::cast(child) {
895        Some(ToplevelItem::AnchorDefinition(ad.into()))
896    } else if let Some(vrd) = fea_rs::typed::ValueRecordDef::cast(child) {
897        Some(ToplevelItem::ValueRecordDefinition(vrd.into()))
898    } else if let Some(cs) = fea_rs::typed::ConditionSet::cast(child) {
899        Some(ToplevelItem::ConditionSet(cs.into()))
900    } else if let Some(fv) = fea_rs::typed::FeatureVariation::cast(child) {
901        Some(ToplevelItem::VariationBlock(fv.into()))
902    } else if let Some(base) = fea_rs::typed::BaseTable::cast(child) {
903        Some(ToplevelItem::Base(base.into()))
904    } else if let Some(gdef) = fea_rs::typed::GdefTable::cast(child) {
905        Some(ToplevelItem::Gdef(gdef.into()))
906    } else if let Some(head) = fea_rs::typed::HeadTable::cast(child) {
907        Some(ToplevelItem::Head(head.into()))
908    } else if let Some(hhea) = fea_rs::typed::HheaTable::cast(child) {
909        Some(ToplevelItem::Hhea(hhea.into()))
910    } else if let Some(vhea) = fea_rs::typed::VheaTable::cast(child) {
911        Some(ToplevelItem::Vhea(vhea.into()))
912    } else if let Some(name) = fea_rs::typed::NameTable::cast(child) {
913        Some(ToplevelItem::Name(name.into()))
914    } else if let Some(os2) = fea_rs::typed::Os2Table::cast(child) {
915        Some(ToplevelItem::Os2(os2.into()))
916    } else if let Some(stat) = fea_rs::typed::StatTable::cast(child) {
917        Some(ToplevelItem::Stat(stat.into()))
918    } else {
919        None
920    }
921}
922
923/// A complete OpenType Feature File.
924///
925/// This is the root structure representing a parsed .fea file, containing
926/// a sequence of top-level statements such as glyph class definitions,
927/// feature blocks, lookup blocks, and table definitions.
928///
929/// # Examples
930///
931/// ```
932/// use fea_rs_ast::FeatureFile;
933///
934/// let fea_code = "languagesystem DFLT dflt;";
935/// let feature_file = FeatureFile::try_from(fea_code).unwrap();
936/// ```
937pub struct FeatureFile {
938    /// The top-level statements in the feature file
939    pub statements: Vec<ToplevelItem>,
940}
941impl FeatureFile {
942    /// Creates a new `FeatureFile` from a list of top-level statements.
943    pub fn new(statements: Vec<ToplevelItem>) -> Self {
944        Self { statements }
945    }
946
947    /// Returns an iterator over the top-level statements in the file.
948    pub fn iter(&self) -> impl Iterator<Item = &ToplevelItem> {
949        self.statements.iter()
950    }
951
952    /// Parses a feature file from a string with optional glyph name resolution.
953    ///
954    /// # Arguments
955    ///
956    /// * `features` - The feature file source code as a string
957    /// * `glyph_names` - Optional list of glyph names for validation and range expansion
958    /// * `project_root` - Optional project root directory for resolving `include` statements
959    ///
960    /// # Examples
961    ///
962    /// ```
963    /// use fea_rs_ast::FeatureFile;
964    ///
965    /// let fea_code = "languagesystem DFLT dflt;";
966    /// let feature_file = FeatureFile::new_from_fea(
967    ///     fea_code,
968    ///     None::<&[&str]>,
969    ///     None::<&str>,
970    /// ).unwrap();
971    /// ```
972    pub fn new_from_fea(
973        features: &str,
974        glyph_names: Option<&[&str]>,
975        project_root: Option<impl Into<PathBuf>>,
976    ) -> Result<Self, crate::Error> {
977        let glyph_map = glyph_names.map(|gn| GlyphMap::from_iter(gn.iter().cloned()));
978        let resolver: Box<dyn fea_rs::parse::SourceResolver> =
979            if let Some(project_root) = project_root {
980                let path = project_root.into();
981                Box::new(FileSystemResolver::new(path))
982            } else {
983                Box::new(dummyresolver::DummyResolver)
984            };
985        let features_text: Arc<str> = Arc::from(features);
986        let (parse_tree, mut diagnostics) = fea_rs::parse::parse_root(
987            "get_parse_tree".into(),
988            glyph_map.as_ref(),
989            Box::new(move |s: &Path| {
990                if s == Path::new("get_parse_tree") {
991                    Ok(features_text.clone())
992                } else {
993                    let path = resolver.resolve_raw_path(s.as_ref(), None);
994                    let canonical = resolver.canonicalize(&path)?;
995                    resolver.get_contents(&canonical)
996                }
997            }),
998        )?;
999        diagnostics.split_off_warnings();
1000        if diagnostics.has_errors() {
1001            return Err(crate::Error::FeatureParsing(diagnostics));
1002        }
1003        Ok(parse_tree.into())
1004    }
1005}
1006impl AsFea for FeatureFile {
1007    fn as_fea(&self, indent: &str) -> String {
1008        let mut res = String::new();
1009        for stmt in &self.statements {
1010            res.push_str(&stmt.as_fea(indent));
1011            res.push('\n');
1012        }
1013        res
1014    }
1015}
1016impl From<ParseTree> for FeatureFile {
1017    fn from(val: ParseTree) -> Self {
1018        let statements: Vec<ToplevelItem> = val
1019            .root()
1020            .iter_children()
1021            .filter_map(to_toplevel_item)
1022            .collect();
1023        FeatureFile { statements }
1024    }
1025}
1026
1027/// Turn a string into a FeatureFile
1028///
1029/// Only suitable for simple cases and testing; does not resolve glyph name
1030/// ranges or includes.
1031impl TryFrom<&str> for FeatureFile {
1032    type Error = fea_rs::DiagnosticSet;
1033
1034    fn try_from(value: &str) -> Result<Self, Self::Error> {
1035        let (parsed, diag) = fea_rs::parse::parse_string(value);
1036        if diag.has_errors() {
1037            Err(diag)
1038        } else {
1039            Ok(parsed.into())
1040        }
1041    }
1042}
1043#[cfg(test)]
1044mod tests {
1045    use rstest::rstest;
1046
1047    use super::*;
1048
1049    #[test]
1050    fn test_parse() {
1051        const FEA: &str = r#"feature smcp {
1052            sub a by a.smcp;
1053        } smcp;
1054        "#;
1055        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1056        let feature_block = parsed.root().iter_children().next().unwrap();
1057
1058        let Some(feature) = fea_rs::typed::Feature::cast(feature_block) else {
1059            panic!("Expected Feature, got {:?}", feature_block.kind());
1060        };
1061        let feature_block: FeatureBlock = feature.into();
1062        assert_eq!(feature_block.name.as_str(), "smcp");
1063        assert_eq!(feature_block.statements.len(), 1);
1064        assert_eq!(
1065            normalize_whitespace(&feature_block.as_fea("")),
1066            normalize_whitespace("feature smcp {\n    sub a by a.smcp;\n} smcp;\n")
1067        );
1068    }
1069
1070    fn normalize_whitespace(s: &str) -> String {
1071        s.replace("#", "\n#")
1072            .replace("\n\n", "\n")
1073            .lines()
1074            .filter(|l| !l.trim().is_empty())
1075            .map(|l| l.trim())
1076            .collect::<Vec<_>>()
1077            .join("\n")
1078            .replace("\t", "    ")
1079            .replace("position ", "pos ")
1080            .replace("substitute ", "sub ")
1081            .replace("reversesub ", "rsub ")
1082    }
1083
1084    #[rstest]
1085    fn for_each_file(
1086        #[files("resources/test/*.fea")]
1087        #[exclude("ChainPosSubtable_fea")] // fontTools doesn't support it either
1088        #[exclude("AlternateChained.fea")] // fontTools doesn't support it either
1089        #[exclude("baseClass.fea")] // Fine, just the line breaks are different
1090        #[exclude("STAT_bad.fea")] // Fine, just the line breaks are different
1091        #[exclude("include0.fea")] // We don't process includes
1092        #[exclude("GSUB_error.fea")] // Literally a parse failure
1093        #[exclude("spec10.fea")] // I don't care
1094        path: std::path::PathBuf,
1095    ) {
1096        let fea_str = std::fs::read_to_string(&path).unwrap();
1097        let (parsed, diag) = fea_rs::parse::parse_string(fea_str.clone());
1098        if diag.has_errors() {
1099            panic!("fea-rs didn't like file {:?}:\n{:#?}", path, diag);
1100        }
1101        let feature_file: FeatureFile = parsed.into();
1102        let fea_output = feature_file.as_fea("");
1103        let orig = normalize_whitespace(&fea_str);
1104        let output = normalize_whitespace(&fea_output);
1105        let mut orig_lines = orig.lines().collect::<Vec<_>>();
1106        for i in 0..orig_lines.len() {
1107            if let Some(replacement) = orig_lines[i].strip_prefix("#test-fea2fea: ") {
1108                orig_lines[i + 1] = replacement;
1109            }
1110        }
1111        let orig = orig_lines.join("\n");
1112        pretty_assertions::assert_eq!(orig, output, "Mismatch in file {:?}", path);
1113    }
1114}