diff --git a/vscode/package.json b/vscode/package.json index 584183418c..25b1f4222c 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -194,6 +194,10 @@ { "command": "qsharp-vscode.populateFilesList", "when": "resourceFilename == qsharp.json" + }, + { + "command": "qsharp-vscode.addProjectReference", + "when": "resourceFilename == qsharp.json" } ], "view/title": [ @@ -241,6 +245,10 @@ { "command": "qsharp-vscode.populateFilesList", "when": "resourceFilename == qsharp.json" + }, + { + "command": "qsharp-vscode.addProjectReference", + "when": "resourceFilename == qsharp.json" } ] }, @@ -378,6 +386,11 @@ "command": "qsharp-vscode.populateFilesList", "category": "Q#", "title": "Populate qsharp.json files list" + }, + { + "command": "qsharp-vscode.addProjectReference", + "category": "Q#", + "title": "Add Q# project reference" } ], "breakpoints": [ diff --git a/vscode/src/createProject.ts b/vscode/src/createProject.ts index 80054269fb..08edc4729b 100644 --- a/vscode/src/createProject.ts +++ b/vscode/src/createProject.ts @@ -105,6 +105,22 @@ export async function initProjectCreator(context: vscode.ExtensionContext) { const files: string[] = []; const srcDir = vscode.Uri.joinPath(qsharpJsonUri, "..", "src"); + // Verify the src directory exists + try { + const srcDirStat = await vscode.workspace.fs.stat(srcDir); + if (srcDirStat.type !== vscode.FileType.Directory) { + await vscode.window.showErrorMessage( + "The ./src path is not a directory", + ); + return; + } + } catch (err) { + await vscode.window.showErrorMessage( + "The ./src directory does not exist. Create the directory and add .qs files to it", + ); + return; + } + async function getQsFilesInDir(dir: vscode.Uri) { const dirFiles = (await vscode.workspace.fs.readDirectory(dir)).sort( (a, b) => { @@ -149,4 +165,199 @@ export async function initProjectCreator(context: vscode.ExtensionContext) { }, ), ); + + type LocalProjectRef = { + path: string; // Absolute or relative path to the project dir + }; + + type GitHubProjectRef = { + github: { + owner: string; + repo: string; + ref: string; + path?: string; // Optional, defaults to the root of the repo + }; + }; + + type Dependency = LocalProjectRef | GitHubProjectRef; + + // TODO: Replace with a list of legitimate known Q# projects on GitHub + const githubProjects: { [name: string]: GitHubProjectRef } = { + assert: { + github: { + owner: "swernli", + repo: "MyQsLib", + ref: "29dc67f", + }, + }, + // Add a template to the end of the list users can use to easily add their own + "": { + github: { + owner: "", + repo: "", + ref: "", + }, + }, + }; + + // Given two directory paths, return the relative path from the first to the second + function getRelativeDirPath(from: string, to: string): string { + // Ensure we have something + if (!from || !to) throw "Invalid arguments"; + + // Trim trailing slashes (even from the root "/" case) + if (from.endsWith("/")) from = from.slice(0, -1); + if (to.endsWith("/")) to = to.slice(0, -1); + + // Break both paths into their components + const fromParts = from.split("/"); + const toParts = to.split("/"); + + // Remove the common beginning of the paths + while (fromParts[0] === toParts[0]) { + fromParts.shift(); + toParts.shift(); + } + + // Add a .. for each remaining part in the from path + let result = ""; + while (fromParts.length) { + result += "../"; + fromParts.shift(); + } + // Add the remaining path from the to path + result += toParts.join("/"); + if (result.endsWith("/")) { + result = result.slice(0, -1); + } + return result; + } + + context.subscriptions.push( + vscode.commands.registerCommand( + "qsharp-vscode.addProjectReference", + async (qsharpJsonUri: vscode.Uri | undefined) => { + // If called from the content menu qsharpJsonUri will be the full qsharp.json uri + // If called from the command palette is will be undefined, so use the active editor + log.info("addProjectReference called with", qsharpJsonUri); + + qsharpJsonUri = + qsharpJsonUri ?? vscode.window.activeTextEditor?.document.uri; + if (!qsharpJsonUri) { + log.error( + "addProjectReference called, but argument or active editor is not qsharp.json", + ); + return; + } + + log.debug("Adding project reference to ", qsharpJsonUri.path); + + // First, verify the qsharp.json can be opened and is a valid json file + const qsharpJsonDoc = + await vscode.workspace.openTextDocument(qsharpJsonUri); + if (!qsharpJsonDoc) { + log.error("Unable to open the qsharp.json file at ", qsharpJsonDoc); + return; + } + const qsharpJsonDir = vscode.Uri.joinPath(qsharpJsonUri, ".."); + + let manifestObj: any = {}; + try { + manifestObj = JSON.parse(qsharpJsonDoc.getText()); + } catch (err: any) { + await vscode.window.showErrorMessage( + `Unable to parse the contents of ${qsharpJsonUri.path}`, + ); + return; + } + + // Find all the other Q# projects in the workspace + const projectFiles = ( + await vscode.workspace.findFiles("**/qsharp.json") + ).filter((file) => file.toString() !== qsharpJsonUri.toString()); + + const projectChoices: Array<{ name: string; ref: Dependency }> = []; + + projectFiles.forEach((file) => { + const dirName = file.path.slice(0, -"/qsharp.json".length); + const relPath = getRelativeDirPath(qsharpJsonDir.path, dirName); + projectChoices.push({ + name: dirName.slice(dirName.lastIndexOf("/") + 1), + ref: { + path: relPath, + }, + }); + }); + + Object.keys(githubProjects).forEach((name) => { + projectChoices.push({ + name: name, + ref: githubProjects[name], + }); + }); + + // Convert any spaces, dashes, dots, tildes, or quotes in project names + // to underscores. (Leave more 'exotic' non-identifier patterns to the user to fix) + // + // Note: At some point we may want to detect/avoid duplicate names, e.g. if the user already + // references a project via 'foo', and they add a reference to a 'foo' on GitHub or in another dir. + projectChoices.forEach( + (val, idx, arr) => + (arr[idx].name = val.name.replace(/[- "'.~]/g, "_")), + ); + + const folderIcon = new vscode.ThemeIcon("folder"); + const githubIcon = new vscode.ThemeIcon("github"); + + // Ask the user to pick a project to add as a reference + const projectChoice = await vscode.window.showQuickPick( + projectChoices.map((choice) => { + if ("github" in choice.ref) { + return { + label: choice.name, + detail: `github://${choice.ref.github.owner}/${choice.ref.github.repo}#${choice.ref.github.ref}`, + iconPath: githubIcon, + ref: choice.ref, + }; + } else { + return { + label: choice.name, + detail: choice.ref.path, + iconPath: folderIcon, + ref: choice.ref, + }; + } + }), + { placeHolder: "Pick a project to add as a reference" }, + ); + + if (!projectChoice) { + log.info("User cancelled project choice"); + return; + } + + log.info("User picked project: ", projectChoice); + + if (!manifestObj["dependencies"]) manifestObj["dependencies"] = {}; + manifestObj["dependencies"][projectChoice.label] = projectChoice.ref; + + // Apply the edits to the qsharp.json + const edit = new vscode.WorkspaceEdit(); + edit.replace( + qsharpJsonUri, + new vscode.Range(0, 0, qsharpJsonDoc.lineCount, 0), + JSON.stringify(manifestObj, null, 2), + ); + if (!(await vscode.workspace.applyEdit(edit))) { + vscode.window.showErrorMessage( + "Unable to update the qsharp.json file. Check the file is writable", + ); + return; + } + + // Bring the qsharp.json to the front for the user to save + await vscode.window.showTextDocument(qsharpJsonDoc); + }, + ), + ); }