1# Copyright (c) 2017 Open Source Foundries Limited.
2#
3# SPDX-License-Identifier: Apache-2.0
4
5'''Sphinx extensions related to managing Zephyr applications.'''
6
7from pathlib import Path
8
9from docutils import nodes
10from docutils.parsers.rst import Directive, directives
11
12ZEPHYR_BASE = Path(__file__).parents[3]
13
14
15# TODO: extend and modify this for Windows.
16#
17# This could be as simple as generating a couple of sets of instructions, one
18# for Unix environments, and another for Windows.
19class ZephyrAppCommandsDirective(Directive):
20    r'''
21    This is a Zephyr directive for generating consistent documentation
22    of the shell commands needed to manage (build, flash, etc.) an application.
23    '''
24
25    has_content = False
26    required_arguments = 0
27    optional_arguments = 0
28    final_argument_whitespace = False
29    option_spec = {
30        'tool': directives.unchanged,
31        'app': directives.unchanged,
32        'zephyr-app': directives.unchanged,
33        'cd-into': directives.flag,
34        'generator': directives.unchanged,
35        'host-os': directives.unchanged,
36        'board': directives.unchanged,
37        'shield': directives.unchanged,
38        'conf': directives.unchanged,
39        'gen-args': directives.unchanged,
40        'build-args': directives.unchanged,
41        'snippets': directives.unchanged,
42        'build-dir': directives.unchanged,
43        'build-dir-fmt': directives.unchanged,
44        'goals': directives.unchanged_required,
45        'maybe-skip-config': directives.flag,
46        'compact': directives.flag,
47        'west-args': directives.unchanged,
48        'flash-args': directives.unchanged,
49        'debug-args': directives.unchanged,
50        'debugserver-args': directives.unchanged,
51        'attach-args': directives.unchanged,
52    }
53
54    TOOLS = ['cmake', 'west', 'all']
55    GENERATORS = ['make', 'ninja']
56    HOST_OS = ['unix', 'win', 'all']
57    IN_TREE_STR = '# From the root of the zephyr repository'
58
59    def run(self):
60        # Re-run on the current document if this directive's source changes.
61        self.state.document.settings.env.note_dependency(__file__)
62
63        # Parse directive options.  Don't use os.path.sep or os.path.join here!
64        # That would break if building the docs on Windows.
65        tool = self.options.get('tool', 'west').lower()
66        app = self.options.get('app', None)
67        zephyr_app = self.options.get('zephyr-app', None)
68        cd_into = 'cd-into' in self.options
69        generator = self.options.get('generator', 'ninja').lower()
70        host_os = self.options.get('host-os', 'all').lower()
71        board = self.options.get('board', None)
72        shield = self.options.get('shield', None)
73        conf = self.options.get('conf', None)
74        gen_args = self.options.get('gen-args', None)
75        build_args = self.options.get('build-args', None)
76        snippets = self.options.get('snippets', None)
77        build_dir_append = self.options.get('build-dir', '').strip('/')
78        build_dir_fmt = self.options.get('build-dir-fmt', None)
79        goals = self.options.get('goals').split()
80        skip_config = 'maybe-skip-config' in self.options
81        compact = 'compact' in self.options
82        west_args = self.options.get('west-args', None)
83        flash_args = self.options.get('flash-args', None)
84        debug_args = self.options.get('debug-args', None)
85        debugserver_args = self.options.get('debugserver-args', None)
86        attach_args = self.options.get('attach-args', None)
87
88        if tool not in self.TOOLS:
89            raise self.error(f'Unknown tool {tool}; choose from: {self.TOOLS}')
90
91        if app and zephyr_app:
92            raise self.error('Both app and zephyr-app options were given.')
93
94        if build_dir_append != '' and build_dir_fmt:
95            raise self.error('Both build-dir and build-dir-fmt options were given.')
96
97        if build_dir_fmt and tool != 'west':
98            raise self.error('build-dir-fmt is only supported for the west build tool.')
99
100        if generator not in self.GENERATORS:
101            raise self.error(f'Unknown generator {generator}; choose from: {self.GENERATORS}')
102
103        if host_os not in self.HOST_OS:
104            raise self.error(f'Unknown host-os {host_os}; choose from: {self.HOST_OS}')
105
106        if compact and skip_config:
107            raise self.error('Both compact and maybe-skip-config options were given.')
108
109        # as folks might use "<...>" notation to indicate a variable portion of the path, we
110        # deliberately don't check for the validity of such paths.
111        if zephyr_app and not any([x in zephyr_app for x in ["<", ">"]]):
112            app_path = ZEPHYR_BASE / zephyr_app
113            if not app_path.is_dir():
114                raise self.error(
115                    f"zephyr-app: {zephyr_app} is not a valid folder in the zephyr tree."
116                )
117
118        app = app or zephyr_app
119        in_tree = self.IN_TREE_STR if zephyr_app else None
120        # Allow build directories which are nested.
121        build_dir = ('build' + '/' + build_dir_append).rstrip('/')
122
123        # Prepare repeatable arguments
124        host_os = [host_os] if host_os != "all" else [v for v in self.HOST_OS if v != 'all']
125        tools = [tool] if tool != "all" else [v for v in self.TOOLS if v != 'all']
126        build_args_list = build_args.split(' ') if build_args is not None else None
127        snippet_list = snippets.split(',') if snippets is not None else None
128        shield_list = shield.split(',') if shield is not None else None
129
130        # Build the command content as a list, then convert to string.
131        content = []
132        tool_comment = None
133        if len(tools) > 1:
134            tool_comment = 'Using {}:'
135
136        run_config = {
137            'host_os': host_os,
138            'app': app,
139            'in_tree': in_tree,
140            'cd_into': cd_into,
141            'board': board,
142            'shield': shield_list,
143            'conf': conf,
144            'gen_args': gen_args,
145            'build_args': build_args_list,
146            'snippets': snippet_list,
147            'build_dir': build_dir,
148            'build_dir_fmt': build_dir_fmt,
149            'goals': goals,
150            'compact': compact,
151            'skip_config': skip_config,
152            'generator': generator,
153            'west_args': west_args,
154            'flash_args': flash_args,
155            'debug_args': debug_args,
156            'debugserver_args': debugserver_args,
157            'attach_args': attach_args,
158        }
159
160        if 'west' in tools:
161            w = self._generate_west(**run_config)
162            if tool_comment:
163                paragraph = nodes.paragraph()
164                paragraph += nodes.Text(tool_comment.format('west'))
165                content.append(paragraph)
166                content.append(self._lit_block(w))
167            else:
168                content.extend(w)
169
170        if 'cmake' in tools:
171            c = self._generate_cmake(**run_config)
172            if tool_comment:
173                paragraph = nodes.paragraph()
174                paragraph += nodes.Text(tool_comment.format(f'CMake and {generator}'))
175                content.append(paragraph)
176                content.append(self._lit_block(c))
177            else:
178                content.extend(c)
179
180        if not tool_comment:
181            content = [self._lit_block(content)]
182
183        return content
184
185    def _lit_block(self, content):
186        content = '\n'.join(content)
187
188        # Create the nodes.
189        literal = nodes.literal_block(content, content)
190        self.add_name(literal)
191        literal['language'] = 'shell'
192        return literal
193
194    def _generate_west(self, **kwargs):
195        content = []
196        generator = kwargs['generator']
197        board = kwargs['board']
198        app = kwargs['app']
199        in_tree = kwargs['in_tree']
200        goals = kwargs['goals']
201        cd_into = kwargs['cd_into']
202        build_dir = kwargs['build_dir']
203        build_dir_fmt = kwargs['build_dir_fmt']
204        compact = kwargs['compact']
205        shield = kwargs['shield']
206        snippets = kwargs['snippets']
207        build_args = kwargs["build_args"]
208        west_args = kwargs['west_args']
209        flash_args = kwargs['flash_args']
210        debug_args = kwargs['debug_args']
211        debugserver_args = kwargs['debugserver_args']
212        attach_args = kwargs['attach_args']
213        kwargs['board'] = None
214        # west always defaults to ninja
215        gen_arg = ' -G\'Unix Makefiles\'' if generator == 'make' else ''
216        cmake_args = gen_arg + self._cmake_args(**kwargs)
217        cmake_args = f' --{cmake_args}' if cmake_args != '' else ''
218        build_args = "".join(f" -o {b}" for b in build_args) if build_args else ""
219        west_args = f' {west_args}' if west_args else ''
220        flash_args = f' {flash_args}' if flash_args else ''
221        debug_args = f' {debug_args}' if debug_args else ''
222        debugserver_args = f' {debugserver_args}' if debugserver_args else ''
223        attach_args = f' {attach_args}' if attach_args else ''
224        snippet_args = ''.join(f' -S {s}' for s in snippets) if snippets else ''
225        shield_args = ''.join(f' --shield {s}' for s in shield) if shield else ''
226        # ignore zephyr_app since west needs to run within
227        # the installation. Instead rely on relative path.
228        src = f' {app}' if app and not cd_into else ''
229
230        if build_dir_fmt is None:
231            dst = f' -d {build_dir}' if build_dir != 'build' else ''
232            build_dst = dst
233        else:
234            app_name = app.split('/')[-1]
235            build_dir_formatted = build_dir_fmt.format(app=app_name, board=board, source_dir=app)
236            dst = f' -d {build_dir_formatted}'
237            build_dst = ''
238
239        if in_tree and not compact:
240            content.append(in_tree)
241
242        if cd_into and app:
243            content.append(f'cd {app}')
244
245        # We always have to run west build.
246        #
247        # FIXME: doing this unconditionally essentially ignores the
248        # maybe-skip-config option if set.
249        #
250        # This whole script and its users from within the
251        # documentation needs to be overhauled now that we're
252        # defaulting to west.
253        #
254        # For now, this keeps the resulting commands working.
255        content.append(
256            f"west build -b {board}{build_args}{west_args}{snippet_args}"
257            f"{shield_args}{build_dst}{src}{cmake_args}"
258        )
259
260        # If we're signing, we want to do that next, so that flashing
261        # etc. commands can use the signed file which must be created
262        # in this step.
263        if 'sign' in goals:
264            content.append(f'west sign{dst}')
265
266        for goal in goals:
267            if goal in {'build', 'sign'}:
268                continue
269            elif goal == 'flash':
270                content.append(f'west flash{flash_args}{dst}')
271            elif goal == 'debug':
272                content.append(f'west debug{debug_args}{dst}')
273            elif goal == 'debugserver':
274                content.append(f'west debugserver{debugserver_args}{dst}')
275            elif goal == 'attach':
276                content.append(f'west attach{attach_args}{dst}')
277            else:
278                content.append(f'west build -t {goal}{dst}')
279
280        return content
281
282    @staticmethod
283    def _mkdir(mkdir, build_dir, host_os, skip_config):
284        content = []
285        if skip_config:
286            content.append(
287                f"# If you already made a build directory ({build_dir}) and ran cmake, "
288                f"just 'cd {build_dir}' instead."
289            )
290        if host_os == 'all':
291            content.append(f'mkdir {build_dir} && cd {build_dir}')
292        if host_os == "unix":
293            content.append(f'{mkdir} {build_dir} && cd {build_dir}')
294        elif host_os == "win":
295            build_dir = build_dir.replace('/', '\\')
296            content.append(f'mkdir {build_dir} & cd {build_dir}')
297        return content
298
299    @staticmethod
300    def _cmake_args(**kwargs):
301        board = kwargs['board']
302        conf = kwargs['conf']
303        gen_args = kwargs['gen_args']
304        board_arg = f' -DBOARD={board}' if board else ''
305        conf_arg = f' -DCONF_FILE={conf}' if conf else ''
306        gen_args = f' {gen_args}' if gen_args else ''
307
308        return f'{board_arg}{conf_arg}{gen_args}'
309
310    def _cd_into(self, mkdir, **kwargs):
311        app = kwargs['app']
312        host_os = kwargs['host_os']
313        compact = kwargs['compact']
314        build_dir = kwargs['build_dir']
315        skip_config = kwargs['skip_config']
316        content = []
317        os_comment = None
318        if len(host_os) > 1:
319            os_comment = '# On {}'
320            num_slashes = build_dir.count('/')
321            if not app and mkdir and num_slashes == 0:
322                # When there's no app and a single level deep build dir,
323                # simplify output
324                content.extend(self._mkdir(mkdir, build_dir, 'all', skip_config))
325                if not compact:
326                    content.append('')
327                return content
328        for host in host_os:
329            if host == "unix":
330                if os_comment:
331                    content.append(os_comment.format('Linux/macOS'))
332                if app:
333                    content.append(f'cd {app}')
334            elif host == "win":
335                if os_comment:
336                    content.append(os_comment.format('Windows'))
337                if app:
338                    backslashified = app.replace('/', '\\')
339                    content.append(f'cd {backslashified}')
340            if mkdir:
341                content.extend(self._mkdir(mkdir, build_dir, host, skip_config))
342            if not compact:
343                content.append('')
344        return content
345
346    def _generate_cmake(self, **kwargs):
347        generator = kwargs['generator']
348        cd_into = kwargs['cd_into']
349        app = kwargs['app']
350        in_tree = kwargs['in_tree']
351        build_dir = kwargs['build_dir']
352        build_args = kwargs['build_args']
353        snippets = kwargs['snippets']
354        shield = kwargs['shield']
355        skip_config = kwargs['skip_config']
356        goals = kwargs['goals']
357        compact = kwargs['compact']
358
359        content = []
360
361        if in_tree and not compact:
362            content.append(in_tree)
363
364        if cd_into:
365            num_slashes = build_dir.count('/')
366            mkdir = 'mkdir' if num_slashes == 0 else 'mkdir -p'
367            content.extend(self._cd_into(mkdir, **kwargs))
368            # Prepare cmake/ninja/make variables
369            source_dir = ' ' + '/'.join(['..' for i in range(num_slashes + 1)])
370            cmake_build_dir = ''
371            tool_build_dir = ''
372        else:
373            source_dir = f' {app}' if app else ' .'
374            cmake_build_dir = f' -B{build_dir}'
375            tool_build_dir = f' -C{build_dir}'
376
377        # Now generate the actual cmake and make/ninja commands
378        gen_arg = ' -GNinja' if generator == 'ninja' else ''
379        build_args = f' {build_args}' if build_args else ''
380        snippet_args = ' -DSNIPPET="{}"'.format(';'.join(snippets)) if snippets else ''
381        shield_args = ' -DSHIELD="{}"'.format(';'.join(shield)) if shield else ''
382        cmake_args = self._cmake_args(**kwargs)
383
384        if not compact:
385            if not cd_into and skip_config:
386                content.append(
387                    f'# If you already ran cmake with -B{build_dir}, you '
388                    f'can skip this step and run {generator} directly.'
389                )
390            else:
391                content.append(
392                    f'# Use cmake to configure a {generator.capitalize()}-based buildsystem:'
393                )
394
395        content.append(
396            f'cmake{cmake_build_dir}{gen_arg}{cmake_args}{snippet_args}{shield_args}{source_dir}'
397        )
398        if not compact:
399            content.extend(['', '# Now run the build tool on the generated build system:'])
400
401        if 'build' in goals:
402            content.append(f'{generator}{tool_build_dir}{build_args}')
403        for goal in goals:
404            if goal == 'build':
405                continue
406            content.append(f'{generator}{tool_build_dir} {goal}')
407
408        return content
409
410
411def setup(app):
412    app.add_directive('zephyr-app-commands', ZephyrAppCommandsDirective)
413
414    return {'version': '1.0', 'parallel_read_safe': True, 'parallel_write_safe': True}
415