1# Copyright 2018 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.
14import re
15import os, os.path, pathlib
16import shutil
17import typing as T
18
19from . import ExtensionModule, ModuleReturnValue, ModuleObject
20
21from .. import build, mesonlib, mlog, dependencies
22from ..cmake import SingleTargetOptions, TargetOptions, cmake_defines_to_args
23from ..interpreter import ConfigurationDataObject, SubprojectHolder
24from ..interpreterbase import (
25    FeatureNew,
26    FeatureNewKwargs,
27    FeatureDeprecatedKwargs,
28
29    stringArgs,
30    permittedKwargs,
31    noPosargs,
32    noKwargs,
33
34    InvalidArguments,
35    InterpreterException,
36)
37from ..programs import ExternalProgram
38
39
40COMPATIBILITIES = ['AnyNewerVersion', 'SameMajorVersion', 'SameMinorVersion', 'ExactVersion']
41
42# Taken from https://github.com/Kitware/CMake/blob/master/Modules/CMakePackageConfigHelpers.cmake
43PACKAGE_INIT_BASE = '''
44####### Expanded from \\@PACKAGE_INIT\\@ by configure_package_config_file() #######
45####### Any changes to this file will be overwritten by the next CMake run ####
46####### The input file was @inputFileName@ ########
47
48get_filename_component(PACKAGE_PREFIX_DIR "${CMAKE_CURRENT_LIST_DIR}/@PACKAGE_RELATIVE_PATH@" ABSOLUTE)
49'''
50PACKAGE_INIT_EXT = '''
51# Use original install prefix when loaded through a "/usr move"
52# cross-prefix symbolic link such as /lib -> /usr/lib.
53get_filename_component(_realCurr "${CMAKE_CURRENT_LIST_DIR}" REALPATH)
54get_filename_component(_realOrig "@absInstallDir@" REALPATH)
55if(_realCurr STREQUAL _realOrig)
56  set(PACKAGE_PREFIX_DIR "@installPrefix@")
57endif()
58unset(_realOrig)
59unset(_realCurr)
60'''
61PACKAGE_INIT_SET_AND_CHECK = '''
62macro(set_and_check _var _file)
63  set(${_var} "${_file}")
64  if(NOT EXISTS "${_file}")
65    message(FATAL_ERROR "File or directory ${_file} referenced by variable ${_var} does not exist !")
66  endif()
67endmacro()
68
69####################################################################################
70'''
71
72class CMakeSubproject(ModuleObject):
73    def __init__(self, subp, pv):
74        assert isinstance(subp, SubprojectHolder)
75        assert hasattr(subp, 'cm_interpreter')
76        super().__init__()
77        self.subp = subp
78        self.methods.update({'get_variable': self.get_variable,
79                             'dependency': self.dependency,
80                             'include_directories': self.include_directories,
81                             'target': self.target,
82                             'target_type': self.target_type,
83                             'target_list': self.target_list,
84                             'found': self.found_method,
85                             })
86
87    def _args_to_info(self, args):
88        if len(args) != 1:
89            raise InterpreterException('Exactly one argument is required.')
90
91        tgt = args[0]
92        res = self.subp.cm_interpreter.target_info(tgt)
93        if res is None:
94            raise InterpreterException(f'The CMake target {tgt} does not exist\n' +
95                                       '  Use the following command in your meson.build to list all available targets:\n\n' +
96                                       '    message(\'CMaket targets:\\n - \' + \'\\n - \'.join(<cmake_subproject>.target_list()))')
97
98        # Make sure that all keys are present (if not this is a bug)
99        assert all([x in res for x in ['inc', 'src', 'dep', 'tgt', 'func']])
100        return res
101
102    @noKwargs
103    @stringArgs
104    def get_variable(self, state, args, kwargs):
105        return self.subp.get_variable_method(args, kwargs)
106
107    @FeatureNewKwargs('dependency', '0.56.0', ['include_type'])
108    @permittedKwargs({'include_type'})
109    @stringArgs
110    def dependency(self, state, args, kwargs):
111        info = self._args_to_info(args)
112        if info['func'] == 'executable':
113            raise InvalidArguments(f'{args[0]} is an executable and does not support the dependency() method. Use target() instead.')
114        orig = self.get_variable(state, [info['dep']], {})
115        assert isinstance(orig, dependencies.Dependency)
116        actual = orig.include_type
117        if 'include_type' in kwargs and kwargs['include_type'] != actual:
118            mlog.debug('Current include type is {}. Converting to requested {}'.format(actual, kwargs['include_type']))
119            return orig.generate_system_dependency(kwargs['include_type'])
120        return orig
121
122    @noKwargs
123    @stringArgs
124    def include_directories(self, state, args, kwargs):
125        info = self._args_to_info(args)
126        return self.get_variable(state, [info['inc']], kwargs)
127
128    @noKwargs
129    @stringArgs
130    def target(self, state, args, kwargs):
131        info = self._args_to_info(args)
132        return self.get_variable(state, [info['tgt']], kwargs)
133
134    @noKwargs
135    @stringArgs
136    def target_type(self, state, args, kwargs):
137        info = self._args_to_info(args)
138        return info['func']
139
140    @noPosargs
141    @noKwargs
142    def target_list(self, state, args, kwargs):
143        return self.subp.cm_interpreter.target_list()
144
145    @noPosargs
146    @noKwargs
147    @FeatureNew('CMakeSubproject.found()', '0.53.2')
148    def found_method(self, state, args, kwargs):
149        return self.subp is not None
150
151
152class CMakeSubprojectOptions(ModuleObject):
153    def __init__(self) -> None:
154        super().__init__()
155        self.cmake_options = []  # type: T.List[str]
156        self.target_options = TargetOptions()
157
158        self.methods.update(
159            {
160                'add_cmake_defines': self.add_cmake_defines,
161                'set_override_option': self.set_override_option,
162                'set_install': self.set_install,
163                'append_compile_args': self.append_compile_args,
164                'append_link_args': self.append_link_args,
165                'clear': self.clear,
166            }
167        )
168
169    def _get_opts(self, kwargs: dict) -> SingleTargetOptions:
170        if 'target' in kwargs:
171            return self.target_options[kwargs['target']]
172        return self.target_options.global_options
173
174    @noKwargs
175    def add_cmake_defines(self, state, args, kwargs) -> None:
176        self.cmake_options += cmake_defines_to_args(args)
177
178    @stringArgs
179    @permittedKwargs({'target'})
180    def set_override_option(self, state, args, kwargs) -> None:
181        if len(args) != 2:
182            raise InvalidArguments('set_override_option takes exactly 2 positional arguments')
183        self._get_opts(kwargs).set_opt(args[0], args[1])
184
185    @permittedKwargs({'target'})
186    def set_install(self, state, args, kwargs) -> None:
187        if len(args) != 1 or not isinstance(args[0], bool):
188            raise InvalidArguments('set_install takes exactly 1 boolean argument')
189        self._get_opts(kwargs).set_install(args[0])
190
191    @stringArgs
192    @permittedKwargs({'target'})
193    def append_compile_args(self, state, args, kwargs) -> None:
194        if len(args) < 2:
195            raise InvalidArguments('append_compile_args takes at least 2 positional arguments')
196        self._get_opts(kwargs).append_args(args[0], args[1:])
197
198    @stringArgs
199    @permittedKwargs({'target'})
200    def append_link_args(self, state, args, kwargs) -> None:
201        if not args:
202            raise InvalidArguments('append_link_args takes at least 1 positional argument')
203        self._get_opts(kwargs).append_link_args(args)
204
205    @noPosargs
206    @noKwargs
207    def clear(self, state, args, kwargs) -> None:
208        self.cmake_options.clear()
209        self.target_options = TargetOptions()
210
211
212class CmakeModule(ExtensionModule):
213    cmake_detected = False
214    cmake_root = None
215
216    def __init__(self, interpreter):
217        super().__init__(interpreter)
218        self.methods.update({
219            'write_basic_package_version_file': self.write_basic_package_version_file,
220            'configure_package_config_file': self.configure_package_config_file,
221            'subproject': self.subproject,
222            'subproject_options': self.subproject_options,
223        })
224
225    def detect_voidp_size(self, env):
226        compilers = env.coredata.compilers.host
227        compiler = compilers.get('c', None)
228        if not compiler:
229            compiler = compilers.get('cpp', None)
230
231        if not compiler:
232            raise mesonlib.MesonException('Requires a C or C++ compiler to compute sizeof(void *).')
233
234        return compiler.sizeof('void *', '', env)
235
236    def detect_cmake(self):
237        if self.cmake_detected:
238            return True
239
240        cmakebin = ExternalProgram('cmake', silent=False)
241        p, stdout, stderr = mesonlib.Popen_safe(cmakebin.get_command() + ['--system-information', '-G', 'Ninja'])[0:3]
242        if p.returncode != 0:
243            mlog.log(f'error retrieving cmake information: returnCode={p.returncode} stdout={stdout} stderr={stderr}')
244            return False
245
246        match = re.search('\nCMAKE_ROOT \\"([^"]+)"\n', stdout.strip())
247        if not match:
248            mlog.log('unable to determine cmake root')
249            return False
250
251        cmakePath = pathlib.PurePath(match.group(1))
252        self.cmake_root = os.path.join(*cmakePath.parts)
253        self.cmake_detected = True
254        return True
255
256    @permittedKwargs({'version', 'name', 'compatibility', 'install_dir'})
257    def write_basic_package_version_file(self, state, _args, kwargs):
258        version = kwargs.get('version', None)
259        if not isinstance(version, str):
260            raise mesonlib.MesonException('Version must be specified.')
261
262        name = kwargs.get('name', None)
263        if not isinstance(name, str):
264            raise mesonlib.MesonException('Name not specified.')
265
266        compatibility = kwargs.get('compatibility', 'AnyNewerVersion')
267        if not isinstance(compatibility, str):
268            raise mesonlib.MesonException('compatibility is not string.')
269        if compatibility not in COMPATIBILITIES:
270            raise mesonlib.MesonException('compatibility must be either AnyNewerVersion, SameMajorVersion or ExactVersion.')
271
272        if not self.detect_cmake():
273            raise mesonlib.MesonException('Unable to find cmake')
274
275        pkgroot = pkgroot_name = kwargs.get('install_dir', None)
276        if pkgroot is None:
277            pkgroot = os.path.join(state.environment.coredata.get_option(mesonlib.OptionKey('libdir')), 'cmake', name)
278            pkgroot_name = os.path.join('{libdir}', 'cmake', name)
279        if not isinstance(pkgroot, str):
280            raise mesonlib.MesonException('Install_dir must be a string.')
281
282        template_file = os.path.join(self.cmake_root, 'Modules', f'BasicConfigVersion-{compatibility}.cmake.in')
283        if not os.path.exists(template_file):
284            raise mesonlib.MesonException(f'your cmake installation doesn\'t support the {compatibility} compatibility')
285
286        version_file = os.path.join(state.environment.scratch_dir, f'{name}ConfigVersion.cmake')
287
288        conf = {
289            'CVF_VERSION': (version, ''),
290            'CMAKE_SIZEOF_VOID_P': (str(self.detect_voidp_size(state.environment)), '')
291        }
292        mesonlib.do_conf_file(template_file, version_file, conf, 'meson')
293
294        res = build.Data([mesonlib.File(True, state.environment.get_scratch_dir(), version_file)], pkgroot, pkgroot_name, None, state.subproject)
295        return ModuleReturnValue(res, [res])
296
297    def create_package_file(self, infile, outfile, PACKAGE_RELATIVE_PATH, extra, confdata):
298        package_init = PACKAGE_INIT_BASE.replace('@PACKAGE_RELATIVE_PATH@', PACKAGE_RELATIVE_PATH)
299        package_init = package_init.replace('@inputFileName@', infile)
300        package_init += extra
301        package_init += PACKAGE_INIT_SET_AND_CHECK
302
303        try:
304            with open(infile, encoding='utf-8') as fin:
305                data = fin.readlines()
306        except Exception as e:
307            raise mesonlib.MesonException('Could not read input file {}: {}'.format(infile, str(e)))
308
309        result = []
310        regex = re.compile(r'(?:\\\\)+(?=\\?@)|\\@|@([-a-zA-Z0-9_]+)@')
311        for line in data:
312            line = line.replace('@PACKAGE_INIT@', package_init)
313            line, _missing = mesonlib.do_replacement(regex, line, 'meson', confdata)
314
315            result.append(line)
316
317        outfile_tmp = outfile + "~"
318        with open(outfile_tmp, "w", encoding='utf-8') as fout:
319            fout.writelines(result)
320
321        shutil.copymode(infile, outfile_tmp)
322        mesonlib.replace_if_different(outfile, outfile_tmp)
323
324    @permittedKwargs({'input', 'name', 'install_dir', 'configuration'})
325    def configure_package_config_file(self, state, args, kwargs):
326        if args:
327            raise mesonlib.MesonException('configure_package_config_file takes only keyword arguments.')
328
329        if 'input' not in kwargs:
330            raise mesonlib.MesonException('configure_package_config_file requires "input" keyword.')
331        inputfile = kwargs['input']
332        if isinstance(inputfile, list):
333            if len(inputfile) != 1:
334                m = "Keyword argument 'input' requires exactly one file"
335                raise mesonlib.MesonException(m)
336            inputfile = inputfile[0]
337        if not isinstance(inputfile, (str, mesonlib.File)):
338            raise mesonlib.MesonException("input must be a string or a file")
339        if isinstance(inputfile, str):
340            inputfile = mesonlib.File.from_source_file(state.environment.source_dir, state.subdir, inputfile)
341
342        ifile_abs = inputfile.absolute_path(state.environment.source_dir, state.environment.build_dir)
343
344        if 'name' not in kwargs:
345            raise mesonlib.MesonException('"name" not specified.')
346        name = kwargs['name']
347
348        (ofile_path, ofile_fname) = os.path.split(os.path.join(state.subdir, f'{name}Config.cmake'))
349        ofile_abs = os.path.join(state.environment.build_dir, ofile_path, ofile_fname)
350
351        install_dir = kwargs.get('install_dir', os.path.join(state.environment.coredata.get_option(mesonlib.OptionKey('libdir')), 'cmake', name))
352        if not isinstance(install_dir, str):
353            raise mesonlib.MesonException('"install_dir" must be a string.')
354
355        if 'configuration' not in kwargs:
356            raise mesonlib.MesonException('"configuration" not specified.')
357        conf = kwargs['configuration']
358        if not isinstance(conf, ConfigurationDataObject):
359            raise mesonlib.MesonException('Argument "configuration" is not of type configuration_data')
360
361        prefix = state.environment.coredata.get_option(mesonlib.OptionKey('prefix'))
362        abs_install_dir = install_dir
363        if not os.path.isabs(abs_install_dir):
364            abs_install_dir = os.path.join(prefix, install_dir)
365
366        PACKAGE_RELATIVE_PATH = os.path.relpath(prefix, abs_install_dir)
367        extra = ''
368        if re.match('^(/usr)?/lib(64)?/.+', abs_install_dir):
369            extra = PACKAGE_INIT_EXT.replace('@absInstallDir@', abs_install_dir)
370            extra = extra.replace('@installPrefix@', prefix)
371
372        self.create_package_file(ifile_abs, ofile_abs, PACKAGE_RELATIVE_PATH, extra, conf.conf_data)
373        conf.mark_used()
374
375        conffile = os.path.normpath(inputfile.relative_name())
376        if conffile not in self.interpreter.build_def_files:
377            self.interpreter.build_def_files.append(conffile)
378
379        res = build.Data([mesonlib.File(True, ofile_path, ofile_fname)], install_dir, install_dir, None, state.subproject)
380        self.interpreter.build.data.append(res)
381
382        return res
383
384    @FeatureNew('subproject', '0.51.0')
385    @FeatureNewKwargs('subproject', '0.55.0', ['options'])
386    @FeatureDeprecatedKwargs('subproject', '0.55.0', ['cmake_options'])
387    @permittedKwargs({'cmake_options', 'required', 'options'})
388    @stringArgs
389    def subproject(self, state, args, kwargs):
390        if len(args) != 1:
391            raise InterpreterException('Subproject takes exactly one argument')
392        if 'cmake_options' in kwargs and 'options' in kwargs:
393            raise InterpreterException('"options" cannot be used together with "cmake_options"')
394        dirname = args[0]
395        subp = self.interpreter.do_subproject(dirname, 'cmake', kwargs)
396        if not subp.found():
397            return subp
398        return CMakeSubproject(subp, dirname)
399
400    @FeatureNew('subproject_options', '0.55.0')
401    @noKwargs
402    @noPosargs
403    def subproject_options(self, state, args, kwargs) -> CMakeSubprojectOptions:
404        return CMakeSubprojectOptions()
405
406def initialize(*args, **kwargs):
407    return CmakeModule(*args, **kwargs)
408