Repository: mesonbuild/meson

Issue:
Support export_dynamic on macOS
**Describe the bug**

PostgreSQL's meson.build file contains this:

```meson
postgres = executable('postgres',
  [...]
  export_dynamic: true,
  [...]
)
```

On macOS, the `-Wl,--export-dynamic` flag is **not** appended. The linker doesn't support this flag, but supports a different variant `-export_dynamic` instead. See the [man pages](https://opensource.apple.com/source/ld64/ld64-609/doc/man/man1/ld.1.auto.html).

This doesn't make much of a difference for non-LTO builds, but once you pass `-flto` it does.

**To Reproduce**

In the PostgreSQL repo:

```
CFLAGS=-flto meson setup build
cd build
ninja -v src/backend/postgres
```

```
[868/868] cc  -o src/backend/postgres
[... object files stripped ...]
-Wl,-dead_strip_dylibs
-Wl,-headerpad_max_install_names
-isysroot
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.5.sdk
-Wl,-no_warn_duplicate_libraries
-flto
[... static libs and -l flags stripped ...]
```

**Expected behavior**

The very last build step (868/868 for me) should have `-Wl,-export_dynamic`.

**system parameters**
- native build
- macOS Sonoma 14.5
- Xcode 15.4, clang 15.0.0
- meson 1.4.1
- ninja 1.12.1
- python 3.12.3

Also discussed in https://www.postgresql.org/message-id/flat/427c7c25-e8e1-4fc5-a1fb-01ceff185e5b@technowledgy.de

Discussion:

Patch:
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 76440de7a..ea511f33f 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -26,7 +26,7 @@ trigger:
       - 'test cases'
       - 'unittests'
       - 'azure-pipelines.yml'
-      - 'ci/azure-steps.yml'
+      - 'ci/run.ps1'
       - 'run_project_tests.py'
       - 'run_tests.py'
       - 'run_unittests.py'
@@ -41,7 +41,7 @@ pr:
       - 'test cases'
       - 'unittests'
       - 'azure-pipelines.yml'
-      - 'ci/azure-steps.yml'
+      - 'ci/run.ps1'
       - 'run_project_tests.py'
       - 'run_tests.py'
       - 'run_unittests.py'
