1# Copyright (c) 2018 Open Source Foundries Limited.
2#
3# SPDX-License-Identifier: Apache-2.0
4'''Common definitions for building Zephyr applications with CMake.
5
6This provides some default settings and convenience wrappers for
7building Zephyr applications needed by multiple commands.
8
9See build.py for the build command itself.
10'''
11
12from collections import OrderedDict
13import argparse
14import os.path
15import re
16import subprocess
17import shutil
18import sys
19
20import packaging.version
21from west import log
22from west.util import quote_sh_list
23
24DEFAULT_CACHE = 'CMakeCache.txt'
25
26DEFAULT_CMAKE_GENERATOR = 'Ninja'
27'''Name of the default CMake generator.'''
28
29
30def run_cmake(args, cwd=None, capture_output=False, dry_run=False, env=None):
31    '''Run cmake to (re)generate a build system, a script, etc.
32
33    :param args: arguments to pass to CMake
34    :param cwd: directory to run CMake in, cwd is default
35    :param capture_output: if True, the output is returned instead of being
36                           displayed (None is returned by default, or if
37                           dry_run is also True)
38    :param dry_run: don't actually execute the command, just print what
39                    would have been run
40    :param env: used adjusted environment when running CMake
41
42    If capture_output is set to True, returns the output of the command instead
43    of displaying it on stdout/stderr..'''
44    cmake = shutil.which('cmake')
45    if cmake is None and not dry_run:
46        log.die('CMake is not installed or cannot be found; cannot build.')
47    _ensure_min_version(cmake, dry_run)
48
49    cmd = [cmake] + args
50
51    kwargs = dict()
52    if capture_output:
53        kwargs['stdout'] = subprocess.PIPE
54        # CMake sends the output of message() to stderr unless it's STATUS
55        kwargs['stderr'] = subprocess.STDOUT
56    if cwd:
57        kwargs['cwd'] = cwd
58
59    if dry_run:
60        in_cwd = ' (in {})'.format(cwd) if cwd else ''
61        log.inf('Dry run{}:'.format(in_cwd), quote_sh_list(cmd))
62        return None
63
64    log.dbg('Running CMake:', quote_sh_list(cmd), level=log.VERBOSE_NORMAL)
65    p = subprocess.Popen(cmd, env=env, **kwargs)
66    out, _ = p.communicate()
67    if p.returncode == 0:
68        if out:
69            return out.decode(sys.getdefaultencoding()).splitlines()
70        else:
71            return None
72    else:
73        # A real error occurred, raise an exception
74        raise subprocess.CalledProcessError(p.returncode, p.args)
75
76
77def run_build(build_directory, **kwargs):
78    '''Run cmake in build tool mode.
79
80    :param build_directory: runs "cmake --build build_directory"
81    :param extra_args: optional kwarg. List of additional CMake arguments;
82                       these come after "--build <build_directory>"
83                       on the command line.
84
85    Any additional keyword arguments are passed as-is to run_cmake().
86    '''
87    cmake_env = None
88    extra_args = kwargs.pop('extra_args', [])
89
90    try:
91        index = extra_args.index('--') + 1
92        build_opt_parser = argparse.ArgumentParser(allow_abbrev=False)
93        build_opt_parser.add_argument('-j', '--jobs')
94        build_opt_parser.add_argument('-v', '--verbose', action='store_true')
95        build_opts, native_args = build_opt_parser.parse_known_args(extra_args[index:])
96        extra_args = extra_args[:index] + native_args
97
98        if build_opts:
99            cmake_env = os.environ.copy()
100            if build_opts.jobs:
101                cmake_env["CMAKE_BUILD_PARALLEL_LEVEL"] = build_opts.jobs
102
103            if build_opts.verbose:
104                cmake_env["VERBOSE"] = "1"
105
106    except ValueError:
107        pass # Ignore, no presence of '--' so nothing to do.
108
109    return run_cmake(['--build', build_directory] + extra_args, env=cmake_env, **kwargs)
110
111
112def make_c_identifier(string):
113    '''Make a C identifier from a string in the same way CMake does.
114    '''
115    # The behavior of CMake's string(MAKE_C_IDENTIFIER ...)  is not
116    # precisely documented. This behavior matches the test case
117    # that introduced the function:
118    #
119    # https://gitlab.kitware.com/cmake/cmake/commit/0ab50aea4c4d7099b339fb38b4459d0debbdbd85
120    ret = []
121
122    alpha_under = re.compile('[A-Za-z_]')
123    alpha_num_under = re.compile('[A-Za-z0-9_]')
124
125    if not alpha_under.match(string):
126        ret.append('_')
127    for c in string:
128        if alpha_num_under.match(c):
129            ret.append(c)
130        else:
131            ret.append('_')
132
133    return ''.join(ret)
134
135
136class CMakeCacheEntry:
137    '''Represents a CMake cache entry.
138
139    This class understands the type system in a CMakeCache.txt, and
140    converts the following cache types to Python types:
141
142    Cache Type    Python type
143    ----------    -------------------------------------------
144    FILEPATH      str
145    PATH          str
146    STRING        str OR list of str (if ';' is in the value)
147    BOOL          bool
148    INTERNAL      str OR list of str (if ';' is in the value)
149    STATIC        str OR list of str (if ';' is in the value)
150    UNINITIALIZED str OR list of str (if ';' is in the value)
151    ----------    -------------------------------------------
152    '''
153
154    # Regular expression for a cache entry.
155    #
156    # CMake variable names can include escape characters, allowing a
157    # wider set of names than is easy to match with a regular
158    # expression. To be permissive here, use a non-greedy match up to
159    # the first colon (':'). This breaks if the variable name has a
160    # colon inside, but it's good enough.
161    CACHE_ENTRY = re.compile(
162        r'''(?P<name>.*?)                                                   # name
163         :(?P<type>FILEPATH|PATH|STRING|BOOL|INTERNAL|STATIC|UNINITIALIZED) # type
164         =(?P<value>.*)                                                     # value
165        ''', re.X)
166
167    @classmethod
168    def _to_bool(cls, val):
169        # Convert a CMake BOOL string into a Python bool.
170        #
171        #   "True if the constant is 1, ON, YES, TRUE, Y, or a
172        #   non-zero number. False if the constant is 0, OFF, NO,
173        #   FALSE, N, IGNORE, NOTFOUND, the empty string, or ends in
174        #   the suffix -NOTFOUND. Named boolean constants are
175        #   case-insensitive. If the argument is not one of these
176        #   constants, it is treated as a variable."
177        #
178        # https://cmake.org/cmake/help/v3.0/command/if.html
179        val = val.upper()
180        if val in ('ON', 'YES', 'TRUE', 'Y'):
181            return True
182        elif val in ('OFF', 'NO', 'FALSE', 'N', 'IGNORE', 'NOTFOUND', ''):
183            return False
184        elif val.endswith('-NOTFOUND'):
185            return False
186        else:
187            try:
188                v = int(val)
189                return v != 0
190            except ValueError as exc:
191                raise ValueError('invalid bool {}'.format(val)) from exc
192
193    @classmethod
194    def from_line(cls, line, line_no):
195        # Comments can only occur at the beginning of a line.
196        # (The value of an entry could contain a comment character).
197        if line.startswith('//') or line.startswith('#'):
198            return None
199
200        # Whitespace-only lines do not contain cache entries.
201        if not line.strip():
202            return None
203
204        m = cls.CACHE_ENTRY.match(line)
205        if not m:
206            return None
207
208        name, type_, value = (m.group(g) for g in ('name', 'type', 'value'))
209        if type_ == 'BOOL':
210            try:
211                value = cls._to_bool(value)
212            except ValueError as exc:
213                args = exc.args + ('on line {}: {}'.format(line_no, line),)
214                raise ValueError(args) from exc
215        elif type_ in {'STRING', 'INTERNAL', 'STATIC', 'UNINITIALIZED'}:
216            # If the value is a CMake list (i.e. is a string which
217            # contains a ';'), convert to a Python list.
218            if ';' in value:
219                value = value.split(';')
220
221        return CMakeCacheEntry(name, value)
222
223    def __init__(self, name, value):
224        self.name = name
225        self.value = value
226
227    def __str__(self):
228        fmt = 'CMakeCacheEntry(name={}, value={})'
229        return fmt.format(self.name, self.value)
230
231
232class CMakeCache:
233    '''Parses and represents a CMake cache file.'''
234
235    @staticmethod
236    def from_build_dir(build_dir):
237        return CMakeCache(os.path.join(build_dir, DEFAULT_CACHE))
238
239    def __init__(self, cache_file):
240        self.cache_file = cache_file
241        self.load(cache_file)
242
243    def load(self, cache_file):
244        entries = []
245        with open(cache_file, 'r', encoding="utf-8") as cache:
246            for line_no, line in enumerate(cache):
247                entry = CMakeCacheEntry.from_line(line, line_no)
248                if entry:
249                    entries.append(entry)
250        self._entries = OrderedDict((e.name, e) for e in entries)
251
252    def get(self, name, default=None):
253        entry = self._entries.get(name)
254        if entry is not None:
255            return entry.value
256        else:
257            return default
258
259    def get_list(self, name, default=None):
260        if default is None:
261            default = []
262        entry = self._entries.get(name)
263        if entry is not None:
264            value = entry.value
265            if isinstance(value, list):
266                return value
267            elif isinstance(value, str):
268                return [value] if value else []
269            else:
270                msg = 'invalid value {} type {}'
271                raise RuntimeError(msg.format(value, type(value)))
272        else:
273            return default
274
275    def __contains__(self, name):
276        return name in self._entries
277
278    def __getitem__(self, name):
279        return self._entries[name].value
280
281    def __setitem__(self, name, entry):
282        if not isinstance(entry, CMakeCacheEntry):
283            msg = 'improper type {} for value {}, expecting CMakeCacheEntry'
284            raise TypeError(msg.format(type(entry), entry))
285        self._entries[name] = entry
286
287    def __delitem__(self, name):
288        del self._entries[name]
289
290    def __iter__(self):
291        return iter(self._entries.values())
292
293def _ensure_min_version(cmake, dry_run):
294    cmd = [cmake, '--version']
295    if dry_run:
296        log.inf('Dry run:', quote_sh_list(cmd))
297        return
298
299    try:
300        version_out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
301    except subprocess.CalledProcessError as cpe:
302        log.die('cannot get cmake version:', str(cpe))
303    decoded = version_out.decode('utf-8')
304    lines = decoded.splitlines()
305    if not lines:
306        log.die('can\'t get cmake version: ' +
307                'unexpected "cmake --version" output:\n{}\n'.
308                format(decoded) +
309                'Please install CMake ' + _MIN_CMAKE_VERSION_STR +
310                ' or higher (https://cmake.org/download/).')
311    version = lines[0].split()[2]
312    if '-' in version:
313        # Handle semver cases like "3.19.20210206-g1e50ab6"
314        # which Kitware uses for prerelease versions.
315        version = version.split('-', 1)[0]
316    if packaging.version.parse(version) < _MIN_CMAKE_VERSION:
317        log.die('cmake version', version,
318                'is less than minimum version {};'.
319                format(_MIN_CMAKE_VERSION_STR),
320                'please update your CMake (https://cmake.org/download/).')
321    else:
322        log.dbg('cmake version', version, 'is OK; minimum version is',
323                _MIN_CMAKE_VERSION_STR)
324
325_MIN_CMAKE_VERSION_STR = '3.13.1'
326_MIN_CMAKE_VERSION = packaging.version.parse(_MIN_CMAKE_VERSION_STR)
327