1#
2# Copyright (C) 2023-2024 Siemens AG
3#
4# SPDX-License-Identifier: GPL-2.0-only
5#
6"""Devtool ide-sdk IDE plugin for VSCode and VSCodium"""
7
8import json
9import logging
10import os
11import shutil
12from devtool.ide_plugins import BuildTool, IdeBase, GdbCrossConfig, get_devtool_deploy_opts
13
14logger = logging.getLogger('devtool')
15
16
17class GdbCrossConfigVSCode(GdbCrossConfig):
18    def __init__(self, image_recipe, modified_recipe, binary):
19        super().__init__(image_recipe, modified_recipe, binary, False)
20
21    def initialize(self):
22        self._gen_gdbserver_start_script()
23
24
25class IdeVSCode(IdeBase):
26    """Manage IDE configurations for VSCode
27
28    Modified recipe mode:
29    - cmake: use the cmake-preset generated by devtool ide-sdk
30    - meson: meson is called via a wrapper script generated by devtool ide-sdk
31
32    Shared sysroot mode:
33    In shared sysroot mode, the cross tool-chain is exported to the user's global configuration.
34    A workspace cannot be created because there is no recipe that defines how a workspace could
35    be set up.
36    - cmake: adds a cmake-kit to .local/share/CMakeTools/cmake-tools-kits.json
37             The cmake-kit uses the environment script and the tool-chain file
38             generated by meta-ide-support.
39    - meson: Meson needs manual workspace configuration.
40    """
41
42    @classmethod
43    def ide_plugin_priority(cls):
44        """If --ide is not passed this is the default plugin"""
45        if shutil.which('code'):
46            return 100
47        return 0
48
49    def setup_shared_sysroots(self, shared_env):
50        """Expose the toolchain of the shared sysroots SDK"""
51        datadir = shared_env.ide_support.datadir
52        deploy_dir_image = shared_env.ide_support.deploy_dir_image
53        real_multimach_target_sys = shared_env.ide_support.real_multimach_target_sys
54        standalone_sysroot_native = shared_env.build_sysroots.standalone_sysroot_native
55        vscode_ws_path = os.path.join(
56            os.environ['HOME'], '.local', 'share', 'CMakeTools')
57        cmake_kits_path = os.path.join(vscode_ws_path, 'cmake-tools-kits.json')
58        oecmake_generator = "Ninja"
59        env_script = os.path.join(
60            deploy_dir_image, 'environment-setup-' + real_multimach_target_sys)
61
62        if not os.path.isdir(vscode_ws_path):
63            os.makedirs(vscode_ws_path)
64        cmake_kits_old = []
65        if os.path.exists(cmake_kits_path):
66            with open(cmake_kits_path, 'r', encoding='utf-8') as cmake_kits_file:
67                cmake_kits_old = json.load(cmake_kits_file)
68        cmake_kits = cmake_kits_old.copy()
69
70        cmake_kit_new = {
71            "name": "OE " + real_multimach_target_sys,
72            "environmentSetupScript": env_script,
73            "toolchainFile": standalone_sysroot_native + datadir + "/cmake/OEToolchainConfig.cmake",
74            "preferredGenerator": {
75                "name": oecmake_generator
76            }
77        }
78
79        def merge_kit(cmake_kits, cmake_kit_new):
80            i = 0
81            while i < len(cmake_kits):
82                if 'environmentSetupScript' in cmake_kits[i] and \
83                        cmake_kits[i]['environmentSetupScript'] == cmake_kit_new['environmentSetupScript']:
84                    cmake_kits[i] = cmake_kit_new
85                    return
86                i += 1
87            cmake_kits.append(cmake_kit_new)
88        merge_kit(cmake_kits, cmake_kit_new)
89
90        if cmake_kits != cmake_kits_old:
91            logger.info("Updating: %s" % cmake_kits_path)
92            with open(cmake_kits_path, 'w', encoding='utf-8') as cmake_kits_file:
93                json.dump(cmake_kits, cmake_kits_file, indent=4)
94        else:
95            logger.info("Already up to date: %s" % cmake_kits_path)
96
97        cmake_native = os.path.join(
98            shared_env.build_sysroots.standalone_sysroot_native, 'usr', 'bin', 'cmake')
99        if os.path.isfile(cmake_native):
100            logger.info('cmake-kits call cmake by default. If the cmake provided by this SDK should be used, please add the following line to ".vscode/settings.json" file: "cmake.cmakePath": "%s"' % cmake_native)
101        else:
102            logger.error("Cannot find cmake native at: %s" % cmake_native)
103
104    def dot_code_dir(self, modified_recipe):
105        return os.path.join(modified_recipe.srctree, '.vscode')
106
107    def __vscode_settings_meson(self, settings_dict, modified_recipe):
108        if modified_recipe.build_tool is not BuildTool.MESON:
109            return
110        settings_dict["mesonbuild.mesonPath"] = modified_recipe.meson_wrapper
111
112        confopts = modified_recipe.mesonopts.split()
113        confopts += modified_recipe.meson_cross_file.split()
114        confopts += modified_recipe.extra_oemeson.split()
115        settings_dict["mesonbuild.configureOptions"] = confopts
116        settings_dict["mesonbuild.buildFolder"] = modified_recipe.b
117
118    def __vscode_settings_cmake(self, settings_dict, modified_recipe):
119        """Add cmake specific settings to settings.json.
120
121        Note: most settings are passed to the cmake preset.
122        """
123        if modified_recipe.build_tool is not BuildTool.CMAKE:
124            return
125        settings_dict["cmake.configureOnOpen"] = True
126        settings_dict["cmake.sourceDirectory"] = modified_recipe.real_srctree
127
128    def vscode_settings(self, modified_recipe, image_recipe):
129        files_excludes = {
130            "**/.git/**": True,
131            "**/oe-logs/**": True,
132            "**/oe-workdir/**": True,
133            "**/source-date-epoch/**": True
134        }
135        python_exclude = [
136            "**/.git/**",
137            "**/oe-logs/**",
138            "**/oe-workdir/**",
139            "**/source-date-epoch/**"
140        ]
141        files_readonly = {
142            modified_recipe.recipe_sysroot + '/**': True,
143            modified_recipe.recipe_sysroot_native + '/**': True,
144        }
145        if image_recipe.rootfs_dbg is not None:
146            files_readonly[image_recipe.rootfs_dbg + '/**'] = True
147        settings_dict = {
148            "files.watcherExclude": files_excludes,
149            "files.exclude": files_excludes,
150            "files.readonlyInclude": files_readonly,
151            "python.analysis.exclude": python_exclude
152        }
153        self.__vscode_settings_cmake(settings_dict, modified_recipe)
154        self.__vscode_settings_meson(settings_dict, modified_recipe)
155
156        settings_file = 'settings.json'
157        IdeBase.update_json_file(
158            self.dot_code_dir(modified_recipe), settings_file, settings_dict)
159
160    def __vscode_extensions_cmake(self, modified_recipe, recommendations):
161        if modified_recipe.build_tool is not BuildTool.CMAKE:
162            return
163        recommendations += [
164            "twxs.cmake",
165            "ms-vscode.cmake-tools",
166            "ms-vscode.cpptools",
167            "ms-vscode.cpptools-extension-pack",
168            "ms-vscode.cpptools-themes"
169        ]
170
171    def __vscode_extensions_meson(self, modified_recipe, recommendations):
172        if modified_recipe.build_tool is not BuildTool.MESON:
173            return
174        recommendations += [
175            'mesonbuild.mesonbuild',
176            "ms-vscode.cpptools",
177            "ms-vscode.cpptools-extension-pack",
178            "ms-vscode.cpptools-themes"
179        ]
180
181    def vscode_extensions(self, modified_recipe):
182        recommendations = []
183        self.__vscode_extensions_cmake(modified_recipe, recommendations)
184        self.__vscode_extensions_meson(modified_recipe, recommendations)
185        extensions_file = 'extensions.json'
186        IdeBase.update_json_file(
187            self.dot_code_dir(modified_recipe), extensions_file, {"recommendations": recommendations})
188
189    def vscode_c_cpp_properties(self, modified_recipe):
190        properties_dict = {
191            "name": modified_recipe.recipe_id_pretty,
192        }
193        if modified_recipe.build_tool is BuildTool.CMAKE:
194            properties_dict["configurationProvider"] = "ms-vscode.cmake-tools"
195        elif modified_recipe.build_tool is BuildTool.MESON:
196            properties_dict["configurationProvider"] = "mesonbuild.mesonbuild"
197            properties_dict["compilerPath"] = os.path.join(modified_recipe.staging_bindir_toolchain, modified_recipe.cxx.split()[0])
198        else:  # no C/C++ build
199            return
200
201        properties_dicts = {
202            "configurations": [
203                properties_dict
204            ],
205            "version": 4
206        }
207        prop_file = 'c_cpp_properties.json'
208        IdeBase.update_json_file(
209            self.dot_code_dir(modified_recipe), prop_file, properties_dicts)
210
211    def vscode_launch_bin_dbg(self, gdb_cross_config):
212        modified_recipe = gdb_cross_config.modified_recipe
213
214        launch_config = {
215            "name": gdb_cross_config.id_pretty,
216            "type": "cppdbg",
217            "request": "launch",
218            "program": os.path.join(modified_recipe.d, gdb_cross_config.binary.lstrip('/')),
219            "stopAtEntry": True,
220            "cwd": "${workspaceFolder}",
221            "environment": [],
222            "externalConsole": False,
223            "MIMode": "gdb",
224            "preLaunchTask": gdb_cross_config.id_pretty,
225            "miDebuggerPath": modified_recipe.gdb_cross.gdb,
226            "miDebuggerServerAddress": "%s:%d" % (modified_recipe.gdb_cross.host, gdb_cross_config.gdbserver_port)
227        }
228
229        # Search for header files in recipe-sysroot.
230        src_file_map = {
231            "/usr/include": os.path.join(modified_recipe.recipe_sysroot, "usr", "include")
232        }
233        # First of all search for not stripped binaries in the image folder.
234        # These binaries are copied (and optionally stripped) by deploy-target
235        setup_commands = [
236            {
237                "description": "sysroot",
238                "text": "set sysroot " + modified_recipe.d
239            }
240        ]
241
242        if gdb_cross_config.image_recipe.rootfs_dbg:
243            launch_config['additionalSOLibSearchPath'] = modified_recipe.solib_search_path_str(
244                gdb_cross_config.image_recipe)
245            # First: Search for sources of this recipe in the workspace folder
246            if modified_recipe.pn in modified_recipe.target_dbgsrc_dir:
247                src_file_map[modified_recipe.target_dbgsrc_dir] = "${workspaceFolder}"
248            else:
249                logger.error(
250                    "TARGET_DBGSRC_DIR must contain the recipe name PN.")
251            # Second: Search for sources of other recipes in the rootfs-dbg
252            if modified_recipe.target_dbgsrc_dir.startswith("/usr/src/debug"):
253                src_file_map["/usr/src/debug"] = os.path.join(
254                    gdb_cross_config.image_recipe.rootfs_dbg, "usr", "src", "debug")
255            else:
256                logger.error(
257                    "TARGET_DBGSRC_DIR must start with /usr/src/debug.")
258        else:
259            logger.warning(
260                "Cannot setup debug symbols configuration for GDB. IMAGE_GEN_DEBUGFS is not enabled.")
261
262        launch_config['sourceFileMap'] = src_file_map
263        launch_config['setupCommands'] = setup_commands
264        return launch_config
265
266    def vscode_launch(self, modified_recipe):
267        """GDB Launch configuration for binaries (elf files)"""
268
269        configurations = []
270        for gdb_cross_config in self.gdb_cross_configs:
271            if gdb_cross_config.modified_recipe is modified_recipe:
272                configurations.append(self.vscode_launch_bin_dbg(gdb_cross_config))
273        launch_dict = {
274            "version": "0.2.0",
275            "configurations": configurations
276        }
277        launch_file = 'launch.json'
278        IdeBase.update_json_file(
279            self.dot_code_dir(modified_recipe), launch_file, launch_dict)
280
281    def vscode_tasks_cpp(self, args, modified_recipe):
282        run_install_deploy = modified_recipe.gen_install_deploy_script(args)
283        install_task_name = "install && deploy-target %s" % modified_recipe.recipe_id_pretty
284        tasks_dict = {
285            "version": "2.0.0",
286            "tasks": [
287                {
288                    "label": install_task_name,
289                    "type": "shell",
290                    "command": run_install_deploy,
291                    "problemMatcher": []
292                }
293            ]
294        }
295        for gdb_cross_config in self.gdb_cross_configs:
296            if gdb_cross_config.modified_recipe is not modified_recipe:
297                continue
298            tasks_dict['tasks'].append(
299                {
300                    "label": gdb_cross_config.id_pretty,
301                    "type": "shell",
302                    "isBackground": True,
303                    "dependsOn": [
304                        install_task_name
305                    ],
306                    "command": gdb_cross_config.gdbserver_script,
307                    "problemMatcher": [
308                        {
309                            "pattern": [
310                                {
311                                    "regexp": ".",
312                                    "file": 1,
313                                    "location": 2,
314                                    "message": 3
315                                }
316                            ],
317                            "background": {
318                                "activeOnStart": True,
319                                "beginsPattern": ".",
320                                "endsPattern": ".",
321                            }
322                        }
323                    ]
324                })
325        tasks_file = 'tasks.json'
326        IdeBase.update_json_file(
327            self.dot_code_dir(modified_recipe), tasks_file, tasks_dict)
328
329    def vscode_tasks_fallback(self, args, modified_recipe):
330        oe_init_dir = modified_recipe.oe_init_dir
331        oe_init = ". %s %s > /dev/null && " % (modified_recipe.oe_init_build_env, modified_recipe.topdir)
332        dt_build = "devtool build "
333        dt_build_label = dt_build + modified_recipe.recipe_id_pretty
334        dt_build_cmd = dt_build + modified_recipe.bpn
335        clean_opt = " --clean"
336        dt_build_clean_label = dt_build + modified_recipe.recipe_id_pretty + clean_opt
337        dt_build_clean_cmd = dt_build + modified_recipe.bpn + clean_opt
338        dt_deploy = "devtool deploy-target "
339        dt_deploy_label = dt_deploy + modified_recipe.recipe_id_pretty
340        dt_deploy_cmd = dt_deploy + modified_recipe.bpn
341        dt_build_deploy_label = "devtool build & deploy-target %s" % modified_recipe.recipe_id_pretty
342        deploy_opts = ' '.join(get_devtool_deploy_opts(args))
343        tasks_dict = {
344            "version": "2.0.0",
345            "tasks": [
346                {
347                    "label": dt_build_label,
348                    "type": "shell",
349                    "command": "bash",
350                    "linux": {
351                        "options": {
352                            "cwd": oe_init_dir
353                        }
354                    },
355                    "args": [
356                        "--login",
357                        "-c",
358                        "%s%s" % (oe_init, dt_build_cmd)
359                    ],
360                    "problemMatcher": []
361                },
362                {
363                    "label": dt_deploy_label,
364                    "type": "shell",
365                    "command": "bash",
366                    "linux": {
367                        "options": {
368                            "cwd": oe_init_dir
369                        }
370                    },
371                    "args": [
372                        "--login",
373                        "-c",
374                        "%s%s %s" % (
375                            oe_init, dt_deploy_cmd, deploy_opts)
376                    ],
377                    "problemMatcher": []
378                },
379                {
380                    "label": dt_build_deploy_label,
381                    "dependsOrder": "sequence",
382                    "dependsOn": [
383                        dt_build_label,
384                        dt_deploy_label
385                    ],
386                    "problemMatcher": [],
387                    "group": {
388                        "kind": "build",
389                        "isDefault": True
390                    }
391                },
392                {
393                    "label": dt_build_clean_label,
394                    "type": "shell",
395                    "command": "bash",
396                    "linux": {
397                        "options": {
398                            "cwd": oe_init_dir
399                        }
400                    },
401                    "args": [
402                        "--login",
403                        "-c",
404                        "%s%s" % (oe_init, dt_build_clean_cmd)
405                    ],
406                    "problemMatcher": []
407                }
408            ]
409        }
410        if modified_recipe.gdb_cross:
411            for gdb_cross_config in self.gdb_cross_configs:
412                if gdb_cross_config.modified_recipe is not modified_recipe:
413                    continue
414                tasks_dict['tasks'].append(
415                    {
416                        "label": gdb_cross_config.id_pretty,
417                        "type": "shell",
418                        "isBackground": True,
419                        "dependsOn": [
420                            dt_build_deploy_label
421                        ],
422                        "command": gdb_cross_config.gdbserver_script,
423                        "problemMatcher": [
424                            {
425                                "pattern": [
426                                    {
427                                        "regexp": ".",
428                                        "file": 1,
429                                        "location": 2,
430                                        "message": 3
431                                    }
432                                ],
433                                "background": {
434                                    "activeOnStart": True,
435                                    "beginsPattern": ".",
436                                    "endsPattern": ".",
437                                }
438                            }
439                        ]
440                    })
441        tasks_file = 'tasks.json'
442        IdeBase.update_json_file(
443            self.dot_code_dir(modified_recipe), tasks_file, tasks_dict)
444
445    def vscode_tasks(self, args, modified_recipe):
446        if modified_recipe.build_tool.is_c_ccp:
447            self.vscode_tasks_cpp(args, modified_recipe)
448        else:
449            self.vscode_tasks_fallback(args, modified_recipe)
450
451    def setup_modified_recipe(self, args, image_recipe, modified_recipe):
452        self.vscode_settings(modified_recipe, image_recipe)
453        self.vscode_extensions(modified_recipe)
454        self.vscode_c_cpp_properties(modified_recipe)
455        if args.target:
456            self.initialize_gdb_cross_configs(
457                image_recipe, modified_recipe, gdb_cross_config_class=GdbCrossConfigVSCode)
458            self.vscode_launch(modified_recipe)
459            self.vscode_tasks(args, modified_recipe)
460
461
462def register_ide_plugin(ide_plugins):
463    ide_plugins['code'] = IdeVSCode
464