Add support for opaque build targets. Closes #10627.
diff --git a/mesonbuild/backend/backends.py b/mesonbuild/backend/backends.py
index 8943464..68efb3d 100644
--- a/mesonbuild/backend/backends.py
+++ b/mesonbuild/backend/backends.py
@@ -131,6 +131,7 @@
         self.symlinks: T.List[InstallSymlinkData] = []
         self.install_scripts: T.List[ExecutableSerialisation] = []
         self.install_subdirs: T.List[SubdirInstallData] = []
+        self.install_opaque: T.List[(str, str)] = []
 
 @dataclass(eq=False)
 class TargetInstallData:
@@ -1510,6 +1511,7 @@
         self.generate_symlink_install(d)
         self.generate_custom_install_script(d)
         self.generate_subdir_install(d)
+        self.generate_opaque_install(d)
         return d
 
     def create_install_data_files(self) -> None:
@@ -1637,6 +1639,11 @@
                                               tag=tag)
                         d.targets.append(i)
             elif isinstance(t, build.CustomTarget):
+                if t.is_opaque:
+                    # The contents of opaque dirs are installed in a separate function.
+                    # The "output" of this target is the stampfile and we do not want
+                    # to install that.
+                    continue
                 # If only one install_dir is specified, assume that all
                 # outputs will be installed into it. This is for
                 # backwards-compatibility and because it makes sense to
@@ -1771,6 +1778,15 @@
             i = SubdirInstallData(src_dir, dst_dir, dst_name, sd.install_mode, sd.exclude, sd.subproject, sd.install_tag)
             d.install_subdirs.append(i)
 
