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    ----------    -------------------------------------------
128    '''
129
130    # Regular expression for a cache entry.
131    #
132    # CMake variable names can include escape characters, allowing a
133    # wider set of names than is easy to match with a regular
134    # expression. To be permissive here, use a non-greedy match up to
135    # the first colon (':'). This breaks if the variable name has a
136    # colon inside, but it's good enough.
137    CACHE_ENTRY = re.compile(
138        r'''(?P<name>.*?)                                      # name
139         :(?P<type>FILEPATH|PATH|STRING|BOOL|INTERNAL|STATIC)  # type
140         =(?P<value>.*)                                        # value
141        ''', re.X)
142
143    @classmethod
144    def _to_bool(cls, val):
145        # Convert a CMake BOOL string into a Python bool.
146        #
147        #   "True if the constant is 1, ON, YES, TRUE, Y, or a
148        #   non-zero number. False if the constant is 0, OFF, NO,
149        #   FALSE, N, IGNORE, NOTFOUND, the empty string, or ends in
150        #   the suffix -NOTFOUND. Named boolean constants are
151        #   case-insensitive. If the argument is not one of these
152        #   constants, it is treated as a variable."
153        #
154        # https://cmake.org/cmake/help/v3.0/command/if.html
155        val = val.upper()
156        if val in ('ON', 'YES', 'TRUE', 'Y'):
157            return True
158        elif val in ('OFF', 'NO', 'FALSE', 'N', 'IGNORE', 'NOTFOUND', ''):
159            return False
160        elif val.endswith('-NOTFOUND'):
161            return False
162        else:
163            try:
164                v = int(val)
165                return v != 0
166            except ValueError as exc:
167                raise ValueError('invalid bool {}'.format(val)) from exc
168
169    @classmethod
170    def from_line(cls, line, line_no):
171        # Comments can only occur at the beginning of a line.
172        # (The value of an entry could contain a comment character).
173        if line.startswith('//') or line.startswith('#'):
174            return None
175
176        # Whitespace-only lines do not contain cache entries.
177        if not line.strip():
178            return None
179
180        m = cls.CACHE_ENTRY.match(line)
181        if not m:
182            return None
183
184        name, type_, value = (m.group(g) for g in ('name', 'type', 'value'))
185        if type_ == 'BOOL':
186            try:
187                value = cls._to_bool(value)
188            except ValueError as exc:
189                args = exc.args + ('on line {}: {}'.format(line_no, line),)
190                raise ValueError(args) from exc
191        elif type_ in {'STRING', 'INTERNAL', 'STATIC'}:
192            # If the value is a CMake list (i.e. is a string which
193            # contains a ';'), convert to a Python list.
194            if ';' in value:
195                value = value.split(';')
196
197        return CMakeCacheEntry(name, value)
198
199    def __init__(self, name, value):
200        self.name = name
201        self.value = value
202
203    def __str__(self):
204        fmt = 'CMakeCacheEntry(name={}, value={})'
205        return fmt.format(self.name, self.value)
206
207
208class CMakeCache:
209    '''Parses and represents a CMake cache file.'''
210
211    @staticmethod
212    def from_build_dir(build_dir):
213        return CMakeCache(os.path.join(build_dir, DEFAULT_CACHE))
214
215    def __init__(self, cache_file):
216        self.cache_file = cache_file
217        self.load(cache_file)
218
219    def load(self, cache_file):
220        entries = []
221        with open(cache_file, 'r', encoding="utf-8") as cache:
222            for line_no, line in enumerate(cache):
223                entry = CMakeCacheEntry.from_line(line, line_no)
224                if entry:
225                    entries.append(entry)
226        self._entries = OrderedDict((e.name, e) for e in entries)
227
228    def get(self, name, default=None):
229        entry = self._entries.get(name)
230        if entry is not None:
231            return entry.value
232        else:
233            return default
234
235    def get_list(self, name, default=None):
236        if default is None:
237            default = []
238        entry = self._entries.get(name)
239        if entry is not None:
240            value = entry.value
241            if isinstance(value, list):
242                return value
243            elif isinstance(value, str):
244                return [value] if value else []
245            else:
246                msg = 'invalid value {} type {}'
247                raise RuntimeError(msg.format(value, type(value)))
248        else:
249            return default
250
251    def __contains__(self, name):
252        return name in self._entries
253
254    def __getitem__(self, name):
255        return self._entries[name].value
256
257    def __setitem__(self, name, entry):
258        if not isinstance(entry, CMakeCacheEntry):
259            msg = 'improper type {} for value {}, expecting CMakeCacheEntry'
260            raise TypeError(msg.format(type(entry), entry))
261        self._entries[name] = entry
262
263    def __delitem__(self, name):
264        del self._entries[name]
265
266    def __iter__(self):
267        return iter(self._entries.values())
268
269def _ensure_min_version(cmake, dry_run):
270    cmd = [cmake, '--version']
271    if dry_run:
272        log.inf('Dry run:', quote_sh_list(cmd))
273        return
274
275    try:
276        version_out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
277    except subprocess.CalledProcessError as cpe:
278        log.die('cannot get cmake version:', str(cpe))
279    decoded = version_out.decode('utf-8')
280    lines = decoded.splitlines()
281    if not lines:
282        log.die('can\'t get cmake version: ' +
283                'unexpected "cmake --version" output:\n{}\n'.
284                format(decoded) +
285                'Please install CMake ' + _MIN_CMAKE_VERSION_STR +
286                ' or higher (https://cmake.org/download/).')
287    version = lines[0].split()[2]
288    if '-' in version:
289        # Handle semver cases like "3.19.20210206-g1e50ab6"
290        # which Kitware uses for prerelease versions.
291        version = version.split('-', 1)[0]
292    if packaging.version.parse(version) < _MIN_CMAKE_VERSION:
293        log.die('cmake version', version,
294                'is less than minimum version {};'.
295                format(_MIN_CMAKE_VERSION_STR),
296                'please update your CMake (https://cmake.org/download/).')
297    else:
298        log.dbg('cmake version', version, 'is OK; minimum version is',
299                _MIN_CMAKE_VERSION_STR)
300
301_MIN_CMAKE_VERSION_STR = '3.13.1'
302_MIN_CMAKE_VERSION = packaging.version.parse(_MIN_CMAKE_VERSION_STR)
303