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::{parse::FileSystemResolver, typed::AstNode as _, GlyphMap, NodeOrToken, ParseTree};
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/// Helper function for serde: return default Range<usize> (0..0)
283#[cfg(feature = "serde")]
284pub(crate) fn default_range() -> Range<usize> {
285    0..0
286}
287
288/// Helper function for serde: check if a Range<usize> is the default (0..0)
289#[cfg(feature = "serde")]
290pub(crate) fn is_default_range(r: &Range<usize>) -> bool {
291    r.start == 0 && r.end == 0
292}
293
294/// Trait for converting AST nodes back to feature file syntax.
295pub trait AsFea {
296    /// Convert the AST node to feature file syntax with the given indentation.
297    fn as_fea(&self, indent: &str) -> String;
298}
299
300// All possible statements in a feature file need to go
301// here, regardless of context, because we need to be able to
302// treat them as a heterogeneous collection when we do visiting etc.
303// We split them up by context in later enums.
304/// An AST node representing a single statement in a feature file.
305#[allow(clippy::large_enum_variant)]
306#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub enum Statement {
309    // GSUB statements
310    /// A single substitution (GSUB type 1) statement: `sub a by b;`
311    SingleSubst(SingleSubstStatement),
312    /// A multiple substitution (GSUB type 2) statement: `sub a by b c;`
313    MultipleSubst(MultipleSubstStatement),
314    /// An alternate substitution (GSUB type 3) statement: `sub a from [b c d];`
315    AlternateSubst(AlternateSubstStatement),
316    /// A ligature substitution (GSUB type 4) statement: `sub a b by c;`
317    LigatureSubst(LigatureSubstStatement),
318    /// A reverse chaining contextual single substitution (GSUB type 8) statement
319    ReverseChainSubst(ReverseChainSingleSubstStatement),
320    /// A chaining contextual substitution (GSUB type 6) statement: `sub a' lookup foo b;`
321    ChainedContextSubst(ChainedContextStatement<Subst>),
322    /// An ignore substitution rule: `ignore sub a b;`
323    IgnoreSubst(IgnoreStatement<Subst>),
324    // GPOS
325    /// A single adjustment positioning (GPOS type 1) statement: `pos a <10 0 20 0>;`
326    SinglePos(SinglePosStatement),
327    /// A pair adjustment positioning (GPOS type 2) statement: `pos a b <10 0 20 0>;`
328    PairPos(PairPosStatement),
329    /// A cursive attachment positioning (GPOS type 3) statement
330    CursivePos(CursivePosStatement),
331    /// A mark-to-base attachment positioning (GPOS type 4) statement
332    MarkBasePos(MarkBasePosStatement),
333    /// A mark-to-ligature attachment positioning (GPOS type 5) statement
334    MarkLigPos(MarkLigPosStatement),
335    /// A mark-to-mark attachment positioning (GPOS type 6) statement
336    MarkMarkPos(MarkMarkPosStatement),
337    /// A chaining contextual positioning (GPOS type 8) statement: `pos a' lookup foo b;`
338    ChainedContextPos(ChainedContextStatement<Pos>),
339    /// An ignore positioning rule: `ignore pos a b;`
340    IgnorePos(IgnoreStatement<Pos>),
341    // Miscellenea
342    /// An anchor definition: `anchorDef 100 200 contourpoint 5 MyAnchor;`
343    AnchorDefinition(AnchorDefinition),
344    /// A mark class definition: `markClass a <anchor 100 200> @TOP_MARKS;`
345    MarkClassDefinition(MarkClassDefinition),
346    /// A comment in the feature file: `# This is a comment`
347    Comment(Comment),
348    /// A feature name statement within a `featureNames` block
349    FeatureNameStatement(NameRecord),
350    /// A font revision statement: `FontRevision 1.000;`
351    FontRevision(FontRevisionStatement),
352    /// A feature reference statement: `feature liga;`
353    FeatureReference(FeatureReferenceStatement),
354    /// A glyph class definition: `@lowercase = [a b c];`
355    GlyphClassDefinition(GlyphClassDefinition),
356    /// A language statement: `language dflt;`
357    Language(LanguageStatement),
358    /// A language system statement: `languagesystem DFLT dflt;`
359    LanguageSystem(LanguageSystemStatement),
360    /// A lookup flag statement: `lookupflag RightToLeft;`
361    LookupFlag(LookupFlagStatement),
362    /// A lookup reference statement: `lookup MyLookup;`
363    LookupReference(LookupReferenceStatement),
364    /// Size feature parameters: `parameters 10.0 0;`
365    SizeParameters(SizeParameters),
366    /// A size menu name statement: `sizemenuname 3 1 0x409 "Small";`
367    SizeMenuName(NameRecord),
368    /// A subtable statement: `subtable;`
369    Subtable(SubtableStatement),
370    /// A script statement: `script latn;`
371    Script(ScriptStatement),
372    /// A value record definition: `valueRecordDef 10 MyValue;`
373    ValueRecordDefinition(ValueRecordDefinition),
374    /// A condition set for variable fonts: `conditionset heavy { wght 700 900; } heavy;`
375    ConditionSet(ConditionSet),
376    /// A variation block for variable fonts: `variation rvrn heavy { ... } rvrn;`
377    VariationBlock(VariationBlock),
378    // Tables and blocks
379    /// A BASE table definition: `table BASE { ... } BASE;`
380    Base(Table<Base>),
381    /// A GDEF table definition: `table GDEF { ... } GDEF;`
382    Gdef(Table<Gdef>),
383    /// A head table definition: `table head { ... } head;`
384    Head(Table<Head>),
385    /// An hhea table definition: `table hhea { ... } hhea;`
386    Hhea(Table<Hhea>),
387    /// A name table definition: `table name { ... } name;`
388    Name(Table<Name>),
389    /// An OS/2 table definition: `table OS/2 { ... } OS/2;`
390    Os2(Table<Os2>),
391    /// A STAT table definition: `table STAT { ... } STAT;`
392    Stat(Table<Stat>),
393    /// A vhea table definition: `table vhea { ... } vhea;`
394    Vhea(Table<Vhea>),
395    /// A feature block: `feature liga { ... } liga;`
396    FeatureBlock(FeatureBlock),
397    /// A lookup block: `lookup MyLookup { ... } MyLookup;`
398    LookupBlock(LookupBlock),
399    /// A nested block (e.g., `featureNames { ... };`)
400    NestedBlock(NestedBlock),
401    // GDEF-related statements
402    /// A GDEF Attach statement: `Attach a 1 2 3;`
403    GdefAttach(AttachStatement),
404    /// A GDEF GlyphClassDef statement: `GlyphClassDef [a b], [c d], , [e f];`
405    GdefClassDef(GlyphClassDefStatement),
406    /// A GDEF LigatureCaretByIndex statement: `LigatureCaret a 1 2;`
407    GdefLigatureCaretByIndex(LigatureCaretByIndexStatement),
408    /// A GDEF LigatureCaretByPos statement: `LigatureCaretByPos a 100 200;`
409    GdefLigatureCaretByPos(LigatureCaretByPosStatement),
410}
411impl AsFea for Statement {
412    fn as_fea(&self, indent: &str) -> String {
413        match self {
414            // GSUB
415            Statement::SingleSubst(ss) => ss.as_fea(indent),
416            Statement::MultipleSubst(ms) => ms.as_fea(indent),
417            Statement::AlternateSubst(alt) => alt.as_fea(indent),
418            Statement::LigatureSubst(ls) => ls.as_fea(indent),
419            Statement::ChainedContextSubst(ccs) => ccs.as_fea(indent),
420            Statement::IgnoreSubst(is) => is.as_fea(indent),
421            Statement::ReverseChainSubst(rss) => rss.as_fea(indent),
422            // GPOS
423            Statement::SinglePos(sp) => sp.as_fea(indent),
424            Statement::PairPos(pp) => pp.as_fea(indent),
425            Statement::CursivePos(cp) => cp.as_fea(indent),
426            Statement::MarkBasePos(mbp) => mbp.as_fea(indent),
427            Statement::MarkLigPos(mlp) => mlp.as_fea(indent),
428            Statement::MarkMarkPos(mmp) => mmp.as_fea(indent),
429            Statement::ChainedContextPos(ccs) => ccs.as_fea(indent),
430            Statement::IgnorePos(ip) => ip.as_fea(indent),
431            // Miscellenea
432            Statement::AnchorDefinition(ad) => ad.as_fea(indent),
433            Statement::Comment(c) => c.as_fea(indent),
434            Statement::FeatureReference(fr) => fr.as_fea(indent),
435            Statement::FeatureNameStatement(fr) => fr.as_fea(indent),
436            Statement::FontRevision(fr) => fr.as_fea(indent),
437            Statement::GlyphClassDefinition(gcd) => gcd.as_fea(indent),
438            Statement::Language(ls) => ls.as_fea(indent),
439            Statement::LanguageSystem(ls) => ls.as_fea(indent),
440            Statement::LookupFlag(lf) => lf.as_fea(indent),
441            Statement::LookupReference(lr) => lr.as_fea(indent),
442            Statement::MarkClassDefinition(mc) => mc.as_fea(indent),
443            Statement::Script(sc) => sc.as_fea(indent),
444            Statement::SizeMenuName(sm) => sm.as_fea(indent),
445            Statement::SizeParameters(sp) => sp.as_fea(indent),
446            Statement::Subtable(st) => st.as_fea(indent),
447            Statement::ValueRecordDefinition(vrd) => vrd.as_fea(indent),
448            Statement::ConditionSet(cs) => cs.as_fea(indent),
449            Statement::VariationBlock(vb) => vb.as_fea(indent),
450            // GDEF-related statements
451            Statement::GdefAttach(at) => at.as_fea(indent),
452            Statement::GdefClassDef(gcd) => gcd.as_fea(indent),
453            Statement::GdefLigatureCaretByIndex(lc) => lc.as_fea(indent),
454            Statement::GdefLigatureCaretByPos(lc) => lc.as_fea(indent),
455            // Tables and blocks
456            Statement::Base(base) => base.as_fea(indent),
457            Statement::Gdef(gdef) => gdef.as_fea(indent),
458            Statement::Head(head) => head.as_fea(indent),
459            Statement::Hhea(hhea) => hhea.as_fea(indent),
460            Statement::Name(name) => name.as_fea(indent),
461            Statement::Os2(os2) => os2.as_fea(indent),
462            Statement::Stat(stat) => stat.as_fea(indent),
463            Statement::Vhea(vhea) => vhea.as_fea(indent),
464            Statement::FeatureBlock(fb) => fb.as_fea(indent),
465            Statement::LookupBlock(lb) => lb.as_fea(indent),
466            Statement::NestedBlock(nb) => nb.as_fea(indent),
467        }
468    }
469}
470
471fn to_statement(child: &NodeOrToken) -> Option<Statement> {
472    if child.kind() == fea_rs::Kind::Comment {
473        return Some(Statement::Comment(Comment::from(
474            child.token_text().unwrap(),
475        )));
476    } else if child.kind() == fea_rs::Kind::SubtableNode {
477        return Some(Statement::Subtable(SubtableStatement::new()));
478    }
479    #[allow(clippy::manual_map)]
480    // GSUB
481    if let Some(gsub1) = fea_rs::typed::Gsub1::cast(child) {
482        Some(Statement::SingleSubst(gsub1.into()))
483    } else if let Some(gsub2) = fea_rs::typed::Gsub2::cast(child) {
484        Some(Statement::MultipleSubst(gsub2.into()))
485    } else if let Some(gsub3) = fea_rs::typed::Gsub3::cast(child) {
486        Some(Statement::AlternateSubst(gsub3.into()))
487    } else if let Some(gsub4) = fea_rs::typed::Gsub4::cast(child) {
488        Some(Statement::LigatureSubst(gsub4.into()))
489    } else if let Some(gsub6) = fea_rs::typed::Gsub6::cast(child) {
490        Some(gsub6.into())
491    } else if let Some(rss) = fea_rs::typed::Gsub8::cast(child) {
492        Some(Statement::ReverseChainSubst(rss.into()))
493    } else if let Some(gsig) = fea_rs::typed::GsubIgnore::cast(child) {
494        Some(Statement::IgnoreSubst(gsig.into()))
495        // GPOS
496    } else if let Some(gpos1) = fea_rs::typed::Gpos1::cast(child) {
497        Some(Statement::SinglePos(gpos1.into()))
498    } else if let Some(gpos2) = fea_rs::typed::Gpos2::cast(child) {
499        Some(Statement::PairPos(gpos2.into()))
500    } else if let Some(gpos3) = fea_rs::typed::Gpos3::cast(child) {
501        Some(Statement::CursivePos(gpos3.into()))
502    } else if let Some(gpos4) = fea_rs::typed::Gpos4::cast(child) {
503        Some(Statement::MarkBasePos(gpos4.into()))
504    } else if let Some(gpos5) = fea_rs::typed::Gpos5::cast(child) {
505        Some(Statement::MarkLigPos(gpos5.into()))
506    } else if let Some(gpos6) = fea_rs::typed::Gpos6::cast(child) {
507        Some(Statement::MarkMarkPos(gpos6.into()))
508    } else if let Some(gpos8) = fea_rs::typed::Gpos8::cast(child) {
509        Some(gpos8.into())
510    } else if let Some(gpig) = fea_rs::typed::GposIgnore::cast(child) {
511        Some(Statement::IgnorePos(gpig.into()))
512    // Miscellenea
513    } else if let Some(ad) = fea_rs::typed::AnchorDef::cast(child) {
514        Some(Statement::AnchorDefinition(ad.into()))
515    } else if let Some(at) = fea_rs::typed::GdefAttach::cast(child) {
516        Some(Statement::GdefAttach(at.into()))
517    } else if let Some(gcd) = fea_rs::typed::GdefClassDef::cast(child) {
518        Some(Statement::GdefClassDef(gcd.into()))
519    } else if let Some(lc) = fea_rs::typed::GdefLigatureCaret::cast(child) {
520        // Check if it's by position or by index based on the first keyword
521        let is_by_pos = lc
522            .iter()
523            .next()
524            .map(|t| t.kind() == fea_rs::Kind::LigatureCaretByPosKw)
525            .unwrap_or(false);
526        if is_by_pos {
527            Some(Statement::GdefLigatureCaretByPos(lc.into()))
528        } else {
529            Some(Statement::GdefLigatureCaretByIndex(lc.into()))
530        }
531    } else if let Some(fr) = fea_rs::typed::FeatureRef::cast(child) {
532        Some(Statement::FeatureReference(fr.into()))
533    } else if let Some(fr) = fea_rs::typed::HeadFontRevision::cast(child) {
534        Some(Statement::FontRevision(fr.into()))
535    } else if let Some(gcd) = fea_rs::typed::GlyphClassDef::cast(child) {
536        Some(Statement::GlyphClassDefinition(gcd.into()))
537    } else if let Some(lang) = fea_rs::typed::Language::cast(child) {
538        Some(Statement::Language(lang.into()))
539    } else if let Some(langsys) = fea_rs::typed::LanguageSystem::cast(child) {
540        Some(Statement::LanguageSystem(langsys.into()))
541    } else if let Some(lookupflag) = fea_rs::typed::LookupFlag::cast(child) {
542        Some(Statement::LookupFlag(lookupflag.into()))
543    } else if let Some(lookupref) = fea_rs::typed::LookupRef::cast(child) {
544        Some(Statement::LookupReference(lookupref.into()))
545    } else if let Some(mcd) = fea_rs::typed::MarkClassDef::cast(child) {
546        Some(Statement::MarkClassDefinition(mcd.into()))
547    } else if let Some(script) = fea_rs::typed::Script::cast(child) {
548        Some(Statement::Script(script.into()))
549    } else if let Some(menuname) = fea_rs::typed::SizeMenuName::cast(child) {
550        Some(Statement::SizeMenuName(menuname.into()))
551    } else if let Some(sizeparams) = fea_rs::typed::Parameters::cast(child) {
552        Some(Statement::SizeParameters(sizeparams.into()))
553    } else if let Some(featurenames) = fea_rs::typed::FeatureNames::cast(child) {
554        Some(Statement::NestedBlock(featurenames.into()))
555    // Doesn't exist in fea_rs AST!
556    // } else if let Some(subtable) = fea_rs::typed::Subtable::cast(child) {
557    //     Some(Statement::Subtable(SubtableStatement::new()))
558    } else if let Some(vrd) = fea_rs::typed::ValueRecordDef::cast(child) {
559        Some(Statement::ValueRecordDefinition(vrd.into()))
560    } else if let Some(cs) = fea_rs::typed::ConditionSet::cast(child) {
561        Some(Statement::ConditionSet(cs.into()))
562    } else if let Some(fv) = fea_rs::typed::FeatureVariation::cast(child) {
563        Some(Statement::VariationBlock(fv.into()))
564    // Lookup blocks can exist within features
565    } else if let Some(lookup) = fea_rs::typed::LookupBlock::cast(child) {
566        Some(Statement::LookupBlock(lookup.into()))
567    } else {
568        None
569    }
570}
571
572/// A named feature block. (`feature foo { ... } foo;`)
573#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct FeatureBlock {
576    /// The name of the feature (also called the tag)
577    pub name: SmolStr,
578    /// The statements in the feature block
579    pub statements: Vec<Statement>,
580    /// Whether the feature uses `useExtension`
581    pub use_extension: bool,
582    /// The position of the feature block in the source
583    #[cfg_attr(
584        feature = "serde",
585        serde(
586            default = "crate::default_range",
587            skip_serializing_if = "crate::is_default_range"
588        )
589    )]
590    pub pos: Range<usize>,
591}
592
593impl FeatureBlock {
594    /// Creates a new FeatureBlock.
595    pub fn new(
596        name: SmolStr,
597        statements: Vec<Statement>,
598        use_extension: bool,
599        pos: Range<usize>,
600    ) -> Self {
601        Self {
602            name,
603            statements,
604            use_extension,
605            pos,
606        }
607    }
608}
609
610impl AsFea for FeatureBlock {
611    fn as_fea(&self, indent: &str) -> String {
612        let mut res = String::new();
613        res.push_str(&format!("{}feature {} {{\n", indent, self.name));
614        let mid_indent = indent.to_string() + SHIFT;
615        res.push_str(&format!(
616            "{}\n",
617            self.statements
618                .iter()
619                .map(|s| s.as_fea(&mid_indent))
620                .collect::<Vec<_>>()
621                .join(&format!("\n{mid_indent}"))
622        ));
623        res.push_str(&format!("{}}} {};", indent, self.name));
624        res
625    }
626}
627
628impl From<fea_rs::typed::Feature> for FeatureBlock {
629    fn from(val: fea_rs::typed::Feature) -> Self {
630        let statements: Vec<Statement> = val
631            .node()
632            .iter_children()
633            .filter_map(to_statement)
634            .collect();
635        FeatureBlock {
636            name: SmolStr::new(&val.tag().token().text),
637            use_extension: val.iter().any(|t| t.kind() == fea_rs::Kind::UseExtensionKw),
638            statements,
639            pos: val.node().range(),
640        }
641    }
642}
643
644/// A named lookup block. (`lookup foo { ... } foo;`)
645#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct LookupBlock {
648    /// The name of the lookup
649    pub name: SmolStr,
650    /// The statements in the lookup block
651    pub statements: Vec<Statement>,
652    /// Whether the lookup should be placed in a separate extension subtable
653    pub use_extension: bool,
654    /// The position of the lookup block in the source
655    #[cfg_attr(
656        feature = "serde",
657        serde(
658            default = "crate::default_range",
659            skip_serializing_if = "crate::is_default_range"
660        )
661    )]
662    pub pos: Range<usize>,
663}
664
665impl LookupBlock {
666    /// Creates a new LookupBlock.
667    pub fn new(
668        name: SmolStr,
669        statements: Vec<Statement>,
670        use_extension: bool,
671        pos: Range<usize>,
672    ) -> Self {
673        Self {
674            name,
675            statements,
676            use_extension,
677            pos,
678        }
679    }
680}
681
682impl AsFea for LookupBlock {
683    fn as_fea(&self, indent: &str) -> String {
684        let mut res = String::new();
685        res.push_str(&format!("{}lookup {} {{\n", indent, self.name));
686        let mid_indent = indent.to_string() + SHIFT;
687        res.push_str(&format!(
688            "{mid_indent}{}\n",
689            self.statements
690                .iter()
691                .map(|s| s.as_fea(&mid_indent))
692                .collect::<Vec<_>>()
693                .join(&format!("\n{mid_indent}"))
694        ));
695        res.push_str(&format!("{}}} {};", indent, self.name));
696        res
697    }
698}
699
700impl From<fea_rs::typed::LookupBlock> for LookupBlock {
701    fn from(val: fea_rs::typed::LookupBlock) -> Self {
702        let statements: Vec<Statement> = val
703            .node()
704            .iter_children()
705            .filter_map(to_statement)
706            .collect();
707        let label = val
708            .iter()
709            .find(|t| t.kind() == fea_rs::Kind::Label)
710            .unwrap();
711        LookupBlock {
712            name: SmolStr::from(label.as_token().unwrap().text.as_str()),
713            use_extension: val.iter().any(|t| t.kind() == fea_rs::Kind::UseExtensionKw),
714            statements,
715            pos: val.node().range(),
716        }
717    }
718}
719
720/// A nested block containing statements (e.g., `featureNames { ... };`)
721#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
722#[derive(Debug, Clone, PartialEq, Eq)]
723pub struct NestedBlock {
724    /// The tag identifying the block type
725    pub tag: SmolStr,
726    /// The statements contained in the block
727    pub statements: Vec<Statement>,
728    /// The position of the block in the source
729    #[cfg_attr(
730        feature = "serde",
731        serde(
732            default = "crate::default_range",
733            skip_serializing_if = "crate::is_default_range"
734        )
735    )]
736    pub pos: Range<usize>,
737}
738
739impl AsFea for NestedBlock {
740    fn as_fea(&self, indent: &str) -> String {
741        let mut res = String::new();
742        res.push_str(&format!("{}{} {{\n", indent, self.tag));
743        let mid_indent = indent.to_string() + SHIFT;
744        res.push_str(&format!(
745            "{mid_indent}{}\n",
746            self.statements
747                .iter()
748                .map(|s| s.as_fea(&mid_indent))
749                .collect::<Vec<_>>()
750                .join(&format!("\n{mid_indent}"))
751        ));
752        res.push_str(&format!("{}}};\n", indent));
753        res
754    }
755}
756
757impl From<fea_rs::typed::FeatureNames> for NestedBlock {
758    fn from(val: fea_rs::typed::FeatureNames) -> Self {
759        #[allow(clippy::manual_map)]
760        let statements: Vec<Statement> = val
761            .node()
762            .iter_children()
763            .filter_map(|child| {
764                // Preserve comments
765                if child.kind() == fea_rs::Kind::Comment {
766                    return Some(Statement::Comment(Comment::from(
767                        child.token_text().unwrap(),
768                    )));
769                }
770                if let Some(name_spec) = fea_rs::typed::NameSpec::cast(child) {
771                    let (platform_id, plat_enc_id, lang_id, string) = parse_namespec(name_spec);
772                    Some(Statement::FeatureNameStatement(NameRecord {
773                        platform_id,
774                        plat_enc_id,
775                        lang_id,
776                        string,
777                        kind: NameRecordKind::FeatureName,
778                        location: child.range(),
779                    }))
780                } else {
781                    None
782                }
783            })
784            .collect();
785        NestedBlock {
786            tag: SmolStr::new("featureNames"),
787            statements,
788            pos: val.node().range(),
789        }
790    }
791}
792
793/// Statements that can appear at the top level of a feature file.
794///
795/// This is a subset of [`Statement`] containing only constructs that are
796/// valid at the top level, excluding statements that can only appear within
797/// features, lookups, or table definitions.
798#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
799#[derive(Debug, Clone, PartialEq, Eq)]
800pub enum ToplevelItem {
801    /// A glyph class definition: `@lowercase = [a b c];`
802    GlyphClassDefinition(GlyphClassDefinition),
803    /// A mark class definition: `markClass a <anchor 100 200> @TOP_MARKS;`
804    MarkClassDefinition(MarkClassDefinition),
805    /// A language system statement: `languagesystem DFLT dflt;`
806    LanguageSystem(LanguageSystemStatement),
807    // Include(IncludeStatement),
808    /// A feature block: `feature liga { ... } liga;`
809    Feature(FeatureBlock),
810    /// A lookup block: `lookup MyLookup { ... } MyLookup;`
811    Lookup(LookupBlock),
812    /// A comment in the feature file: `# This is a comment`
813    Comment(Comment),
814    /// An anchor definition: `anchorDef 100 200 contourpoint 5 MyAnchor;`
815    AnchorDefinition(AnchorDefinition),
816    /// A value record definition: `valueRecordDef 10 MyValue;`
817    ValueRecordDefinition(ValueRecordDefinition),
818    /// A condition set for variable fonts: `conditionset heavy { wght 700 900; } heavy;`
819    ConditionSet(ConditionSet),
820    /// A variation block for variable fonts: `variation rvrn heavy { ... } rvrn;`
821    VariationBlock(VariationBlock),
822    /// A GDEF Glyph Class definition statement
823    GdefClassDef(GlyphClassDefStatement),
824    // Tables
825    /// A BASE table definition: `table BASE { ... } BASE;`
826    Base(Table<Base>),
827    /// A GDEF table definition: `table GDEF { ... } GDEF;`
828    Gdef(Table<Gdef>),
829    /// A head table definition: `table head { ... } head;`
830    Head(Table<Head>),
831    /// An hhea table definition: `table hhea { ... } hhea;`
832    Hhea(Table<Hhea>),
833    /// A name table definition: `table name { ... } name;`
834    Name(Table<Name>),
835    /// An OS/2 table definition: `table OS/2 { ... } OS/2;`
836    Os2(Table<Os2>),
837    /// A STAT table definition: `table STAT { ... } STAT;`
838    Stat(Table<Stat>),
839    /// A vhea table definition: `table vhea { ... } vhea;`
840    Vhea(Table<Vhea>),
841}
842impl From<ToplevelItem> for Statement {
843    fn from(val: ToplevelItem) -> Self {
844        match val {
845            ToplevelItem::GlyphClassDefinition(gcd) => Statement::GlyphClassDefinition(gcd),
846            ToplevelItem::MarkClassDefinition(gcd) => Statement::MarkClassDefinition(gcd),
847            ToplevelItem::GdefClassDef(gcds) => Statement::GdefClassDef(gcds),
848
849            ToplevelItem::LanguageSystem(ls) => Statement::LanguageSystem(ls),
850            ToplevelItem::Feature(fb) => Statement::FeatureBlock(fb),
851            ToplevelItem::Lookup(lb) => Statement::LookupBlock(lb),
852            ToplevelItem::Comment(cmt) => Statement::Comment(cmt),
853            ToplevelItem::AnchorDefinition(ad) => Statement::AnchorDefinition(ad),
854            ToplevelItem::ValueRecordDefinition(vrd) => Statement::ValueRecordDefinition(vrd),
855            ToplevelItem::ConditionSet(cs) => Statement::ConditionSet(cs),
856            ToplevelItem::VariationBlock(vb) => Statement::VariationBlock(vb),
857            ToplevelItem::Base(base) => Statement::Base(base),
858            ToplevelItem::Gdef(gdef) => Statement::Gdef(gdef),
859            ToplevelItem::Head(head) => Statement::Head(head),
860            ToplevelItem::Hhea(hhea) => Statement::Hhea(hhea),
861            ToplevelItem::Name(name) => Statement::Name(name),
862            ToplevelItem::Os2(os2) => Statement::Os2(os2),
863            ToplevelItem::Stat(stat) => Statement::Stat(stat),
864            ToplevelItem::Vhea(vhea) => Statement::Vhea(vhea),
865        }
866    }
867}
868impl TryFrom<Statement> for ToplevelItem {
869    type Error = crate::Error;
870
871    fn try_from(value: Statement) -> Result<Self, Self::Error> {
872        match value {
873            Statement::GlyphClassDefinition(gcd) => Ok(ToplevelItem::GlyphClassDefinition(gcd)),
874            Statement::MarkClassDefinition(mcd) => Ok(ToplevelItem::MarkClassDefinition(mcd)),
875            Statement::GdefClassDef(gcds) => Ok(ToplevelItem::GdefClassDef(gcds)),
876            Statement::LanguageSystem(ls) => Ok(ToplevelItem::LanguageSystem(ls)),
877            Statement::FeatureBlock(fb) => Ok(ToplevelItem::Feature(fb)),
878            Statement::LookupBlock(lb) => Ok(ToplevelItem::Lookup(lb)),
879            Statement::Comment(cmt) => Ok(ToplevelItem::Comment(cmt)),
880            Statement::AnchorDefinition(ad) => Ok(ToplevelItem::AnchorDefinition(ad)),
881            Statement::ValueRecordDefinition(vrd) => Ok(ToplevelItem::ValueRecordDefinition(vrd)),
882            Statement::ConditionSet(cs) => Ok(ToplevelItem::ConditionSet(cs)),
883            Statement::VariationBlock(vb) => Ok(ToplevelItem::VariationBlock(vb)),
884            Statement::Base(base) => Ok(ToplevelItem::Base(base)),
885            Statement::Gdef(gdef) => Ok(ToplevelItem::Gdef(gdef)),
886            Statement::Head(head) => Ok(ToplevelItem::Head(head)),
887            Statement::Hhea(hhea) => Ok(ToplevelItem::Hhea(hhea)),
888            Statement::Name(name) => Ok(ToplevelItem::Name(name)),
889            Statement::Os2(os2) => Ok(ToplevelItem::Os2(os2)),
890            Statement::Stat(stat) => Ok(ToplevelItem::Stat(stat)),
891            Statement::Vhea(vhea) => Ok(ToplevelItem::Vhea(vhea)),
892            _ => Err(crate::Error::CannotConvert),
893        }
894    }
895}
896
897impl AsFea for ToplevelItem {
898    fn as_fea(&self, indent: &str) -> String {
899        match self {
900            ToplevelItem::GlyphClassDefinition(gcd) => gcd.as_fea(indent),
901            ToplevelItem::MarkClassDefinition(mcd) => mcd.as_fea(indent),
902            ToplevelItem::GdefClassDef(gcds) => gcds.as_fea(indent),
903            ToplevelItem::LanguageSystem(ls) => ls.as_fea(indent),
904            ToplevelItem::Feature(fb) => fb.as_fea(indent),
905            ToplevelItem::Lookup(lb) => lb.as_fea(indent),
906            ToplevelItem::Comment(cmt) => cmt.as_fea(indent),
907            ToplevelItem::AnchorDefinition(ad) => ad.as_fea(indent),
908            ToplevelItem::ValueRecordDefinition(vrd) => vrd.as_fea(indent),
909            ToplevelItem::ConditionSet(cs) => cs.as_fea(indent),
910            ToplevelItem::VariationBlock(vb) => vb.as_fea(indent),
911            ToplevelItem::Base(base) => base.as_fea(indent),
912            ToplevelItem::Gdef(gdef) => gdef.as_fea(indent),
913            ToplevelItem::Head(head) => head.as_fea(indent),
914            ToplevelItem::Hhea(hhea) => hhea.as_fea(indent),
915            ToplevelItem::Name(name) => name.as_fea(indent),
916            ToplevelItem::Os2(os2) => os2.as_fea(indent),
917            ToplevelItem::Stat(stat) => stat.as_fea(indent),
918            ToplevelItem::Vhea(vhea) => vhea.as_fea(indent),
919        }
920    }
921}
922#[allow(clippy::manual_map)]
923fn to_toplevel_item(child: &NodeOrToken) -> Option<ToplevelItem> {
924    if child.kind() == fea_rs::Kind::Comment {
925        Some(ToplevelItem::Comment(Comment::from(
926            child.token_text().unwrap(),
927        )))
928    } else if let Some(gcd) = fea_rs::typed::GlyphClassDef::cast(child) {
929        Some(ToplevelItem::GlyphClassDefinition(gcd.into()))
930    } else if let Some(mcd) = fea_rs::typed::MarkClassDef::cast(child) {
931        Some(ToplevelItem::MarkClassDefinition(mcd.into()))
932    } else if let Some(langsys) = fea_rs::typed::LanguageSystem::cast(child) {
933        Some(ToplevelItem::LanguageSystem(langsys.into()))
934    } else if let Some(feature) = fea_rs::typed::Feature::cast(child) {
935        Some(ToplevelItem::Feature(feature.into()))
936    } else if let Some(lookup) = fea_rs::typed::LookupBlock::cast(child) {
937        Some(ToplevelItem::Lookup(lookup.into()))
938    } else if let Some(ad) = fea_rs::typed::AnchorDef::cast(child) {
939        Some(ToplevelItem::AnchorDefinition(ad.into()))
940    } else if let Some(vrd) = fea_rs::typed::ValueRecordDef::cast(child) {
941        Some(ToplevelItem::ValueRecordDefinition(vrd.into()))
942    } else if let Some(cs) = fea_rs::typed::ConditionSet::cast(child) {
943        Some(ToplevelItem::ConditionSet(cs.into()))
944    } else if let Some(fv) = fea_rs::typed::FeatureVariation::cast(child) {
945        Some(ToplevelItem::VariationBlock(fv.into()))
946    } else if let Some(base) = fea_rs::typed::BaseTable::cast(child) {
947        Some(ToplevelItem::Base(base.into()))
948    } else if let Some(gdef) = fea_rs::typed::GdefTable::cast(child) {
949        Some(ToplevelItem::Gdef(gdef.into()))
950    } else if let Some(head) = fea_rs::typed::HeadTable::cast(child) {
951        Some(ToplevelItem::Head(head.into()))
952    } else if let Some(hhea) = fea_rs::typed::HheaTable::cast(child) {
953        Some(ToplevelItem::Hhea(hhea.into()))
954    } else if let Some(vhea) = fea_rs::typed::VheaTable::cast(child) {
955        Some(ToplevelItem::Vhea(vhea.into()))
956    } else if let Some(name) = fea_rs::typed::NameTable::cast(child) {
957        Some(ToplevelItem::Name(name.into()))
958    } else if let Some(os2) = fea_rs::typed::Os2Table::cast(child) {
959        Some(ToplevelItem::Os2(os2.into()))
960    } else if let Some(stat) = fea_rs::typed::StatTable::cast(child) {
961        Some(ToplevelItem::Stat(stat.into()))
962    } else {
963        None
964    }
965}
966
967/// A complete OpenType Feature File.
968///
969/// This is the root structure representing a parsed .fea file, containing
970/// a sequence of top-level statements such as glyph class definitions,
971/// feature blocks, lookup blocks, and table definitions.
972///
973/// # Examples
974///
975/// ```
976/// use fea_rs_ast::FeatureFile;
977///
978/// let fea_code = "languagesystem DFLT dflt;";
979/// let feature_file = FeatureFile::try_from(fea_code).unwrap();
980/// ```
981#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
982#[derive(Debug, Clone, PartialEq, Eq)]
983pub struct FeatureFile {
984    /// The top-level statements in the feature file
985    pub statements: Vec<ToplevelItem>,
986}
987impl FeatureFile {
988    /// Creates a new `FeatureFile` from a list of top-level statements.
989    pub fn new(statements: Vec<ToplevelItem>) -> Self {
990        Self { statements }
991    }
992
993    /// Returns an iterator over the top-level statements in the file.
994    pub fn iter(&self) -> impl Iterator<Item = &ToplevelItem> {
995        self.statements.iter()
996    }
997
998    /// Parses a feature file from a string with optional glyph name resolution.
999    ///
1000    /// # Arguments
1001    ///
1002    /// * `features` - The feature file source code as a string
1003    /// * `glyph_names` - Optional list of glyph names for validation and range expansion
1004    /// * `project_root` - Optional project root directory for resolving `include` statements
1005    ///
1006    /// # Examples
1007    ///
1008    /// ```
1009    /// use fea_rs_ast::FeatureFile;
1010    ///
1011    /// let fea_code = "languagesystem DFLT dflt;";
1012    /// let feature_file = FeatureFile::new_from_fea(
1013    ///     fea_code,
1014    ///     None::<&[&str]>,
1015    ///     None::<&str>,
1016    /// ).unwrap();
1017    /// ```
1018    pub fn new_from_fea(
1019        features: &str,
1020        glyph_names: Option<&[&str]>,
1021        project_root: Option<impl Into<PathBuf>>,
1022    ) -> Result<Self, crate::Error> {
1023        let glyph_map = glyph_names
1024            .map(|gn| GlyphMap::new(gn.iter().cloned()))
1025            .transpose()?;
1026        let resolver: Box<dyn fea_rs::parse::SourceResolver> =
1027            if let Some(project_root) = project_root {
1028                let path = project_root.into();
1029                Box::new(FileSystemResolver::new(path))
1030            } else {
1031                Box::new(dummyresolver::DummyResolver)
1032            };
1033        let features_text: Arc<str> = Arc::from(features);
1034        let (parse_tree, mut diagnostics) = fea_rs::parse::parse_root(
1035            "get_parse_tree".into(),
1036            glyph_map.as_ref(),
1037            Box::new(move |s: &Path| {
1038                if s == Path::new("get_parse_tree") {
1039                    Ok(features_text.clone())
1040                } else {
1041                    let path = resolver.resolve_raw_path(s.as_ref(), None);
1042                    let canonical = resolver.canonicalize(&path)?;
1043                    resolver.get_contents(&canonical)
1044                }
1045            }),
1046        )?;
1047        diagnostics.split_off_warnings();
1048        if diagnostics.has_errors() {
1049            return Err(crate::Error::FeatureParsing(diagnostics));
1050        }
1051        Ok(parse_tree.into())
1052    }
1053}
1054impl AsFea for FeatureFile {
1055    fn as_fea(&self, indent: &str) -> String {
1056        let mut res = String::new();
1057        for stmt in &self.statements {
1058            res.push_str(&stmt.as_fea(indent));
1059            res.push('\n');
1060        }
1061        res
1062    }
1063}
1064impl From<ParseTree> for FeatureFile {
1065    fn from(val: ParseTree) -> Self {
1066        let statements: Vec<ToplevelItem> = val
1067            .root()
1068            .iter_children()
1069            .filter_map(to_toplevel_item)
1070            .collect();
1071        FeatureFile { statements }
1072    }
1073}
1074
1075/// Turn a string into a FeatureFile
1076///
1077/// Only suitable for simple cases and testing; does not resolve glyph name
1078/// ranges or includes.
1079impl TryFrom<&str> for FeatureFile {
1080    type Error = fea_rs::DiagnosticSet;
1081
1082    fn try_from(value: &str) -> Result<Self, Self::Error> {
1083        let (parsed, diag) = fea_rs::parse::parse_string(value);
1084        if diag.has_errors() {
1085            Err(diag)
1086        } else {
1087            Ok(parsed.into())
1088        }
1089    }
1090}
1091#[cfg(test)]
1092mod tests {
1093    use rstest::rstest;
1094
1095    use super::*;
1096
1097    #[test]
1098    fn test_parse() {
1099        const FEA: &str = r#"feature smcp {
1100            sub a by a.smcp;
1101        } smcp;
1102        "#;
1103        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1104        let feature_block = parsed.root().iter_children().next().unwrap();
1105
1106        let Some(feature) = fea_rs::typed::Feature::cast(feature_block) else {
1107            panic!("Expected Feature, got {:?}", feature_block.kind());
1108        };
1109        let feature_block: FeatureBlock = feature.into();
1110        assert_eq!(feature_block.name.as_str(), "smcp");
1111        assert_eq!(feature_block.statements.len(), 1);
1112        assert_eq!(
1113            normalize_whitespace(&feature_block.as_fea("")),
1114            normalize_whitespace("feature smcp {\n    sub a by a.smcp;\n} smcp;\n")
1115        );
1116    }
1117
1118    fn normalize_whitespace(s: &str) -> String {
1119        s.replace("#", "\n#")
1120            .replace("\n\n", "\n")
1121            .lines()
1122            .filter(|l| !l.trim().is_empty())
1123            .map(|l| l.trim())
1124            .collect::<Vec<_>>()
1125            .join("\n")
1126            .replace("\t", "    ")
1127            .replace("position ", "pos ")
1128            .replace("substitute ", "sub ")
1129            .replace("reversesub ", "rsub ")
1130    }
1131
1132    #[rstest]
1133    fn for_each_file(
1134        #[files("resources/test/*.fea")]
1135        #[exclude("ChainPosSubtable_fea")] // fontTools doesn't support it either
1136        #[exclude("AlternateChained.fea")] // fontTools doesn't support it either
1137        #[exclude("baseClass.fea")] // Fine, just the line breaks are different
1138        #[exclude("STAT_bad.fea")] // Fine, just the line breaks are different
1139        #[exclude("include0.fea")] // We don't process includes
1140        #[exclude("GSUB_error.fea")] // Literally a parse failure
1141        #[exclude("spec10.fea")] // I don't care
1142        path: std::path::PathBuf,
1143    ) {
1144        let fea_str = std::fs::read_to_string(&path).unwrap();
1145        let (parsed, diag) = fea_rs::parse::parse_string(fea_str.clone());
1146        if diag.has_errors() {
1147            panic!("fea-rs didn't like file {:?}:\n{:#?}", path, diag);
1148        }
1149        let feature_file: FeatureFile = parsed.into();
1150        let fea_output = feature_file.as_fea("");
1151        let orig = normalize_whitespace(&fea_str);
1152        let output = normalize_whitespace(&fea_output);
1153        let mut orig_lines = orig.lines().collect::<Vec<_>>();
1154        for i in 0..orig_lines.len() {
1155            if let Some(replacement) = orig_lines[i].strip_prefix("#test-fea2fea: ") {
1156                orig_lines[i + 1] = replacement;
1157            }
1158        }
1159        let orig = orig_lines.join("\n");
1160        pretty_assertions::assert_eq!(orig, output, "Mismatch in file {:?}", path);
1161    }
1162}