+    def generate_opaque_install(self, d: InstallData) -> None:
+        for ct in self.build.get_targets().values():
+            if isinstance(ct, build.CustomTarget) and ct.is_opaque:
+                opdata = mesonlib.get_opaque_data(ct.subproject, ct.name)
+                if len(ct.install_dir) !=1:
+                    raise RuntimeError('Internal error, install_dir must have only one string.')
+                installdir = ct.install_dir[0]
+                d.install_opaque.append((opdata.out, installdir))
+
     def get_introspection_data(self, target_id: str, target: build.Target) -> T.List['TargetIntrospectionData']:
         '''
         Returns a list of source dicts with the following format for a given target:
diff --git a/mesonbuild/backend/ninjabackend.py b/mesonbuild/backend/ninjabackend.py
index 593c201..8377dcf 100644
--- a/mesonbuild/backend/ninjabackend.py
+++ b/mesonbuild/backend/ninjabackend.py
@@ -3445,15 +3445,17 @@
         # empty. https://github.com/mesonbuild/meson/issues/1220
         ctlist = []
         for t in self.build.get_targets().values():
-            if isinstance(t, build.CustomTarget):
+            if isinstance(t, build.CustomTarget) and not t.is_opaque:
                 # Create a list of all custom target outputs
                 for o in t.get_outputs():
                     ctlist.append(os.path.join(self.get_target_dir(t), o))
         # As above, but restore the top level directory after deletion.
+        gendir_list = []
         for t in self.build.get_targets().values():
-            if isinstance(t, build.CustomTarget):
-                if False:
-                    gendir_list.append('fake')
+            if isinstance(t, build.CustomTarget) and t.is_opaque:
+                opdata = mesonlib.get_opaque_data(t.subproject, t.name)
+                gendir_list.append(opdata.scratch)
+                gendir_list.append(opdata.out)
 
         if ctlist or gendir_list:
             elem.add_dep(self.generate_custom_target_clean(ctlist, gendir_list))
diff --git a/mesonbuild/build.py b/mesonbuild/build.py
index bf87071..4e5150e 100644
--- a/mesonbuild/build.py
+++ b/mesonbuild/build.py
@@ -2443,6 +2443,7 @@
         self.install_mode = install_mode
         self.install_tag = _process_install_tag(install_tag, len(self.outputs))
         self.name = name if name else self.outputs[0]
+        self.is_opaque = False
 
         # Whether to use absolute paths for all files on the commandline
         self.absolute_paths = absolute_paths
diff --git a/mesonbuild/interpreter/interpreter.py b/mesonbuild/interpreter/interpreter.py
index 1f9a99a..523b3e7 100644
--- a/mesonbuild/interpreter/interpreter.py
+++ b/mesonbuild/interpreter/interpreter.py
@@ -408,6 +408,7 @@
                            'join_paths': self.func_join_paths,
                            'library': self.func_library,
                            'message': self.func_message,
+                           'opaque_target': self.func_opaque_target,
                            'option': self.func_option,
                            'project': self.func_project,
                            'range': self.func_range,
@@ -1984,6 +1985,24 @@
         self.add_target(tg.name, tg)
         return tg
 
+    def func_opaque_target(self, node: mparser.FunctionNode, args: T.Tuple[str],
+                           kwargs: 'kwtypes.OpaqueTarget') -> build.CustomTarget:
+        if len(args) != 1:
+            raise InterpreterException('Opaque_target takes exactly one positional argument.')
+        opaque_data = mesonlib.get_opaque_data(self.subproject, args[0])
+        kwargs['output'] = opaque_data.stamp
+        kwargs['depfile'] = opaque_data.dep
+        integration_args = ['--stamp=@OUTPUT@', 
+                            '--dep=@DEPFILE@',
+                            '--scratch=' + opaque_data.scratch,
+                            '--out=' + opaque_data.out,
+                            '--']
+        kwargs['command'] = listify(kwargs['command']) + integration_args + kwargs['args']
+        del kwargs['args']
+        ct = self.func_custom_target(node, args, kwargs)
+        ct.is_opaque = True
+        return ct
+
     @typed_pos_args('run_target', str)
     @typed_kwargs(
         'run_target',
diff --git a/mesonbuild/minstall.py b/mesonbuild/minstall.py
index 69763fa..8a97c8a 100644
--- a/mesonbuild/minstall.py
+++ b/mesonbuild/minstall.py
@@ -548,6 +548,7 @@
                 self.install_emptydir(d, dm, destdir, fullprefix)
                 self.install_data(d, dm, destdir, fullprefix)
                 self.install_symlinks(d, dm, destdir, fullprefix)
+                self.install_opaque(d, dm, destdir, fullprefix)
                 self.restore_selinux_contexts(destdir)
                 self.run_install_script(d, destdir, fullprefix)
                 if not self.did_install_something:
@@ -604,6 +605,20 @@
             if self.do_symlink(s.target, full_link_name, destdir, full_dst_dir, s.allow_missing):
                 self.did_install_something = True
 
+    def install_opaque(self, d: InstallData, dm: DirMaker, destdir: str, fullprefix: str) -> None:
+        for s in d.install_opaque:
+            from_dir, dst_dir = s
+            from_dir_abs = os.path.join(d.build_dir, from_dir)
+            full_dst_dir = get_destdir_path(destdir, fullprefix, dst_dir)
+            from_path = Path(from_dir_abs )
+            for f in from_path.glob('*'):
+                if f.is_file():
+                    self.do_copyfile(f, os.path.join(full_dst_dir), f.parts[-1])
+                else:
+                    
+                    self.do_copydir(d, str(f), os.path.join(full_dst_dir, f.parts[-1]), None, None, dm)
+            self.did_install_something = True
+
     def install_man(self, d: InstallData, dm: DirMaker, destdir: str, fullprefix: str) -> None:
         for m in d.man:
             if not self.should_install(m):
diff --git a/mesonbuild/utils/universal.py b/mesonbuild/utils/universal.py
index aaefbc5..a8f2d97 100644
--- a/mesonbuild/utils/universal.py
+++ b/mesonbuild/utils/universal.py
@@ -1,4 +1,4 @@
-# Copyright 2012-2020 The Meson development team
+# Copyright 2012-2022 The Meson development team
 
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -94,6 +94,7 @@
     'get_compiler_for_source',
     'get_filenames_templates_dict',
     'get_library_dirs',
+    'get_opaque_data',
     'get_variable_regex',
     'get_wine_shortpath',
     'git',
@@ -2277,3 +2278,21 @@
         if predicate(i):
             return i
     return None
+
+class OpaqueData:
+    def __init__(self, scratch, out, stamp, dep):
+        self.scratch = scratch
+        self.out = out
+        self.stamp = stamp
+        self.dep = dep
+
+def get_opaque_data(subproject, target_name):
+    if subproject:
+        sub_str = subproject + '_'
+    else:
+        sub_str = ''
+    gendir = 'meson-gen'
+    return OpaqueData(os.path.join(gendir, sub_str, target_name + '_s'),
+                      os.path.join(gendir, sub_str, target_name),
+                      target_name + '.stamp',
+                      target_name + '.d')
diff --git a/test cases/frameworks/14 doxygen/doc/meson.build b/test cases/frameworks/14 doxygen/doc/meson.build
index bde2d7c..084151d 100644
--- a/test cases/frameworks/14 doxygen/doc/meson.build
+++ b/test cases/frameworks/14 doxygen/doc/meson.build
@@ -1,6 +1,8 @@
 cdata.set('TOP_SRCDIR', meson.source_root())
 cdata.set('TOP_BUILDDIR', meson.build_root())
 
+wrapper_script = find_program('doxyrunner.py')
+
 doxyfile = configure_file(input: 'Doxyfile.in',
                           output: 'Doxyfile',
                           configuration: cdata,
@@ -8,9 +10,8 @@
 
 datadir = join_paths(get_option('datadir'), 'doc', 'spede')
 
-html_target = custom_target('spede-docs',
-                            input: doxyfile,
-                            output: 'html',
-                            command: [doxygen, doxyfile],
-                            install: true,
-                            install_dir: datadir)
+opaque_target('spede-docs',
+              command: wrapper_script,
+              args: [doxyfile],
+              install: true,
+              install_dir: datadir)