Repository: mesonbuild/meson

Issue:
llvm: Allow using cmake for dependency
We know have a cmake dependency finder, and LLVM can be found using cmake. It should be possible to find LLVM using cmake, for mesa there are some caveats for it's use but this should be investigated and turned on.


Discussion:
From what I can tell this should be possible. However, this might require minor adjustments to the CMake dependency backend to use a different `CMakeLists.txt`. This way, all (required) CMake variables can be extracted:

```cmake
find_package(LLVM REQUIRED CONFIG)
set(MESON_SOME_LLVM_VAR ${SOME_LLVM_VAR})
```

In this case the value of `MESON_SOME_LLVM_VAR` can be retrieved with `get_cmake_var('MESON_SOME_LLVM_VAR')`. Accessing `SOME_LLVM_VAR` directly can be a bit dodgy because of the CMake trace limitations. Also, the CMake files seem to define some targets, which can be easily extracted with the existing meson code.

I have never worked with LLVM, so I am not an expert on that issue.
LLVM is a giant pile of pain as a dependency. Upstream very much expects you to vendor. I really wish we didn't rely on it in mesa :/

Resolving patch:
diff --git a/mesonbuild/dependencies/base.py b/mesonbuild/dependencies/base.py
--- a/mesonbuild/dependencies/base.py
+++ b/mesonbuild/dependencies/base.py
@@ -35,7 +35,7 @@
 from ..compilers import clib_langs
 from ..environment import BinaryTable, Environment, MachineInfo
 from ..mesonlib import MachineChoice, MesonException, OrderedSet, PerMachine
-from ..mesonlib import Popen_safe, version_compare_many, version_compare, listify
+from ..mesonlib import Popen_safe, version_compare_many, version_compare, listify, stringlistify, extract_as_list
 from ..mesonlib import Version, LibType
 
 # These must be defined in this file to avoid cyclical references.
@@ -971,6 +971,24 @@ class CMakeDependency(ExternalDependency):
     def _gen_exception(self, msg):
         return DependencyException('Dependency {} not found: {}'.format(self.name, msg))
 
+    def _main_cmake_file(self) -> str:
+        return 'CMakeLists.txt'
+
+    def _extra_cmake_opts(self) -> List[str]:
+        return []
+
+    def _map_module_list(self, modules: List[Tuple[str, bool]]) -> List[Tuple[str, bool]]:
+        # Map the input module list to something else
+        # This function will only be executed AFTER the initial CMake
+        # interpreter pass has completed. Thus variables defined in the
+        # CMakeLists.txt can be accessed here.
+        return modules
+
+    def _original_module_name(self, module: str) -> str:
+        # Reverse the module mapping done by _map_module_list for
+        # one module
+        return module
+
     def __init__(self, name: str, environment: Environment, kwargs, language=None):
         super().__init__('cmake', environment, language, kwargs)
         self.name = name
@@ -990,6 +1008,9 @@ def __init__(self, name: str, environment: Environment, kwargs, language=None):
         # Where all CMake "build dirs" are located
         self.cmake_root_dir = environment.scratch_dir
 
+        # List of successfully found modules
+        self.found_modules = []
+
         # When finding dependencies for cross-compiling, we don't care about
         # the 'native' CMake binary
         # TODO: Test if this works as expected
@@ -1061,15 +1082,10 @@ def search():
         if self.cmakeinfo is None:
             raise self._gen_exception('Unable to obtain CMake system information')
 
-        modules = kwargs.get('modules', [])
-        cm_path = kwargs.get('cmake_module_path', [])
-        cm_args = kwargs.get('cmake_args', [])
-        if not isinstance(modules, list):
-            modules = [modules]
-        if not isinstance(cm_path, list):
-            cm_path = [cm_path]
-        if not isinstance(cm_args, list):
-            cm_args = [cm_args]
+        modules = [(x, True) for x in stringlistify(extract_as_list(kwargs, 'modules'))]
+        modules += [(x, False) for x in stringlistify(extract_as_list(kwargs, 'optional_modules'))]
+        cm_path = stringlistify(extract_as_list(kwargs, 'cmake_module_path'))
+        cm_args = stringlistify(extract_as_list(kwargs, 'cmake_args'))
         cm_path = [x if os.path.isabs(x) else os.path.join(environment.get_source_dir(), x) for x in cm_path]
         if cm_path:
             cm_args += ['-DCMAKE_MODULE_PATH={}'.format(';'.join(cm_path))]
@@ -1242,7 +1258,7 @@ def search_lib_dirs(path: str) -> bool:
 
         return False
 
-    def _detect_dep(self, name: str, modules: List[str], args: List[str]):
+    def _detect_dep(self, name: str, modules: List[Tuple[str, bool]], args: List[str]):
         # Detect a dependency with CMake using the '--find-package' mode
         # and the trace output (stderr)
         #
@@ -1265,11 +1281,12 @@ def _detect_dep(self, name: str, modules: List[str], args: List[str]):
 
             # Prepare options
             cmake_opts = ['--trace-expand', '-DNAME={}'.format(name), '-DARCHS={}'.format(';'.join(self.cmakeinfo['archs']))] + args + ['.']
+            cmake_opts += self._extra_cmake_opts()
             if len(i) > 0:
                 cmake_opts = ['-G', i] + cmake_opts
 
             # Run CMake
-            ret1, out1, err1 = self._call_cmake(cmake_opts, 'CMakeLists.txt')
+            ret1, out1, err1 = self._call_cmake(cmake_opts, self._main_cmake_file())
 
             # Current generator was successful
             if ret1 == 0:
@@ -1327,6 +1344,11 @@ def _detect_dep(self, name: str, modules: List[str], args: List[str]):
             self.version = vers_raw[0]
             self.version.strip('"\' ')
 
+        # Post-process module list. Used in derived classes to modify the
+        # module list (append prepend a string, etc.).
+        modules = self._map_module_list(modules)
+        autodetected_module_list = False
+
         # Try guessing a CMake target if none is provided
         if len(modules) == 0:
             for i in self.targets:
@@ -1334,17 +1356,19 @@ def _detect_dep(self, name: str, modules: List[str], args: List[str]):
                 lname = name.lower()
                 if '{}::{}'.format(lname, lname) == tg or lname == tg.replace('::', ''):
                     mlog.debug('Guessed CMake target \'{}\''.format(i))
-                    modules = [i]
+                    modules = [(i, True)]
+                    autodetected_module_list = True
                     break
 
         # Failed to guess a target --> try the old-style method
         if len(modules) == 0:
             incDirs = self.get_first_cmake_var_of(['PACKAGE_INCLUDE_DIRS'])
+            defs = self.get_first_cmake_var_of(['PACKAGE_DEFINITIONS'])
             libs = self.get_first_cmake_var_of(['PACKAGE_LIBRARIES'])
 
             # Try to use old style variables if no module is specified
             if len(libs) > 0:
-                self.compile_args = list(map(lambda x: '-I{}'.format(x), incDirs))
+                self.compile_args = list(map(lambda x: '-I{}'.format(x), incDirs)) + defs
                 self.link_args = libs
                 mlog.debug('using old-style CMake variables for dependency {}'.format(name))
                 return
@@ -1361,13 +1385,19 @@ def _detect_dep(self, name: str, modules: List[str], args: List[str]):
         compileDefinitions = []
         compileOptions = []
         libraries = []
-        for i in modules:
+        for i, required in modules:
             if i not in self.targets:
-                raise self._gen_exception('CMake: invalid CMake target {} for {}.\n'
+                if not required:
+                    mlog.warning('CMake: Optional module', mlog.bold(self._original_module_name(i)), 'for', mlog.bold(name), 'was not found')
+                    continue
+                raise self._gen_exception('CMake: invalid module {} for {}.\n'
                                           'Try to explicitly specify one or more targets with the "modules" property.\n'
-                                          'Valid targets are:\n{}'.format(i, name, list(self.targets.keys())))
+                                          'Valid targets are:\n{}'.format(self._original_module_name(i), name, list(self.targets.keys())))
 
             targets = [i]
