1# Copyright 2013-2021 The Meson development team
2
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6
7#     http://www.apache.org/licenses/LICENSE-2.0
8
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from .base import ExternalDependency, DependencyException, sort_libpaths, DependencyTypeName
16from ..mesonlib import MachineChoice, OptionKey, OrderedSet, PerMachine, Popen_safe
17from ..programs import find_external_program, ExternalProgram
18from .. import mlog
19from pathlib import PurePath
20import re
21import os
22import shlex
23import typing as T
24
25if T.TYPE_CHECKING:
26    from ..environment import Environment
27
28class PkgConfigDependency(ExternalDependency):
29    # The class's copy of the pkg-config path. Avoids having to search for it
30    # multiple times in the same Meson invocation.
31    class_pkgbin: PerMachine[T.Union[None, bool, ExternalProgram]] = PerMachine(None, None)
32    # We cache all pkg-config subprocess invocations to avoid redundant calls
33    pkgbin_cache: T.Dict[
34        T.Tuple[ExternalProgram, T.Tuple[str, ...], T.FrozenSet[T.Tuple[str, str]]],
35        T.Tuple[int, str, str]
36    ] = {}
37
38    def __init__(self, name: str, environment: 'Environment', kwargs: T.Dict[str, T.Any], language: T.Optional[str] = None) -> None:
39        super().__init__(DependencyTypeName('pkgconfig'), environment, kwargs, language=language)
40        self.name = name
41        self.is_libtool = False
42        # Store a copy of the pkg-config path on the object itself so it is
43        # stored in the pickled coredata and recovered.
44        self.pkgbin: T.Union[None, bool, ExternalProgram] = None
45
46        # Only search for pkg-config for each machine the first time and store
47        # the result in the class definition
48        if PkgConfigDependency.class_pkgbin[self.for_machine] is False:
49            mlog.debug('Pkg-config binary for %s is cached as not found.' % self.for_machine)
50        elif PkgConfigDependency.class_pkgbin[self.for_machine] is not None:
51            mlog.debug('Pkg-config binary for %s is cached.' % self.for_machine)
52        else:
53            assert PkgConfigDependency.class_pkgbin[self.for_machine] is None
54            mlog.debug('Pkg-config binary for %s is not cached.' % self.for_machine)
55            for potential_pkgbin in find_external_program(
56                    self.env, self.for_machine, 'pkgconfig', 'Pkg-config',
57                    environment.default_pkgconfig, allow_default_for_cross=False):
58                version_if_ok = self.check_pkgconfig(potential_pkgbin)
59                if not version_if_ok:
60                    continue
61                if not self.silent:
62                    mlog.log('Found pkg-config:', mlog.bold(potential_pkgbin.get_path()),
63                             '(%s)' % version_if_ok)
64                PkgConfigDependency.class_pkgbin[self.for_machine] = potential_pkgbin
65                break
66            else:
67                if not self.silent:
68                    mlog.log('Found Pkg-config:', mlog.red('NO'))
69                # Set to False instead of None to signify that we've already
70                # searched for it and not found it
71                PkgConfigDependency.class_pkgbin[self.for_machine] = False
72
73        self.pkgbin = PkgConfigDependency.class_pkgbin[self.for_machine]
74        if self.pkgbin is False:
75            self.pkgbin = None
76            msg = 'Pkg-config binary for machine %s not found. Giving up.' % self.for_machine
77            if self.required:
78                raise DependencyException(msg)
79            else:
80                mlog.debug(msg)
81                return
82
83        assert isinstance(self.pkgbin, ExternalProgram)
84        mlog.debug('Determining dependency {!r} with pkg-config executable '
85                   '{!r}'.format(name, self.pkgbin.get_path()))
86        ret, self.version, _ = self._call_pkgbin(['--modversion', name])
87        if ret != 0:
88            return
89
90        self.is_found = True
91
92        try:
93            # Fetch cargs to be used while using this dependency
94            self._set_cargs()
95            # Fetch the libraries and library paths needed for using this
96            self._set_libs()
97        except DependencyException as e:
98            mlog.debug(f"pkg-config error with '{name}': {e}")
99            if self.required:
100                raise
101            else:
102                self.compile_args = []
103                self.link_args = []
104                self.is_found = False
105                self.reason = e
106
107    def __repr__(self) -> str:
108        s = '<{0} {1}: {2} {3}>'
109        return s.format(self.__class__.__name__, self.name, self.is_found,
110                        self.version_reqs)
111
112    def _call_pkgbin_real(self, args: T.List[str], env: T.Dict[str, str]) -> T.Tuple[int, str, str]:
113        assert isinstance(self.pkgbin, ExternalProgram)
114        cmd = self.pkgbin.get_command() + args
115        p, out, err = Popen_safe(cmd, env=env)
116        rc, out, err = p.returncode, out.strip(), err.strip()
117        call = ' '.join(cmd)
118        mlog.debug(f"Called `{call}` -> {rc}\n{out}")
119        return rc, out, err
120
121    @staticmethod
122    def setup_env(env: T.MutableMapping[str, str], environment: 'Environment', for_machine: MachineChoice,
123                  extra_path: T.Optional[str] = None) -> None:
124        extra_paths: T.List[str] = environment.coredata.options[OptionKey('pkg_config_path', machine=for_machine)].value[:]
125        if extra_path and extra_path not in extra_paths:
126            extra_paths.append(extra_path)
127        sysroot = environment.properties[for_machine].get_sys_root()
128        if sysroot:
129            env['PKG_CONFIG_SYSROOT_DIR'] = sysroot
130        new_pkg_config_path = ':'.join([p for p in extra_paths])
131        env['PKG_CONFIG_PATH'] = new_pkg_config_path
132
133        pkg_config_libdir_prop = environment.properties[for_machine].get_pkg_config_libdir()
134        if pkg_config_libdir_prop:
135            new_pkg_config_libdir = ':'.join([p for p in pkg_config_libdir_prop])
136            env['PKG_CONFIG_LIBDIR'] = new_pkg_config_libdir
137        # Dump all PKG_CONFIG environment variables
138        for key, value in env.items():
139            if key.startswith('PKG_'):
140                mlog.debug(f'env[{key}]: {value}')
141
142    def _call_pkgbin(self, args: T.List[str], env: T.Optional[T.Dict[str, str]] = None) -> T.Tuple[int, str, str]:
143        # Always copy the environment since we're going to modify it
144        # with pkg-config variables
145        if env is None:
146            env = os.environ.copy()
147        else:
148            env = env.copy()
149
150        assert isinstance(self.pkgbin, ExternalProgram)
151        PkgConfigDependency.setup_env(env, self.env, self.for_machine)
152
153        fenv = frozenset(env.items())
154        targs = tuple(args)
155        cache = PkgConfigDependency.pkgbin_cache
156        if (self.pkgbin, targs, fenv) not in cache:
157            cache[(self.pkgbin, targs, fenv)] = self._call_pkgbin_real(args, env)
158        return cache[(self.pkgbin, targs, fenv)]
159
160    def _convert_mingw_paths(self, args: T.List[str]) -> T.List[str]:
161        '''
162        Both MSVC and native Python on Windows cannot handle MinGW-esque /c/foo
163        paths so convert them to C:/foo. We cannot resolve other paths starting
164        with / like /home/foo so leave them as-is so that the user gets an
165        error/warning from the compiler/linker.
166        '''
167        if not self.env.machines.build.is_windows():
168            return args
169        converted = []
170        for arg in args:
171            pargs: T.Tuple[str, ...] = tuple()
172            # Library search path
173            if arg.startswith('-L/'):
174                pargs = PurePath(arg[2:]).parts
175                tmpl = '-L{}:/{}'
176            elif arg.startswith('-I/'):
177                pargs = PurePath(arg[2:]).parts
178                tmpl = '-I{}:/{}'
179            # Full path to library or .la file
180            elif arg.startswith('/'):
181                pargs = PurePath(arg).parts
182                tmpl = '{}:/{}'
183            elif arg.startswith(('-L', '-I')) or (len(arg) > 2 and arg[1] == ':'):
184                # clean out improper '\\ ' as comes from some Windows pkg-config files
185                arg = arg.replace('\\ ', ' ')
186            if len(pargs) > 1 and len(pargs[1]) == 1:
187                arg = tmpl.format(pargs[1], '/'.join(pargs[2:]))
188            converted.append(arg)
189        return converted
190
191    def _split_args(self, cmd: str) -> T.List[str]:
192        # pkg-config paths follow Unix conventions, even on Windows; split the
193        # output using shlex.split rather than mesonlib.split_args
194        return shlex.split(cmd)
195
196    def _set_cargs(self) -> None:
197        env = None
198        if self.language == 'fortran':
199            # gfortran doesn't appear to look in system paths for INCLUDE files,
200            # so don't allow pkg-config to suppress -I flags for system paths
201            env = os.environ.copy()
202            env['PKG_CONFIG_ALLOW_SYSTEM_CFLAGS'] = '1'
203        ret, out, err = self._call_pkgbin(['--cflags', self.name], env=env)
204        if ret != 0:
205            raise DependencyException('Could not generate cargs for %s:\n%s\n' %
206                                      (self.name, err))
207        self.compile_args = self._convert_mingw_paths(self._split_args(out))
208
209    def _search_libs(self, out: str, out_raw: str) -> T.Tuple[T.List[str], T.List[str]]:
210        '''
211        @out: PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 pkg-config --libs
212        @out_raw: pkg-config --libs
213
214        We always look for the file ourselves instead of depending on the
215        compiler to find it with -lfoo or foo.lib (if possible) because:
216        1. We want to be able to select static or shared
217        2. We need the full path of the library to calculate RPATH values
218        3. De-dup of libraries is easier when we have absolute paths
219
220        Libraries that are provided by the toolchain or are not found by
221        find_library() will be added with -L -l pairs.
222        '''
223        # Library paths should be safe to de-dup
224        #
225        # First, figure out what library paths to use. Originally, we were
226        # doing this as part of the loop, but due to differences in the order
227        # of -L values between pkg-config and pkgconf, we need to do that as
228        # a separate step. See:
229        # https://github.com/mesonbuild/meson/issues/3951
230        # https://github.com/mesonbuild/meson/issues/4023
231        #
232        # Separate system and prefix paths, and ensure that prefix paths are
233        # always searched first.
234        prefix_libpaths: OrderedSet[str] = OrderedSet()
235        # We also store this raw_link_args on the object later
236        raw_link_args = self._convert_mingw_paths(self._split_args(out_raw))
237        for arg in raw_link_args:
238            if arg.startswith('-L') and not arg.startswith(('-L-l', '-L-L')):
239                path = arg[2:]
240                if not os.path.isabs(path):
241                    # Resolve the path as a compiler in the build directory would
242                    path = os.path.join(self.env.get_build_dir(), path)
243                prefix_libpaths.add(path)
244        # Library paths are not always ordered in a meaningful way
245        #
246        # Instead of relying on pkg-config or pkgconf to provide -L flags in a
247        # specific order, we reorder library paths ourselves, according to th
248        # order specified in PKG_CONFIG_PATH. See:
249        # https://github.com/mesonbuild/meson/issues/4271
250        #
251        # Only prefix_libpaths are reordered here because there should not be
252        # too many system_libpaths to cause library version issues.
253        pkg_config_path: T.List[str] = self.env.coredata.options[OptionKey('pkg_config_path', machine=self.for_machine)].value
254        pkg_config_path = self._convert_mingw_paths(pkg_config_path)
255        prefix_libpaths = OrderedSet(sort_libpaths(list(prefix_libpaths), pkg_config_path))
256        system_libpaths: OrderedSet[str] = OrderedSet()
257        full_args = self._convert_mingw_paths(self._split_args(out))
258        for arg in full_args:
259            if arg.startswith(('-L-l', '-L-L')):
260                # These are D language arguments, not library paths
261                continue
262            if arg.startswith('-L') and arg[2:] not in prefix_libpaths:
263                system_libpaths.add(arg[2:])
264        # Use this re-ordered path list for library resolution
265        libpaths = list(prefix_libpaths) + list(system_libpaths)
266        # Track -lfoo libraries to avoid duplicate work
267        libs_found: OrderedSet[str] = OrderedSet()
268        # Track not-found libraries to know whether to add library paths
269        libs_notfound = []
270        # Generate link arguments for this library
271        link_args = []
272        for lib in full_args:
273            if lib.startswith(('-L-l', '-L-L')):
274                # These are D language arguments, add them as-is
275                pass
276            elif lib.startswith('-L'):
277                # We already handled library paths above
278                continue
279            elif lib.startswith('-l:'):
280                # see: https://stackoverflow.com/questions/48532868/gcc-library-option-with-a-colon-llibevent-a
281                # also : See the documentation of -lnamespec | --library=namespec in the linker manual
282                #                     https://sourceware.org/binutils/docs-2.18/ld/Options.html
283
284                # Don't resolve the same -l:libfoo.a argument again
285                if lib in libs_found:
286                    continue
287                libfilename = lib[3:]
288                foundname = None
289                for libdir in libpaths:
290                    target = os.path.join(libdir, libfilename)
291                    if os.path.exists(target):
292                        foundname = target
293                        break
294                if foundname is None:
295                    if lib in libs_notfound:
296                        continue
297                    else:
298                        mlog.warning('Library {!r} not found for dependency {!r}, may '
299                                    'not be successfully linked'.format(libfilename, self.name))
300                    libs_notfound.append(lib)
301                else:
302                    lib = foundname
303            elif lib.startswith('-l'):
304                # Don't resolve the same -lfoo argument again
305                if lib in libs_found:
306                    continue
307                if self.clib_compiler:
308                    args = self.clib_compiler.find_library(lib[2:], self.env,
309                                                           libpaths, self.libtype)
310                # If the project only uses a non-clib language such as D, Rust,
311                # C#, Python, etc, all we can do is limp along by adding the
312                # arguments as-is and then adding the libpaths at the end.
313                else:
314                    args = None
315                if args is not None:
316                    libs_found.add(lib)
317                    # Replace -l arg with full path to library if available
318                    # else, library is either to be ignored, or is provided by
319                    # the compiler, can't be resolved, and should be used as-is
320                    if args:
321                        if not args[0].startswith('-l'):
322                            lib = args[0]
323                    else:
324                        continue
325                else:
326                    # Library wasn't found, maybe we're looking in the wrong
327                    # places or the library will be provided with LDFLAGS or
328                    # LIBRARY_PATH from the environment (on macOS), and many
329                    # other edge cases that we can't account for.
330                    #
331                    # Add all -L paths and use it as -lfoo
332                    if lib in libs_notfound:
333                        continue
334                    if self.static:
335                        mlog.warning('Static library {!r} not found for dependency {!r}, may '
336                                     'not be statically linked'.format(lib[2:], self.name))
337                    libs_notfound.append(lib)
338            elif lib.endswith(".la"):
339                shared_libname = self.extract_libtool_shlib(lib)
340                shared_lib = os.path.join(os.path.dirname(lib), shared_libname)
341                if not os.path.exists(shared_lib):
342                    shared_lib = os.path.join(os.path.dirname(lib), ".libs", shared_libname)
343
344                if not os.path.exists(shared_lib):
345                    raise DependencyException('Got a libtools specific "%s" dependencies'
346                                              'but we could not compute the actual shared'
347                                              'library path' % lib)
348                self.is_libtool = True
349                lib = shared_lib
350                if lib in link_args:
351                    continue
352            link_args.append(lib)
353        # Add all -Lbar args if we have -lfoo args in link_args
354        if libs_notfound:
355            # Order of -L flags doesn't matter with ld, but it might with other
356            # linkers such as MSVC, so prepend them.
357            link_args = ['-L' + lp for lp in prefix_libpaths] + link_args
358        return link_args, raw_link_args
359
360    def _set_libs(self) -> None:
361        env = None
362        libcmd = ['--libs']
363
364        if self.static:
365            libcmd.append('--static')
366
367        libcmd.append(self.name)
368
369        # Force pkg-config to output -L fields even if they are system
370        # paths so we can do manual searching with cc.find_library() later.
371        env = os.environ.copy()
372        env['PKG_CONFIG_ALLOW_SYSTEM_LIBS'] = '1'
373        ret, out, err = self._call_pkgbin(libcmd, env=env)
374        if ret != 0:
375            raise DependencyException('Could not generate libs for %s:\n%s\n' %
376                                      (self.name, err))
377        # Also get the 'raw' output without -Lfoo system paths for adding -L
378        # args with -lfoo when a library can't be found, and also in
379        # gnome.generate_gir + gnome.gtkdoc which need -L -l arguments.
380        ret, out_raw, err_raw = self._call_pkgbin(libcmd)
381        if ret != 0:
382            raise DependencyException('Could not generate libs for %s:\n\n%s' %
383                                      (self.name, out_raw))
384        self.link_args, self.raw_link_args = self._search_libs(out, out_raw)
385
386    def get_pkgconfig_variable(self, variable_name: str, kwargs: T.Dict[str, T.Union[str, T.List[str]]]) -> str:
387        options = ['--variable=' + variable_name, self.name]
388
389        if 'define_variable' in kwargs:
390            definition = kwargs.get('define_variable', [])
391            if not isinstance(definition, list):
392                raise DependencyException('define_variable takes a list')
393
394            if len(definition) != 2 or not all(isinstance(i, str) for i in definition):
395                raise DependencyException('define_variable must be made up of 2 strings for VARIABLENAME and VARIABLEVALUE')
396
397            options = ['--define-variable=' + '='.join(definition)] + options
398
399        ret, out, err = self._call_pkgbin(options)
400        variable = ''
401        if ret != 0:
402            if self.required:
403                raise DependencyException('dependency %s not found:\n%s\n' %
404                                          (self.name, err))
405        else:
406            variable = out.strip()
407
408            # pkg-config doesn't distinguish between empty and non-existent variables
409            # use the variable list to check for variable existence
410            if not variable:
411                ret, out, _ = self._call_pkgbin(['--print-variables', self.name])
412                if not re.search(r'^' + variable_name + r'$', out, re.MULTILINE):
413                    if 'default' in kwargs:
414                        assert isinstance(kwargs['default'], str)
415                        variable = kwargs['default']
416                    else:
417                        mlog.warning(f"pkgconfig variable '{variable_name}' not defined for dependency {self.name}.")
418
419        mlog.debug(f'Got pkgconfig variable {variable_name} : {variable}')
420        return variable
421
422    def check_pkgconfig(self, pkgbin: ExternalProgram) -> T.Optional[str]:
423        if not pkgbin.found():
424            mlog.log(f'Did not find pkg-config by name {pkgbin.name!r}')
425            return None
426        command_as_string = ' '.join(pkgbin.get_command())
427        try:
428            helptext = Popen_safe(pkgbin.get_command() + ['--help'])[1]
429            if 'Pure-Perl' in helptext:
430                mlog.log(f'found pkg-config {command_as_string!r} but it is Strawberry Perl and thus broken. Ignoring...')
431                return None
432            p, out = Popen_safe(pkgbin.get_command() + ['--version'])[0:2]
433            if p.returncode != 0:
434                mlog.warning(f'Found pkg-config {command_as_string!r} but it failed when run')
435                return None
436        except FileNotFoundError:
437            mlog.warning(f'We thought we found pkg-config {command_as_string!r} but now it\'s not there. How odd!')
438            return None
439        except PermissionError:
440            msg = f'Found pkg-config {command_as_string!r} but didn\'t have permissions to run it.'
441            if not self.env.machines.build.is_windows():
442                msg += '\n\nOn Unix-like systems this is often caused by scripts that are not executable.'
443            mlog.warning(msg)
444            return None
445        return out.strip()
446
447    def extract_field(self, la_file: str, fieldname: str) -> T.Optional[str]:
448        with open(la_file, encoding='utf-8') as f:
449            for line in f:
450                arr = line.strip().split('=')
451                if arr[0] == fieldname:
452                    return arr[1][1:-1]
453        return None
454
455    def extract_dlname_field(self, la_file: str) -> T.Optional[str]:
456        return self.extract_field(la_file, 'dlname')
457
458    def extract_libdir_field(self, la_file: str) -> T.Optional[str]:
459        return self.extract_field(la_file, 'libdir')
460
461    def extract_libtool_shlib(self, la_file: str) -> T.Optional[str]:
462        '''
463        Returns the path to the shared library
464        corresponding to this .la file
465        '''
466        dlname = self.extract_dlname_field(la_file)
467        if dlname is None:
468            return None
469
470        # Darwin uses absolute paths where possible; since the libtool files never
471        # contain absolute paths, use the libdir field
472        if self.env.machines[self.for_machine].is_darwin():
473            dlbasename = os.path.basename(dlname)
474            libdir = self.extract_libdir_field(la_file)
475            if libdir is None:
476                return dlbasename
477            return os.path.join(libdir, dlbasename)
478        # From the comments in extract_libtool(), older libtools had
479        # a path rather than the raw dlname
480        return os.path.basename(dlname)
481
482    def log_tried(self) -> str:
483        return self.type_name
484
485    def get_variable(self, *, cmake: T.Optional[str] = None, pkgconfig: T.Optional[str] = None,
486                     configtool: T.Optional[str] = None, internal: T.Optional[str] = None,
487                     default_value: T.Optional[str] = None,
488                     pkgconfig_define: T.Optional[T.List[str]] = None) -> T.Union[str, T.List[str]]:
489        if pkgconfig:
490            kwargs: T.Dict[str, T.Union[str, T.List[str]]] = {}
491            if default_value is not None:
492                kwargs['default'] = default_value
493            if pkgconfig_define is not None:
494                kwargs['define_variable'] = pkgconfig_define
495            try:
496                return self.get_pkgconfig_variable(pkgconfig, kwargs)
497            except DependencyException:
498                pass
499        if default_value is not None:
500            return default_value
501        raise DependencyException(f'Could not get pkg-config variable and no default provided for {self!r}')
502