Skip to main content

fea_rs_ast/
miscellenea.rs

1use std::ops::Range;
2
3use fea_rs::typed::{AstNode as _, Tag};
4use smol_str::SmolStr;
5
6use crate::{
7    Anchor, AsFea, GlyphClass, GlyphContainer, MarkClass, Metric, SHIFT, Statement, ValueRecord,
8    from_anchor,
9};
10
11/// A named anchor definition. (2.e.viii)
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct AnchorDefinition {
14    /// The X coordinate of the anchor
15    pub x: Metric,
16    /// The Y coordinate of the anchor
17    pub y: Metric,
18    /// The contour point index, if any
19    pub contourpoint: Option<u16>,
20    /// The name of the anchor
21    pub name: String,
22    /// The location of the anchor definition in the source FEA
23    pub location: Range<usize>,
24}
25impl AnchorDefinition {
26    /// Creates a new `Anchor` statement.
27    pub fn new(
28        x: Metric,
29        y: Metric,
30        contourpoint: Option<u16>,
31        name: String,
32        location: Range<usize>,
33    ) -> Self {
34        Self {
35            x,
36            y,
37            contourpoint,
38            name,
39            location,
40        }
41    }
42}
43impl AsFea for AnchorDefinition {
44    fn as_fea(&self, _indent: &str) -> String {
45        let mut res = format!("anchorDef {} {}", self.x.as_fea(""), self.y.as_fea(""));
46        if let Some(cp) = self.contourpoint {
47            res.push_str(&format!(" contourpoint {}", cp));
48        }
49        res.push_str(&format!(" {};", self.name));
50        res
51    }
52}
53impl From<fea_rs::typed::AnchorDef> for AnchorDefinition {
54    fn from(val: fea_rs::typed::AnchorDef) -> Self {
55        let anchor_node = val
56            .iter()
57            .filter_map(fea_rs::typed::Anchor::cast)
58            .next()
59            .unwrap();
60        let our_anchor: Anchor = from_anchor(anchor_node).unwrap();
61        let name = val
62            .iter()
63            .find(|t| t.kind() == fea_rs::Kind::Ident)
64            .unwrap();
65        AnchorDefinition::new(
66            our_anchor.x,
67            our_anchor.y,
68            our_anchor.contourpoint,
69            name.token_text().unwrap().to_string(),
70            val.node().range(),
71        )
72    }
73}
74
75/// A comment in a feature file
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Comment {
78    /// The text of the comment, which should include the initial `#`.
79    pub text: String,
80}
81impl Comment {
82    /// Creates a new comment
83    pub fn new(text: String) -> Self {
84        Self { text }
85    }
86}
87impl AsFea for Comment {
88    fn as_fea(&self, _indent: &str) -> String {
89        self.text.clone()
90    }
91}
92impl From<&str> for Comment {
93    fn from(text: &str) -> Self {
94        Self::new(text.to_string())
95    }
96}
97
98/// Example: `feature salt;`
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct FeatureReferenceStatement {
101    /// The name of the referenced feature
102    pub feature_name: String,
103}
104impl FeatureReferenceStatement {
105    /// Creates a new FeatureReferenceStatement.
106    pub fn new(feature_name: String) -> Self {
107        Self { feature_name }
108    }
109}
110impl AsFea for FeatureReferenceStatement {
111    fn as_fea(&self, _indent: &str) -> String {
112        format!("feature {};", self.feature_name)
113    }
114}
115impl From<fea_rs::typed::FeatureRef> for FeatureReferenceStatement {
116    fn from(feature: fea_rs::typed::FeatureRef) -> Self {
117        Self::new(
118            feature
119                .iter()
120                .find_map(Tag::cast)
121                .unwrap()
122                .text()
123                .to_string(),
124        )
125    }
126}
127
128/// A `head` table `FontRevision` statement.
129///
130/// `revision` should be a number, and will be formatted to three
131/// significant decimal places.
132#[derive(Debug, Clone)]
133pub struct FontRevisionStatement {
134    /// The font revision number
135    pub revision: f32,
136}
137impl FontRevisionStatement {
138    /// Create a new `FontRevision` statement.
139    pub fn new(revision: f32) -> Self {
140        Self { revision }
141    }
142}
143impl AsFea for FontRevisionStatement {
144    fn as_fea(&self, _indent: &str) -> String {
145        format!("FontRevision {:.3};", self.revision)
146    }
147}
148impl From<fea_rs::typed::HeadFontRevision> for FontRevisionStatement {
149    fn from(val: fea_rs::typed::HeadFontRevision) -> Self {
150        let revision_token = val
151            .iter()
152            .find(|t| t.kind() == fea_rs::Kind::Float)
153            .unwrap();
154        FontRevisionStatement {
155            revision: revision_token.as_token().unwrap().text.parse().unwrap(),
156        }
157    }
158}
159impl PartialEq for FontRevisionStatement {
160    fn eq(&self, other: &Self) -> bool {
161        (self.revision * 1000.0).round() == (other.revision * 1000.0).round()
162    }
163}
164impl Eq for FontRevisionStatement {}
165
166/// A glyph class definition
167///
168/// Example: `@UPPERCASE = [A-Z];`
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct GlyphClassDefinition {
171    /// class name as a string, without initial ``@``
172    pub name: String,
173    /// The glyphs in the class
174    pub glyphs: GlyphClass,
175    /// The location of the definition in the source feature file
176    pub location: Range<usize>,
177}
178impl GlyphClassDefinition {
179    /// Create a new glyph class definition.
180    pub fn new(name: String, glyphs: GlyphClass, location: Range<usize>) -> Self {
181        Self {
182            name,
183            glyphs,
184            location,
185        }
186    }
187}
188impl AsFea for GlyphClassDefinition {
189    fn as_fea(&self, _indent: &str) -> String {
190        format!("@{} = {};", self.name, self.glyphs.as_fea(""))
191    }
192}
193impl From<fea_rs::typed::GlyphClassDef> for GlyphClassDefinition {
194    fn from(val: fea_rs::typed::GlyphClassDef) -> Self {
195        let label = val
196            .iter()
197            .find_map(fea_rs::typed::GlyphClassName::cast)
198            .unwrap();
199        let members: fea_rs::typed::GlyphClassLiteral = val
200            .iter()
201            .find_map(fea_rs::typed::GlyphClassLiteral::cast)
202            .unwrap();
203        GlyphClassDefinition {
204            name: label.text().trim_start_matches('@').to_string(),
205            glyphs: members.into(),
206            location: val.node().range(),
207        }
208    }
209}
210
211/// A ``language`` statement within a feature
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct LanguageStatement {
214    /// The OpenType language tag for the language
215    pub tag: String,
216    /// Whether to include the default language system
217    pub include_dflt: bool,
218    /// Whether the language is required
219    pub required: bool,
220}
221impl LanguageStatement {
222    /// Create a new `language` statement.
223    pub fn new(tag: String, include_dflt: bool, required: bool) -> Self {
224        Self {
225            tag,
226            include_dflt,
227            required,
228        }
229    }
230}
231impl AsFea for LanguageStatement {
232    fn as_fea(&self, _indent: &str) -> String {
233        format!(
234            "language {}{}{};",
235            self.tag,
236            if !self.include_dflt {
237                " exclude_dflt"
238            } else {
239                ""
240            },
241            if self.required { " required" } else { "" },
242        )
243    }
244}
245impl From<fea_rs::typed::Language> for LanguageStatement {
246    fn from(language: fea_rs::typed::Language) -> Self {
247        let exclude_dflt = language
248            .iter()
249            .any(|t| t.kind() == fea_rs::Kind::ExcludeDfltKw);
250        let required = language
251            .iter()
252            .any(|t| t.kind() == fea_rs::Kind::RequiredKw);
253        Self::new(
254            language
255                .iter()
256                .find_map(Tag::cast)
257                .unwrap()
258                .text()
259                .to_string(),
260            !exclude_dflt,
261            required,
262        )
263    }
264}
265
266/// A top-level ``languagesystem`` statement.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct LanguageSystemStatement {
269    /// The OpenType script tag for the script
270    pub script: String,
271    /// The OpenType language tag for the language
272    pub language: String,
273}
274impl LanguageSystemStatement {
275    /// Create a new `languagesystem` statement.
276    pub fn new(script: String, language: String) -> Self {
277        Self { script, language }
278    }
279}
280impl AsFea for LanguageSystemStatement {
281    fn as_fea(&self, _indent: &str) -> String {
282        format!(
283            "languagesystem {} {};",
284            self.script,
285            self.language.trim_ascii_end()
286        )
287    }
288}
289impl From<fea_rs::typed::LanguageSystem> for LanguageSystemStatement {
290    fn from(langsys: fea_rs::typed::LanguageSystem) -> Self {
291        let mut tags = langsys.iter().filter_map(Tag::cast);
292        let script = tags.next().unwrap().text().to_string();
293        let language = tags.next().unwrap().text().to_string();
294        Self::new(script, language)
295    }
296}
297
298/// Represents a ``lookup ...;`` statement to include a lookup in a feature.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct LookupReferenceStatement {
301    /// The name of the lookup to include
302    ///
303    /// Note: unlike in Python's fontTools, this is simply the name of the
304    /// lookup rather than a `LookupBlock` object.
305    pub lookup_name: String,
306    /// The location of the statement in the source feature file
307    pub location: Range<usize>,
308}
309impl LookupReferenceStatement {
310    /// Create a new lookup reference statement.
311    pub fn new(lookup_name: String, location: Range<usize>) -> Self {
312        Self {
313            lookup_name,
314            location,
315        }
316    }
317}
318impl AsFea for LookupReferenceStatement {
319    fn as_fea(&self, _indent: &str) -> String {
320        format!("lookup {};", self.lookup_name)
321    }
322}
323impl From<fea_rs::typed::LookupRef> for LookupReferenceStatement {
324    fn from(lookup_ref: fea_rs::typed::LookupRef) -> Self {
325        Self::new(
326            lookup_ref
327                .iter()
328                .find(|t| t.kind() == fea_rs::Kind::Ident)
329                .unwrap()
330                .token_text()
331                .unwrap()
332                .to_string(),
333            lookup_ref.node().range(),
334        )
335    }
336}
337
338/// A ``script`` statement
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct ScriptStatement {
341    /// The OpenType script tag for the script
342    pub tag: String,
343}
344impl ScriptStatement {
345    /// Create a new `script` statement.
346    pub fn new(tag: String) -> Self {
347        Self { tag }
348    }
349}
350impl AsFea for ScriptStatement {
351    fn as_fea(&self, _indent: &str) -> String {
352        format!("script {};", self.tag)
353    }
354}
355impl From<fea_rs::typed::Script> for ScriptStatement {
356    fn from(script: fea_rs::typed::Script) -> Self {
357        Self::new(
358            script
359                .iter()
360                .find_map(Tag::cast)
361                .unwrap()
362                .text()
363                .to_string(),
364        )
365    }
366}
367
368/// Represents a subtable break
369#[derive(Debug, Clone, PartialEq, Eq, Default)]
370pub struct SubtableStatement;
371impl SubtableStatement {
372    /// Create a new `subtable;` statement.
373    pub fn new() -> Self {
374        Self {}
375    }
376}
377impl AsFea for SubtableStatement {
378    fn as_fea(&self, _indent: &str) -> String {
379        "subtable;".to_string()
380    }
381}
382
383/// A ``parameters`` statement for the `size` feature.
384///
385/// Example: `parameters 10.0 0;` or `parameters 10.0 0 80 120;`
386///
387/// Note: `range_start` and `range_end` are stored in **points** internally,
388/// but the FEA format uses **decipoints** (tenths of a point). The conversion
389/// is handled automatically during parsing and serialization.
390#[derive(Debug, Clone, PartialEq)]
391pub struct SizeParameters {
392    /// Design size in points
393    pub design_size: f64,
394    /// Subfamily identifier
395    pub subfamily_id: u16,
396    /// Range start in points (FEA format stores as decipoints, divided by 10 on read)
397    pub range_start: f64,
398    /// Range end in points (FEA format stores as decipoints, divided by 10 on read)
399    pub range_end: f64,
400    /// Location in the source FEA file
401    pub location: Range<usize>,
402}
403impl Eq for SizeParameters {}
404
405impl SizeParameters {
406    /// Create a new SizeParameters statement.
407    pub fn new(
408        design_size: f64,
409        subfamily_id: u16,
410        range_start: f64,
411        range_end: f64,
412        location: Range<usize>,
413    ) -> Self {
414        Self {
415            design_size,
416            subfamily_id,
417            range_start,
418            range_end,
419            location,
420        }
421    }
422}
423
424impl AsFea for SizeParameters {
425    fn as_fea(&self, _indent: &str) -> String {
426        let mut res = format!("parameters {:.1} {}", self.design_size, self.subfamily_id);
427        if self.range_start != 0.0 || self.range_end != 0.0 {
428            res.push_str(&format!(
429                " {} {}",
430                (self.range_start * 10.0) as i32,
431                (self.range_end * 10.0) as i32
432            ));
433        }
434        res.push(';');
435        res
436    }
437}
438
439impl From<fea_rs::typed::Parameters> for SizeParameters {
440    fn from(val: fea_rs::typed::Parameters) -> Self {
441        // Helper to parse FloatLike into f64
442        let parse_float = |fl: fea_rs::typed::FloatLike| -> f64 {
443            match fl {
444                fea_rs::typed::FloatLike::Float(f) => f.text().parse().unwrap(),
445                fea_rs::typed::FloatLike::Number(n) => n.text().parse::<i16>().unwrap() as f64,
446            }
447        };
448
449        // Extract design_size (first FloatLike)
450        let design_size = val
451            .iter()
452            .find_map(fea_rs::typed::FloatLike::cast)
453            .map(parse_float)
454            .unwrap();
455
456        // Extract subfamily_id (second number, after the first FloatLike)
457        let subfamily_id = val
458            .iter()
459            .filter(|t| t.kind() == fea_rs::Kind::Number || t.kind() == fea_rs::Kind::Float)
460            .nth(1)
461            .and_then(fea_rs::typed::Number::cast)
462            .map(|n| n.text().parse().unwrap())
463            .unwrap();
464
465        // Extract range_start (third FloatLike, if present) - FEA stores in decipoints, convert to points
466        let range_start = val
467            .iter()
468            .filter_map(fea_rs::typed::FloatLike::cast)
469            .nth(2)
470            .map(|fl| parse_float(fl) / 10.0)
471            .unwrap_or(0.0);
472
473        // Extract range_end (fourth FloatLike, if present) - FEA stores in decipoints, convert to points
474        let range_end = val
475            .iter()
476            .filter_map(fea_rs::typed::FloatLike::cast)
477            .nth(3)
478            .map(|fl| parse_float(fl) / 10.0)
479            .unwrap_or(0.0);
480
481        Self::new(
482            design_size,
483            subfamily_id,
484            range_start,
485            range_end,
486            val.range(),
487        )
488    }
489}
490
491/// A variable layout conditionset.
492///
493/// Example:
494/// ```fea
495/// conditionset heavy {
496///     wght 700 900;
497/// } heavy;
498/// ```
499#[derive(Debug, Clone, PartialEq)]
500pub struct ConditionSet {
501    /// The name of this conditionset
502    pub name: String,
503    /// A map of axis tags to (min, max) userspace coordinates
504    pub conditions: Vec<(String, f32, f32)>,
505    /// Location in the source FEA file
506    pub location: Range<usize>,
507}
508impl Eq for ConditionSet {}
509
510impl ConditionSet {
511    /// Create a new `conditionset` statement.
512    pub fn new(name: String, conditions: Vec<(String, f32, f32)>, location: Range<usize>) -> Self {
513        Self {
514            name,
515            conditions,
516            location,
517        }
518    }
519}
520
521impl From<fea_rs::typed::ConditionSet> for ConditionSet {
522    fn from(val: fea_rs::typed::ConditionSet) -> Self {
523        // Extract the label (name)
524        let name = val
525            .iter()
526            .find_map(|t| {
527                if t.kind() == fea_rs::Kind::Label {
528                    t.as_token().map(|tok| tok.text.to_string())
529                } else {
530                    None
531                }
532            })
533            .unwrap();
534
535        // Helper to parse numbers as f32
536        let parse_number =
537            |n: fea_rs::typed::Number| -> f32 { n.text().parse::<i16>().unwrap() as f32 };
538
539        // Extract conditions
540        let conditions: Vec<(String, f32, f32)> = val
541            .iter()
542            .filter_map(fea_rs::typed::Condition::cast)
543            .map(|cond| {
544                // Get tag
545                let tag = cond
546                    .iter()
547                    .find_map(fea_rs::typed::Tag::cast)
548                    .unwrap()
549                    .text()
550                    .to_string();
551
552                // Get min and max values
553                let mut numbers = cond.iter().filter_map(fea_rs::typed::Number::cast);
554                let min = parse_number(numbers.next().unwrap());
555                let max = parse_number(numbers.next().unwrap());
556
557                (tag, min, max)
558            })
559            .collect();
560
561        Self::new(name, conditions, val.node().range())
562    }
563}
564
565impl AsFea for ConditionSet {
566    fn as_fea(&self, indent: &str) -> String {
567        let mut res = format!("{}conditionset {} {{\n", indent, self.name);
568        for (tag, min, max) in &self.conditions {
569            // Format numbers nicely - remove trailing zeros and decimal point if integer
570            let format_num = |n: &f32| {
571                let s = format!("{}", n);
572                if s.contains('.') {
573                    s.trim_end_matches('0').trim_end_matches('.').to_string()
574                } else {
575                    s
576                }
577            };
578            res.push_str(&format!(
579                "{}\t{} {} {};\n",
580                indent,
581                tag,
582                format_num(min),
583                format_num(max)
584            ));
585        }
586        res.push_str(&format!("{}}}", indent));
587        res.push_str(&format!(" {};\n", self.name));
588        res
589    }
590}
591
592/// A variable layout variation block.
593///
594/// Example:
595/// ```fea
596/// variation rvrn heavy {
597///     lookup symbols_heavy;
598/// } rvrn;
599/// ```
600#[derive(Debug, Clone, PartialEq, Eq)]
601pub struct VariationBlock {
602    /// The feature tag for this variation
603    pub name: SmolStr,
604    /// The name of the conditionset this variation applies to
605    pub conditionset: String,
606    /// Statements within this variation block
607    pub statements: Vec<Statement>,
608    /// Whether to use extension subtables
609    pub use_extension: bool,
610    /// Location in the source FEA file
611    pub location: Range<usize>,
612}
613
614impl VariationBlock {
615    /// Create a new `variation ABCD { ... } ABCD;` statement.
616    pub fn new(
617        name: SmolStr,
618        conditionset: String,
619        statements: Vec<Statement>,
620        use_extension: bool,
621        location: Range<usize>,
622    ) -> Self {
623        Self {
624            name,
625            conditionset,
626            statements,
627            use_extension,
628            location,
629        }
630    }
631}
632
633impl From<fea_rs::typed::FeatureVariation> for VariationBlock {
634    fn from(val: fea_rs::typed::FeatureVariation) -> Self {
635        // Extract the feature tag (first tag)
636        let name = val
637            .iter()
638            .find_map(fea_rs::typed::Tag::cast)
639            .map(|tag| SmolStr::new(tag.text()))
640            .unwrap();
641
642        // Extract conditionset name - it's a label/identifier after the tag
643        let conditionset = val
644            .iter()
645            .skip_while(|t| t.kind() != fea_rs::Kind::Tag) // skip to tag
646            .skip(1) // skip the tag itself
647            .find_map(|t| {
648                if t.kind() == fea_rs::Kind::Label || t.kind() == fea_rs::Kind::Ident {
649                    t.as_token().map(|tok| tok.text.to_string())
650                } else {
651                    None
652                }
653            })
654            .unwrap_or_default();
655
656        // Check for useExtension flag
657        let use_extension = val.iter().any(|t| t.kind() == fea_rs::Kind::UseExtensionKw);
658
659        // Parse statements within the block
660        let statements: Vec<Statement> = val
661            .node()
662            .iter_children()
663            .filter_map(crate::to_statement)
664            .collect();
665
666        Self::new(
667            name,
668            conditionset,
669            statements,
670            use_extension,
671            val.node().range(),
672        )
673    }
674}
675
676impl AsFea for VariationBlock {
677    fn as_fea(&self, indent: &str) -> String {
678        let mut res = format!("{}variation {} {}", indent, self.name, self.conditionset);
679        if self.use_extension {
680            res.push_str(" useExtension");
681        }
682        res.push_str(" {\n");
683
684        let mid_indent = indent.to_string() + SHIFT;
685        for stmt in &self.statements {
686            res.push_str(&stmt.as_fea(&mid_indent));
687            res.push('\n');
688        }
689
690        res.push_str(&format!("{}}} {};\n", indent, self.name));
691        res
692    }
693}
694
695/// A ``lookupflag`` statement
696#[derive(Debug, Clone, PartialEq, Eq)]
697pub struct LookupFlagStatement {
698    /// The value of the flag
699    pub value: u16,
700    /// Optional MarkAttachmentType
701    pub mark_attachment: Option<GlyphContainer>,
702    /// Optional UseMarkFilteringSet
703    pub mark_filtering_set: Option<GlyphContainer>,
704    /// Location in the source FEA file
705    pub location: Range<usize>,
706}
707
708impl LookupFlagStatement {
709    /// Create a new `lookupflag` statement.
710    pub fn new(
711        value: u16,
712        mark_attachment: Option<GlyphContainer>,
713        mark_filtering_set: Option<GlyphContainer>,
714        location: Range<usize>,
715    ) -> Self {
716        Self {
717            value,
718            mark_attachment,
719            mark_filtering_set,
720            location,
721        }
722    }
723}
724
725impl AsFea for LookupFlagStatement {
726    fn as_fea(&self, _indent: &str) -> String {
727        let mut res = Vec::new();
728        let flags = [
729            "RightToLeft",
730            "IgnoreBaseGlyphs",
731            "IgnoreLigatures",
732            "IgnoreMarks",
733        ];
734        let mut curr = 1u16;
735        for flag in &flags {
736            if self.value & curr != 0 {
737                res.push(flag.to_string());
738            }
739            curr <<= 1;
740        }
741        if let Some(mark_attachment) = &self.mark_attachment {
742            res.push(format!("MarkAttachmentType {}", mark_attachment.as_fea("")));
743        }
744        if let Some(mark_filtering_set) = &self.mark_filtering_set {
745            res.push(format!(
746                "UseMarkFilteringSet {}",
747                mark_filtering_set.as_fea("")
748            ));
749        }
750        if res.is_empty() {
751            res.push("0".to_string());
752        }
753        format!("lookupflag {};", res.join(" "))
754    }
755}
756
757impl From<fea_rs::typed::LookupFlag> for LookupFlagStatement {
758    fn from(val: fea_rs::typed::LookupFlag) -> Self {
759        let mut value = 0u16;
760        // Check for a numeric value
761        if let Some(number) = val.iter().find_map(fea_rs::typed::Number::cast) {
762            value = number.text().parse().unwrap();
763        } else {
764            for item in val.iter() {
765                match item.kind() {
766                    fea_rs::Kind::RightToLeftKw => value |= 1,
767                    fea_rs::Kind::IgnoreBaseGlyphsKw => value |= 2,
768                    fea_rs::Kind::IgnoreLigaturesKw => value |= 4,
769                    fea_rs::Kind::IgnoreMarksKw => value |= 8,
770                    _ => {}
771                }
772            }
773        }
774
775        // Collect all items and process MarkAttachment and UseMarkFilteringSet
776        let mark_attachment = val
777            .iter()
778            .skip_while(|k| k.kind() != fea_rs::Kind::MarkAttachmentTypeKw)
779            .find_map(|gc| fea_rs::typed::GlyphClass::cast(gc).map(|g| g.into()));
780        let mark_filtering_set = val
781            .iter()
782            .skip_while(|k| k.kind() != fea_rs::Kind::UseMarkFilteringSetKw)
783            .find_map(|gc| fea_rs::typed::GlyphClass::cast(gc).map(|g| g.into()));
784
785        LookupFlagStatement::new(value, mark_attachment, mark_filtering_set, val.range())
786    }
787}
788
789/// A definition of a glyph in a mark class, associating it with an anchor point.
790///
791/// See the notes for [`MarkClass`] to understand how this differs from the
792/// Python `fontTools` representation.
793#[derive(Debug, Clone, PartialEq, Eq)]
794pub struct MarkClassDefinition {
795    /// The name of the mark class
796    pub mark_class: MarkClass,
797    /// The anchor associated with this mark class glyph
798    pub anchor: crate::Anchor,
799    /// The glyphs in this mark class
800    pub glyphs: GlyphContainer,
801}
802impl MarkClassDefinition {
803    /// Create a new `markClass` definition.
804    pub fn new(mark_class: MarkClass, anchor: crate::Anchor, glyphs: GlyphContainer) -> Self {
805        Self {
806            mark_class,
807            anchor,
808            glyphs,
809        }
810    }
811}
812impl AsFea for MarkClassDefinition {
813    fn as_fea(&self, _indent: &str) -> String {
814        format!(
815            "markClass {} {} @{};",
816            self.glyphs.as_fea(""),
817            self.anchor.as_fea(""),
818            self.mark_class.name,
819        )
820    }
821}
822impl From<fea_rs::typed::MarkClassDef> for MarkClassDefinition {
823    fn from(val: fea_rs::typed::MarkClassDef) -> Self {
824        // Glyphs are the first GlyphOrClass
825        let glyphs_node = val
826            .iter()
827            .find_map(fea_rs::typed::GlyphOrClass::cast)
828            .unwrap();
829        // Anchor is the first Anchor
830        let anchor_node = val.iter().find_map(fea_rs::typed::Anchor::cast).unwrap();
831        let anchor = from_anchor(anchor_node).unwrap();
832        // MarkClass name is the GlyphClassName after the anchor
833        let mark_class_node = val
834            .iter()
835            .skip_while(|t| t.kind() != fea_rs::Kind::AnchorNode)
836            .find_map(fea_rs::typed::GlyphClassName::cast)
837            .unwrap();
838        let mark_class = MarkClass::new(mark_class_node.text().trim_start_matches('@'));
839        MarkClassDefinition::new(mark_class, anchor, GlyphContainer::from(glyphs_node))
840    }
841}
842
843/// Represents a named value record definition.
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub struct ValueRecordDefinition {
846    /// The name of the value record
847    pub name: SmolStr,
848    /// The value record data
849    pub value: ValueRecord,
850    /// The location of the definition in the source feature file
851    pub location: Range<usize>,
852}
853impl ValueRecordDefinition {
854    /// Create a new value record definition.
855    pub fn new(name: SmolStr, value: ValueRecord, location: Range<usize>) -> Self {
856        Self {
857            name,
858            value,
859            location,
860        }
861    }
862}
863
864impl AsFea for ValueRecordDefinition {
865    fn as_fea(&self, _indent: &str) -> String {
866        format!("valueRecordDef {} {};", self.value.as_fea(""), self.name)
867    }
868}
869
870impl From<fea_rs::typed::ValueRecordDef> for ValueRecordDefinition {
871    fn from(val: fea_rs::typed::ValueRecordDef) -> Self {
872        let name = val
873            .iter()
874            .find(|t| t.kind() == fea_rs::Kind::Ident)
875            .unwrap();
876        let value_record_node = val
877            .iter()
878            .find_map(fea_rs::typed::ValueRecord::cast)
879            .unwrap();
880        ValueRecordDefinition::new(
881            name.as_token().unwrap().text.clone(),
882            ValueRecord::from(value_record_node),
883            val.node().range(),
884        )
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use crate::{GlyphContainer, GlyphName};
892
893    #[test]
894    fn test_roundtrip_lookupflag_simple() {
895        const FEA: &str = "lookup test { lookupflag RightToLeft; } test;";
896        let (parsed, _) = fea_rs::parse::parse_string(FEA);
897        let lookup = parsed
898            .root()
899            .iter_children()
900            .find_map(fea_rs::typed::LookupBlock::cast)
901            .unwrap();
902        let lookupflag = lookup
903            .node()
904            .iter_children()
905            .find_map(fea_rs::typed::LookupFlag::cast)
906            .unwrap();
907        let stmt = LookupFlagStatement::from(lookupflag);
908        assert_eq!(stmt.value, 1);
909        assert_eq!(stmt.as_fea(""), "lookupflag RightToLeft;");
910    }
911
912    #[test]
913    fn test_roundtrip_lookupflag_multiple() {
914        const FEA: &str = "lookup test { lookupflag RightToLeft IgnoreMarks; } test;";
915        let (parsed, _) = fea_rs::parse::parse_string(FEA);
916        let lookup = parsed
917            .root()
918            .iter_children()
919            .find_map(fea_rs::typed::LookupBlock::cast)
920            .unwrap();
921        let lookupflag = lookup
922            .node()
923            .iter_children()
924            .find_map(fea_rs::typed::LookupFlag::cast)
925            .unwrap();
926        let stmt = LookupFlagStatement::from(lookupflag);
927        assert_eq!(stmt.value, 9); // 1 + 8
928        assert_eq!(stmt.as_fea(""), "lookupflag RightToLeft IgnoreMarks;");
929    }
930
931    #[test]
932    fn test_roundtrip_lookupflag_zero() {
933        const FEA: &str = "lookup test { lookupflag 0; } test;";
934        let (parsed, _) = fea_rs::parse::parse_string(FEA);
935        let lookup = parsed
936            .root()
937            .iter_children()
938            .find_map(fea_rs::typed::LookupBlock::cast)
939            .unwrap();
940        let lookupflag = lookup
941            .node()
942            .iter_children()
943            .find_map(fea_rs::typed::LookupFlag::cast)
944            .unwrap();
945        let stmt = LookupFlagStatement::from(lookupflag);
946        assert_eq!(stmt.value, 0);
947        assert_eq!(stmt.as_fea(""), "lookupflag 0;");
948    }
949
950    #[test]
951    fn test_generate_lookupflag() {
952        let stmt = LookupFlagStatement::new(
953            10, // IgnoreBaseGlyphs (2) + IgnoreMarks (8)
954            None,
955            None,
956            0..0,
957        );
958        assert_eq!(stmt.as_fea(""), "lookupflag IgnoreBaseGlyphs IgnoreMarks;");
959    }
960
961    #[test]
962    fn test_generate_lookupflag_with_mark_attachment() {
963        let stmt = LookupFlagStatement::new(
964            0,
965            Some(GlyphContainer::GlyphClass(GlyphClass::new(
966                vec![
967                    GlyphContainer::GlyphName(GlyphName::new("acute")),
968                    GlyphContainer::GlyphName(GlyphName::new("grave")),
969                ],
970                0..0,
971            ))),
972            None,
973            0..0,
974        );
975        assert_eq!(
976            stmt.as_fea(""),
977            "lookupflag MarkAttachmentType [acute grave];"
978        );
979    }
980
981    // AnchorDefinition tests
982    #[test]
983    fn test_roundtrip_anchordef_simple() {
984        const FEA: &str = "anchorDef 300 100 ANCHOR_1;";
985        let (parsed, _) = fea_rs::parse::parse_string(FEA);
986        let anchor_def = parsed
987            .root()
988            .iter_children()
989            .find_map(fea_rs::typed::AnchorDef::cast)
990            .unwrap();
991        let stmt = AnchorDefinition::from(anchor_def);
992        assert_eq!(stmt.x, 300.into());
993        assert_eq!(stmt.y, 100.into());
994        assert_eq!(stmt.name, "ANCHOR_1");
995        assert_eq!(stmt.contourpoint, None);
996        assert_eq!(stmt.as_fea(""), "anchorDef 300 100 ANCHOR_1;");
997    }
998
999    #[test]
1000    fn test_roundtrip_anchordef_contourpoint() {
1001        const FEA: &str = "anchorDef 300 100 contourpoint 5 ANCHOR_1;";
1002        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1003        let anchor_def = parsed
1004            .root()
1005            .iter_children()
1006            .find_map(fea_rs::typed::AnchorDef::cast)
1007            .unwrap();
1008        let stmt = AnchorDefinition::from(anchor_def);
1009        assert_eq!(stmt.x, 300.into());
1010        assert_eq!(stmt.y, 100.into());
1011        assert_eq!(stmt.contourpoint, Some(5));
1012        assert_eq!(stmt.name, "ANCHOR_1");
1013        assert_eq!(
1014            stmt.as_fea(""),
1015            "anchorDef 300 100 contourpoint 5 ANCHOR_1;"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_generation_anchordef() {
1021        let stmt = AnchorDefinition::new(150.into(), (-50).into(), None, "BASE".to_string(), 0..0);
1022        assert_eq!(stmt.as_fea(""), "anchorDef 150 -50 BASE;");
1023    }
1024
1025    // FeatureReferenceStatement tests
1026    #[test]
1027    fn test_roundtrip_featurereference() {
1028        const FEA: &str = "feature test { feature salt; } test;";
1029        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1030        let feature = parsed
1031            .root()
1032            .iter_children()
1033            .find_map(fea_rs::typed::Feature::cast)
1034            .unwrap();
1035        let feature_ref = feature
1036            .node()
1037            .iter_children()
1038            .find_map(fea_rs::typed::FeatureRef::cast)
1039            .unwrap();
1040        let stmt = FeatureReferenceStatement::from(feature_ref);
1041        assert_eq!(stmt.feature_name, "salt");
1042        assert_eq!(stmt.as_fea(""), "feature salt;");
1043    }
1044
1045    #[test]
1046    fn test_generation_featurereference() {
1047        let stmt = FeatureReferenceStatement::new("liga".to_string());
1048        assert_eq!(stmt.as_fea(""), "feature liga;");
1049    }
1050
1051    // FontRevisionStatement tests
1052    #[test]
1053    fn test_roundtrip_fontrevision() {
1054        const FEA: &str = "table head { FontRevision 2.500; } head;";
1055        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1056        let table = parsed
1057            .root()
1058            .iter_children()
1059            .find_map(fea_rs::typed::HeadTable::cast)
1060            .unwrap();
1061        let font_rev = table
1062            .node()
1063            .iter_children()
1064            .find_map(fea_rs::typed::HeadFontRevision::cast)
1065            .unwrap();
1066        let stmt = FontRevisionStatement::from(font_rev);
1067        assert_eq!(stmt.revision, 2.5);
1068        assert_eq!(stmt.as_fea(""), "FontRevision 2.500;");
1069    }
1070
1071    #[test]
1072    fn test_generation_fontrevision() {
1073        let stmt = FontRevisionStatement::new(1.125);
1074        assert_eq!(stmt.as_fea(""), "FontRevision 1.125;");
1075    }
1076
1077    // GlyphClassDefinition tests
1078    #[test]
1079    fn test_roundtrip_glyphclassdef() {
1080        const FEA: &str = "@UPPERCASE = [A B C D E F];";
1081        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1082        let glyph_class_def = parsed
1083            .root()
1084            .iter_children()
1085            .find_map(fea_rs::typed::GlyphClassDef::cast)
1086            .unwrap();
1087        let stmt = GlyphClassDefinition::from(glyph_class_def);
1088        assert_eq!(stmt.name, "UPPERCASE");
1089        assert_eq!(stmt.glyphs.glyphs.len(), 6);
1090        assert_eq!(stmt.as_fea(""), "@UPPERCASE = [A B C D E F];");
1091    }
1092
1093    #[test]
1094    fn test_generation_glyphclassdef() {
1095        let glyphs = GlyphClass::new(
1096            vec![
1097                GlyphContainer::GlyphName(GlyphName::new("a")),
1098                GlyphContainer::GlyphName(GlyphName::new("b")),
1099                GlyphContainer::GlyphName(GlyphName::new("c")),
1100            ],
1101            0..0,
1102        );
1103        let stmt = GlyphClassDefinition::new("lowercase".to_string(), glyphs, 0..0);
1104        assert_eq!(stmt.as_fea(""), "@lowercase = [a b c];");
1105    }
1106
1107    // LanguageStatement tests
1108    #[test]
1109    fn test_roundtrip_language() {
1110        const FEA: &str = "feature test { language TRK; } test;";
1111        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1112        let feature = parsed
1113            .root()
1114            .iter_children()
1115            .find_map(fea_rs::typed::Feature::cast)
1116            .unwrap();
1117        let lang = feature
1118            .node()
1119            .iter_children()
1120            .find_map(fea_rs::typed::Language::cast)
1121            .unwrap();
1122        let stmt = LanguageStatement::from(lang);
1123        // Note: tag includes any trailing spaces from the source
1124        assert_eq!(stmt.as_fea(""), "language TRK;");
1125    }
1126
1127    #[test]
1128    fn test_generation_language() {
1129        let stmt = LanguageStatement::new("DEU ".to_string(), true, false);
1130        assert_eq!(stmt.as_fea(""), "language DEU ;");
1131    }
1132
1133    // LanguageSystemStatement tests
1134    #[test]
1135    fn test_roundtrip_languagesystem() {
1136        const FEA: &str = "languagesystem latn dflt;";
1137        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1138        let langsys = parsed
1139            .root()
1140            .iter_children()
1141            .find_map(fea_rs::typed::LanguageSystem::cast)
1142            .unwrap();
1143        let stmt = LanguageSystemStatement::from(langsys);
1144        assert_eq!(stmt.script, "latn");
1145        assert_eq!(stmt.language, "dflt");
1146        assert_eq!(stmt.as_fea(""), "languagesystem latn dflt;");
1147    }
1148
1149    #[test]
1150    fn test_generation_languagesystem() {
1151        let stmt = LanguageSystemStatement::new("cyrl".to_string(), "SRB ".to_string());
1152        assert_eq!(stmt.as_fea(""), "languagesystem cyrl SRB;");
1153    }
1154
1155    // ScriptStatement tests
1156    #[test]
1157    fn test_roundtrip_script() {
1158        const FEA: &str = "feature test { script latn; } test;";
1159        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1160        let feature = parsed
1161            .root()
1162            .iter_children()
1163            .find_map(fea_rs::typed::Feature::cast)
1164            .unwrap();
1165        let script = feature
1166            .node()
1167            .iter_children()
1168            .find_map(fea_rs::typed::Script::cast)
1169            .unwrap();
1170        let stmt = ScriptStatement::from(script);
1171        assert_eq!(stmt.tag, "latn");
1172        assert_eq!(stmt.as_fea(""), "script latn;");
1173    }
1174
1175    #[test]
1176    fn test_generation_script() {
1177        let stmt = ScriptStatement::new("arab".to_string());
1178        assert_eq!(stmt.as_fea(""), "script arab;");
1179    }
1180
1181    // SubtableStatement tests
1182    #[test]
1183    fn test_generation_subtable() {
1184        let stmt = SubtableStatement::new();
1185        assert_eq!(stmt.as_fea(""), "subtable;");
1186    }
1187
1188    // LookupReferenceStatement tests
1189    #[test]
1190    fn test_roundtrip_lookupreference() {
1191        const FEA: &str = "feature test { lookup myLookup; } test;";
1192        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1193        let feature = parsed
1194            .root()
1195            .iter_children()
1196            .find_map(fea_rs::typed::Feature::cast)
1197            .unwrap();
1198        let lookup_ref = feature
1199            .node()
1200            .iter_children()
1201            .find_map(fea_rs::typed::LookupRef::cast)
1202            .unwrap();
1203        let stmt = LookupReferenceStatement::from(lookup_ref);
1204        assert_eq!(stmt.lookup_name, "myLookup");
1205        assert_eq!(stmt.as_fea(""), "lookup myLookup;");
1206    }
1207
1208    #[test]
1209    fn test_generation_lookupreference() {
1210        let stmt = LookupReferenceStatement::new("anotherLookup".to_string(), 0..0);
1211        assert_eq!(stmt.as_fea(""), "lookup anotherLookup;");
1212    }
1213
1214    // SizeParameters tests
1215    #[test]
1216    fn test_roundtrip_sizeparameters_simple() {
1217        const FEA: &str = "feature size { parameters 10.0 0; } size;";
1218        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1219        let feature = parsed
1220            .root()
1221            .iter_children()
1222            .find_map(fea_rs::typed::Feature::cast)
1223            .unwrap();
1224        let params = feature
1225            .node()
1226            .iter_children()
1227            .find_map(fea_rs::typed::Parameters::cast)
1228            .unwrap();
1229        let stmt = SizeParameters::from(params);
1230        assert_eq!(stmt.design_size, 10.0);
1231        assert_eq!(stmt.subfamily_id, 0);
1232        assert_eq!(stmt.range_start, 0.0);
1233        assert_eq!(stmt.range_end, 0.0);
1234        assert_eq!(stmt.as_fea(""), "parameters 10.0 0;");
1235    }
1236
1237    #[test]
1238    fn test_roundtrip_sizeparameters_with_range() {
1239        const FEA: &str = "feature size { parameters 10.0 0 80 120; } size;";
1240        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1241        let feature = parsed
1242            .root()
1243            .iter_children()
1244            .find_map(fea_rs::typed::Feature::cast)
1245            .unwrap();
1246        let params = feature
1247            .node()
1248            .iter_children()
1249            .find_map(fea_rs::typed::Parameters::cast)
1250            .unwrap();
1251        let stmt = SizeParameters::from(params);
1252        assert_eq!(stmt.design_size, 10.0);
1253        assert_eq!(stmt.subfamily_id, 0);
1254        assert_eq!(stmt.range_start, 8.0); // 80 decipoints = 8.0 points
1255        assert_eq!(stmt.range_end, 12.0); // 120 decipoints = 12.0 points
1256        assert_eq!(stmt.as_fea(""), "parameters 10.0 0 80 120;");
1257    }
1258
1259    #[test]
1260    fn test_generate_sizeparameters() {
1261        let stmt = SizeParameters::new(12.5, 1, 100.0, 150.0, 0..0);
1262        assert_eq!(stmt.as_fea(""), "parameters 12.5 1 1000 1500;");
1263    }
1264
1265    #[test]
1266    fn test_generation_lookupflag() {
1267        let stmt = LookupFlagStatement::new(
1268            0,
1269            Some(GlyphContainer::GlyphClass(GlyphClass::new(
1270                vec![
1271                    GlyphContainer::GlyphName(GlyphName::new("acute")),
1272                    GlyphContainer::GlyphName(GlyphName::new("grave")),
1273                ],
1274                0..0,
1275            ))),
1276            None,
1277            0..0,
1278        );
1279        assert_eq!(
1280            stmt.as_fea(""),
1281            "lookupflag MarkAttachmentType [acute grave];"
1282        );
1283        let stmt = LookupFlagStatement::new(
1284            9,
1285            None,
1286            Some(GlyphContainer::GlyphClass(GlyphClass::new(
1287                vec![
1288                    GlyphContainer::GlyphName(GlyphName::new("acute")),
1289                    GlyphContainer::GlyphName(GlyphName::new("grave")),
1290                ],
1291                0..0,
1292            ))),
1293            0..0,
1294        );
1295        assert_eq!(
1296            stmt.as_fea(""),
1297            "lookupflag RightToLeft IgnoreMarks UseMarkFilteringSet [acute grave];"
1298        );
1299    }
1300
1301    #[test]
1302    fn test_roundtrip_lookupflag() {
1303        const FEA: &str = "lookup test { lookupflag RightToLeft IgnoreMarks UseMarkFilteringSet [acute grave]; } test;";
1304        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1305        let lookup = parsed
1306            .root()
1307            .iter_children()
1308            .find_map(fea_rs::typed::LookupBlock::cast)
1309            .unwrap();
1310        let lookupflag = lookup
1311            .node()
1312            .iter_children()
1313            .find_map(fea_rs::typed::LookupFlag::cast)
1314            .unwrap();
1315        let stmt = LookupFlagStatement::from(lookupflag);
1316        assert_eq!(stmt.value, 9); // 1 + 8
1317        assert_eq!(
1318            stmt.clone().mark_filtering_set.unwrap().as_fea(""),
1319            "[acute grave]"
1320        );
1321        assert_eq!(
1322            stmt.as_fea(""),
1323            "lookupflag RightToLeft IgnoreMarks UseMarkFilteringSet [acute grave];"
1324        );
1325
1326        const FEA2: &str =
1327            "lookup test { lookupflag RightToLeft IgnoreMarks MarkAttachmentType @foo; } test;";
1328        let (parsed, _) = fea_rs::parse::parse_string(FEA2);
1329        let lookup = parsed
1330            .root()
1331            .iter_children()
1332            .find_map(fea_rs::typed::LookupBlock::cast)
1333            .unwrap();
1334        let lookupflag = lookup
1335            .node()
1336            .iter_children()
1337            .find_map(fea_rs::typed::LookupFlag::cast)
1338            .unwrap();
1339        let stmt = LookupFlagStatement::from(lookupflag);
1340        assert_eq!(stmt.value, 9);
1341        assert_eq!(
1342            stmt.as_fea(""),
1343            "lookupflag RightToLeft IgnoreMarks MarkAttachmentType @foo;"
1344        );
1345    }
1346
1347    // ConditionSet tests
1348    #[test]
1349    fn test_roundtrip_conditionset_simple() {
1350        const FEA: &str = r#"conditionset heavy {
1351	wght 700 900;
1352} heavy;"#;
1353        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1354        let condset = parsed
1355            .root()
1356            .iter_children()
1357            .find_map(fea_rs::typed::ConditionSet::cast)
1358            .unwrap();
1359        let stmt = ConditionSet::from(condset);
1360        assert_eq!(stmt.name, "heavy");
1361        assert_eq!(stmt.conditions.len(), 1);
1362        assert_eq!(stmt.conditions[0].0, "wght");
1363        assert_eq!(stmt.conditions[0].1, 700.0);
1364        assert_eq!(stmt.conditions[0].2, 900.0);
1365
1366        let output = stmt.as_fea("");
1367        assert!(output.contains("conditionset heavy"));
1368        assert!(output.contains("wght 700 900"));
1369    }
1370
1371    #[test]
1372    fn test_roundtrip_conditionset_multiple_conditions() {
1373        const FEA: &str = r#"conditionset complex {
1374	wght 400 700;
1375	wdth 75 100;
1376} complex;"#;
1377        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1378        let condset = parsed
1379            .root()
1380            .iter_children()
1381            .find_map(fea_rs::typed::ConditionSet::cast)
1382            .unwrap();
1383        let stmt = ConditionSet::from(condset);
1384        assert_eq!(stmt.name, "complex");
1385        assert_eq!(stmt.conditions.len(), 2);
1386        assert_eq!(stmt.conditions[0], ("wght".to_string(), 400.0, 700.0));
1387        assert_eq!(stmt.conditions[1], ("wdth".to_string(), 75.0, 100.0));
1388
1389        let output = stmt.as_fea("");
1390        assert!(output.contains("conditionset complex"));
1391        assert!(output.contains("wght 400 700"));
1392        assert!(output.contains("wdth 75 100"));
1393    }
1394
1395    #[test]
1396    fn test_roundtrip_conditionset_from_file() {
1397        const FEA: &str = include_str!("../resources/test/variable_conditionset.fea");
1398        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1399        let condset = parsed
1400            .root()
1401            .iter_children()
1402            .find_map(fea_rs::typed::ConditionSet::cast)
1403            .unwrap();
1404        let stmt = ConditionSet::from(condset);
1405        assert_eq!(stmt.name, "heavy");
1406        assert_eq!(stmt.conditions.len(), 1);
1407        assert_eq!(stmt.conditions[0], ("wght".to_string(), 700.0, 900.0));
1408    }
1409
1410    #[test]
1411    fn test_generate_conditionset() {
1412        let stmt = ConditionSet::new(
1413            "myCondition".to_string(),
1414            vec![
1415                ("wght".to_string(), 300.0, 500.0),
1416                ("opsz".to_string(), 8.0, 12.0),
1417            ],
1418            0..0,
1419        );
1420
1421        let output = stmt.as_fea("");
1422        assert!(output.contains("conditionset myCondition"));
1423        assert!(output.contains("wght 300 500"));
1424        assert!(output.contains("opsz 8 12"));
1425        assert!(output.contains("} myCondition;"));
1426    }
1427
1428    #[test]
1429    fn test_conditionset_integration() {
1430        // Test that ConditionSet can be parsed as a top-level item
1431        const FEA: &str = r#"languagesystem DFLT dflt;
1432
1433conditionset heavy {
1434    wght 700 900;
1435} heavy;"#;
1436
1437        let ff = crate::FeatureFile::new_from_fea(FEA, None::<&[&str]>, None::<&str>).unwrap();
1438        assert_eq!(ff.statements.len(), 2);
1439
1440        // Check that conditionset is in the statements
1441        let cs = ff
1442            .statements
1443            .iter()
1444            .find_map(|item| {
1445                if let crate::ToplevelItem::ConditionSet(cs) = item {
1446                    Some(cs)
1447                } else {
1448                    None
1449                }
1450            })
1451            .expect("Should have found ConditionSet");
1452
1453        assert_eq!(cs.name, "heavy");
1454        assert_eq!(cs.conditions.len(), 1);
1455        assert_eq!(cs.conditions[0], ("wght".to_string(), 700.0, 900.0));
1456
1457        // Test round-trip
1458        let output = cs.as_fea("");
1459        assert!(output.contains("conditionset heavy"));
1460        assert!(output.contains("wght 700 900"));
1461    }
1462
1463    // VariationBlock tests
1464    #[test]
1465    fn test_roundtrip_variationblock() {
1466        const FEA: &str = include_str!("../resources/test/variable_conditionset.fea");
1467        let (parsed, _) = fea_rs::parse::parse_string(FEA);
1468        let variation = parsed
1469            .root()
1470            .iter_children()
1471            .find_map(fea_rs::typed::FeatureVariation::cast)
1472            .unwrap();
1473        let stmt = VariationBlock::from(variation);
1474
1475        assert_eq!(stmt.name, "rvrn");
1476        assert_eq!(stmt.conditionset, "heavy");
1477        assert_eq!(stmt.statements.len(), 1);
1478
1479        let output = stmt.as_fea("");
1480        assert!(output.contains("variation rvrn heavy"));
1481        assert!(output.contains("lookup symbols_heavy"));
1482    }
1483
1484    #[test]
1485    fn test_generate_variationblock() {
1486        let stmt = VariationBlock::new(
1487            "rvrn".into(),
1488            "myCondition".to_string(),
1489            vec![crate::Statement::Comment(crate::Comment::from("# Test"))],
1490            false,
1491            0..0,
1492        );
1493
1494        let output = stmt.as_fea("");
1495        assert!(output.contains("variation rvrn myCondition"));
1496        assert!(output.contains("# Test"));
1497        assert!(output.contains("} rvrn;"));
1498    }
1499
1500    #[test]
1501    fn test_variationblock_integration() {
1502        const FEA: &str = include_str!("../resources/test/variable_conditionset.fea");
1503
1504        let ff = crate::FeatureFile::new_from_fea(FEA, None::<&[&str]>, None::<&str>).unwrap();
1505
1506        // Should have: languagesystem, lookup, conditionset, variation
1507        assert!(ff.statements.len() >= 4);
1508
1509        // Check that variation block is in the statements
1510        let vb = ff
1511            .statements
1512            .iter()
1513            .find_map(|item| {
1514                if let crate::ToplevelItem::VariationBlock(vb) = item {
1515                    Some(vb)
1516                } else {
1517                    None
1518                }
1519            })
1520            .expect("Should have found VariationBlock");
1521
1522        assert_eq!(vb.name.as_str(), "rvrn");
1523        assert_eq!(vb.conditionset, "heavy");
1524    }
1525}