Change "Edit Starters" to "Add Starters" (#149)

* only allow to add starters for current project

Signed-off-by: Yan Zhang <yanzh@microsoft.com>

* add starters: use workspaceEdit to update pom file

add starters: workspaceEdit

Signed-off-by: Yan Zhang <yanzh@microsoft.com>

workspaceEdit-based add starters

Signed-off-by: Yan Zhang <yanzh@microsoft.com>

* add license

Signed-off-by: Yan Zhang <yanzh@microsoft.com>

* address comments

Signed-off-by: Yan Zhang <yanzh@microsoft.com>
This commit is contained in:
Yan Zhang 2020-08-17 16:39:44 +08:00 коммит произвёл GitHub
Родитель 5a137437a9
Коммит 43825092a6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 505 добавлений и 14 удалений

10
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",

Просмотреть файл

@ -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"
}
}

11
src/Utils/error.ts Normal file
Просмотреть файл

@ -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);
}
}

169
src/Utils/xml/index.ts Normal file
Просмотреть файл

@ -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 <dependencies> 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 <project> 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<vscode.WorkspaceEdit> {
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:
// <tab><tab>|</dependencies> => |<tab><whitespace></dependencies>
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}`),
`</${parent}>`,
];
}
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>${groupId}</groupId>`,
`<artifactId>${artifactId}</artifactId>`,
version && `<version>${version}</version>`,
scope && scope !== "compile" && `<scope>${scope}</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>${groupId}</groupId>`,
`<artifactId>${artifactId}</artifactId>`,
`<version>${version}</version>`,
`<type>pom</type>`,
`<scope>import</scope>`,
];
return PomNode.wrapWithParentNode(lines, indent, "dependency");
}
}

146
src/Utils/xml/lexer.ts Normal file
Просмотреть файл

@ -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
* <dependency>
* <groupId>test-gid</groupId>
* <artifactId>test-aid</artifactId>
* </dependency>
* ```
* 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;
}

Просмотреть файл

@ -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<void> {
@ -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.");
}

Просмотреть файл

@ -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<void> {
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<IStarters>(
{ 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<string> {
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;
}

Просмотреть файл

@ -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 };

Просмотреть файл

@ -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<IStarters> {
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<void> {
try {
const rawJSONString: string = await downloadFile(serviceUrl, true, METADATA_HEADERS);

Просмотреть файл

@ -11,6 +11,7 @@
"interface-name": false,
"object-literal-sort-keys": false,
"trailing-comma": false,
"max-classes-per-file": false,
"no-console": [
true,
"log"