Repository: mesonbuild/meson

Issue:
Support CMake Interface libraries
[Some](https://github.com/skypjack/entt) header-only C++ projects use CMake [interface libraries](https://cmake.org/cmake/help/latest/command/add_library.html#interface-libraries), which [do not currently appear to be supported by meson](https://github.com/mesonbuild/meson/blob/5bd21a96b89efc5fe96025398151dcbce34345ac/mesonbuild/cmake/interpreter.py#L71).

It would be nice if they were supported.

Adding `'INTERFACE_LIBRARY': 'static_library',` to `target_type_map` works, but creates an extra empty library.

`build_target` or `declare_dependency` seem like better choices than `static_library`, but neither work as just a one-line edit. `build_target` requires `target_type`, which is some effort to plumb in and doesn't have a `none` option, and `declare_dependency` gets passed the normal name and (empty) sources, which it can't use.


Discussion:
Yeah, a one-line edit will definitely not be enough to support header-only subprojects. The problem is that the `0.50.*` CMake subprojects implementation only relies on the CMake server introspection API. However, CMake doesn't expose anything besides the name of the library.

Thus, supporting interface libraries will require a similar solution to `add_custom_command` (see #5574), but supporting them should be possible.

Resolving patch:
diff --git a/mesonbuild/cmake/interpreter.py b/mesonbuild/cmake/interpreter.py
--- a/mesonbuild/cmake/interpreter.py
+++ b/mesonbuild/cmake/interpreter.py
@@ -74,8 +74,11 @@
     'SHARED_LIBRARY': 'shared_library',
     'EXECUTABLE': 'executable',
     'OBJECT_LIBRARY': 'static_library',
+    'INTERFACE_LIBRARY': 'header_only'
 }
 
+target_type_requires_trace = ['INTERFACE_LIBRARY']
+
 skip_targets = ['UTILITY']
 
 blacklist_compiler_flags = [
@@ -138,6 +141,7 @@ def __init__(self, target: CMakeTarget, env: Environment):
         self.link_with = []
         self.object_libs = []
         self.compile_opts = {}
+        self.public_compile_opts = []
         self.pie = False
 
         # Project default override options (c_std, cpp_std, etc.)
@@ -170,7 +174,7 @@ def __repr__(self) -> str:
 
     std_regex = re.compile(r'([-]{1,2}std=|/std:v?|[-]{1,2}std:)(.*)')
 
-    def postprocess(self, output_target_map: dict, root_src_dir: str, subdir: str, install_prefix: str) -> None:
+    def postprocess(self, output_target_map: dict, root_src_dir: str, subdir: str, install_prefix: str, trace: CMakeTraceParser) -> None:
         # Detect setting the C and C++ standard
         for i in ['c', 'cpp']:
             if i not in self.compile_opts:
@@ -194,6 +198,18 @@ def postprocess(self, output_target_map: dict, root_src_dir: str, subdir: str, i
         if self.type.upper() == 'OBJECT_LIBRARY':
             self.pie = True
 
+        # Use the CMake trace, if required
+        if self.type.upper() in target_type_requires_trace:
+            if self.name in trace.targets:
+                props = trace.targets[self.name].properies
+
+                self.includes += props.get('INTERFACE_INCLUDE_DIRECTORIES', [])
+                self.public_compile_opts += props.get('INTERFACE_COMPILE_DEFINITIONS', [])
+                self.public_compile_opts += props.get('INTERFACE_COMPILE_OPTIONS', [])
+                self.link_flags += props.get('INTERFACE_LINK_OPTIONS', [])
+            else:
+                mlog.warning('CMake: Target', mlog.bold(self.name), 'not found in CMake trace. This can lead to build errors')
+
         # Fix link libraries
         temp = []
         for i in self.link_libraries:
@@ -584,7 +600,7 @@ def analyse(self) -> None:
         for i in self.custom_targets:
             i.postprocess(output_target_map, self.src_dir, self.subdir, self.build_dir)
         for i in self.targets:
-            i.postprocess(output_target_map, self.src_dir, self.subdir, self.install_prefix)
+            i.postprocess(output_target_map, self.src_dir, self.subdir, self.install_prefix, self.trace)
             if i.type == 'OBJECT_LIBRARY':
                 object_libs += [i]
             self.languages += [x for x in i.languages if x not in self.languages]
@@ -762,17 +778,31 @@ def process_target(tgt: ConverterTarget):
             dep_kwargs = {
                 'link_args': tgt.link_flags + tgt.link_libraries,
                 'link_with': id_node(tgt_var),
+                'compile_args': tgt.public_compile_opts,
                 'include_directories': id_node(inc_var),
             }
 
             # Generate the function nodes
-            inc_node = assign(inc_var, function('include_directories', tgt.includes))
-            src_node = assign(src_var, function('files', sources))
-            tgt_node = assign(tgt_var, function(tgt_func, [base_name, [id_node(src_var)] + generated], tgt_kwargs))
-            dep_node = assign(dep_var, function('declare_dependency', kwargs=dep_kwargs))
+            node_list = []
+            if tgt_func == 'header_only':
+                del dep_kwargs['link_with']
+                inc_node = assign(inc_var, function('include_directories', tgt.includes))
+                dep_node = assign(dep_var, function('declare_dependency', kwargs=dep_kwargs))
+
+                node_list = [inc_node, dep_node]
+                src_var = ''
+                tgt_var = ''
+
+            else:
+                inc_node = assign(inc_var, function('include_directories', tgt.includes))
+                src_node = assign(src_var, function('files', sources))
+                tgt_node = assign(tgt_var, function(tgt_func, [base_name, [id_node(src_var)] + generated], tgt_kwargs))
+                dep_node = assign(dep_var, function('declare_dependency', kwargs=dep_kwargs))
+
+                node_list = [inc_node, src_node, tgt_node, dep_node]
 
             # Add the nodes to the ast
-            root_cb.lines += [inc_node, src_node, tgt_node, dep_node]
+            root_cb.lines += node_list
             processed[tgt.name] = {'inc': inc_var, 'src': src_var, 'dep': dep_var, 'tgt': tgt_var, 'func': tgt_func}
 
         def process_custom_target(tgt: ConverterCustomTarget) -> None:
diff --git a/mesonbuild/cmake/traceparser.py b/mesonbuild/cmake/traceparser.py
--- a/mesonbuild/cmake/traceparser.py
+++ b/mesonbuild/cmake/traceparser.py
@@ -81,7 +81,11 @@ def parse(self, trace: str) -> None:
             'add_custom_command': self._cmake_add_custom_command,
             'add_custom_target': self._cmake_add_custom_target,
             'set_property': self._cmake_set_property,
-            'set_target_properties': self._cmake_set_target_properties
+            'set_target_properties': self._cmake_set_target_properties,
+            'target_compile_definitions': self._cmake_target_compile_definitions,
+            'target_compile_options': self._cmake_target_compile_options,
+            'target_include_directories': self._cmake_target_include_directories,
+            'target_link_options': self._cmake_target_link_options,
         }
 
         # Primary pass -- parse everything
@@ -199,16 +203,23 @@ def _cmake_add_library(self, tline: CMakeTraceLine):
         args = list(tline.args) # Make a working copy
 
         # Make sure the lib is imported
-        if 'IMPORTED' not in args:
-            return self._gen_exception('add_library', 'non imported libraries are not supported', tline)
+        if 'INTERFACE' in args:
+            args.remove('INTERFACE')
 
-        args.remove('IMPORTED')
+            if len(args) < 1:
+                return self._gen_exception('add_library', 'interface library name not specified', tline)
 
-        # No only look at the first two arguments (target_name and target_type) and ignore the rest
-        if len(args) < 2:
-            return self._gen_exception('add_library', 'requires at least 2 arguments', tline)
+            self.targets[args[0]] = CMakeTarget(args[0], 'INTERFACE', {})
+        elif 'IMPORTED' in args:
+            args.remove('IMPORTED')
 
-        self.targets[args[0]] = CMakeTarget(args[0], args[1], {})
+            # No only look at the first two arguments (target_name and target_type) and ignore the rest
+            if len(args) < 2:
+                return self._gen_exception('add_library', 'requires at least 2 arguments', tline)
+
+            self.targets[args[0]] = CMakeTarget(args[0], args[1], {})
+        else:
+            return self._gen_exception('add_library', 'non imported / interface libraries are not supported', tline)
 
     def _cmake_add_custom_command(self, tline: CMakeTraceLine):
         # DOC: https://cmake.org/cmake/help/latest/command/add_custom_command.html
@@ -343,8 +354,8 @@ def _cmake_set_target_properties(self, tline: CMakeTraceLine) -> None:
         # set_property() this is not context free. There are two approaches I
         # can think of, both have drawbacks:
         #
-        #   1. Assume that the property will be capitalized, this is convention
-        #      but cmake doesn't require it.
+        #   1. Assume that the property will be capitalized ([A-Z_]), this is
+        #      convention but cmake doesn't require it.
         #   2. Maintain a copy of the list here: https://cmake.org/cmake/help/latest/manual/cmake-properties.7.html#target-properties
         #
         # Neither of these is awesome for obvious reasons. I'm going to try
@@ -354,8 +365,9 @@ def _cmake_set_target_properties(self, tline: CMakeTraceLine) -> None:
         arglist = []  # type: List[Tuple[str, List[str]]]
         name = args.pop(0)
         values = []
+        prop_regex = re.compile(r'^[A-Z_]+$')
         for a in args:
-            if a.isupper():
+            if prop_regex.match(a):
                 if values:
                     arglist.append((name, ' '.join(values).split(';')))
                 name = a
@@ -372,6 +384,66 @@ def _cmake_set_target_properties(self, tline: CMakeTraceLine) -> None:
 
                 self.targets[i].properies[name] = value
 
+    def _cmake_target_compile_definitions(self, tline: CMakeTraceLine) -> None:
+        # DOC: https://cmake.org/cmake/help/latest/command/target_compile_definitions.html
+        self._parse_common_target_options('target_compile_definitions', 'COMPILE_DEFINITIONS', 'INTERFACE_COMPILE_DEFINITIONS', tline)
+
+    def _cmake_target_compile_options(self, tline: CMakeTraceLine) -> None:
+        # DOC: https://cmake.org/cmake/help/latest/command/target_compile_options.html
+        self._parse_common_target_options('target_compile_options', 'COMPILE_OPTIONS', 'INTERFACE_COMPILE_OPTIONS', tline)
+
+    def _cmake_target_include_directories(self, tline: CMakeTraceLine) -> None:
+        # DOC: https://cmake.org/cmake/help/latest/command/target_include_directories.html
+        self._parse_common_target_options('target_include_directories', 'INCLUDE_DIRECTORIES', 'INTERFACE_INCLUDE_DIRECTORIES', tline, ignore=['SYSTEM', 'BEFORE'], paths=True)
+
+    def _cmake_target_link_options(self, tline: CMakeTraceLine) -> None:
+        # DOC: https://cmake.org/cmake/help/latest/command/target_link_options.html
+        self._parse_common_target_options('target_link_options', 'LINK_OPTIONS', 'INTERFACE_LINK_OPTIONS', tline)
+
+    def _parse_common_target_options(self, func: str, private_prop: str, interface_prop: str, tline: CMakeTraceLine, ignore: Optional[List[str]] = None, paths: bool = False):
+        if ignore is None:
+            ignore = ['BEFORE']
+
+        args = list(tline.args)
+
+        if len(args) < 1:
+            return self._gen_exception(func, 'requires at least one argument', tline)
+
+        target = args[0]
+        if target not in self.targets:
+            return self._gen_exception(func, 'TARGET {} not found'.format(target), tline)
+
+        interface = []
+        private = []
+
+        mode = 'PUBLIC'
+        for i in args[1:]:
+            if i in ignore:
+                continue
+
+            if i in ['INTERFACE', 'PUBLIC', 'PRIVATE']:
+                mode = i
+                continue
+
+            if mode in ['INTERFACE', 'PUBLIC']:
+                interface += [i]
+
+            if mode in ['PUBLIC', 'PRIVATE']:
+                private += [i]
+
+        if paths:
+            interface = self._guess_files(interface)
+            private = self._guess_files(private)
+
+        interface = [x for x in interface if x]
+        private = [x for x in private if x]
+
+        for i in [(private_prop, private), (interface_prop, interface)]:
+            if not i[0] in self.targets[target].properies:
+                self.targets[target].properies[i[0]] = []
+
+            self.targets[target].properies[i[0]] += i[1]
+
     def _lex_trace(self, trace):
         # The trace format is: '<file>(<line>):  <func>(<args -- can contain \n> )\n'
         reg_tline = re.compile(r'\s*(.*\.(cmake|txt))\(([0-9]+)\):\s*(\w+)\(([\s\S]*?) ?\)\s*\n', re.MULTILINE)
@@ -420,7 +492,7 @@ def _guess_files(self, broken_list: List[str]) -> List[str]:
                 # Abort concatination if curr_str no longer matches the regex
                 fixed_list += [curr_str]
                 curr_str = i
-            elif reg_end.match(i):
+            elif reg_end.match(i) or os.path.exists('{} {}'.format(curr_str, i)):
                 # File detected
                 curr_str = '{} {}'.format(curr_str, i)
                 fixed_list += [curr_str]


Test patch:
diff --git a/test cases/cmake/9 header only/main.cpp b/test cases/cmake/9 header only/main.cpp
new file mode 100644
--- /dev/null
+++ b/test cases/cmake/9 header only/main.cpp	
@@ -0,0 +1,10 @@
+#include <iostream>
+#include <cmMod.hpp>
+
+using namespace std;
+
+int main() {
+  cmModClass obj("Hello");
+  cout << obj.getStr() << endl;
+  return 0;
+}
diff --git a/test cases/cmake/9 header only/meson.build b/test cases/cmake/9 header only/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/cmake/9 header only/meson.build	
@@ -0,0 +1,12 @@
+project('cmakeSubTest', ['c', 'cpp'])
+
+cm = import('cmake')
+
+sub_pro = cm.subproject('cmMod')
+sub_dep = sub_pro.dependency('cmModLib')
+
+assert(sub_pro.target_list() == ['cmModLib'], 'There should be exactly one target')
+assert(sub_pro.target_type('cmModLib') == 'header_only', 'Target type should be header_only')
+
+exe1 = executable('main', ['main.cpp'], dependencies: [sub_dep])
+test('test1', exe1)
diff --git a/test cases/cmake/9 header only/subprojects/cmMod/CMakeLists.txt b/test cases/cmake/9 header only/subprojects/cmMod/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/test cases/cmake/9 header only/subprojects/cmMod/CMakeLists.txt	
@@ -0,0 +1,11 @@
+cmake_minimum_required(VERSION 3.5)
+
+project(cmMod)
+set (CMAKE_CXX_STANDARD 14)
+
+add_definitions("-DDO_NOTHING_JUST_A_FLAG=1")
+
+add_library(cmModLib INTERFACE)
+set_target_properties(cmModLib PROPERTIES INTERFACE_COMPILE_OPTIONS "-DCMAKE_FLAG_MUST_BE_PRESENT")
+target_include_directories(cmModLib INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/include")
+target_compile_definitions(cmModLib INTERFACE -DCMAKE_COMPILER_DEFINE_STR="compDef")
diff --git a/test cases/cmake/9 header only/subprojects/cmMod/include/cmMod.hpp b/test cases/cmake/9 header only/subprojects/cmMod/include/cmMod.hpp
new file mode 100644
--- /dev/null
+++ b/test cases/cmake/9 header only/subprojects/cmMod/include/cmMod.hpp	
@@ -0,0 +1,19 @@
+#pragma once
+
+#include <string>
+
+#ifndef CMAKE_FLAG_MUST_BE_PRESENT
+#error "The flag CMAKE_FLAG_MUST_BE_PRESENT was not set"
+#endif
+
+class cmModClass {
+  private:
+    std::string str;
+  public:
+    cmModClass(std::string foo) {
+      str = foo + " World ";
+      str += CMAKE_COMPILER_DEFINE_STR;
+    }
+
+    inline std::string getStr() const { return str; }
+};