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