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