]> Git Repo - pico-vscode.git/blob - src/utils/cmakeUtil.mts
Merge branch 'main' into main
[pico-vscode.git] / src / utils / cmakeUtil.mts
1 import { exec } from "child_process";
2 import { workspace, type Uri, window, ProgressLocation } from "vscode";
3 import { showRequirementsNotMetErrorMessage } from "./requirementsUtil.mjs";
4 import { dirname, join, resolve } from "path";
5 import Settings from "../settings.mjs";
6 import { HOME_VAR, SettingsKey } from "../settings.mjs";
7 import { existsSync, readFileSync, rmSync } from "fs";
8 import Logger from "../logger.mjs";
9 import { readFile, writeFile } from "fs/promises";
10 import { rimraf, windows as rimrafWindows } from "rimraf";
11 import { homedir } from "os";
12 import which from "which";
13 import { compareLtMajor } from "./semverUtil.mjs";
14 import { buildCMakeIncPath } from "./download.mjs";
15
16 export const CMAKE_DO_NOT_EDIT_HEADER_PREFIX =
17   // eslint-disable-next-line max-len
18   "== DO NEVER EDIT THE NEXT LINES for Raspberry Pi Pico VS Code Extension to work ==";
19
20 export async function getPythonPath(): Promise<string> {
21   const settings = Settings.getInstance();
22   if (settings === undefined) {
23     Logger.log("Error: Settings not initialized.");
24
25     return "";
26   }
27
28   const pythonPath = (
29     (await which(
30       settings
31         .getString(SettingsKey.python3Path)
32         ?.replace(HOME_VAR, homedir()) ||
33         (process.platform === "win32" ? "python" : "python3"),
34       { nothrow: true }
35     )) || ""
36   ).replaceAll("\\", "/");
37
38   return `${pythonPath.replaceAll("\\", "/")}`;
39 }
40
41 export async function getPath(): Promise<string> {
42   const settings = Settings.getInstance();
43   if (settings === undefined) {
44     Logger.log("Error: Settings not initialized.");
45
46     return "";
47   }
48
49   const ninjaPath = (
50     (await which(
51       settings.getString(SettingsKey.ninjaPath)?.replace(HOME_VAR, homedir()) ||
52         "ninja",
53       { nothrow: true }
54     )) || ""
55   ).replaceAll("\\", "/");
56   const cmakePath = (
57     (await which(
58       settings.getString(SettingsKey.cmakePath)?.replace(HOME_VAR, homedir()) ||
59         "cmake",
60       { nothrow: true }
61     )) || ""
62   ).replaceAll("\\", "/");
63   Logger.log(
64     settings.getString(SettingsKey.python3Path)?.replace(HOME_VAR, homedir())
65   );
66   // TODO: maybe also check for "python" on unix systems
67   const pythonPath = await getPythonPath();
68
69   if (ninjaPath.length === 0 || cmakePath.length === 0) {
70     const missingTools = [];
71     if (ninjaPath.length === 0) {
72       missingTools.push("Ninja");
73     }
74     if (cmakePath.length === 0) {
75       missingTools.push("CMake");
76     }
77     if (pythonPath.length === 0) {
78       missingTools.push("Python 3");
79     }
80     void showRequirementsNotMetErrorMessage(missingTools);
81
82     return "";
83   }
84
85   const isWindows = process.platform === "win32";
86
87   return `${ninjaPath.includes("/") ? dirname(ninjaPath) : ""}${
88     cmakePath.includes("/")
89       ? `${isWindows ? ";" : ":"}${dirname(cmakePath)}`
90       : ""
91   }${
92     pythonPath.includes("/")
93       ? `${dirname(pythonPath)}${isWindows ? ";" : ":"}`
94       : ""
95   }`;
96 }
97
98 export async function configureCmakeNinja(folder: Uri): Promise<boolean> {
99   if (process.platform !== "win32" && folder.fsPath.includes("\\")) {
100     const errorMsg =
101       "CMake currently does not support folder names with backslashes.";
102     Logger.log(errorMsg);
103     await window.showErrorMessage(
104       "Failed to configure cmake for the current project. " + errorMsg
105     );
106
107     return false;
108   }
109
110   const settings = Settings.getInstance();
111   if (settings === undefined) {
112     Logger.log("Error: Settings not initialized.");
113
114     return false;
115   }
116
117   if (settings.getBoolean(SettingsKey.useCmakeTools)) {
118     await window.showErrorMessage(
119       "You must use the CMake Tools extension to configure your build. " +
120         "To use this extension instead, change the useCmakeTools setting."
121     );
122
123     return false;
124   }
125
126   if (existsSync(join(folder.fsPath, "build", "CMakeCache.txt"))) {
127     // check if the build directory has been moved
128
129     const buildDir = join(folder.fsPath, "build");
130
131     const cacheBuildDir = cmakeGetPicoVar(
132       join(buildDir, "CMakeCache.txt"),
133       "CMAKE_CACHEFILE_DIR"
134     );
135
136     if (cacheBuildDir !== null) {
137       let p1 = resolve(buildDir);
138       let p2 = resolve(cacheBuildDir);
139       if (process.platform === "win32") {
140         p1 = p1.toLowerCase();
141         p2 = p2.toLowerCase();
142       }
143
144       if (p1 !== p2) {
145         console.warn(
146           `Build directory has been moved from ${p1} to ${p2}` +
147             ` - Deleting CMakeCache.txt and regenerating.`
148         );
149
150         rmSync(join(buildDir, "CMakeCache.txt"));
151       }
152     }
153   }
154
155   try {
156     // check if CMakeLists.txt exists in the root folder
157     await workspace.fs.stat(
158       folder.with({ path: join(folder.fsPath, "CMakeLists.txt") })
159     );
160
161     void window.withProgress(
162       {
163         location: ProgressLocation.Notification,
164         cancellable: true,
165         title: "Configuring CMake...",
166       },
167       async (progress, token) => {
168         const cmake =
169           settings
170             .getString(SettingsKey.cmakePath)
171             ?.replace(HOME_VAR, homedir().replaceAll("\\", "/")) || "cmake";
172
173         // TODO: analyze command result
174         // TODO: option for the user to choose the generator
175         // TODO: maybe delete the build folder before running cmake so
176         // all configuration files in build get updates
177         const customEnv = process.env;
178         /*customEnv["PYTHONHOME"] = pythonPath.includes("/")
179           ? resolve(join(dirname(pythonPath), ".."))
180           : "";*/
181         const isWindows = process.platform === "win32";
182         const customPath = await getPath();
183         if (!customPath) {
184           return false;
185         }
186         customEnv[isWindows ? "Path" : "PATH"] =
187           customPath + customEnv[isWindows ? "Path" : "PATH"];
188         const pythonPath = await getPythonPath();
189
190         const command =
191           `${
192             process.env.ComSpec === "powershell.exe" ? "&" : ""
193           }"${cmake}" ${
194             pythonPath.includes("/")
195               ? `-DPython3_EXECUTABLE="${pythonPath.replaceAll("\\", "/")}" `
196               : ""
197           }` + `-G Ninja -B ./build "${folder.fsPath}"`;
198
199         const child = exec(
200           command,
201           {
202             env: customEnv,
203             cwd: folder.fsPath,
204           },
205           (error, stdout, stderr) => {
206             if (error) {
207               console.error(error);
208               console.log(`stdout: ${stdout}`);
209               console.log(`stderr: ${stderr}`);
210             }
211
212             return;
213           }
214         );
215
216         child.on("error", err => {
217           console.error(err);
218         });
219
220         //child.stdout?.on("data", data => {});
221         child.on("close", () => {
222           progress.report({ increment: 100 });
223         });
224         child.on("exit", code => {
225           if (code !== 0) {
226             console.error(`CMake exited with code ${code ?? "unknown"}`);
227           }
228           progress.report({ increment: 100 });
229         });
230
231         token.onCancellationRequested(() => {
232           child.kill();
233         });
234       }
235     );
236
237     return true;
238   } catch {
239     return false;
240   }
241 }
242
243 /**
244  * Changes the board in the CMakeLists.txt file.
245  *
246  * @param folder The root folder of the workspace to configure.
247  * @param newBoard The new board to use
248  */
249 export async function cmakeUpdateBoard(
250   folder: Uri,
251   newBoard: string
252 ): Promise<boolean> {
253   // TODO: support for scaning for seperate locations of the CMakeLists.txt file in the project
254   const cmakeFilePath = join(folder.fsPath, "CMakeLists.txt");
255   const picoBoardRegex = /^set\(PICO_BOARD\s+([^)]+)\)$/m;
256
257   const settings = Settings.getInstance();
258   if (settings === undefined) {
259     Logger.log("Error: Settings not initialized.");
260
261     return false;
262   }
263
264   try {
265     // check if CMakeLists.txt exists in the root folder
266     await workspace.fs.stat(folder.with({ path: cmakeFilePath }));
267
268     const content = await readFile(cmakeFilePath, "utf8");
269
270     const modifiedContent = content.replace(
271       picoBoardRegex,
272       `set(PICO_BOARD ${newBoard} CACHE STRING "Board type")`
273     );
274
275     await writeFile(cmakeFilePath, modifiedContent, "utf8");
276     Logger.log("Updated board in CMakeLists.txt successfully.");
277
278     // reconfigure so .build gets updated
279     // TODO: To get a behavior similar to the rm -rf Unix command,
280     // use rmSync with options { recursive: true, force: true }
281     // to remove rimraf requirement
282     if (process.platform === "win32") {
283       await rimrafWindows(join(folder.fsPath, "build"), { maxRetries: 2 });
284     } else {
285       await rimraf(join(folder.fsPath, "build"), { maxRetries: 2 });
286     }
287     await configureCmakeNinja(folder);
288     Logger.log("Reconfigured CMake successfully.");
289
290     return true;
291   } catch {
292     Logger.log("Error updating board in CMakeLists.txt!");
293
294     return false;
295   }
296 }
297
298 /**
299  * Updates the sdk and toolchain relay paths in the CMakeLists.txt file.
300  *
301  * @param folder The root folder of the workspace to configure.
302  * @param newSDKVersion The verison in "$HOME/.picosdk/sdk/${newSDKVersion}"
303  * @param newToolchainVersion The verison in "$HOME/.picosdk/toolchain/${newToolchainVersion}"
304  */
305 export async function cmakeUpdateSDK(
306   folder: Uri,
307   newSDKVersion: string,
308   newToolchainVersion: string,
309   newPicotoolVersion: string,
310   reconfigure: boolean = true
311 ): Promise<boolean> {
312   // TODO: support for scaning for seperate locations of the CMakeLists.txt file in the project
313   const cmakeFilePath = join(folder.fsPath, "CMakeLists.txt");
314   // This regex requires multiline (m) and dotall (s) flags to work
315   const updateSectionRegex =
316     new RegExp(`^# ${CMAKE_DO_NOT_EDIT_HEADER_PREFIX}.*# =+$`, "ms");
317   const picoBoardRegex = /^set\(PICO_BOARD\s+([^)]+)\)$/m;
318
319   const settings = Settings.getInstance();
320   if (settings === undefined) {
321     Logger.log("Error: Settings not initialized.");
322
323     return false;
324   }
325
326   try {
327     // check if CMakeLists.txt exists in the root folder
328     await workspace.fs.stat(folder.with({ path: cmakeFilePath }));
329
330     const content = await readFile(cmakeFilePath, "utf8");
331
332     let modifiedContent = content
333       .replace(
334         updateSectionRegex,
335         `# ${CMAKE_DO_NOT_EDIT_HEADER_PREFIX}\n` +
336         "if(WIN32)\n" +
337         "    set(USERHOME $ENV{USERPROFILE})\n" +
338         "else()\n" +
339         "    set(USERHOME $ENV{HOME})\n" +
340         "endif()\n" +
341         `set(sdkVersion ${newSDKVersion})\n` +
342         `set(toolchainVersion ${newToolchainVersion})\n` +
343         `set(picotoolVersion ${newPicotoolVersion})\n` +
344         `set(picoVscode ${buildCMakeIncPath(false)}/pico-vscode.cmake)\n` +
345         "if (EXISTS ${picoVscode})\n" +
346         "    include(${picoVscode})\n" +
347         "endif()\n" +
348         // eslint-disable-next-line max-len
349         "# ===================================================================================="
350       );
351
352     const picoBoard = content.match(picoBoardRegex);
353     // update the PICO_BOARD variable if it's a pico2 board and the new sdk
354     // version is less than 2.0.0
355     if (
356       picoBoard !== null &&
357       picoBoard[1].includes("pico2") &&
358       compareLtMajor(newSDKVersion, "2.0.0")
359     ) {
360       const result = await window.showQuickPick(["pico", "pico_w"], {
361         placeHolder: "The new SDK version does not support your current board",
362         canPickMany: false,
363         ignoreFocusOut: true,
364         title: "Please select a new board type",
365       });
366
367       if (result === undefined) {
368         Logger.log("User canceled board type selection during SDK update.");
369
370         return false;
371       }
372
373       modifiedContent = modifiedContent.replace(
374         picoBoardRegex,
375         `set(PICO_BOARD ${result} CACHE STRING "Board type")`
376       );
377     }
378
379     await writeFile(cmakeFilePath, modifiedContent, "utf8");
380     Logger.log("Updated paths in CMakeLists.txt successfully.");
381
382     if (reconfigure) {
383       // reconfigure so .build gets updated
384       // TODO: To get a behavior similar to the rm -rf Unix command,
385       // use rmSync with options { recursive: true, force: true }
386       // to remove rimraf requirement
387       if (process.platform === "win32") {
388         await rimrafWindows(join(folder.fsPath, "build"), { maxRetries: 2 });
389       } else {
390         await rimraf(join(folder.fsPath, "build"), { maxRetries: 2 });
391       }
392       await configureCmakeNinja(folder);
393       Logger.log("Reconfigured CMake successfully.");
394     }
395
396     return true;
397   } catch {
398     Logger.log("Error updating paths in CMakeLists.txt!");
399
400     return false;
401   }
402 }
403
404 /**
405  * Extracts the sdk and toolchain versions from the CMakeLists.txt file.
406  *
407  * @param cmakeFilePath The path to the CMakeLists.txt file.
408  * @returns An tupple with the [sdk, toolchain, picotool] versions or null if the file could not
409  * be read or the versions could not be extracted.
410  */
411 export async function cmakeGetSelectedToolchainAndSDKVersions(
412   folder: Uri
413 ): Promise<[string, string, string] | null> {
414   const cmakeFilePath = join(folder.fsPath, "CMakeLists.txt");
415   const content = readFileSync(cmakeFilePath, "utf8");
416
417   // 0.15.1 and before
418   const sdkPathRegex = /^set\(PICO_SDK_PATH\s+([^)]+)\)$/m;
419   const toolchainPathRegex = /^set\(PICO_TOOLCHAIN_PATH\s+([^)]+)\)$/m;
420   const oldMatch = content.match(sdkPathRegex);
421   const oldMatch2 = content.match(toolchainPathRegex);
422
423   // Current
424   const sdkVersionRegex = /^set\(sdkVersion\s+([^)]+)\)$/m;
425   const toolchainVersionRegex = /^set\(toolchainVersion\s+([^)]+)\)$/m;
426   const picotoolVersionRegex = /^set\(picotoolVersion\s+([^)]+)\)$/m;
427   const match = content.match(sdkVersionRegex);
428   const match2 = content.match(toolchainVersionRegex);
429   const match3 = content.match(picotoolVersionRegex);
430
431   if (match !== null && match2 !== null && match3 !== null) {
432     return [match[1], match2[1], match3[1]];
433   } else if (oldMatch !== null && oldMatch2 !== null) {
434     const path = oldMatch[1];
435     const path2 = oldMatch2[1];
436     const versionRegex = /^\${USERHOME}\/\.pico-sdk\/sdk\/([^)]+)$/m;
437     const versionRegex2 = /^\${USERHOME}\/\.pico-sdk\/toolchain\/([^)]+)$/m;
438     const versionMatch = path.match(versionRegex);
439     const versionMatch2 = path2.match(versionRegex2);
440
441     if (versionMatch === null || versionMatch2 === null) {
442       return null;
443     }
444
445     Logger.log("Updating extension lines in CMake file");
446     await cmakeUpdateSDK(
447       folder, versionMatch[1], versionMatch2[1], versionMatch[1]
448     );
449     Logger.log("Extension lines updated");
450
451     return [versionMatch[1], versionMatch2[1], versionMatch[1]];
452   } else {
453     return null;
454   }
455 }
456
457 /**
458  * Extracts the board from the CMakeLists.txt file.
459  *
460  * @param cmakeFilePath The path to the CMakeLists.txt file.
461  * @returns An string with the board or null if the file could not
462  * be read or the board could not be extracted.
463  */
464 export function cmakeGetSelectedBoard(folder: Uri): string | null {
465   const cmakeFilePath = join(folder.fsPath, "CMakeLists.txt");
466   const content = readFileSync(cmakeFilePath, "utf8");
467
468   const picoBoardRegex = /^set\(PICO_BOARD\s+([^)]+)\)$/m;
469
470   const match = content.match(picoBoardRegex);
471
472   if (match !== null) {
473     const board = match[1].split("CACHE")[0].trim();
474
475     if (board === null) {
476       return null;
477     }
478
479     return board;
480   } else {
481     return null;
482   }
483 }
484
485 /**
486  * Extracts the picoVar from the CMakeCache.txt file.
487  *
488  * @param cmakeFilePath The path to the CMakeCache.txt file.
489  * @returns The variable or null if the file could not
490  * be read or the variable could not be extracted.
491  */
492 export function cmakeGetPicoVar(
493   cmakeFilePath: string,
494   picoVar: string
495 ): string | null {
496   const content = readFileSync(cmakeFilePath, "utf8");
497   const picoVarRegex = new RegExp(`^${picoVar}:.*=(.*)$`, "m");
498   const match = content.match(picoVarRegex);
499
500   if (match === null) {
501     return null;
502   }
503
504   return match[1];
505 }
This page took 0.053704 seconds and 4 git commands to generate.