+            if not autodetected_module_list:
+                self.found_modules += [i]
+
             while len(targets) > 0:
                 curr = targets.pop(0)
 
@@ -1432,7 +1462,7 @@ def _detect_dep(self, name: str, modules: List[str], args: List[str]):
         self.compile_args = compileOptions + compileDefinitions + list(map(lambda x: '-I{}'.format(x), incDirs))
         self.link_args = libraries
 
-    def get_first_cmake_var_of(self, var_list):
+    def get_first_cmake_var_of(self, var_list: List[str]) -> List[str]:
         # Return the first found CMake variable in list var_list
         for i in var_list:
             if i in self.vars:
@@ -1440,7 +1470,7 @@ def get_first_cmake_var_of(self, var_list):
 
         return []
 
-    def get_cmake_var(self, var):
+    def get_cmake_var(self, var: str) -> List[str]:
         # Return the value of the CMake variable var or an empty list if var does not exist
         if var in self.vars:
             return self.vars[var]
@@ -1747,6 +1777,13 @@ def check_cmake(self, cmakebin):
     def log_tried(self):
         return self.type_name
 
+    def log_details(self) -> str:
+        modules = [self._original_module_name(x) for x in self.found_modules]
+        modules = sorted(set(modules))
+        if modules:
+            return 'modules: ' + ', '.join(modules)
+        return ''
+
 class DubDependency(ExternalDependency):
     class_dubbin = None
 
diff --git a/mesonbuild/dependencies/dev.py b/mesonbuild/dependencies/dev.py
--- a/mesonbuild/dependencies/dev.py
+++ b/mesonbuild/dependencies/dev.py
@@ -20,14 +20,16 @@
 import os
 import re
 
-from .. import mesonlib
+from .. import mesonlib, mlog
 from ..mesonlib import version_compare, stringlistify, extract_as_list, MachineChoice
 from .base import (
     DependencyException, DependencyMethods, ExternalDependency, PkgConfigDependency,
-    strip_system_libdirs, ConfigToolDependency,
+    strip_system_libdirs, ConfigToolDependency, CMakeDependency
 )
 from .misc import ThreadDependency
 
+from typing import List, Tuple
+
 
 def get_shared_library_suffix(environment, native):
     """This is only gauranteed to work for languages that compile to machine
@@ -192,7 +194,7 @@ def get_methods():
         return [DependencyMethods.PKGCONFIG, DependencyMethods.SYSTEM]
 
 
-class LLVMDependency(ConfigToolDependency):
+class LLVMDependencyConfigTool(ConfigToolDependency):
     """
     LLVM uses a special tool, llvm-config, which has arguments for getting
     c args, cxx args, and ldargs as well as version.
@@ -399,6 +401,66 @@ def log_details(self):
             return 'modules: ' + ', '.join(self.module_details)
         return ''
 
