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