Repository: googlefonts/glyphsLib

Issue:
enhance support for external feature files (adapt file path)
Trying to build this font; https://github.com/rsms/inter-gf-tight
The features are contained in separated feature files, and I have this error when building;

```
Traceback (most recent call last):
  File "/Users/rosalie/Google/env/bin/gftools-builder.py", line 83, in <module>
    builder.build()
  File "/Users/rosalie/Google/env/lib/python3.9/site-packages/gftools/builder/__init__.py", line 194, in build
    self.build_variable()
  File "/Users/rosalie/Google/env/lib/python3.9/site-packages/gftools/builder/__init__.py", line 298, in build_variable
    output_files = self.run_fontmake(source, args)
  File "/Users/rosalie/Google/env/lib/python3.9/site-packages/gftools/builder/__init__.py", line 338, in run_fontmake
    FontProject().run_from_glyphs(source, **args)
  File "/Users/rosalie/Google/env/lib/python3.9/site-packages/fontmake/font_project.py", line 741, in run_from_glyphs
    self.run_from_designspace(designspace_path, **kwargs)
  File "/Users/rosalie/Google/env/lib/python3.9/site-packages/fontmake/font_project.py", line 1017, in run_from_designspace
    raise FontmakeError(
fontmake.errors.FontmakeError: In 'InterTight.glyphs' -> 'master_ufo/InterTight.designspace': Generating fonts from Designspace failed: /Users/rosalie/Google/forks/inter-gf-tight/sources/master_ufo/InterTight-Thin.ufo/features.fea:85:8: The following feature file should be included but cannot be found: ccmp.fea
```

GlyphsApp can build the font, and it finds the features of course.

I figured that the problem was that the master_ufo is another directory, and therefore the path of the feature should be adapted; instead of having `include(features/ccmp.fea);`, it should then be `include(../features/ccmp.fea);`. Doing it in the Glyphs file makes it works but breaks the export with Glyphs for other users. IMO glyphsLib or glyphs2ufo should adapt the feature path accordingly.

Discussion:

Patch:
diff --git a/Lib/glyphsLib/__init__.py b/Lib/glyphsLib/__init__.py
index 367c3235..a4aea3d1 100644
--- a/Lib/glyphsLib/__init__.py
+++ b/Lib/glyphsLib/__init__.py
@@ -53,6 +53,7 @@ def load_to_ufos(
     family_name=None,
     propagate_anchors=None,
     ufo_module=None,
+    expand_includes=False,
     minimal=False,
     glyph_data=None,
 ):
@@ -70,6 +71,7 @@ def load_to_ufos(
         family_name=family_name,
         propagate_anchors=propagate_anchors,
         ufo_module=ufo_module,
+        expand_includes=expand_includes,
         minimal=minimal,
         glyph_data=glyph_data,
     )
@@ -88,6 +90,7 @@ def build_masters(
     generate_GDEF=True,
     store_editor_state=True,
     write_skipexportglyphs=False,
+    expand_includes=False,
     ufo_module=None,
     minimal=False,
     glyph_data=None,
@@ -126,6 +129,7 @@ def build_masters(
         generate_GDEF=generate_GDEF,
         store_editor_state=store_editor_state,
         write_skipexportglyphs=write_skipexportglyphs,
+        expand_includes=expand_includes,
         ufo_module=ufo_module,
         minimal=minimal,
         glyph_data=glyph_data,
diff --git a/Lib/glyphsLib/builder/__init__.py b/Lib/glyphsLib/builder/__init__.py
index 624a4baf..c52ccc53 100644
--- a/Lib/glyphsLib/builder/__init__.py
+++ b/Lib/glyphsLib/builder/__init__.py
@@ -31,6 +31,7 @@ def to_ufos(
     generate_GDEF=True,
     store_editor_state=True,
     write_skipexportglyphs=False,
+    expand_includes=False,
     minimal=False,
     glyph_data=None,
 ):
@@ -47,6 +48,9 @@ def to_ufos(
     If generate_GDEF is True, write a `table GDEF {...}` statement in the
     UFO's features.fea, containing GlyphClassDef and LigatureCaretByPos.
 
+    If expand_includes is True, resolve include statements in the GSFont features
+    and inline them in the UFO features.fea.
+
     If minimal is True, it is assumed that the UFOs will only be used in
     font production, and unnecessary steps (e.g. converting background layers)
     will be skipped.
@@ -60,6 +64,7 @@ def to_ufos(
         generate_GDEF=generate_GDEF,
         store_editor_state=store_editor_state,
         write_skipexportglyphs=write_skipexportglyphs,
+        expand_includes=expand_includes,
         minimal=minimal,
         glyph_data=glyph_data,
     )
@@ -81,6 +86,7 @@ def to_designspace(
     generate_GDEF=True,
     store_editor_state=True,
     write_skipexportglyphs=False,
+    expand_includes=False,
     minimal=False,
     glyph_data=None,
 ):
@@ -117,6 +123,7 @@ def to_designspace(
         generate_GDEF=generate_GDEF,
         store_editor_state=store_editor_state,
         write_skipexportglyphs=write_skipexportglyphs,
+        expand_includes=expand_includes,
         minimal=minimal,
         glyph_data=glyph_data,
     )
@@ -128,6 +135,7 @@ def to_glyphs(
     glyphs_module=classes,
     ufo_module=None,
     minimize_ufo_diffs=False,
+    expand_includes=False,
 ):
     """
     Take a list of UFOs or a single DesignspaceDocument with attached UFOs
@@ -146,6 +154,7 @@ def to_glyphs(
             glyphs_module=glyphs_module,
             ufo_module=ufo_module,
             minimize_ufo_diffs=minimize_ufo_diffs,
+            expand_includes=expand_includes,
         )
     else:
         builder = GlyphsBuilder(
@@ -153,5 +162,6 @@ def to_glyphs(
             glyphs_module=glyphs_module,
             ufo_module=ufo_module,
             minimize_ufo_diffs=minimize_ufo_diffs,
+            expand_includes=expand_includes,
         )
     return builder.font
diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py
index f3bd845f..3beea470 100644
--- a/Lib/glyphsLib/builder/builders.py
+++ b/Lib/glyphsLib/builder/builders.py
@@ -47,6 +47,7 @@ class UFOBuilder(LoggerMixin):
         generate_GDEF=True,
         store_editor_state=True,
         write_skipexportglyphs=False,
+        expand_includes=False,
         minimal=False,
         glyph_data=None,
     ):
@@ -79,6 +80,10 @@ class UFOBuilder(LoggerMixin):
                                          into the UFOs' and Designspace's lib instead
                                          of the glyph level lib key
                                          "com.schriftgestaltung.Glyphs.Export".
+        expand_includes -- If True, expand include statements in the GSFont features
+                           and inline them in the UFO features.fea.
+        minimal -- If True, it is assumed that the UFOs will only be used in font
+                   production, and unnecessary steps will be skipped.
         glyph_data -- A list of GlyphData.
         """
         self.font = font
@@ -96,6 +101,7 @@ class UFOBuilder(LoggerMixin):
         self.store_editor_state = store_editor_state
         self.bracket_layers = []
         self.write_skipexportglyphs = write_skipexportglyphs
+        self.expand_includes = expand_includes
         self.minimal = minimal
 
         if propagate_anchors is None:
@@ -422,6 +428,7 @@ class GlyphsBuilder(LoggerMixin):
         glyphs_module=classes,
         ufo_module=None,
         minimize_ufo_diffs=False,
+        expand_includes=False,
     ):
         """Create a builder that goes from UFOs + designspace to Glyphs.
 
@@ -450,9 +457,12 @@ class GlyphsBuilder(LoggerMixin):
         minimize_ufo_diffs -- set to True to store extra info in .glyphs files
                               in order to get smaller diffs between UFOs
                               when going UFOs->glyphs->UFOs
+        expand_includes -- If True, expand include statements in the UFOs' features.fea
+                           and inline them in the GSFont features.
         """
         self.glyphs_module = glyphs_module
         self.minimize_ufo_diffs = minimize_ufo_diffs
+        self.expand_includes = expand_includes
 
         if designspace is not None:
             if ufos:
diff --git a/Lib/glyphsLib/builder/features.py b/Lib/glyphsLib/builder/features.py
index d6f642b6..2c821910 100644
--- a/Lib/glyphsLib/builder/features.py
+++ b/Lib/glyphsLib/builder/features.py
@@ -14,6 +14,7 @@
 
 from __future__ import annotations
 
+import os
 import re
 from textwrap import dedent
 from io import StringIO
@@ -50,7 +51,11 @@ def to_ufo_master_features(self, ufo, master):
         ufo.features.text = original
     else:
         ufo.features.text = _to_ufo_features(
-            self.font, ufo, generate_GDEF=self.generate_GDEF, master=master
+            self.font,
+            ufo,
+            generate_GDEF=self.generate_GDEF,
+            master=master,
+            expand_includes=self.expand_includes,
         )
 
 
@@ -71,6 +76,7 @@ def _to_ufo_features(
     ufo: Font | None = None,
     generate_GDEF: bool = False,
     master: GSFontMaster | None = None,
+    expand_includes: bool = False,
 ) -> str:
     """Convert GSFont features, including prefixes and classes, to UFO.
 
@@ -158,7 +164,23 @@ def _to_ufo_features(
         regenerate_opentype_categories(font, ufo)
 
     full_text = "\n\n".join(filter(None, [class_str, prefix_str, fea_str])) + "\n"
-    return full_text if full_text.strip() else ""
+    full_text = full_text if full_text.strip() else ""
+
+    if not full_text or not expand_includes:
+        return full_text
+
+    # use feaLib Parser to resolve include statements relative to the GSFont
+    # fontpath, and inline them in the output features text.
+    feature_file = StringIO(full_text)
+    include_dir = os.path.dirname(font.filepath) if font.filepath else None
+    fea_parser = parser.Parser(
+        feature_file,
+        glyphNames={glyph.name for glyph in font.glyphs},
+        includeDir=include_dir,
+        followIncludes=expand_includes,
+    )
+    doc = fea_parser.parse()
+    return doc.asFea()
 
 
 def _build_public_opentype_categories(ufo: Font) -> dict[str, str]:
@@ -320,11 +342,16 @@ def to_glyphs_features(self):
     ufo = self.designspace.sources[0].font
     if ufo.features.text is None:
         return
+    include_dir = None
+    if self.expand_includes and ufo.path:
+        include_dir = os.path.dirname(os.path.normpath(ufo.path))
     _to_glyphs_features(
         self.font,
         ufo.features.text,
         glyph_names=ufo.keys(),
         glyphs_module=self.glyphs_module,
+        include_dir=include_dir,
+        expand_includes=self.expand_includes,
     )
 
     # Store GDEF category data GSFont-wide to capture bracket glyphs that we
@@ -334,7 +361,14 @@ def to_glyphs_features(self):
         self.font.userData[ORIGINAL_CATEGORY_KEY] = opentype_categories
 
 
-def _to_glyphs_features(font, features_text, glyph_names=None, glyphs_module=None):
+def _to_glyphs_features(
+    font,
+    features_text,
+    glyph_names=None,
+    glyphs_module=None,
+    include_dir=None,
+    expand_includes=False,
+):
     """Import features text in GSFont, split into prefixes, features and classes.
 
     Args:
@@ -342,8 +376,15 @@ def _to_glyphs_features(font, features_text, glyph_names=None, glyphs_module=Non
         feature_text: str
         glyph_names: Optional[Sequence[str]]
         glyphs_module: Optional[Any]
+        include_dir: Optional[str]
+        expand_includes: bool
     """
-    document = FeaDocument(features_text, glyph_names)
+    document = FeaDocument(
+        features_text,
+        glyph_names,
+        include_dir=include_dir,
+        expand_includes=expand_includes,
+    )
     processor = FeatureFileProcessor(document, glyphs_module)
     processor.to_glyphs(font)
 
@@ -389,12 +430,20 @@ def _to_glyphs_features_basic(self):
 class FeaDocument:
     """Parse the string of a fea code into statements."""
 
-    def __init__(self, text, glyph_set=None):
+    def __init__(self, text, glyph_set=None, include_dir=None, expand_includes=False):
         feature_file = StringIO(text)
         glyph_names = glyph_set if glyph_set is not None else ()
         parser_ = parser.Parser(
-            feature_file, glyphNames=glyph_names, followIncludes=False
+            feature_file,
+            glyphNames=glyph_names,
+            includeDir=include_dir,
+            followIncludes=expand_includes,
         )
+        if expand_includes:
+            # if we expand includes, we need to reparse the whole file with the
+            # new content to get the updated locations
+            text = parser_.parse().asFea()
+            parser_ = parser.Parser(StringIO(text), glyphNames=glyph_names)
         self._doc = parser_.parse()
         self.statements = self._doc.statements
         self._lines = text.splitlines(True)  # keepends=True
diff --git a/Lib/glyphsLib/cli.py b/Lib/glyphsLib/cli.py
index f5b0ec76..a24fe671 100644
--- a/Lib/glyphsLib/cli.py
+++ b/Lib/glyphsLib/cli.py
@@ -151,6 +151,14 @@ def main(args=None):
             "key."
         ),
     )
+    group.add_argument(
+        "--expand-includes",
+        action="store_true",
+        help=(
+            "Expand include statements in the .glyphs features and inline them in "
+            "the exported UFO features.fea."
+        ),
+    )
     group = parser_glyphs2ufo.add_argument_group("Glyph data")
     group.add_argument(
         "--glyph-data",
@@ -211,6 +219,14 @@ def main(args=None):
         action="store_false",
         help="Enable automatic alignment of components in glyphs.",
     )
+    group.add_argument(
+        "--expand-includes",
+        action="store_true",
+        help=(
+            "Expand include statements in the UFO features.fea and inline them in "
+            "the exported .glyphs features."
+        ),
+    )
 
     options = parser.parse_args(args)
 
@@ -246,6 +262,7 @@ def glyphs2ufo(options):
         generate_GDEF=options.generate_GDEF,
         store_editor_state=not options.no_store_editor_state,
         write_skipexportglyphs=options.write_public_skip_export_glyphs,
+        expand_includes=options.expand_includes,
         ufo_module=__import__(options.ufo_module),
         minimal=options.minimal,
         glyph_data=options.glyph_data or None,
@@ -298,6 +315,7 @@ def ufo2glyphs(options):
         object_to_read,
         ufo_module=ufo_module,
         minimize_ufo_diffs=options.no_preserve_glyphsapp_metadata,
+        expand_includes=options.expand_includes,
     )
 
     # Make the Glyphs file more suitable for roundtrip:


Test patch:
diff --git a/tests/builder/features_test.py b/tests/builder/features_test.py
index 15b7e367..6030e369 100644
--- a/tests/builder/features_test.py
+++ b/tests/builder/features_test.py
@@ -343,6 +343,50 @@ def test_include_no_semicolon(tmpdir, ufo_module):
     assert rtufo.features.text == ufo.features.text
 
 
+def test_to_glyphs_expand_includes(tmp_path, ufo_module):
+    ufo = ufo_module.Font()
+    ufo.features.text = dedent(
+        """\
+        include(family.fea);
+        """
+    )
+    ufo.save(str(tmp_path / "font.ufo"))
+
+    included_path = tmp_path / "family.fea"
+    included_path.write_text("# hello from family.fea")
+    assert included_path.exists()
+
+    font = to_glyphs([ufo], minimize_ufo_diffs=True, expand_includes=True)
+
+    assert len(font.featurePrefixes) == 1
+    assert font.featurePrefixes[0].code.strip() == "# hello from family.fea"
+
+
+def test_to_ufos_expand_includes(tmp_path, ufo_module):
+    font = classes.GSFont()
+    font.masters.append(classes.GSFontMaster())
+
+    feature_prefix = classes.GSFeaturePrefix()
+    feature_prefix.name = "include"
+    feature_prefix.code = dedent(
+        """\
+        include(family.fea);
+        """
+    )
+    font.featurePrefixes.append(feature_prefix)
+
+    font.filepath = str(tmp_path / "font.glyphs")
+    font.save()
+
+    included_path = tmp_path / "family.fea"
+    included_path.write_text("# hello from family.fea")
+    assert included_path.exists()
+
+    (ufo,) = to_ufos(font, ufo_module=ufo_module, expand_includes=True)
+
+    assert ufo.features.text == ("# Prefix: include\n# hello from family.fea")
+
+
 def test_standalone_lookup(tmpdir, ufo_module):
     ufo = ufo_module.Font()
     # FIXME: (jany) does not preserve whitespace before and after