Repository: mesonbuild/meson

Issue:
[Feature request] Add ability to specify target(/s) to `meson compile`
Proposal:
Add ability to specify target(/s) to `meson compile`.

Reasoning:
- Removes most of the cases where direct invocation of `ninja`/`msbuild` is needed.
- (relative) Feature parity with `cmake --build`.

Proposed interface:
```
usage: meson compile [args [args...]]

positional arguments:
  args                            optional list of targets to build
```

Extracted from #6670.


Discussion:
Note: I'm not sure what these `target`s should be in case of each back-end though.
Cmake uses it's internally defined target names (`add_library`/`add_executable`) for this argument (equivalent of `library`/`executable`/etc in meson).
It would be probably not ideal to just use `ninja` targets as argument values, since it would not be back-end agnostic.
All build targets take a `name` parameter, can we use that? Or are collisions possible?
AFAIK target names are unique project-wide (subproject targets are namespaced).
Those names are guaranteed to be unique.

Resolving patch:
diff --git a/mesonbuild/mcompile.py b/mesonbuild/mcompile.py
--- a/mesonbuild/mcompile.py
+++ b/mesonbuild/mcompile.py
@@ -14,9 +14,11 @@
 
 """Entrypoint script for backend agnostic compile."""
 
-import argparse
+import json
+import re
 import sys
 import typing as T
+from collections import defaultdict
 from pathlib import Path
 
 from . import mlog
@@ -26,10 +28,13 @@
 from mesonbuild.environment import detect_ninja
 from mesonbuild.coredata import UserArrayOption
 
+if T.TYPE_CHECKING:
+    import argparse
+
 def array_arg(value: str) -> T.List[str]:
     return UserArrayOption(None, value, allow_dups=True, user_input=True).value
 