+class LLVMDependencyCMake(CMakeDependency):
+    def __init__(self, env, kwargs):
+        self.llvm_modules = stringlistify(extract_as_list(kwargs, 'modules'))
+        self.llvm_opt_modules = stringlistify(extract_as_list(kwargs, 'optional_modules'))
+        super().__init__(name='LLVM', environment=env, language='cpp', kwargs=kwargs)
+
+        # Extract extra include directories and definitions
+        inc_dirs = self.get_cmake_var('PACKAGE_INCLUDE_DIRS')
+        defs = self.get_cmake_var('PACKAGE_DEFINITIONS')
+        temp = ['-I' + x for x in inc_dirs] + defs
+        self.compile_args += [x for x in temp if x not in self.compile_args]
+        self._add_sub_dependency(ThreadDependency, env, kwargs)
+
+    def _main_cmake_file(self) -> str:
+        # Use a custom CMakeLists.txt for LLVM
+        return 'CMakeListsLLVM.txt'
+
+    def _extra_cmake_opts(self) -> List[str]:
+        return ['-DLLVM_MESON_MODULES={}'.format(';'.join(self.llvm_modules + self.llvm_opt_modules))]
+
+    def _map_module_list(self, modules: List[Tuple[str, bool]]) -> List[Tuple[str, bool]]:
+        res = []
+        for mod, required in modules:
+            cm_targets = self.get_cmake_var('MESON_LLVM_TARGETS_{}'.format(mod))
+            if not cm_targets:
+                if required:
+                    raise self._gen_exception('LLVM module {} was not found'.format(mod))
+                else:
+                    mlog.warning('Optional LLVM module', mlog.bold(mod), 'was not found')
+                    continue
+            for i in cm_targets:
+                res += [(i, required)]
+        return res
+
+    def _original_module_name(self, module: str) -> str:
+        orig_name = self.get_cmake_var('MESON_TARGET_TO_LLVM_{}'.format(module))
+        if orig_name:
+            return orig_name[0]
+        return module
+
+class LLVMDependency(ExternalDependency):
+    def __init__(self, env, kwargs):
+        super().__init__('LLVM', env, 'cpp', kwargs)
+
+    @classmethod
+    def _factory(cls, env, kwargs):
+        methods = cls._process_method_kw(kwargs)
+        candidates = []
+
+        if DependencyMethods.CMAKE in methods:
+            candidates.append(functools.partial(LLVMDependencyCMake, env, kwargs))
+
+        if DependencyMethods.CONFIG_TOOL in methods:
+            candidates.append(functools.partial(LLVMDependencyConfigTool, env, kwargs))
+
+        return candidates
+
+    @staticmethod
+    def get_methods():
+        return [DependencyMethods.CMAKE, DependencyMethods.CONFIG_TOOL]
 
 class ValgrindDependency(PkgConfigDependency):
     '''
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -35,7 +35,7 @@
             'mesonbuild.modules',
             'mesonbuild.scripts',
             'mesonbuild.wrap']
-package_data = {'mesonbuild.dependencies': ['data/CMakeLists.txt', 'data/CMakePathInfo.txt']}
+package_data = {'mesonbuild.dependencies': ['data/CMakeLists.txt', 'data/CMakeListsLLVM.txt', 'data/CMakePathInfo.txt']}
 data_files = []
 if sys.platform != 'win32':
     # Only useful on UNIX-like systems


Test patch:
diff --git a/test cases/common/161 config tool variable/meson.build b/test cases/common/161 config tool variable/meson.build
--- a/test cases/common/161 config tool variable/meson.build	
+++ b/test cases/common/161 config tool variable/meson.build	
@@ -15,7 +15,7 @@
 project('config tool variable', 'cpp')
 
 
-dep_llvm = dependency('llvm', required : false)
+dep_llvm = dependency('llvm', method : 'config-tool', required : false)
 if not dep_llvm.found()
   error('MESON_SKIP_TEST LLVM not installed.')
 endif
diff --git a/test cases/frameworks/15 llvm/meson.build b/test cases/frameworks/15 llvm/meson.build
--- a/test cases/frameworks/15 llvm/meson.build	
+++ b/test cases/frameworks/15 llvm/meson.build	
@@ -35,7 +35,7 @@ foreach static : [true, false]
   llvm_dep = dependency(
     'llvm',
     modules : ['bitwriter', 'asmprinter', 'executionengine', 'target',
-               'mcjit', 'nativecodegen'],
+               'mcjit', 'nativecodegen', 'amdgpu'],
     required : false,
     static : static,
   )
diff --git a/test cases/unit/47 native file binary/meson.build b/test cases/unit/47 native file binary/meson.build
--- a/test cases/unit/47 native file binary/meson.build	
+++ b/test cases/unit/47 native file binary/meson.build	
@@ -8,7 +8,7 @@ if case == 'find_program'
   assert(result.stdout().strip().endswith('12345'), 'Didn\'t load bash from config file')
 elif case == 'config_dep'
   add_languages('cpp')
-  dep = dependency('llvm')
+  dep = dependency('llvm', method : 'config-tool')
   assert(dep.get_configtool_variable('version').endswith('12345'), 'Didn\'t load llvm from config file')
 elif case == 'python3'
   prog = import('python3').find_python()