diff --git a/ci/run.ps1 b/ci/run.ps1
index 05bb6b69e..596253fe2 100644
--- a/ci/run.ps1
+++ b/ci/run.ps1
@@ -8,21 +8,22 @@ if ($LastExitCode -ne 0) {
 $env:Path = ($env:Path.Split(';') | Where-Object { $_ -notmatch 'mingw|Strawberry|Chocolatey|PostgreSQL' }) -join ';'
 
 if ($env:arch -eq 'x64') {
+    rustup default 1.77
     # Rust puts its shared stdlib in a secret place, but it is needed to run tests.
-    $env:Path += ";$HOME/.rustup/toolchains/stable-x86_64-pc-windows-msvc/bin"
+    $env:Path += ";$HOME/.rustup/toolchains/1.77-x86_64-pc-windows-msvc/bin"
 } elseif ($env:arch -eq 'x86') {
     # Switch to the x86 Rust toolchain
-    rustup default stable-i686-pc-windows-msvc
-
-    # Also install clippy
-    rustup component add clippy
+    rustup default 1.77-i686-pc-windows-msvc
 
     # Rust puts its shared stdlib in a secret place, but it is needed to run tests.
-    $env:Path += ";$HOME/.rustup/toolchains/stable-i686-pc-windows-msvc/bin"
+    $env:Path += ";$HOME/.rustup/toolchains/1.77-i686-pc-windows-msvc/bin"
     # Need 32-bit Python for tests that need the Python dependency
     $env:Path = "C:\hostedtoolcache\windows\Python\3.7.9\x86;C:\hostedtoolcache\windows\Python\3.7.9\x86\Scripts;$env:Path"
 }
 
+# Also install clippy
+rustup component add clippy
+
 # Set the CI env var for the meson test framework
 $env:CI = '1'
 
diff --git a/mesonbuild/backend/xcodebackend.py b/mesonbuild/backend/xcodebackend.py
index c801b5aae..69a544b24 100644
--- a/mesonbuild/backend/xcodebackend.py
+++ b/mesonbuild/backend/xcodebackend.py
@@ -3,7 +3,7 @@
 
 from __future__ import annotations
 
-import functools, uuid, os, operator
+import functools, uuid, os, operator, re
 import typing as T
 
 from . import backends
@@ -58,6 +58,35 @@ OPT2XCODEOPT = {'plain': None,
                 }
 BOOL2XCODEBOOL = {True: 'YES', False: 'NO'}
 LINKABLE_EXTENSIONS = {'.o', '.a', '.obj', '.so', '.dylib'}
+XCODEVERSIONS = {'1500': ('Xcode 15.0', 60),
+                 '1400': ('Xcode 14.0', 56),
+                 '1300': ('Xcode 13.0', 55),
+                 '1200': ('Xcode 12.0', 54),
+                 '1140': ('Xcode 11.4', 53),
+                 '1100': ('Xcode 11.0', 52),
+                 '1000': ('Xcode 10.0', 51),
+                 '930': ('Xcode 9.3', 50),
+                 '800': ('Xcode 8.0', 48),
+                 '630': ('Xcode 6.3', 47),
+                 '320': ('Xcode 3.2', 46),
+                 '310': ('Xcode 3.1', 45)
+                 }
+
+def autodetect_xcode_version() -> T.Tuple[str, int]:
+    try:
+        pc, stdout, stderr = mesonlib.Popen_safe(['xcodebuild', '-version'])
+    except FileNotFoundError:
+        raise MesonException('Could not detect Xcode. Please install it if you wish to use the Xcode backend.')
+    if pc.returncode != 0:
+        raise MesonException(f'An error occurred while detecting Xcode: {stderr}')
+    version = int(''.join(re.search(r'\d*\.\d*\.*\d*', stdout).group(0).split('.')))
+    # If the version number does not have two decimal points, pretend it does.
+    if stdout.count('.') < 2:
+        version *= 10
+    for v, r in XCODEVERSIONS.items():
+        if int(v) <= version:
+            return r
+    raise MesonException('Your Xcode installation is too old and is not supported.')
 
 class FileTreeEntry:
 
@@ -203,6 +232,7 @@ class XCodeBackend(backends.Backend):
         self.arch = self.build.environment.machines.host.cpu
         if self.arch == 'aarch64':
             self.arch = 'arm64'
+        self.xcodeversion, self.objversion = autodetect_xcode_version()
         # In Xcode files are not accessed via their file names, but rather every one of them
         # gets an unique id. More precisely they get one unique id per target they are used
         # in. If you generate only one id per file and use them, compilation will work but the
@@ -266,7 +296,8 @@ class XCodeBackend(backends.Backend):
         self.build_targets = self.build.get_build_targets()
         self.custom_targets = self.build.get_custom_targets()
         self.generate_filemap()
-        self.generate_buildstylemap()
+        if self.objversion < 50:
+            self.generate_buildstylemap()
         self.generate_build_phase_map()
         self.generate_build_configuration_map()
         self.generate_build_configurationlist_map()
@@ -298,9 +329,10 @@ class XCodeBackend(backends.Backend):
         self.generate_pbx_build_rule(objects_dict)
         objects_dict.add_comment(PbxComment('End PBXBuildRule section'))
         objects_dict.add_comment(PbxComment('Begin PBXBuildStyle section'))
-        self.generate_pbx_build_style(objects_dict)
-        objects_dict.add_comment(PbxComment('End PBXBuildStyle section'))
-        objects_dict.add_comment(PbxComment('Begin PBXContainerItemProxy section'))
+        if self.objversion < 50:
+            self.generate_pbx_build_style(objects_dict)
+            objects_dict.add_comment(PbxComment('End PBXBuildStyle section'))
+            objects_dict.add_comment(PbxComment('Begin PBXContainerItemProxy section'))
         self.generate_pbx_container_item_proxy(objects_dict)
         objects_dict.add_comment(PbxComment('End PBXContainerItemProxy section'))
         objects_dict.add_comment(PbxComment('Begin PBXFileReference section'))
@@ -728,8 +760,8 @@ class XCodeBackend(backends.Backend):
             odict.add_item('isa', 'PBXBuildFile')
             odict.add_item('fileRef', ref_id)
 
+    # This is skipped if Xcode 9 or above is installed, as PBXBuildStyle was removed on that version.
     def generate_pbx_build_style(self, objects_dict: PbxDict) -> None:
-        # FIXME: Xcode 9 and later does not uses PBXBuildStyle and it gets removed. Maybe we can remove this part.
         for name, idval in self.buildstylemap.items():
             styledict = PbxDict()
             objects_dict.add_item(idval, styledict, name)
@@ -1237,11 +1269,12 @@ class XCodeBackend(backends.Backend):
         attr_dict.add_item('BuildIndependentTargetsInParallel', 'YES')
         project_dict.add_item('buildConfigurationList', self.project_conflist, f'Build configuration list for PBXProject "{self.build.project_name}"')
         project_dict.add_item('buildSettings', PbxDict())
-        style_arr = PbxArray()
-        project_dict.add_item('buildStyles', style_arr)
-        for name, idval in self.buildstylemap.items():
-            style_arr.add_item(idval, name)
-        project_dict.add_item('compatibilityVersion', '"Xcode 3.2"')
+        if self.objversion < 50:
+            style_arr = PbxArray()
+            project_dict.add_item('buildStyles', style_arr)
+            for name, idval in self.buildstylemap.items():
+                style_arr.add_item(idval, name)
+        project_dict.add_item('compatibilityVersion', f'"{self.xcodeversion}"')
         project_dict.add_item('hasScannedForEncodings', 0)
         project_dict.add_item('mainGroup', self.maingroup_id)
         project_dict.add_item('projectDirPath', '"' + self.environment.get_source_dir() + '"')
@@ -1840,7 +1873,7 @@ class XCodeBackend(backends.Backend):
     def generate_prefix(self, pbxdict: PbxDict) -> PbxDict:
         pbxdict.add_item('archiveVersion', '1')
         pbxdict.add_item('classes', PbxDict())
-        pbxdict.add_item('objectVersion', '46')
+        pbxdict.add_item('objectVersion', self.objversion)
         objects_dict = PbxDict()
         pbxdict.add_item('objects', objects_dict)
 
diff --git a/mesonbuild/coredata.py b/mesonbuild/coredata.py
index 76da0b67f..782e77081 100644
--- a/mesonbuild/coredata.py
+++ b/mesonbuild/coredata.py
@@ -6,7 +6,7 @@ from __future__ import annotations
 
 import copy
 
-from . import mlog, mparser, options
+from . import mlog, options
 import pickle, os, uuid
 import sys
 from itertools import chain
@@ -16,14 +16,16 @@ from dataclasses import dataclass
 
 from .mesonlib import (
     MesonBugException,
-    MesonException, EnvironmentException, MachineChoice, PerMachine,
+    MesonException, MachineChoice, PerMachine,
     PerMachineDefaultable,
     OptionKey, OptionType, stringlistify,
     pickle_load
 )
+
+from .machinefile import CmdLineFileParser
+
 import ast
 import argparse
-import configparser
 import enum
 import shlex
 import typing as T
@@ -760,99 +762,6 @@ class CoreData:
             mlog.warning('Base option \'b_bitcode\' is enabled, which is incompatible with many linker options. Incompatible options such as \'b_asneeded\' have been disabled.', fatal=False)
             mlog.warning('Please see https://mesonbuild.com/Builtin-options.html#Notes_about_Apple_Bitcode_support for more details.', fatal=False)
 
-class CmdLineFileParser(configparser.ConfigParser):
-    def __init__(self) -> None:
-        # We don't want ':' as key delimiter, otherwise it would break when
-        # storing subproject options like "subproject:option=value"
-        super().__init__(delimiters=['='], interpolation=None)
-
-    def read(self, filenames: T.Union['StrOrBytesPath', T.Iterable['StrOrBytesPath']], encoding: T.Optional[str] = 'utf-8') -> T.List[str]:
-        return super().read(filenames, encoding)
-
-    def optionxform(self, optionstr: str) -> str:
-        # Don't call str.lower() on keys
-        return optionstr
-
-class MachineFileParser():
-    def __init__(self, filenames: T.List[str], sourcedir: str) -> None:
-        self.parser = CmdLineFileParser()
-        self.constants: T.Dict[str, T.Union[str, bool, int, T.List[str]]] = {'True': True, 'False': False}
-        self.sections: T.Dict[str, T.Dict[str, T.Union[str, bool, int, T.List[str]]]] = {}
-
-        for fname in filenames:
-            try:
-                with open(fname, encoding='utf-8') as f:
-                    content = f.read()
-            except UnicodeDecodeError as e:
-                raise EnvironmentException(f'Malformed machine file {fname!r} failed to parse as unicode: {e}')
-
-            content = content.replace('@GLOBAL_SOURCE_ROOT@', sourcedir)
-            content = content.replace('@DIRNAME@', os.path.dirname(fname))
-            try:
-                self.parser.read_string(content, fname)
-            except configparser.Error as e:
-                raise EnvironmentException(f'Malformed machine file: {e}')
-
-        # Parse [constants] first so they can be used in other sections
-        if self.parser.has_section('constants'):
-            self.constants.update(self._parse_section('constants'))
-
-        for s in self.parser.sections():
-            if s == 'constants':
-                continue
-            self.sections[s] = self._parse_section(s)
-
-    def _parse_section(self, s: str) -> T.Dict[str, T.Union[str, bool, int, T.List[str]]]:
-        self.scope = self.constants.copy()
-        section: T.Dict[str, T.Union[str, bool, int, T.List[str]]] = {}
-        for entry, value in self.parser.items(s):
-            if ' ' in entry or '\t' in entry or "'" in entry or '"' in entry:
-                raise EnvironmentException(f'Malformed variable name {entry!r} in machine file.')
-            # Windows paths...
-            value = value.replace('\\', '\\\\')
-            try:
-                ast = mparser.Parser(value, 'machinefile').parse()
-                if not ast.lines:
-                    raise EnvironmentException('value cannot be empty')
-                res = self._evaluate_statement(ast.lines[0])
-            except MesonException as e:
-                raise EnvironmentException(f'Malformed value in machine file variable {entry!r}: {str(e)}.')
-            except KeyError as e:
-                raise EnvironmentException(f'Undefined constant {e.args[0]!r} in machine file variable {entry!r}.')
-            section[entry] = res
-            self.scope[entry] = res
-        return section
-
-    def _evaluate_statement(self, node: mparser.BaseNode) -> T.Union[str, bool, int, T.List[str]]:
-        if isinstance(node, (mparser.StringNode)):
-            return node.value
-        elif isinstance(node, mparser.BooleanNode):
-            return node.value
-        elif isinstance(node, mparser.NumberNode):
-            return node.value
-        elif isinstance(node, mparser.ParenthesizedNode):
-            return self._evaluate_statement(node.inner)
-        elif isinstance(node, mparser.ArrayNode):
-            # TODO: This is where recursive types would come in handy
-            return [self._evaluate_statement(arg) for arg in node.args.arguments]
-        elif isinstance(node, mparser.IdNode):
-            return self.scope[node.value]
-        elif isinstance(node, mparser.ArithmeticNode):
-            l = self._evaluate_statement(node.left)
-            r = self._evaluate_statement(node.right)
-            if node.operation == 'add':
-                if (isinstance(l, str) and isinstance(r, str)) or \
-                   (isinstance(l, list) and isinstance(r, list)):
-                    return l + r
-            elif node.operation == 'div':
-                if isinstance(l, str) and isinstance(r, str):
-                    return os.path.join(l, r)
-        raise EnvironmentException('Unsupported node type')
-
-def parse_machine_files(filenames: T.List[str], sourcedir: str):
-    parser = MachineFileParser(filenames, sourcedir)
-    return parser.sections
-
 def get_cmd_line_file(build_dir: str) -> str:
     return os.path.join(build_dir, 'meson-private', 'cmd_line.txt')
 
diff --git a/mesonbuild/environment.py b/mesonbuild/environment.py
index 19b9e81b5..1607c32ed 100644
--- a/mesonbuild/environment.py
+++ b/mesonbuild/environment.py
@@ -11,6 +11,10 @@ import collections
 
 from . import coredata
 from . import mesonlib
+from . import machinefile
+
+CmdLineFileParser = machinefile.CmdLineFileParser
+
 from .mesonlib import (
     MesonException, MachineChoice, Popen_safe, PerMachine,
     PerMachineDefaultable, PerThreeMachineDefaultable, split_args, quote_arg, OptionKey,
@@ -589,7 +593,7 @@ class Environment:
         ## Read in native file(s) to override build machine configuration
 
         if self.coredata.config_files is not None:
-            config = coredata.parse_machine_files(self.coredata.config_files, self.source_dir)
+            config = machinefile.parse_machine_files(self.coredata.config_files, self.source_dir)
             binaries.build = BinaryTable(config.get('binaries', {}))
             properties.build = Properties(config.get('properties', {}))
             cmakevars.build = CMakeVariables(config.get('cmake', {}))
@@ -600,7 +604,7 @@ class Environment:
         ## Read in cross file(s) to override host machine configuration
 
         if self.coredata.cross_files:
-            config = coredata.parse_machine_files(self.coredata.cross_files, self.source_dir)
+            config = machinefile.parse_machine_files(self.coredata.cross_files, self.source_dir)
             properties.host = Properties(config.get('properties', {}))
             binaries.host = BinaryTable(config.get('binaries', {}))
             cmakevars.host = CMakeVariables(config.get('cmake', {}))
diff --git a/mesonbuild/linkers/linkers.py b/mesonbuild/linkers/linkers.py
index de08e0f44..7507f5ab7 100644
--- a/mesonbuild/linkers/linkers.py
+++ b/mesonbuild/linkers/linkers.py
@@ -834,6 +834,9 @@ class AppleDynamicLinker(PosixDynamicLinkerMixin, DynamicLinker):
     def get_thinlto_cache_args(self, path: str) -> T.List[str]:
         return ["-Wl,-cache_path_lto," + path]
 
+    def export_dynamic_args(self, env: 'Environment') -> T.List[str]:
+        return self._apply_prefix('-export_dynamic')
+
 
 class LLVMLD64DynamicLinker(AppleDynamicLinker):
 
diff --git a/mesonbuild/machinefile.py b/mesonbuild/machinefile.py
new file mode 100644
index 000000000..afeb4d056
--- /dev/null
+++ b/mesonbuild/machinefile.py
@@ -0,0 +1,117 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2013-2024 Contributors to the The Meson project
+
+import typing as T
+import configparser
+import os
+
+from . import mparser
+
+from .mesonlib import MesonException
+
+if T.TYPE_CHECKING:
+    from .compilers import Compiler
+    from .coredata import StrOrBytesPath
+
+    CompilersDict = T.Dict[str, Compiler]
+
+
+class CmdLineFileParser(configparser.ConfigParser):
+    def __init__(self) -> None:
+        # We don't want ':' as key delimiter, otherwise it would break when
+        # storing subproject options like "subproject:option=value"
+        super().__init__(delimiters=['='], interpolation=None)
+
+    def read(self, filenames: T.Union['StrOrBytesPath', T.Iterable['StrOrBytesPath']], encoding: T.Optional[str] = 'utf-8') -> T.List[str]:
+        return super().read(filenames, encoding)
+
+    def optionxform(self, optionstr: str) -> str:
+        # Don't call str.lower() on keys
+        return optionstr
+
+
+class MachineFileParser():
+    def __init__(self, filenames: T.List[str], sourcedir: str) -> None:
+        self.parser = CmdLineFileParser()
+        self.constants: T.Dict[str, T.Union[str, bool, int, T.List[str]]] = {'True': True, 'False': False}
+        self.sections: T.Dict[str, T.Dict[str, T.Union[str, bool, int, T.List[str]]]] = {}
+
+        for fname in filenames:
+            try:
+                with open(fname, encoding='utf-8') as f:
+                    content = f.read()
+            except UnicodeDecodeError as e:
+                raise MesonException(f'Malformed machine file {fname!r} failed to parse as unicode: {e}')
+
+            content = content.replace('@GLOBAL_SOURCE_ROOT@', sourcedir)
+            content = content.replace('@DIRNAME@', os.path.dirname(fname))
+            try:
+                self.parser.read_string(content, fname)
+            except configparser.Error as e:
+                raise MesonException(f'Malformed machine file: {e}')
+
+        # Parse [constants] first so they can be used in other sections
+        if self.parser.has_section('constants'):
+            self.constants.update(self._parse_section('constants'))
+
+        for s in self.parser.sections():
+            if s == 'constants':
+                continue
+            self.sections[s] = self._parse_section(s)
+
+    def _parse_section(self, s: str) -> T.Dict[str, T.Union[str, bool, int, T.List[str]]]:
+        self.scope = self.constants.copy()
+        section: T.Dict[str, T.Union[str, bool, int, T.List[str]]] = {}
+        for entry, value in self.parser.items(s):
+            if ' ' in entry or '\t' in entry or "'" in entry or '"' in entry:
+                raise MesonException(f'Malformed variable name {entry!r} in machine file.')
+            # Windows paths...
+            value = value.replace('\\', '\\\\')
+            try:
+                ast = mparser.Parser(value, 'machinefile').parse()
+                if not ast.lines:
+                    raise MesonException('value cannot be empty')
+                res = self._evaluate_statement(ast.lines[0])
+            except MesonException as e:
+                raise MesonException(f'Malformed value in machine file variable {entry!r}: {str(e)}.')
+            except KeyError as e:
+                raise MesonException(f'Undefined constant {e.args[0]!r} in machine file variable {entry!r}.')
+            section[entry] = res
+            self.scope[entry] = res
+        return section
+
+    def _evaluate_statement(self, node: mparser.BaseNode) -> T.Union[str, bool, int, T.List[str]]:
+        if isinstance(node, (mparser.StringNode)):
+            return node.value
+        elif isinstance(node, mparser.BooleanNode):
+            return node.value
+        elif isinstance(node, mparser.NumberNode):
+            return node.value
+        elif isinstance(node, mparser.ParenthesizedNode):
+            return self._evaluate_statement(node.inner)
+        elif isinstance(node, mparser.ArrayNode):
+            # TODO: This is where recursive types would come in handy
+            return [self._evaluate_statement(arg) for arg in node.args.arguments]
+        elif isinstance(node, mparser.IdNode):
+            return self.scope[node.value]
+        elif isinstance(node, mparser.ArithmeticNode):
+            l = self._evaluate_statement(node.left)
+            r = self._evaluate_statement(node.right)
+            if node.operation == 'add':
+                if (isinstance(l, str) and isinstance(r, str)) or \
+                   (isinstance(l, list) and isinstance(r, list)):
+                    return l + r
+            elif node.operation == 'div':
+                if isinstance(l, str) and isinstance(r, str):
+                    return os.path.join(l, r)
+        raise MesonException('Unsupported node type')
+
+def parse_machine_files(filenames: T.List[str], sourcedir: str):
+    parser = MachineFileParser(filenames, sourcedir)
+    return parser.sections
+
+
+class MachineFileStore:
+    def __init__(self, native_files, cross_files, source_dir):
+        self.native = MachineFileParser(native_files if native_files is not None else [], source_dir).sections
+        self.cross = MachineFileParser(cross_files if cross_files is not None else [], source_dir).sections
diff --git a/mesonbuild/scripts/scanbuild.py b/mesonbuild/scripts/scanbuild.py
index d7fbcf4fe..b738aeee1 100644
--- a/mesonbuild/scripts/scanbuild.py
+++ b/mesonbuild/scripts/scanbuild.py
@@ -7,7 +7,8 @@ import subprocess
 import shutil
 import tempfile
 from ..environment import detect_ninja, detect_scanbuild
-from ..coredata import get_cmd_line_file, CmdLineFileParser
+from ..coredata import get_cmd_line_file
+from ..machinefile import CmdLineFileParser
 from ..mesonlib import windows_proof_rmtree
 from pathlib import Path
 import typing as T


Test patch:
diff --git a/test cases/unit/116 empty project/expected_mods.json b/test cases/unit/116 empty project/expected_mods.json
index 19f56a5f7..fa5e0ec6c 100644
--- a/test cases/unit/116 empty project/expected_mods.json
+++ b/test cases/unit/116 empty project/expected_mods.json
@@ -217,6 +217,7 @@
       "mesonbuild.linkers",
       "mesonbuild.linkers.base",
       "mesonbuild.linkers.detect",
+      "mesonbuild.machinefile",
       "mesonbuild.mesonlib",
       "mesonbuild.mesonmain",
       "mesonbuild.mintro",
diff --git a/unittests/allplatformstests.py b/unittests/allplatformstests.py
index 9c9f61678..63445ec2b 100644
--- a/unittests/allplatformstests.py
+++ b/unittests/allplatformstests.py
@@ -25,6 +25,7 @@ import mesonbuild.dependencies.factory
 import mesonbuild.envconfig
 import mesonbuild.environment
 import mesonbuild.coredata
+import mesonbuild.machinefile
 import mesonbuild.modules.gnome
 from mesonbuild.mesonlib import (
     BuildDirLock, MachineChoice, is_windows, is_osx, is_cygwin, is_dragonflybsd,
@@ -61,6 +62,8 @@ from run_tests import (
 from .baseplatformtests import BasePlatformTests
 from .helpers import *
 
+UNIT_MACHINEFILE_DIR = Path(__file__).parent / 'machinefiles'
+
 @contextmanager
 def temp_filename():
     '''A context manager which provides a filename to an empty temporary file.
@@ -4111,40 +4114,19 @@ class AllPlatformTests(BasePlatformTests):
         self._check_coverage_files()
 
     def test_cross_file_constants(self):
-        with temp_filename() as crossfile1, temp_filename() as crossfile2:
-            with open(crossfile1, 'w', encoding='utf-8') as f:
-                f.write(textwrap.dedent(
-                    '''
-                    [constants]
-                    compiler = 'gcc'
-                    '''))
-            with open(crossfile2, 'w', encoding='utf-8') as f:
-                f.write(textwrap.dedent(
-                    '''
-                    [constants]
-                    toolchain = '/toolchain/'
-                    common_flags = ['--sysroot=' + toolchain / 'sysroot']
-
-                    [properties]
-                    c_args = common_flags + ['-DSOMETHING']
-                    cpp_args = c_args + ['-DSOMETHING_ELSE']
-                    rel_to_src = '@GLOBAL_SOURCE_ROOT@' / 'tool'
-                    rel_to_file = '@DIRNAME@' / 'tool'
-                    no_escaping = '@@DIRNAME@@' / 'tool'
-
-                    [binaries]
-                    c = toolchain / compiler
-                    '''))
-
-            values = mesonbuild.coredata.parse_machine_files([crossfile1, crossfile2], self.builddir)
-            self.assertEqual(values['binaries']['c'], '/toolchain/gcc')
-            self.assertEqual(values['properties']['c_args'],
-                             ['--sysroot=/toolchain/sysroot', '-DSOMETHING'])
-            self.assertEqual(values['properties']['cpp_args'],
-                             ['--sysroot=/toolchain/sysroot', '-DSOMETHING', '-DSOMETHING_ELSE'])
-            self.assertEqual(values['properties']['rel_to_src'], os.path.join(self.builddir, 'tool'))
-            self.assertEqual(values['properties']['rel_to_file'], os.path.join(os.path.dirname(crossfile2), 'tool'))
-            self.assertEqual(values['properties']['no_escaping'], os.path.join(f'@{os.path.dirname(crossfile2)}@', 'tool'))
+        crossfile1 = UNIT_MACHINEFILE_DIR / 'constant1.txt'
+        crossfile2 = UNIT_MACHINEFILE_DIR / 'constant2.txt'
+        values = mesonbuild.machinefile.parse_machine_files([crossfile1,
+                                                             crossfile2],
+                                                             self.builddir)
+        self.assertEqual(values['binaries']['c'], '/toolchain/gcc')
+        self.assertEqual(values['properties']['c_args'],
+                         ['--sysroot=/toolchain/sysroot', '-DSOMETHING'])
+        self.assertEqual(values['properties']['cpp_args'],
+                         ['--sysroot=/toolchain/sysroot', '-DSOMETHING', '-DSOMETHING_ELSE'])
+        self.assertEqual(values['properties']['rel_to_src'], os.path.join(self.builddir, 'tool'))
+        self.assertEqual(values['properties']['rel_to_file'], os.path.join(os.path.dirname(crossfile2), 'tool'))
+        self.assertEqual(values['properties']['no_escaping'], os.path.join(f'@{os.path.dirname(crossfile2)}@', 'tool'))
 
     @skipIf(is_windows(), 'Directory cleanup fails for some reason')
     def test_wrap_git(self):
diff --git a/unittests/darwintests.py b/unittests/darwintests.py
index 5528bbc9f..740310477 100644
--- a/unittests/darwintests.py
+++ b/unittests/darwintests.py
@@ -81,6 +81,18 @@ class DarwinTests(BasePlatformTests):
         self.build()
         self.run_tests()
 
+    def test_apple_lto_export_dynamic(self):
+        '''
+        Tests that -Wl,-export_dynamic is correctly added, when export_dynamic: true is set.
+        On macOS, this is relevant for LTO builds only.
+        '''
+        testdir = os.path.join(self.common_test_dir, '148 shared module resolving symbol in executable')
+        # Ensure that it builds even with LTO enabled
+        env = {'CFLAGS': '-flto'}
+        self.init(testdir, override_envvars=env)
+        self.build()
+        self.run_tests()
+
     def _get_darwin_versions(self, fname):
         fname = os.path.join(self.builddir, fname)
         out = subprocess.check_output(['otool', '-L', fname], universal_newlines=True)
diff --git a/unittests/machinefiles/constant1.txt b/unittests/machinefiles/constant1.txt
new file mode 100644
index 000000000..eeba7cb10
--- /dev/null
+++ b/unittests/machinefiles/constant1.txt
@@ -0,0 +1,2 @@
+[constants]
+compiler = 'gcc'
diff --git a/unittests/machinefiles/constant2.txt b/unittests/machinefiles/constant2.txt
new file mode 100644
index 000000000..226dcc852
--- /dev/null
+++ b/unittests/machinefiles/constant2.txt
@@ -0,0 +1,13 @@
+[constants]
+toolchain = '/toolchain/'
+common_flags = ['--sysroot=' + toolchain / 'sysroot']
+
+[properties]
+c_args = common_flags + ['-DSOMETHING']
+cpp_args = c_args + ['-DSOMETHING_ELSE']
+rel_to_src = '@GLOBAL_SOURCE_ROOT@' / 'tool'
+rel_to_file = '@DIRNAME@' / 'tool'
+no_escaping = '@@DIRNAME@@' / 'tool'
+
+[binaries]
+c = toolchain / compiler
diff --git a/unittests/machinefiletests.py b/unittests/machinefiletests.py
index 22341cb9a..5ff862cdc 100644
--- a/unittests/machinefiletests.py
+++ b/unittests/machinefiletests.py
@@ -12,7 +12,7 @@ import functools
 import threading
 import sys
 from itertools import chain
-from unittest import mock, skipIf, SkipTest
+from unittest import mock, skipIf, SkipTest, TestCase
 from pathlib import Path
 import typing as T
 
@@ -23,6 +23,9 @@ import mesonbuild.envconfig
 import mesonbuild.environment
 import mesonbuild.coredata
 import mesonbuild.modules.gnome
+
+from mesonbuild import machinefile
+
 from mesonbuild.mesonlib import (
     MachineChoice, is_windows, is_osx, is_cygwin, is_haiku, is_sunos
 )
@@ -50,6 +53,14 @@ def is_real_gnu_compiler(path):
     out = subprocess.check_output([path, '--version'], universal_newlines=True, stderr=subprocess.STDOUT)
     return 'Free Software Foundation' in out
 
+cross_dir = Path(__file__).parent.parent / 'cross'
+
+class MachineFileStoreTests(TestCase):
+
+    def test_loading(self):
+        store = machinefile.MachineFileStore([cross_dir / 'ubuntu-armhf.txt'], [], str(cross_dir))
+        self.assertIsNotNone(store)
+
 class NativeFileTests(BasePlatformTests):
 
     def setUp(self):
diff --git a/unittests/platformagnostictests.py b/unittests/platformagnostictests.py
index 33a789b4b..fe598a72b 100644
--- a/unittests/platformagnostictests.py
+++ b/unittests/platformagnostictests.py
@@ -274,7 +274,7 @@ class PlatformAgnosticTests(BasePlatformTests):
             expected = json.load(f)['meson']['modules']
 
         self.assertEqual(data['modules'], expected)
-        self.assertEqual(data['count'], 69)
+        self.assertEqual(data['count'], 70)
 
     def test_meson_package_cache_dir(self):
         # Copy testdir into temporary directory to not pollute meson source tree.