-def validate_builddir(builddir: Path):
+def validate_builddir(builddir: Path) -> None:
     if not (builddir / 'meson-private' / 'coredata.dat' ).is_file():
         raise MesonException('Current directory is not a meson build directory: `{}`.\n'
                              'Please specify a valid build dir or change the working directory to it.\n'
@@ -42,7 +47,93 @@ def get_backend_from_coredata(builddir: Path) -> str:
     """
     return coredata.load(str(builddir)).get_builtin_option('backend')
 
-def get_parsed_args_ninja(options: 'argparse.Namespace', builddir: Path):
+def parse_introspect_data(builddir: Path) -> T.Dict[str, T.List[dict]]:
+    """
+    Converts a List of name-to-dict to a dict of name-to-dicts (since names are not unique)
+    """
+    path_to_intro = builddir / 'meson-info' / 'intro-targets.json'
+    if not path_to_intro.exists():
+        raise MesonException('`{}` is missing! Directory is not configured yet?'.format(path_to_intro.name))
+    with path_to_intro.open() as f:
+        schema = json.load(f)
+
+    parsed_data = defaultdict(list) # type: T.Dict[str, T.List[dict]]
+    for target in schema:
+        parsed_data[target['name']] += [target]
+    return parsed_data
+
+class ParsedTargetName:
+    full_name = ''
+    name = ''
+    type = ''
+    path = ''
+
+    def __init__(self, target: str):
+        self.full_name = target
+        split = target.rsplit(':', 1)
+        if len(split) > 1:
+            self.type = split[1]
+            if not self._is_valid_type(self.type):
+                raise MesonException('Can\'t invoke target `{}`: unknown target type: `{}`'.format(target, self.type))
+
+        split = split[0].rsplit('/', 1)
+        if len(split) > 1:
+            self.path = split[0]
+            self.name = split[1]
+        else:
+            self.name = split[0]
+
+    @staticmethod
+    def _is_valid_type(type: str) -> bool:
+        # Ammend docs in Commands.md when editing this list
+        allowed_types = {
+            'executable',
+            'static_library',
+            'shared_library',
+            'shared_module',
+            'custom',
+            'run',
+            'jar',
+        }
+        return type in allowed_types
+
+def get_target_from_intro_data(target: ParsedTargetName, builddir: Path, introspect_data: dict) -> dict:
+    if target.name not in introspect_data:
+        raise MesonException('Can\'t invoke target `{}`: target not found'.format(target.full_name))
+
+    intro_targets = introspect_data[target.name]
+    found_targets = []
+
+    resolved_bdir = builddir.resolve()
+
+    if not target.type and not target.path:
+        found_targets = intro_targets
+    else:
+        for intro_target in intro_targets:
+            if (intro_target['subproject'] or
+                    (target.type and target.type != intro_target['type'].replace(' ', '_')) or
+                    (target.path
+                         and intro_target['filename'] != 'no_name'
+                         and Path(target.path) != Path(intro_target['filename'][0]).relative_to(resolved_bdir).parent)):
+                continue
+            found_targets += [intro_target]
+
+    if not found_targets:
+        raise MesonException('Can\'t invoke target `{}`: target not found'.format(target.full_name))
+    elif len(found_targets) > 1:
+        raise MesonException('Can\'t invoke target `{}`: ambigious name. Add target type and/or path: `PATH/NAME:TYPE`'.format(target.full_name))
+
+    return found_targets[0]
+
+def generate_target_names_ninja(target: ParsedTargetName, builddir: Path, introspect_data: dict) -> T.List[str]:
+    intro_target = get_target_from_intro_data(target, builddir, introspect_data)
+
+    if intro_target['type'] == 'run':
+        return [target.name]
+    else:
+        return [str(Path(out_file).relative_to(builddir.resolve())) for out_file in intro_target['filename']]
+
+def get_parsed_args_ninja(options: 'argparse.Namespace', builddir: Path) -> T.List[str]:
     runner = detect_ninja()
     if runner is None:
         raise MesonException('Cannot find ninja.')
@@ -50,57 +141,100 @@ def get_parsed_args_ninja(options: 'argparse.Namespace', builddir: Path):
 
     cmd = [runner, '-C', builddir.as_posix()]
 
+    if options.targets:
+        intro_data = parse_introspect_data(builddir)
+        for t in options.targets:
+            cmd.extend(generate_target_names_ninja(ParsedTargetName(t), builddir, intro_data))
+    if options.clean:
+        cmd.append('clean')
+
     # If the value is set to < 1 then don't set anything, which let's
     # ninja/samu decide what to do.
     if options.jobs > 0:
         cmd.extend(['-j', str(options.jobs)])
     if options.load_average > 0:
         cmd.extend(['-l', str(options.load_average)])
+
     if options.verbose:
-        cmd.append('-v')
-    if options.clean:
-        cmd.append('clean')
+        cmd.append('--verbose')
+
+    cmd += options.ninja_args
 
     return cmd
 
-def get_parsed_args_vs(options: 'argparse.Namespace', builddir: Path):
+def generate_target_name_vs(target: ParsedTargetName, builddir: Path, introspect_data: dict) -> str:
+    intro_target = get_target_from_intro_data(target, builddir, introspect_data)
+
+    assert intro_target['type'] != 'run', 'Should not reach here: `run` targets must be handle above'
+
+    # Normalize project name
+    # Source: https://docs.microsoft.com/en-us/visualstudio/msbuild/how-to-build-specific-targets-in-solutions-by-using-msbuild-exe
+    target_name = re.sub('[\%\$\@\;\.\(\)\']', '_', intro_target['id'])
+    rel_path = Path(intro_target['filename'][0]).relative_to(builddir.resolve()).parent
+    if rel_path != '.':
+        target_name = str(rel_path / target_name)
+    return target_name
+
+def get_parsed_args_vs(options: 'argparse.Namespace', builddir: Path) -> T.List[str]:
     slns = list(builddir.glob('*.sln'))
     assert len(slns) == 1, 'More than one solution in a project?'
-
     sln = slns[0]
-    cmd = ['msbuild', str(sln.resolve())]
 
-    # In msbuild `-m` with no number means "detect cpus", the default is `-m1`
+    cmd = ['msbuild']
+
+    if options.targets:
+        intro_data = parse_introspect_data(builddir)
+        has_run_target = any(map(
+            lambda t:
+                get_target_from_intro_data(ParsedTargetName(t), builddir, intro_data)['type'] == 'run',
+            options.targets
+        ))
+
+        if has_run_target:
+            # `run` target can't be used the same way as other targets on `vs` backend.
+            # They are defined as disabled projects, which can't be invoked as `.sln`
+            # target and have to be invoked directly as project instead.
+            # Issue: https://github.com/microsoft/msbuild/issues/4772
+
+            if len(options.targets) > 1:
+                raise MesonException('Only one target may be specified when `run` target type is used on this backend.')
+            intro_target = get_target_from_intro_data(ParsedTargetName(options.targets[0]), builddir, intro_data)
+            proj_dir = Path(intro_target['filename'][0]).parent
+            proj = proj_dir/'{}.vcxproj'.format(intro_target['id'])
+            cmd += [str(proj.resolve())]
+        else:
+            cmd += [str(sln.resolve())]
+            cmd.extend(['-target:{}'.format(generate_target_name_vs(ParsedTargetName(t), builddir, intro_data)) for t in options.targets])
+    else:
+        cmd += [str(sln.resolve())]
+
+    if options.clean:
+        cmd.extend(['-target:Clean'])
+
+    # In msbuild `-maxCpuCount` with no number means "detect cpus", the default is `-maxCpuCount:1`
     if options.jobs > 0:
-        cmd.append('-m{}'.format(options.jobs))
+        cmd.append('-maxCpuCount:{}'.format(options.jobs))
     else:
-        cmd.append('-m')
+        cmd.append('-maxCpuCount')
 
     if options.load_average:
         mlog.warning('Msbuild does not have a load-average switch, ignoring.')
+
     if not options.verbose:
-        cmd.append('/v:minimal')
-    if options.clean:
-        cmd.append('/t:Clean')
+        cmd.append('-verbosity:minimal')
+
+    cmd += options.vs_args
 
     return cmd
 
 def add_arguments(parser: 'argparse.ArgumentParser') -> None:
     """Add compile specific arguments."""
     parser.add_argument(
-        '-j', '--jobs',
-        action='store',
-        default=0,
-        type=int,
-        help='The number of worker jobs to run (if supported). If the value is less than 1 the build program will guess.'
-    )
-    parser.add_argument(
-        '-l', '--load-average',
-        action='store',
-        default=0,
-        type=int,
-        help='The system load average to try to maintain (if supported)'
-    )
+        'targets',
+        metavar='TARGET',
+        nargs='*',
+        default=None,
+        help='Targets to build. Target has the following format: [PATH_TO_TARGET/]TARGET_NAME[:TARGET_TYPE].')
     parser.add_argument(
         '--clean',
         action='store_true',
@@ -114,6 +248,20 @@ def add_arguments(parser: 'argparse.ArgumentParser') -> None:
         default='.',
         help='The directory containing build files to be built.'
     )
+    parser.add_argument(
+        '-j', '--jobs',
+        action='store',
+        default=0,
+        type=int,
+        help='The number of worker jobs to run (if supported). If the value is less than 1 the build program will guess.'
+    )
+    parser.add_argument(
+        '-l', '--load-average',
+        action='store',
+        default=0,
+        type=int,
+        help='The system load average to try to maintain (if supported).'
+    )
     parser.add_argument(
         '--verbose',
         action='store_true',
@@ -138,13 +286,14 @@ def run(options: 'argparse.Namespace') -> int:
 
     cmd = []  # type: T.List[str]
 
+    if options.targets and options.clean:
+        raise MesonException('`TARGET` and `--clean` can\'t be used simultaneously')
+
     backend = get_backend_from_coredata(bdir)
     if backend == 'ninja':
         cmd = get_parsed_args_ninja(options, bdir)
-        cmd += options.ninja_args
     elif backend.startswith('vs'):
         cmd = get_parsed_args_vs(options, bdir)
-        cmd += options.vs_args
     else:
         # TODO: xcode?
         raise MesonException(
diff --git a/run_unittests.py b/run_unittests.py
--- a/run_unittests.py
+++ b/run_unittests.py
@@ -4630,33 +4630,83 @@ def test_summary(self):
 
     def test_meson_compile(self):
         """Test the meson compile command."""
-        prog = 'trivialprog'
-        if is_windows():
-            prog = '{}.exe'.format(prog)
+
+        def get_exe_name(basename: str) -> str:
+            if is_windows():
+                return '{}.exe'.format(basename)
+            else:
+                return basename
+
+        def get_shared_lib_name(basename: str) -> str:
+            if mesonbuild.environment.detect_msys2_arch():
+                return 'lib{}.dll'.format(basename)
+            elif is_windows():
+                return '{}.dll'.format(basename)
+            elif is_cygwin():
+                return 'cyg{}.dll'.format(basename)
+            elif is_osx():
+                return 'lib{}.dylib'.format(basename)
+            else:
+                return 'lib{}.so'.format(basename)
+
+        def get_static_lib_name(basename: str) -> str:
+            return 'lib{}.a'.format(basename)
+
+        # Base case (no targets or additional arguments)
 
         testdir = os.path.join(self.common_test_dir, '1 trivial')
         self.init(testdir)
 
         self._run([*self.meson_command, 'compile', '-C', self.builddir])
-        # If compile worked then we should get a program
-        self.assertPathExists(os.path.join(self.builddir, prog))
+        self.assertPathExists(os.path.join(self.builddir, get_exe_name('trivialprog')))
+
+        # `--clean`
 
         self._run([*self.meson_command, 'compile', '-C', self.builddir, '--clean'])
-        self.assertPathDoesNotExist(os.path.join(self.builddir, prog))
+        self.assertPathDoesNotExist(os.path.join(self.builddir, get_exe_name('trivialprog')))
+
+        # Target specified in a project with unique names
+
+        testdir = os.path.join(self.common_test_dir, '6 linkshared')
+        self.init(testdir, extra_args=['--wipe'])
+        # Multiple targets and target type specified
+        self._run([*self.meson_command, 'compile', '-C', self.builddir, 'mylib', 'mycpplib:shared_library'])
+        # Check that we have a shared lib, but not an executable, i.e. check that target actually worked
+        self.assertPathExists(os.path.join(self.builddir, get_shared_lib_name('mylib')))
+        self.assertPathDoesNotExist(os.path.join(self.builddir, get_exe_name('prog')))
+        self.assertPathExists(os.path.join(self.builddir, get_shared_lib_name('mycpplib')))
+        self.assertPathDoesNotExist(os.path.join(self.builddir, get_exe_name('cppprog')))
+
+        # Target specified in a project with non unique names
+
+        testdir = os.path.join(self.common_test_dir, '190 same target name')
+        self.init(testdir, extra_args=['--wipe'])
+        self._run([*self.meson_command, 'compile', '-C', self.builddir, './foo'])
+        self.assertPathExists(os.path.join(self.builddir, get_static_lib_name('foo')))
+        self._run([*self.meson_command, 'compile', '-C', self.builddir, 'sub/foo'])
+        self.assertPathExists(os.path.join(self.builddir, 'sub', get_static_lib_name('foo')))
+
+        # run_target
+
+        testdir = os.path.join(self.common_test_dir, '54 run target')
+        self.init(testdir, extra_args=['--wipe'])
+        out = self._run([*self.meson_command, 'compile', '-C', self.builddir, 'py3hi'])
+        self.assertIn('I am Python3.', out)
 
         # `--$BACKEND-args`
 
+        testdir = os.path.join(self.common_test_dir, '1 trivial')
         if self.backend is Backend.ninja:
             self.init(testdir, extra_args=['--wipe'])
             # Dry run - should not create a program
             self._run([*self.meson_command, 'compile', '-C', self.builddir, '--ninja-args=-n'])
-            self.assertPathDoesNotExist(os.path.join(self.builddir, prog))
+            self.assertPathDoesNotExist(os.path.join(self.builddir, get_exe_name('trivialprog')))
         elif self.backend is Backend.vs:
             self.init(testdir, extra_args=['--wipe'])
             self._run([*self.meson_command, 'compile', '-C', self.builddir])
             # Explicitly clean the target through msbuild interface
-            self._run([*self.meson_command, 'compile', '-C', self.builddir, '--vs-args=-t:{}:Clean'.format(re.sub(r'[\%\$\@\;\.\(\)\']', '_', prog))])
-            self.assertPathDoesNotExist(os.path.join(self.builddir, prog))
+            self._run([*self.meson_command, 'compile', '-C', self.builddir, '--vs-args=-t:{}:Clean'.format(re.sub(r'[\%\$\@\;\.\(\)\']', '_', get_exe_name('trivialprog')))])
+            self.assertPathDoesNotExist(os.path.join(self.builddir, get_exe_name('trivialprog')))
 
     def test_spurious_reconfigure_built_dep_file(self):
         testdir = os.path.join(self.unit_test_dir, '75 dep files')


Test patch: