diff --git a/package-lock.json b/package-lock.json index 118df32..dc93aab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,11 @@ "integrity": "sha512-xRas+PW/fM/MoonB+Pawg48bGTjCqJsFUFwZpH/q2oW80AMHGDAUTUqySBnqeQ18e/SNi7NOCf3ZkYOzKm3Pqw==", "dev": true }, + "@types/xml-zero-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/xml-zero-lexer/-/xml-zero-lexer-2.1.0.tgz", + "integrity": "sha512-UP1ZV82+PbGEvy1+vFZa55Q3FWc7wX64iFsbFlp+D3ULFPw8F8J5WGgRsQ575DXhFBeW9dQkV/KJsLkHp+xqqw==" + }, "@types/xml2js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.4.tgz", @@ -5078,6 +5083,11 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "xml-zero-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/xml-zero-lexer/-/xml-zero-lexer-2.1.0.tgz", + "integrity": "sha512-tr5RJrQOo4LBoSiknbeRJpedAiOKLdsgq1o5pV6aMa3prZbc2Gd2RnrxmcOELP15zkxJCqN2msC+mRYvVvEtkQ==" + }, "xml2js": { "version": "0.4.19", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", diff --git a/package.json b/package.json index bfd701d..542805c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "activationEvents": [ "onCommand:spring.initializr.maven-project", "onCommand:spring.initializr.gradle-project", - "onCommand:spring.initializr.editStarters", + "onCommand:spring.initializr.addStarters", "onCommand:spring.initializr.createProject" ], "main": "./dist/extension", @@ -28,22 +28,22 @@ "commands": [ { "command": "spring.initializr.createProject", - "title": "Create a Spring Boot Project", + "title": "Create a Spring Boot Project...", "category": "Spring Initializr" }, { "command": "spring.initializr.maven-project", - "title": "Generate a Maven Project", + "title": "Create a Maven Project...", "category": "Spring Initializr" }, { "command": "spring.initializr.gradle-project", - "title": "Generate a Gradle Project", + "title": "Create a Gradle Project...", "category": "Spring Initializr" }, { - "command": "spring.initializr.editStarters", - "title": "Edit starters", + "command": "spring.initializr.addStarters", + "title": "Add Starters...", "category": "Spring Initializr" } ], @@ -57,14 +57,14 @@ "editor/context": [ { "when": "resourceFilename == pom.xml", - "command": "spring.initializr.editStarters", + "command": "spring.initializr.addStarters", "group": "SpringInitializr" } ], "explorer/context": [ { "when": "resourceFilename == pom.xml", - "command": "spring.initializr.editStarters", + "command": "spring.initializr.addStarters", "group": "SpringInitializr" } ] @@ -149,6 +149,7 @@ "@types/mocha": "^5.2.7", "@types/node": "^10.14.13", "@types/vscode": "1.30.0", + "@types/xml-zero-lexer": "^2.1.0", "@types/xml2js": "^0.4.4", "glob": "^7.1.4", "mocha": "^7.1.1", @@ -165,6 +166,7 @@ "lodash": "^4.17.19", "md5": "^2.2.1", "vscode-extension-telemetry-wrapper": "^0.8.0", + "xml-zero-lexer": "^2.1.0", "xml2js": "^0.4.19" } } diff --git a/src/Utils/error.ts b/src/Utils/error.ts new file mode 100644 index 0000000..9367173 --- /dev/null +++ b/src/Utils/error.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { setUserError } from "vscode-extension-telemetry-wrapper"; + +export class UserError extends Error { + constructor(msg?: string) { + super(msg); + setUserError(this); + } +} diff --git a/src/Utils/xml/index.ts b/src/Utils/xml/index.ts new file mode 100644 index 0000000..0391996 --- /dev/null +++ b/src/Utils/xml/index.ts @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as vscode from "vscode"; +import { UserError } from "../error"; +import { ElementNode, getNodesByTag, XmlTagName } from "./lexer"; + +export async function updatePom(pomPath: string, deps: IArtifact[], boms: IBom[]) { + const edit = new vscode.WorkspaceEdit(); + + const projectNode: ElementNode = await getActiveProjectNode(); + const dependenciesNode: ElementNode | undefined = projectNode.children && projectNode.children.find(node => node.tag === XmlTagName.Dependencies); + if (dependenciesNode !== undefined) { + await updateWorkspaceEdit(edit, pomPath, dependenciesNode, new DependencyNodes(deps)); + } else { + await updateWorkspaceEdit(edit, pomPath, projectNode, new DependencyNodes(deps, {initParent: true})); + } + + if (boms && boms.length > 0) { + const depMgmtNode: ElementNode | undefined = projectNode.children && projectNode.children.find(node => node.tag === XmlTagName.DependencyManagement); + if (depMgmtNode !== undefined) { + const depsNodes: ElementNode | undefined = depMgmtNode.children && depMgmtNode.children.find(node => node.tag === XmlTagName.Dependencies); + if (depsNodes !== undefined) { + await updateWorkspaceEdit(edit, pomPath, depsNodes, new BOMNodes(boms)); + } else { + await updateWorkspaceEdit(edit, pomPath, depMgmtNode, new BOMNodes(boms, {parents: ["dependencies"]})); + } + } else { + await updateWorkspaceEdit(edit, pomPath, projectNode, new BOMNodes(boms, {parents: ["dependencies", "dependencyManagement"]})); + } + } + + vscode.workspace.applyEdit(edit); +} + +async function getActiveProjectNode() { + if (!vscode.window.activeTextEditor) { + throw new UserError("No POM file is open."); + } + + // Find out node and insert content. + const content = vscode.window.activeTextEditor.document.getText(); + const projectNodes: ElementNode[] = getNodesByTag(content, XmlTagName.Project); + if (projectNodes === undefined || projectNodes.length !== 1) { + throw new UserError("Only support POM file with single node."); + } + + return projectNodes[0]; +} + +function constructNodeText(nodeToInsert: PomNode, baseIndent: string, indent: string, eol: string): string { + const lines: string[] = nodeToInsert.getTextLines(indent); + return ["", ...lines].join(`${eol}${baseIndent}${indent}`) + eol; +} + +async function updateWorkspaceEdit(edit: vscode.WorkspaceEdit, pomPath: string, parentNode: ElementNode, nodeToInsert: PomNode): Promise { + if (parentNode.contentStart === undefined || parentNode.contentEnd === undefined) { + throw new UserError("Invalid target XML node to insert dependency."); + } + const currentDocument: vscode.TextDocument = await vscode.workspace.openTextDocument(pomPath); + const textEditor: vscode.TextEditor = await vscode.window.showTextDocument(currentDocument); + const baseIndent: string = getIndentation(currentDocument, parentNode.contentEnd); + const options: vscode.TextEditorOptions = textEditor.options; + const indent: string = options.insertSpaces ? " ".repeat(options.tabSize as number) : "\t"; + const eol: string = currentDocument.eol === vscode.EndOfLine.LF ? "\n" : "\r\n"; + + let insertPos: vscode.Position = currentDocument.positionAt(parentNode.contentEnd); + // Not to mess up indentation, move cursor to line start: + // | => | + const insPosLineStart: vscode.Position = new vscode.Position(insertPos.line, 0); + const contentBefore: string = currentDocument.getText(new vscode.Range(insPosLineStart, insertPos)); + if (contentBefore.trim() === "") { + insertPos = insPosLineStart; + } + + const targetText: string = constructNodeText(nodeToInsert, baseIndent, indent, eol); + + edit.insert(currentDocument.uri, insertPos, targetText); + return edit; +} + +function getIndentation(document: vscode.TextDocument, offset: number): string { + const closingTagPosition: vscode.Position = document.positionAt(offset); + return document.getText(new vscode.Range( + new vscode.Position(closingTagPosition.line, 0), + closingTagPosition + )); +} + +interface IArtifact { + groupId: string; + artifactId: string; + version?: string; + scope?: string; +} + +interface IBom { + groupId: string; + artifactId: string; + version: string; + scope?: string; + type?: string; +} + +abstract class PomNode { + protected static wrapWithParentNode(lines: string[], indent: string, parent: string) { + return [ + `<${parent}>`, + ...lines.map(line => `${indent}${line}`), + ``, + ]; + } + + public abstract getTextLines(indent: string): string[]; +} + +class DependencyNodes extends PomNode { + constructor(private artifacts: IArtifact[], private options?: { initParent?: boolean }) { + super(); + } + + public getTextLines(indent: string): string[] { + const listOfLines: string[] = [].concat(...this.artifacts.map(artifact => this.toTextLine(artifact, indent))); + if (this.options && this.options.initParent) { + return PomNode.wrapWithParentNode(listOfLines, indent, "dependencies"); + } else { + return listOfLines; + } + } + + private toTextLine(artifact: IArtifact, indent: string): string[] { + const { groupId, artifactId, version, scope } = artifact; + const lines: string[] = [ + `${groupId}`, + `${artifactId}`, + version && `${version}`, + scope && scope !== "compile" && `${scope}`, + ].filter(Boolean); + return PomNode.wrapWithParentNode(lines, indent, "dependency"); + } +} + +class BOMNodes extends PomNode { + constructor(private boms: IBom[], private options?: { parents?: string[] }) { super(); } + + public getTextLines(indent: string): string[] { + const listOfLines: string[][] = this.boms.map(bom => this.bomToTextLine(bom, indent)); + let lines: string[] = [].concat(...listOfLines); + if (this.options && this.options.parents && this.options.parents.length > 0) { + for (const parent of this.options.parents) { + lines = PomNode.wrapWithParentNode(lines, indent, parent); + } + } + return lines; + } + + private bomToTextLine(bom: IBom, indent: string): string[] { + const { groupId, artifactId, version } = bom; + const lines: string[] = [ + `${groupId}`, + `${artifactId}`, + `${version}`, + `pom`, + `import`, + ]; + return PomNode.wrapWithParentNode(lines, indent, "dependency"); + } + +} diff --git a/src/Utils/xml/lexer.ts b/src/Utils/xml/lexer.ts new file mode 100644 index 0000000..f3a2be0 --- /dev/null +++ b/src/Utils/xml/lexer.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import Lexx, { NodeTypes } from "xml-zero-lexer"; + +export enum XmlTagName { + GroupId = "groupId", + ArtifactId = "artifactId", + Version = "version", + Dependencies = "dependencies", + Plugins = "plugins", + Project = "project", + DependencyManagement = "dependencyManagement" +} + +export class ElementNode { + public parent: ElementNode; + public tag: string; + public text?: string; + public contentStart?: number; + public contentEnd?: number; + public children?: ElementNode[]; + + constructor(parent: ElementNode, tag: string) { + this.parent = parent; + this.tag = tag; + this.text = ""; + } + + /** + * Add a child for current node. + * NOTE: an ElementNode can have **either** a value or a list of children. + * E.g. + * ```xml + * + * test-gid + * test-aid + * + * ``` + * For node `dependency`, it has two children. + * For node `groupId`, it has value of `test-gid`. + * + * @param child the child element node. + */ + public addChild(child: ElementNode): void { + if (this.text !== undefined) { + this.text = undefined; // value cannot exist with children. + } + if (this.children === undefined) { + this.children = []; + } + this.children.push(child); + } + +} + +export function getNodesByTag(text: string, tag: string): ElementNode[] { + const tokens: number[][] = Lexx(text); + return getElementHierarchy(text, tokens, tag); +} + +export function getCurrentNode(text: string, offset: number): ElementNode | undefined { + const tokens: number[][] = Lexx(text); + return getElementHierarchy(text, tokens, offset); +} + +function getElementHierarchy(text: string, tokens: number[][], targetTag: string): ElementNode[]; +function getElementHierarchy(text: string, tokens: number[][], cursorOffset: number): ElementNode; +// tslint:disable-next-line:cyclomatic-complexity +function getElementHierarchy(text: string, tokens: number[][], tagOrOffset: number | string): ElementNode | ElementNode[] | undefined { + let targetTag: string | undefined; + let cursorOffset: number | undefined; + if (typeof tagOrOffset === "string") { + targetTag = tagOrOffset; + } else if (typeof tagOrOffset === "number") { + cursorOffset = tagOrOffset; + } + const n: number = tokens.length; + const elementNodes: ElementNode[] = []; + const tagNodes: ElementNode[] = []; + let cursorNode: ElementNode | undefined; + let pointer: number = 0; + let i: number = 0; + + while (i < n) { + const token: number[] = tokens[i]; + const currentNode: ElementNode = elementNodes[elementNodes.length - 1]; + switch (token[0]) { + case NodeTypes.XML_DECLARATION: + case NodeTypes.ELEMENT_NODE: { + // [_type, start, end] = token; + const [start, end] = token.slice(1, 3); + const newElement: ElementNode = new ElementNode(currentNode, text.substring(start, end)); + if (currentNode !== undefined) { + currentNode.addChild(newElement); + } + + pointer = end + 1; // pass ">" mark. + elementNodes.push(newElement); + newElement.contentStart = pointer; + break; + } + case NodeTypes.ATTRIBUTE_NODE: { + // [_type, _keyStart, _keyEnd, _valueStart, valueEnd] = token; + const valueEnd: number = token[4]; + // Attributes not handled yet. + pointer = valueEnd + 1; // pass ">" mark. + currentNode.contentStart = pointer; + break; + } + case NodeTypes.TEXT_NODE: { + // [_type, start, end] = token; + const [start, end] = token.slice(1, 3); + if (currentNode !== undefined) { + currentNode.text = text.substring(start, end); + } + pointer = end; + break; + } + case NodeTypes.CLOSE_ELEMENT: { + currentNode.contentEnd = pointer; + elementNodes.pop(); + break; + } + default: + break; + } + if (targetTag !== undefined && currentNode !== undefined && targetTag === currentNode.tag && tagNodes.indexOf(currentNode) < 0) { + tagNodes.push(currentNode); + } + if (cursorOffset !== undefined + && cursorNode === undefined + && currentNode !== undefined + && currentNode.contentStart !== undefined && currentNode.contentStart <= cursorOffset + && currentNode.contentEnd !== undefined && cursorOffset <= currentNode.contentEnd) { + cursorNode = currentNode; + } + i += 1; + } + if (targetTag !== undefined) { + return tagNodes; + } else if (cursorOffset !== undefined) { + return cursorNode !== undefined ? cursorNode : elementNodes[elementNodes.length - 1]; + } + return undefined; +} diff --git a/src/extension.ts b/src/extension.ts index 7fcecb7..5ceb6b2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,7 +8,7 @@ import { initializeFromJsonFile, instrumentOperation } from "vscode-extension-telemetry-wrapper"; -import { GenerateProjectHandler } from "./handler"; +import { AddStartersHandler, GenerateProjectHandler } from "./handler"; import { getTargetPomXml, loadPackageInfo } from "./Utils"; export async function activate(context: vscode.ExtensionContext): Promise { @@ -38,11 +38,11 @@ async function initializeExtension(_operationId: string, context: vscode.Extensi } })); - context.subscriptions.push(instrumentAndRegisterCommand("spring.initializr.editStarters", async (_oid: string, entry?: vscode.Uri) => { - throw new Error("Not implemented"); + context.subscriptions.push(instrumentAndRegisterCommand("spring.initializr.addStarters", async (_oid: string, entry?: vscode.Uri) => { const targetFile: vscode.Uri = entry || await getTargetPomXml(); if (targetFile) { - // await vscode.window.showTextDocument(targetFile); + await vscode.window.showTextDocument(targetFile); + await new AddStartersHandler().run(_oid, targetFile); } else { vscode.window.showInformationMessage("No pom.xml found in the workspace."); } diff --git a/src/handler/AddStartersHandler.ts b/src/handler/AddStartersHandler.ts new file mode 100644 index 0000000..30a633e --- /dev/null +++ b/src/handler/AddStartersHandler.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as fse from "fs-extra"; +import * as path from "path"; +import * as vscode from "vscode"; +import { setUserError } from "vscode-extension-telemetry-wrapper"; +import { DependencyManager, IDependenciesItem } from "../DependencyManager"; +import { + getBootVersion, + getDependencyNodes, + getParentReletivePath, + IMavenId, + IStarters, + serviceManager, + XmlNode +} from "../model"; +import { readXmlContent } from "../Utils"; +import { updatePom } from "../Utils/xml"; +import { BaseHandler } from "./BaseHandler"; +import { specifyServiceUrl } from "./utils"; + +export class AddStartersHandler extends BaseHandler { + private serviceUrl: string; + + protected get failureMessage(): string { + return "Fail to edit starters."; + } + + public async runSteps(_operationId: string, entry: vscode.Uri): Promise { + const bootVersion: string = await searchForBootVersion(entry.fsPath); + if (!bootVersion) { + const ex = new Error("Not a valid Spring Boot project."); + setUserError(ex); + throw ex; + } + + const deps: string[] = []; // gid:aid + // Read pom.xml for $dependencies(gid, aid) + const content: string = vscode.window.activeTextEditor.document.getText(); + const xml: { project: XmlNode } = await readXmlContent(content); + + getDependencyNodes(xml.project).forEach(elem => { + deps.push(`${elem.groupId[0]}:${elem.artifactId[0]}`); + }); + + this.serviceUrl = await specifyServiceUrl(); + if (this.serviceUrl === undefined) { + return; + } + // [interaction] Step: Dependencies, with pre-selected deps + const starters: IStarters = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window }, + async (p) => { + p.report({ message: `Fetching metadata for version ${bootVersion} ...` }); + return await serviceManager.getStarters(this.serviceUrl, bootVersion); + }, + ); + + const oldStarterIds: string[] = []; + if (!starters.dependencies) { + await vscode.window.showErrorMessage("Unable to retrieve information of available starters."); + return; + } + + Object.keys(starters.dependencies).forEach(key => { + const elem: IMavenId = starters.dependencies[key]; + if (deps.indexOf(`${elem.groupId}:${elem.artifactId}`) >= 0) { + oldStarterIds.push(key); + } + }); + const dependencyManager = new DependencyManager(); + dependencyManager.selectedIds = [].concat(oldStarterIds); + let current: IDependenciesItem = null; + do { + current = await vscode.window.showQuickPick( + dependencyManager.getQuickPickItems(this.serviceUrl, bootVersion), + { + ignoreFocusOut: true, + matchOnDescription: true, + matchOnDetail: true, + placeHolder: "Select dependencies to add.", + }, + ); + if (current && current.itemType === "dependency" && oldStarterIds.indexOf(current.id) === -1) { + dependencyManager.toggleDependency(current.id); + } + } while (current && current.itemType === "dependency"); + if (!current) { return; } + + // Diff deps for adding + const toAdd: string[] = dependencyManager.selectedIds.filter(elem => oldStarterIds.indexOf(elem) < 0); + if (toAdd.length === 0) { + vscode.window.showInformationMessage("No changes."); + return; + } + + const msgAdd: string = (toAdd && toAdd.length) ? `Adding: [${toAdd.map(d => dependencyManager.dict[d] && dependencyManager.dict[d].name).filter(Boolean).join(", ")}].` : ""; + const choice: string = await vscode.window.showWarningMessage(`${msgAdd} Proceed?`, "Proceed", "Cancel"); + if (choice !== "Proceed") { + return; + } + + const artifacts = toAdd.map(id => starters.dependencies[id]); + const bomIds = toAdd.map(id => starters.dependencies[id].bom).filter(Boolean); + const boms = bomIds.map(id => starters.boms[id]); + + updatePom(entry.fsPath, artifacts, boms); + vscode.window.showInformationMessage("Pom file successfully updated."); + return; + } + +} + +async function searchForBootVersion(pomPath: string): Promise { + const content: Buffer = await fse.readFile(pomPath); + const { project: projectNode } = await readXmlContent(content.toString()); + const bootVersion: string = getBootVersion(projectNode); + if (bootVersion) { + return bootVersion; + } + + // search recursively in parent pom + const relativePath = getParentReletivePath(projectNode); + if (relativePath) { + let absolutePath = path.join(path.dirname(pomPath), relativePath); + if ((await fse.stat(absolutePath)).isDirectory()) { + absolutePath = path.join(absolutePath, "pom.xml"); + } + if (await fse.pathExists(absolutePath)) { + return await searchForBootVersion(absolutePath); + } + } + return undefined; +} diff --git a/src/handler/index.ts b/src/handler/index.ts index bf8959b..57e79d2 100644 --- a/src/handler/index.ts +++ b/src/handler/index.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +import { AddStartersHandler } from "./AddStartersHandler"; import { GenerateProjectHandler } from "./GenerateProjectHandler"; -export { GenerateProjectHandler }; +export { GenerateProjectHandler, AddStartersHandler }; diff --git a/src/model/ServiceManager.ts b/src/model/ServiceManager.ts index 85e5bac..d7af0a6 100644 --- a/src/model/ServiceManager.ts +++ b/src/model/ServiceManager.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -import { IDependency } from "."; +import { IDependency, IStarters } from "."; import { downloadFile } from "../Utils"; import { matchRange } from "../Utils/VersionHelper"; import { DependencyGroup, Metadata } from "./Metadata"; @@ -46,6 +46,22 @@ class ServiceManager { return ret; } + /** + * @deprecated `dependencies` endpoint will be removed from metadata v3 + * This function returns information needed for current implementation of "add starters", e.g. gid/aid/repository/bom etc. + * Should be removed in future refactoring. + */ + public async getStarters(serviceUrl: string, bootVersion: string): Promise { + const url = `${serviceUrl}dependencies?bootVersion=${bootVersion}`; + const rawJSONString: string = await downloadFile(url, true, METADATA_HEADERS); + try { + const ret = JSON.parse(rawJSONString); + return ret; + } catch (error) { + throw new Error(`failed to parse response from ${url}`); + } + } + private async fetch(serviceUrl: string): Promise { try { const rawJSONString: string = await downloadFile(serviceUrl, true, METADATA_HEADERS); diff --git a/tslint.json b/tslint.json index 11fa1be..b15d4c6 100644 --- a/tslint.json +++ b/tslint.json @@ -11,6 +11,7 @@ "interface-name": false, "object-literal-sort-keys": false, "trailing-comma": false, + "max-classes-per-file": false, "no-console": [ true, "log"