From d6ceeaab13b4fa7910987bf38953b0e80a6b00d5 Mon Sep 17 00:00:00 2001 From: Maruf Rasully <100434800+marufrasully@users.noreply.github.com> Date: Wed, 21 Aug 2024 15:19:58 +0200 Subject: [PATCH] feat: Generate unique ID across view files in an application (#719) * feat: support unique ID generation across a project * fix: validate all opened documents * fix: validate all opened documents on file delete or rename * fix: add and adapt tests * fix: add change set * fix: failing test and clean up * fix: remove dependency * fix: remove nyc. jest has build in coverage * fix: remove nyc cofig.js * refactor: cache control in context and avoid context manipulation * fix: change set * fix: escpe SonarCloud reporting * fix: snoare cloud issues * fix: review comments and small improvment * fix: performance optimization * chore: inclusive language --- .changeset/spicy-trainers-vanish.md | 15 + nyc.config.js | 10 - package.json | 4 +- packages/context/package.json | 4 +- packages/context/src/api.ts | 33 +- packages/context/src/cache.ts | 107 ++++- packages/context/src/types.ts | 14 +- packages/context/src/utils/control-ids.ts | 78 ++++ packages/context/src/utils/ids-collector.ts | 56 +++ packages/context/src/utils/index.ts | 4 + packages/context/src/utils/view-files.ts | 76 ++++ packages/context/src/watcher.ts | 53 +++ packages/context/test/unit/api.test.ts | 13 + packages/context/test/unit/cache.test.ts | 128 ++++++ .../test/unit/utils/control-ids.test.ts | 86 ++++ .../test/unit/utils/view-files.test.ts | 107 +++++ packages/context/test/unit/watcher.test.ts | 93 +++++ packages/language-server/src/hover.ts | 6 +- packages/language-server/src/quick-fix.ts | 32 +- packages/language-server/src/server.ts | 109 +++-- .../src/xml-view-diagnostics.ts | 24 +- .../unit/__snapshots__/quick-fix.test.ts.snap | 24 +- .../test/unit/completion-items-utils.ts | 8 +- .../language-server/test/unit/hover.test.ts | 10 +- .../test/unit/quick-fix.test.ts | 13 +- .../non-unique-id/options.js | 24 ++ .../non-unique-id/output-lsp-response.json | 26 +- .../snapshots-diagnostics.test.ts | 2 +- .../xml-view-diagnostics/snapshots-utils.ts | 35 +- packages/logic-utils/api.d.ts | 8 + packages/logic-utils/package.json | 6 +- packages/logic-utils/src/api.ts | 11 + packages/logic-utils/src/utils/range.ts | 32 ++ .../src/utils/special-namespaces.ts | 4 +- .../src/utils/ui5-classes.ts | 7 +- .../src/utils/xml-node-to-ui5-node.ts | 2 +- packages/logic-utils/test/unit/range.test.ts | 47 +++ .../logic-utils/test/unit/ui5-classes.test.ts | 66 +++ packages/user-facing-text/src/commands.ts | 4 +- .../xml-views-completion/test/unit/utils.ts | 3 + .../src/controller/index.ts | 15 +- .../test/unit/controller/index.test.ts | 3 +- packages/xml-views-quick-fix/api.d.ts | 9 +- packages/xml-views-quick-fix/package.json | 1 + .../src/quick-fix-stable-id.ts | 84 ++-- .../test/unit/quick-fix-stable-id.test.ts | 234 ++++++++--- packages/xml-views-tooltip/test/unit/utils.ts | 3 + packages/xml-views-validation/api.d.ts | 11 +- packages/xml-views-validation/package.json | 2 + packages/xml-views-validation/src/api.ts | 4 +- .../attributes/unknown-attribute-key.ts | 7 +- .../src/validators/document/non-unique-id.ts | 91 ---- .../src/validators/elements/non-stable-id.ts | 13 +- .../validators/elements/unknown-tag-name.ts | 2 +- .../src/validators/index.ts | 5 +- .../src/validators/non-unique-id.ts | 35 ++ .../test/unit/test-utils.ts | 4 + .../validators/document/non-unique-id.test.ts | 283 ------------- .../test/unit/validators/index.test.ts | 4 +- .../unit/validators/non-unique-id.test.ts | 394 ++++++++++++++++++ scripts/merge-coverage.js | 56 --- test-packages/framework/package.json | 4 +- test-packages/framework/src/framework.ts | 9 +- test-packages/framework/src/utils/project.ts | 14 +- test-packages/framework/tsconfig-build.json | 8 - yarn.lock | 214 +--------- 66 files changed, 1960 insertions(+), 933 deletions(-) create mode 100644 .changeset/spicy-trainers-vanish.md delete mode 100644 nyc.config.js create mode 100644 packages/context/src/utils/control-ids.ts create mode 100644 packages/context/src/utils/ids-collector.ts create mode 100644 packages/context/src/utils/view-files.ts create mode 100644 packages/context/test/unit/utils/control-ids.test.ts create mode 100644 packages/context/test/unit/utils/view-files.test.ts create mode 100644 packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/options.js create mode 100644 packages/logic-utils/src/utils/range.ts rename packages/{xml-views-validation => logic-utils}/src/utils/special-namespaces.ts (96%) rename packages/{xml-views-validation => logic-utils}/src/utils/ui5-classes.ts (87%) create mode 100644 packages/logic-utils/test/unit/range.test.ts create mode 100644 packages/logic-utils/test/unit/ui5-classes.test.ts delete mode 100644 packages/xml-views-validation/src/validators/document/non-unique-id.ts create mode 100644 packages/xml-views-validation/src/validators/non-unique-id.ts delete mode 100644 packages/xml-views-validation/test/unit/validators/document/non-unique-id.test.ts create mode 100644 packages/xml-views-validation/test/unit/validators/non-unique-id.test.ts delete mode 100644 scripts/merge-coverage.js delete mode 100644 test-packages/framework/tsconfig-build.json diff --git a/.changeset/spicy-trainers-vanish.md b/.changeset/spicy-trainers-vanish.md new file mode 100644 index 000000000..5372e5c32 --- /dev/null +++ b/.changeset/spicy-trainers-vanish.md @@ -0,0 +1,15 @@ +--- +"@ui5-language-assistant/vscode-ui5-language-assistant-bas-ext": patch +"vscode-ui5-language-assistant": patch +"@ui5-language-assistant/context": patch +"@ui5-language-assistant/language-server": patch +"@ui5-language-assistant/logic-utils",: patch +"@ui5-language-assistant/user-facing-text": patch +"@ui5-language-assistant/xml-views-completion": patch +"@ui5-language-assistant/xml-views-definition": patch +"@ui5-language-assistant/xml-views-quick-fix": patch +"@ui5-language-assistant/xml-views-tooltip": patch +"@ui5-language-assistant/xml-views-validation": patch +--- + +feat: support unique id generation across view files in an application diff --git a/nyc.config.js b/nyc.config.js deleted file mode 100644 index 187bf8c02..000000000 --- a/nyc.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - reporter: ["text", "lcov"], - "check-coverage": true, - all: true, - // https://reflectoring.io/100-percent-test-coverage/ - branches: 95, - lines: 95, - functions: 95, - statements: 95, -}; diff --git a/package.json b/package.json index 08ccd32ca..a417b12fd 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build:quick": "lerna run compile && lerna run bundle && lerna run package", "release:version": "lerna version --force-publish", "release:publish": "lerna publish from-package --yes", - "ci": "npm-run-all format:validate ci:subpackages coverage:merge legal:*", + "ci": "npm-run-all format:validate ci:subpackages legal:*", "compile": "yarn run clean && tsc --build", "compile:watch": "yarn run clean && tsc --build --watch", "format:fix": "prettier --write \"**/*.@(js|ts|json|md)\" --ignore-path=.gitignore", @@ -26,7 +26,6 @@ "ci:subpackages": "lerna run ci", "test": "lerna run test", "coverage": "lerna run coverage", - "coverage:merge": "node ./scripts/merge-coverage", "clean": "lerna run clean", "update-snapshots": "lerna run update-snapshots", "legal:delete": "lerna exec \"shx rm -rf .reuse LICENSES\" || true", @@ -80,7 +79,6 @@ "make-dir": "3.1.0", "mock-fs": "^5.2.0", "npm-run-all": "4.1.5", - "nyc": "15.1.0", "prettier": "2.8.7", "rimraf": "3.0.2", "shx": "0.3.3", diff --git a/packages/context/package.json b/packages/context/package.json index 911266fe2..835610af4 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -29,7 +29,9 @@ "lodash": "4.17.21", "semver": "7.3.7", "vscode-languageserver": "8.0.2", - "vscode-uri": "2.1.2" + "vscode-uri": "2.1.2", + "@xml-tools/ast": "5.0.0", + "@xml-tools/parser": "1.0.7" }, "devDependencies": { "@sap-ux/vocabularies-types": "0.10.14", diff --git a/packages/context/src/api.ts b/packages/context/src/api.ts index 281fd1fff..1e8bc6201 100644 --- a/packages/context/src/api.ts +++ b/packages/context/src/api.ts @@ -8,6 +8,9 @@ import { getServices } from "./services"; import { Context } from "./types"; import { getSemanticModel } from "./ui5-model"; import { getYamlDetails } from "./ui5-yaml"; +import { getViewFiles } from "./utils/view-files"; +import { getControlIds } from "./utils/control-ids"; +import { getLogger } from "./utils"; export { initializeManifestData, @@ -27,6 +30,7 @@ export { reactOnUI5YamlChange, reactOnManifestChange, reactOnXmlFileChange, + reactOnViewFileChange, reactOnPackageJson, } from "./watcher"; @@ -34,10 +38,12 @@ export { * Get context for a file * @param documentPath path to a file e.g. absolute/path/webapp/ext/main/Main.view.xml * @param modelCachePath path to a cached UI5 model + * @param content document content. If provided, it will re-parse and re-assign it to current document of xml views */ export async function getContext( documentPath: string, - modelCachePath?: string + modelCachePath?: string, + content?: string ): Promise { try { const manifestDetails = await getManifestDetails(documentPath); @@ -55,8 +61,31 @@ export async function getContext( ); const services = await getServices(documentPath); const customViewId = await getCustomViewId(documentPath); - return { manifestDetails, yamlDetails, ui5Model, services, customViewId }; + const manifestPath = manifestDetails.manifestPath; + const viewFiles = await getViewFiles({ + manifestPath, + documentPath, + content, + }); + const controlIds = getControlIds({ + manifestPath, + documentPath, + content, + }); + return { + manifestDetails, + yamlDetails, + ui5Model, + services, + customViewId, + viewFiles, + controlIds, + documentPath, + }; } catch (error) { + getLogger().debug("getContext failed:", { + error, + }); return error as Error; } } diff --git a/packages/context/src/cache.ts b/packages/context/src/cache.ts index 3b650b543..bb1bd044c 100644 --- a/packages/context/src/cache.ts +++ b/packages/context/src/cache.ts @@ -1,7 +1,9 @@ import type { Manifest } from "@sap-ux/project-access"; import type { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; - -import type { App, Project, YamlDetails } from "./types"; +import { accept, type XMLDocument } from "@xml-tools/ast"; +import type { App, ControlIdLocation, Project, YamlDetails } from "./types"; +import { createDocumentAst, IdsCollectorVisitor } from "./utils"; +import { FileChangeType } from "vscode-languageserver/node"; type AbsoluteAppRoot = string; type AbsoluteProjectRoot = string; @@ -13,6 +15,11 @@ class Cache { private CAPServices: Map>; private ui5YamlDetails: Map; private ui5Model: Map; + private viewFiles: Map>; + private controlIds: Map< + string, + Record> + >; constructor() { this.project = new Map(); this.manifest = new Map(); @@ -20,6 +27,8 @@ class Cache { this.CAPServices = new Map(); this.ui5YamlDetails = new Map(); this.ui5Model = new Map(); + this.viewFiles = new Map(); + this.controlIds = new Map(); } reset() { this.project = new Map(); @@ -28,6 +37,8 @@ class Cache { this.CAPServices = new Map(); this.ui5YamlDetails = new Map(); this.ui5Model = new Map(); + this.viewFiles = new Map(); + this.controlIds = new Map(); } /** * Get entries of cached project @@ -124,6 +135,98 @@ class Cache { deleteUI5Model(key: string): boolean { return this.ui5Model.delete(key); } + /** + * Get entries of view files + */ + getViewFiles(manifestPath: string): Record { + return this.viewFiles.get(manifestPath) ?? {}; + } + + setViewFiles( + manifestPath: string, + viewFiles: Record + ): void { + this.viewFiles.set(manifestPath, viewFiles); + } + + /** + * Set view file. Use this API to manipulate cache for single view file. This is to avoid cache manipulation outside of cache file. + * + * @param param - The parameter object + * @param param.manifestPath - The path to the manifest.json + * @param param.documentPath - The path to the document + * @param param.operation - The operation to be performed (create or delete) + * @param param.content - The content of the document (optional, only required for 'create' operation) + * @returns - A Promise that resolves to void + */ + async setViewFile(param: { + manifestPath: string; + documentPath: string; + operation: Exclude; + content?: string; + }): Promise { + const { manifestPath, documentPath, operation, content } = param; + if (operation === FileChangeType.Created) { + const viewFiles = this.getViewFiles(manifestPath); + viewFiles[documentPath] = await createDocumentAst(documentPath, content); + // assign new view files to cache + this.setViewFiles(manifestPath, viewFiles); + return; + } + + const viewFiles = this.getViewFiles(manifestPath); + delete viewFiles[documentPath]; + // assign new view files to cache + this.setViewFiles(manifestPath, viewFiles); + } + /** + * Get entries of control ids + */ + getControlIds( + manifestPath: string + ): Record> { + return this.controlIds.get(manifestPath) ?? {}; + } + setControlIds( + manifestPath: string, + controlIds: Record> + ) { + this.controlIds.set(manifestPath, controlIds); + } + + /** + * Set control's id for xml view. Use this API to manipulate cache for controls ids of a single view file. This is to avoid cache manipulation out side of cache file. + * + * @param manifestPath - The path to the manifest.json + * @param documentPath - The path to the document + * @param param.operation - The operation to be performed (create or delete) + */ + setControlIdsForViewFile(param: { + manifestPath: string; + documentPath: string; + operation: Exclude; + }): void { + const { manifestPath, documentPath, operation } = param; + + if (operation === FileChangeType.Created) { + const viewFiles = this.getViewFiles(manifestPath); + // for current document, re-collect and re-assign it to avoid cache issue + if (viewFiles[documentPath]) { + const idCollector = new IdsCollectorVisitor(documentPath); + accept(viewFiles[documentPath], idCollector); + const idControls = this.getControlIds(manifestPath); + idControls[documentPath] = idCollector.getControlIds(); + // assign new control ids to cache + this.setControlIds(manifestPath, idControls); + } + return; + } + + const idControls = this.getControlIds(manifestPath); + delete idControls[documentPath]; + // assign new control ids to cache + this.setControlIds(manifestPath, idControls); + } } /** diff --git a/packages/context/src/types.ts b/packages/context/src/types.ts index 6ebd8e1be..7baaa5125 100644 --- a/packages/context/src/types.ts +++ b/packages/context/src/types.ts @@ -4,7 +4,12 @@ import type { } from "@ui5-language-assistant/semantic-model-types"; import { ConvertedMetadata } from "@sap-ux/vocabularies-types"; import type { Manifest } from "@sap-ux/project-access"; -import { FetchResponse } from "@ui5-language-assistant/logic-utils"; +import { + FetchResponse, + OffsetRange, +} from "@ui5-language-assistant/logic-utils"; +import type { XMLDocument } from "@xml-tools/ast"; +import { Location } from "vscode-languageserver"; export const UI5_VERSION_S4_PLACEHOLDER = "${sap.ui5.dist.version}"; @@ -18,12 +23,19 @@ export enum DirName { Ext = "ext", } +export interface ControlIdLocation extends Location { + offsetRange: OffsetRange; +} + export interface Context { ui5Model: UI5SemanticModel; manifestDetails: ManifestDetails; yamlDetails: YamlDetails; services: Record; customViewId: string; + viewFiles: Record; + controlIds: Map; + documentPath: string; } /** diff --git a/packages/context/src/utils/control-ids.ts b/packages/context/src/utils/control-ids.ts new file mode 100644 index 000000000..62690e6b7 --- /dev/null +++ b/packages/context/src/utils/control-ids.ts @@ -0,0 +1,78 @@ +import { FileChangeType } from "vscode-languageserver/node"; +import { cache } from "../api"; +import { ControlIdLocation } from "../types"; +import { IdsCollectorVisitor } from "./ids-collector"; +import { accept } from "@xml-tools/ast"; + +/** + * Process control ids + * + * @param param parameter object + * @param param.manifestPath path to manifest.json file + * @param param.documentPath path to xml view file + */ +function processControlIds(param: { + manifestPath: string; + documentPath: string; + content?: string; +}): void { + const { documentPath, manifestPath, content } = param; + // check cache + if (Object.keys(cache.getControlIds(manifestPath)).length > 0) { + if (content) { + // for current document, re-collect and re-assign it to avoid cache issue + cache.setControlIdsForViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Created, + }); + } + return; + } + + // build fresh + const ctrIds: Record> = {}; + const viewFiles = cache.getViewFiles(manifestPath); + const files = Object.keys(viewFiles); + for (const docPath of files) { + const idCollector = new IdsCollectorVisitor(docPath); + accept(viewFiles[docPath], idCollector); + ctrIds[docPath] = idCollector.getControlIds(); + } + cache.setControlIds(manifestPath, ctrIds); +} + +/** + * Get control ids of all xml files. + * + * @param param parameter object + * @param param.manifestPath path to manifest.json file + * @param param.documentPath path to xml view file + * @returns merged control ids of all xml files + */ +export function getControlIds(param: { + manifestPath: string; + documentPath: string; + content?: string; +}): Map { + const { manifestPath } = param; + + processControlIds(param); + + const allDocumentsIds = cache.getControlIds(manifestPath); + const keys = Object.keys(allDocumentsIds); + + const mergedIds: Map = new Map(); + for (const doc of keys) { + const ids = allDocumentsIds[doc]; + for (const [id, location] of ids) { + const existing = mergedIds.get(id); + if (existing) { + mergedIds.set(id, [...existing, ...location]); + } else { + mergedIds.set(id, location); + } + } + } + return mergedIds; +} diff --git a/packages/context/src/utils/ids-collector.ts b/packages/context/src/utils/ids-collector.ts new file mode 100644 index 000000000..48b0d18ec --- /dev/null +++ b/packages/context/src/utils/ids-collector.ts @@ -0,0 +1,56 @@ +import { XMLAstVisitor, XMLAttribute } from "@xml-tools/ast"; +import { URI } from "vscode-uri"; +import { + isPossibleCustomClass, + locationToRange, +} from "@ui5-language-assistant/logic-utils"; +import { ControlIdLocation } from "../types"; + +export class IdsCollectorVisitor implements XMLAstVisitor { + private ids: Map; + private uri: string; + constructor(documentPath: string) { + this.uri = URI.file(documentPath).toString(); + this.ids = new Map(); + } + + getControlIds(): Map { + return this.ids; + } + + visitXMLAttribute(attrib: XMLAttribute): void { + if ( + attrib.key === "id" && + attrib.value !== null && + attrib.value !== "" && + attrib.syntax.value !== undefined && + attrib.parent.name !== null && + // @ts-expect-error - we already checked that xmlElement.name is not null + isPossibleCustomClass(attrib.parent) + ) { + const existing = this.ids.get(attrib.value); + const offsetRange = { + start: attrib.syntax.value?.startOffset ?? 0, + end: attrib.syntax.value?.endOffset ?? 0, + }; + if (existing) { + this.ids.set(attrib.value, [ + ...existing, + { + range: locationToRange(attrib.syntax.value), + offsetRange, + uri: this.uri, + }, + ]); + } else { + this.ids.set(attrib.value, [ + { + range: locationToRange(attrib.syntax.value), + offsetRange, + uri: this.uri, + }, + ]); + } + } + } +} diff --git a/packages/context/src/utils/index.ts b/packages/context/src/utils/index.ts index 1c2b1584e..c9516653c 100644 --- a/packages/context/src/utils/index.ts +++ b/packages/context/src/utils/index.ts @@ -7,3 +7,7 @@ export { } from "./project"; export { getLogger } from "./logger"; + +export { getViewFiles, createDocumentAst } from "./view-files"; + +export { IdsCollectorVisitor } from "./ids-collector"; diff --git a/packages/context/src/utils/view-files.ts b/packages/context/src/utils/view-files.ts new file mode 100644 index 000000000..e9062d0aa --- /dev/null +++ b/packages/context/src/utils/view-files.ts @@ -0,0 +1,76 @@ +import { readdir, readFile } from "fs/promises"; +import { statSync } from "fs"; +import { join } from "path"; +import { isXMLView } from "@ui5-language-assistant/logic-utils"; +import { parse, DocumentCstNode } from "@xml-tools/parser"; +import { buildAst, XMLDocument } from "@xml-tools/ast"; +import { cache } from "../cache"; +import { FileChangeType } from "vscode-languageserver"; + +export async function createDocumentAst( + documentPath: string, + content?: string +): Promise { + if (!content) { + content = await readFile(documentPath, "utf-8"); + } + const { cst, tokenVector } = parse(content); + const ast = buildAst(cst as DocumentCstNode, tokenVector); + return ast; +} + +/** + * Get `.view.xml` or `.fragment.xml` files under webapp folder. + * + * @param base base path to a folder or a file + * @param files xml files with its XMLDocument + */ +async function processViewFiles( + base: string, + files: Record +): Promise { + const fileOrFolder = await readdir(base); + for (const item of fileOrFolder) { + const itemPath = join(base, item); + if (statSync(itemPath).isDirectory()) { + await processViewFiles(itemPath, files); + } else if (isXMLView(itemPath)) { + const ast = await createDocumentAst(itemPath); + files[itemPath] = ast; + } + } +} + +/** + * Get `.view.xml` or `.fragment.xml` files under webapp folder. + * + * @param manifestPath - path to manifest.json file + * @param documentPath - path to xml view file + * @param content document content. If provided, it will re-parse and re-assign it to current document of xml views + * @returns xml view files + */ +export async function getViewFiles(param: { + manifestPath: string; + documentPath: string; + content?: string; +}): Promise> { + const { manifestPath, documentPath, content } = param; + if (Object.keys(cache.getViewFiles(manifestPath)).length > 0) { + if (content) { + // rebuild XMLDocument and assign it to viewFiles to avoid any cache issue + await cache.setViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Created, + content, + }); + } + const viewFiles = cache.getViewFiles(manifestPath); + return viewFiles; + } + + const files = {}; + await processViewFiles(join(manifestPath, ".."), files); + cache.setViewFiles(manifestPath, files); + return files; +} diff --git a/packages/context/src/watcher.ts b/packages/context/src/watcher.ts index 0948639d6..69d6b847c 100644 --- a/packages/context/src/watcher.ts +++ b/packages/context/src/watcher.ts @@ -19,6 +19,7 @@ import { getYamlDetails } from "./ui5-yaml"; import { join } from "path"; import { FileName } from "@sap-ux/project-access"; import { CAP_PROJECT_TYPE, UI5_PROJECT_TYPE } from "./types"; +import { isXMLView } from "@ui5-language-assistant/logic-utils"; /** * React on manifest.json file change @@ -224,11 +225,13 @@ export const reactOnXmlFileChange = async ( xmlUri: uri, changeType, }); + const documentPath = URI.parse(uri).fsPath; const manifestPath = await findManifestPath(documentPath); if (!manifestPath) { return; } + const manifest = await getUI5Manifest(manifestPath); if (!manifest) { return; @@ -252,6 +255,56 @@ export const reactOnXmlFileChange = async ( await getProject(documentPath); }; +/** + * React to an `.view.xml` or `.fragment.xml` file change. + * + * @param uri uri to an view file + * @param changeType change type + * @description + * a. for remove operation, it delete view file and controls ids entry + * + * b. for create operation, it add view file and controls ids entry + * + * c. trigger validator at last to revalidate + */ +export async function reactOnViewFileChange( + uri: string, + changeType: FileChangeType, + validator: () => Promise +): Promise { + getLogger().debug("`reactOnViewFileChange` function called", { + xmlUri: uri, + changeType, + }); + + const documentPath = URI.parse(uri).fsPath; + if (!isXMLView(documentPath)) { + return; + } + + const manifestPath = await findManifestPath(documentPath); + if (!manifestPath) { + return; + } + + if ( + changeType === FileChangeType.Created || + changeType === FileChangeType.Deleted + ) { + await cache.setViewFile({ + manifestPath, + documentPath, + operation: changeType, + }); + + cache.setControlIdsForViewFile({ + manifestPath, + documentPath, + operation: changeType, + }); + await validator(); + } +} /** * React on package.json file. In `package.json` user can define `cds`, `sapux` or similar configurations * diff --git a/packages/context/test/unit/api.test.ts b/packages/context/test/unit/api.test.ts index 2f4df2afa..a6f0cc35b 100644 --- a/packages/context/test/unit/api.test.ts +++ b/packages/context/test/unit/api.test.ts @@ -2,6 +2,8 @@ import * as manifest from "../../src/manifest"; import * as ui5Yaml from "../../src/ui5-yaml"; import * as ui5Model from "../../src/ui5-model"; import * as services from "../../src/services"; +import * as viewFiles from "../../src/utils/view-files"; +import * as controlIds from "../../src/utils/control-ids"; import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; import { getContext, isContext } from "../../src/api"; import type { Context } from "../../src/types"; @@ -49,6 +51,12 @@ describe("context", () => { const getServicesStub = jest .spyOn(services, "getServices") .mockResolvedValue({}); + const getViewFilesStub = jest + .spyOn(viewFiles, "getViewFiles") + .mockResolvedValue({}); + const getControlIdsStub = jest + .spyOn(controlIds, "getControlIds") + .mockReturnValue(new Map()); // act const result = await getContext("path/to/xml/file"); // assert @@ -59,12 +67,17 @@ describe("context", () => { expect(getYamlDetailsStub).toHaveBeenCalled(); expect(getSemanticModelStub).toHaveBeenCalled(); expect(getServicesStub).toHaveBeenCalled(); + expect(getViewFilesStub).toHaveBeenCalled(); + expect(getControlIdsStub).toHaveBeenCalled(); expect(result).toContainAllKeys([ "services", "manifestDetails", "yamlDetails", "customViewId", "ui5Model", + "viewFiles", + "controlIds", + "documentPath", ]); }); it("throw connection error", async () => { diff --git a/packages/context/test/unit/cache.test.ts b/packages/context/test/unit/cache.test.ts index 1d54fdd03..8cb0c876c 100644 --- a/packages/context/test/unit/cache.test.ts +++ b/packages/context/test/unit/cache.test.ts @@ -1,8 +1,47 @@ import { App, Project } from "../../src/types"; import { cache } from "../../src/cache"; import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; +import type { XMLDocument } from "@xml-tools/ast"; +import { + Config, + ProjectName, + ProjectType, + TestFramework, +} from "@ui5-language-assistant/test-framework"; +import { join } from "path"; +import { FileChangeType } from "vscode-languageserver/node"; + +const getManifestPath = (projectRoot: string) => + join(projectRoot, "app", "manage_travels", "webapp", "manifest.json"); +const getDocumentPath = (projectRoot: string) => + join( + projectRoot, + "app", + "manage_travels", + "webapp", + "ext", + "main", + "Main.view.xml" + ); describe("cache", () => { + let testFramework: TestFramework; + beforeAll(function () { + const useConfig: Config = { + projectInfo: { + name: ProjectName.cap, + type: ProjectType.CAP, + npmInstall: true, + }, + }; + testFramework = new TestFramework(useConfig); + }, 5 * 60000 + 10000); // 5 min for initial npm install + 10 sec + + afterEach(() => { + cache.reset(); + jest.restoreAllMocks(); + }); + it("show singleton instance", async () => { cache.setApp("key01", {} as unknown as App); // importing again - no new instance is generated @@ -31,4 +70,93 @@ describe("cache", () => { expect(cache.getUI5ModelEntries()).toBeEmpty(); }); }); + + it("view files", () => { + cache.setViewFiles("manifest.json", { file: {} as XMLDocument }); + expect(cache.getViewFiles("manifest.json")).toEqual({ file: {} }); + }); + it("setViewFile - create", async () => { + // arrange + const projectRoot = testFramework.getProjectRoot(); + const manifestPath = getManifestPath(projectRoot); + const documentPath = getDocumentPath(projectRoot); + // act + await cache.setViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Created, + }); + // assert + const cachedData = cache.getViewFiles(manifestPath)[documentPath]; + expect(cachedData).toBeDefined(); + }); + it("setViewFile - delete", async () => { + // arrange + const projectRoot = testFramework.getProjectRoot(); + const manifestPath = getManifestPath(projectRoot); + const documentPath = getDocumentPath(projectRoot); + // set view file + const data = {}; + data[documentPath] = {}; + cache.setViewFiles(manifestPath, data); + // act + await cache.setViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Deleted, + }); + // assert + const cachedData = cache.getViewFiles(manifestPath)[documentPath]; + expect(cachedData).toBeUndefined(); + }); + it("control ids", () => { + const data = {}; + data["manifest.json"] = new Map(); + cache.setControlIds("manifest.json", data); + expect(cache.getControlIds("manifest.json")).toEqual(data); + }); + it("setControlIdsForViewFile - create", async () => { + // arrange + const projectRoot = testFramework.getProjectRoot(); + const manifestPath = getManifestPath(projectRoot); + const documentPath = getDocumentPath(projectRoot); + // create view file entry + await cache.setViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Created, + }); + // create controls id + const data = {}; + data[documentPath] = new Map(); + cache.setControlIds(manifestPath, data); + // act + cache.setControlIdsForViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Created, + }); + // assert + const cachedData = cache.getControlIds(manifestPath)[documentPath]; + expect(cachedData.get("Main")).toBeDefined(); + }); + it("setControlIdsForViewFile - delete", () => { + // arrange + const projectRoot = testFramework.getProjectRoot(); + const manifestPath = getManifestPath(projectRoot); + const documentPath = getDocumentPath(projectRoot); + // create controls id + const data = {}; + data[documentPath] = new Map(); + cache.setControlIds(manifestPath, data); + // act + cache.setControlIdsForViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Deleted, + }); + // assert + const cachedData = cache.getControlIds(manifestPath)[documentPath]; + expect(cachedData).toBeUndefined(); + }); }); diff --git a/packages/context/test/unit/utils/control-ids.test.ts b/packages/context/test/unit/utils/control-ids.test.ts new file mode 100644 index 000000000..3b1e69ecb --- /dev/null +++ b/packages/context/test/unit/utils/control-ids.test.ts @@ -0,0 +1,86 @@ +import { + Config, + ProjectName, + ProjectType, + TestFramework, +} from "@ui5-language-assistant/test-framework"; +import { join } from "path"; +import { getControlIds } from "../../../src/utils/control-ids"; +import { getViewFiles } from "../../../src/utils"; +import { cache } from "../../../src/cache"; +import { FileChangeType } from "vscode-languageserver/node"; +const getManifestPath = (projectRoot: string) => + join(projectRoot, "app", "manage_travels", "webapp", "manifest.json"); +const mainViewSeg = [ + "app", + "manage_travels", + "webapp", + "ext", + "main", + "Main.view.xml", +]; +const getDocumentPath = (projectRoot: string) => + join(projectRoot, ...mainViewSeg); + +describe("control-ids", () => { + let testFramework: TestFramework; + beforeAll(function () { + const useConfig: Config = { + projectInfo: { + name: ProjectName.cap, + type: ProjectType.CAP, + npmInstall: false, + }, + }; + testFramework = new TestFramework(useConfig); + }, 5 * 60000); + + beforeEach(() => { + cache.reset(); + jest.resetAllMocks(); + }); + + it("getControlIds", async () => { + // arrange + const projectRoot = testFramework.getProjectRoot(); + const manifestPath = getManifestPath(projectRoot); + const documentPath = getDocumentPath(projectRoot); + // get view files to fill viewFiles cache + await getViewFiles({ manifestPath, documentPath }); + // act + const result = getControlIds({ manifestPath, documentPath }); + // assert + expect(result).toBeDefined(); + expect(result.get("Main")).toBeDefined(); + // check cache. rebuild for current document + const content = ` + + + + + + + + `; + // update view file with new content + await cache.setViewFile({ + manifestPath, + documentPath, + operation: FileChangeType.Created, + content, + }); + await testFramework.updateFileContent(mainViewSeg, content); + // act + const resultCached = getControlIds({ manifestPath, documentPath, content }); + // assert + expect(resultCached).toBeDefined(); + expect(resultCached.get("Main")).toBeUndefined(); + expect(resultCached.get("myNewTestId")).toBeDefined(); + }); +}); diff --git a/packages/context/test/unit/utils/view-files.test.ts b/packages/context/test/unit/utils/view-files.test.ts new file mode 100644 index 000000000..6e7903751 --- /dev/null +++ b/packages/context/test/unit/utils/view-files.test.ts @@ -0,0 +1,107 @@ +import { + Config, + ProjectName, + ProjectType, + TestFramework, +} from "@ui5-language-assistant/test-framework"; +import { join } from "path"; +import { getViewFiles } from "../../../src/utils"; +import { cache } from "../../../src/cache"; +import type { XMLDocument } from "@xml-tools/ast"; +import { FileChangeType } from "vscode-languageserver/node"; + +const getManifestPath = (projectRoot: string) => + join(projectRoot, "app", "manage_travels", "webapp", "manifest.json"); +const getDocumentPath = (projectRoot: string) => + join( + projectRoot, + "app", + "manage_travels", + "webapp", + "ext", + "main", + "Main.view.xml" + ); + +describe("view-files", () => { + let testFramework: TestFramework; + beforeAll(function () { + const useConfig: Config = { + projectInfo: { + name: ProjectName.cap, + type: ProjectType.CAP, + npmInstall: false, + }, + }; + testFramework = new TestFramework(useConfig); + }, 5 * 60000); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("get cached view files - with no content", async () => { + // arrange + const getViewFilesStub = jest + .spyOn(cache, "getViewFiles") + .mockReturnValue({ documentPath: {} as XMLDocument }); + + const projectRoot = testFramework.getProjectRoot(); + const documentPath = getDocumentPath(projectRoot); + const manifestPath = getManifestPath(projectRoot); + // act + const viewFiles = await getViewFiles({ manifestPath, documentPath }); + // assert + expect(getViewFilesStub).toHaveBeenCalledTimes(2); + expect(Object.keys(viewFiles).length).toBeGreaterThan(0); + }); + + it("get cached view files - with content", async () => { + // arrange + const getViewFilesStub = jest + .spyOn(cache, "getViewFiles") + .mockReturnValue({ documentPath: {} as XMLDocument }); + const setViewFileStub = jest + .spyOn(cache, "setViewFile") + .mockResolvedValue(); + const content = ``; + const projectRoot = testFramework.getProjectRoot(); + const documentPath = getDocumentPath(projectRoot); + const manifestPath = getManifestPath(projectRoot); + // act + const viewFiles = await getViewFiles({ + manifestPath, + documentPath, + content, + }); + // assert + expect(getViewFilesStub).toHaveBeenCalledTimes(2); + expect(setViewFileStub).toHaveBeenNthCalledWith(1, { + manifestPath, + documentPath, + operation: FileChangeType.Created, + content, + }); + expect(Object.keys(viewFiles).length).toBeGreaterThan(0); + }); + + it("get view files", async () => { + // arrange + cache.reset(); + const getViewFilesStub = jest + .spyOn(cache, "getViewFiles") + .mockReturnValue({}); + const projectRoot = testFramework.getProjectRoot(); + const documentPath = getDocumentPath(projectRoot); + const manifestPath = getManifestPath(projectRoot); + // act + const viewFiles = await getViewFiles({ + manifestPath, + documentPath, + }); + // assert + expect(getViewFilesStub).toHaveBeenCalledTimes(1); + expect(Object.keys(viewFiles).length).toBeGreaterThan(1); + expect(viewFiles[documentPath]).toBeDefined(); + }); +}); diff --git a/packages/context/test/unit/watcher.test.ts b/packages/context/test/unit/watcher.test.ts index f598e027a..7fe6db556 100644 --- a/packages/context/test/unit/watcher.test.ts +++ b/packages/context/test/unit/watcher.test.ts @@ -12,6 +12,7 @@ import { reactOnManifestChange, reactOnPackageJson, reactOnUI5YamlChange, + reactOnViewFileChange, reactOnXmlFileChange, } from "../../src/watcher"; import { CAPProject, Project, YamlDetails } from "../../src/types"; @@ -23,6 +24,8 @@ import { DEFAULT_UI5_FRAMEWORK, OPEN_FRAMEWORK, } from "@ui5-language-assistant/constant"; +import { FileChangeType } from "vscode-languageserver"; +import { join } from "path"; describe("watcher", () => { let testFramework: TestFramework; @@ -537,6 +540,96 @@ describe("watcher", () => { }); }); + describe("reactOnViewFileChange", () => { + const setViewFileSpy = jest.spyOn(cache, "setViewFile"); + const setControlIdsForViewFileSpy = jest.spyOn( + cache, + "setControlIdsForViewFile" + ); + const validatorSpy = jest.fn(); + const getManifestPath = (projectRoot: string) => + join(projectRoot, "app", "manage_travels", "webapp", "manifest.json"); + + let fileUri, documentPath; + + beforeAll(() => { + fileUri = testFramework.getFileUri([ + "app", + "manage_travels", + "webapp", + "ext", + "main", + "Main.view.xml", + ]); + documentPath = URI.parse(fileUri).fsPath; + }); + + beforeEach(() => { + // reset cache for consistency + cache.reset(); + jest.resetAllMocks(); + }); + + it("test unregistered .view.xml file", async () => { + // arrange + const manifestPath = getManifestPath(testFramework.getProjectRoot()); + const findManifestSpy = jest + .spyOn(manifest, "findManifestPath") + .mockResolvedValue(manifestPath); + setViewFileSpy.mockResolvedValue(); + setControlIdsForViewFileSpy.mockReturnValue(); + // act + await reactOnViewFileChange( + fileUri, + FileChangeType.Deleted, + validatorSpy + ); + + // assert + expect(findManifestSpy).toHaveBeenNthCalledWith(1, documentPath); + expect(setViewFileSpy).toHaveBeenNthCalledWith(1, { + documentPath, + manifestPath, + operation: FileChangeType.Deleted, + }); + expect(setControlIdsForViewFileSpy).toHaveBeenNthCalledWith(1, { + documentPath, + manifestPath, + operation: FileChangeType.Deleted, + }); + expect(validatorSpy).toHaveBeenCalledOnce(); + }); + + it("test registered xml file", async () => { + // arrange + const manifestPath = getManifestPath(testFramework.getProjectRoot()); + const findManifestSpy = jest + .spyOn(manifest, "findManifestPath") + .mockResolvedValue(manifestPath); + setViewFileSpy.mockResolvedValue(); + setControlIdsForViewFileSpy.mockReturnValue(); + // act + await reactOnViewFileChange( + fileUri, + FileChangeType.Created, + validatorSpy + ); + + // assert + expect(findManifestSpy).toHaveBeenNthCalledWith(1, documentPath); + expect(setViewFileSpy).toHaveBeenNthCalledWith(1, { + documentPath, + manifestPath, + operation: FileChangeType.Created, + }); + expect(setControlIdsForViewFileSpy).toHaveBeenNthCalledWith(1, { + documentPath, + manifestPath, + operation: FileChangeType.Created, + }); + expect(validatorSpy).toHaveBeenCalledOnce(); + }); + }); describe("reactOnPackageJson", () => { const deleteAppSpy = jest.spyOn(cache, "deleteApp"); const deleteProjectSpy = jest.spyOn(cache, "deleteProject"); diff --git a/packages/language-server/src/hover.ts b/packages/language-server/src/hover.ts index 9ea16e60a..472c070ef 100644 --- a/packages/language-server/src/hover.ts +++ b/packages/language-server/src/hover.ts @@ -5,8 +5,6 @@ import { MarkupContent, MarkupKind, } from "vscode-languageserver"; -import { parse, DocumentCstNode } from "@xml-tools/parser"; -import { buildAst } from "@xml-tools/ast"; import { astPositionAtOffset } from "@xml-tools/ast-position"; import { UI5SemanticModel, @@ -23,9 +21,7 @@ export function getHoverResponse( textDocumentPosition: TextDocumentPositionParams, document: TextDocument ): Hover | undefined { - const documentText = document.getText(); - const { cst, tokenVector } = parse(documentText); - const ast = buildAst(cst as DocumentCstNode, tokenVector); + const ast = context.viewFiles[context.documentPath]; const offset = document.offsetAt(textDocumentPosition.position); const astPosition = astPositionAtOffset(ast, offset); if (astPosition !== undefined) { diff --git a/packages/language-server/src/quick-fix.ts b/packages/language-server/src/quick-fix.ts index f83363836..737d0db34 100644 --- a/packages/language-server/src/quick-fix.ts +++ b/packages/language-server/src/quick-fix.ts @@ -10,8 +10,6 @@ import { TextDocumentEdit, TextEdit, } from "vscode-languageserver-types"; -import { parse, DocumentCstNode } from "@xml-tools/parser"; -import { buildAst, XMLDocument } from "@xml-tools/ast"; import { validateXMLView, validators, @@ -34,17 +32,12 @@ export function diagnosticToCodeActionFix( diagnostics: Diagnostic[], context: Context ): CodeAction[] { - const documentText = document.getText(); - // We prefer to parse the document again to avoid cache state handling - const { cst, tokenVector } = parse(documentText); - const xmlDocAst = buildAst(cst as DocumentCstNode, tokenVector); const codeActions = flatMap(diagnostics, (diagnostic) => { switch (diagnostic.code) { case validations.NON_STABLE_ID.code: { // non stable id return computeCodeActionsForQuickFixStableId({ document, - xmlDocument: xmlDocAst, nonStableIdDiagnostic: diagnostic, context, }); @@ -59,7 +52,6 @@ export function diagnosticToCodeActionFix( function computeCodeActionsForQuickFixStableId(opts: { document: TextDocument; - xmlDocument: XMLDocument; nonStableIdDiagnostic: Diagnostic; context: Context; }): CodeAction[] { @@ -68,10 +60,15 @@ function computeCodeActionsForQuickFixStableId(opts: { opts.nonStableIdDiagnostic.range, opts.document ); - - const quickFixStableIdInfo = computeQuickFixStableIdInfo(opts.xmlDocument, [ - errorOffset, - ]); + const existingIds: Record = {}; + for (const [key, value] of opts.context.controlIds) { + existingIds[key] = value.length; + } + const quickFixStableIdInfo = computeQuickFixStableIdInfo( + opts.context, + [errorOffset], + existingIds + ); const replaceRange = offsetRangeToLSPRange( quickFixStableIdInfo[0].replaceRange, @@ -96,8 +93,8 @@ function computeCodeActionsForQuickFixStableId(opts: { const quickFixFileStableIdCodeActions = computeCodeActionsForQuickFixFileStableId({ document: opts.document, - xmlDocument: opts.xmlDocument, context: opts.context, + existingIds, }); codeActions = codeActions.concat(quickFixFileStableIdCodeActions); @@ -107,8 +104,8 @@ function computeCodeActionsForQuickFixStableId(opts: { function computeCodeActionsForQuickFixFileStableId(opts: { document: TextDocument; - xmlDocument: XMLDocument; context: Context; + existingIds: Record; }): CodeAction[] { const actualValidators = { document: [], @@ -120,7 +117,7 @@ function computeCodeActionsForQuickFixFileStableId(opts: { const nonStableIdFileIssues = validateXMLView({ validators: actualValidators, context: opts.context, - xmlView: opts.xmlDocument, + xmlView: opts.context.viewFiles[opts.context.documentPath], }); // We don't suggest quick fix stable stable id for entire file when there is only one non-stable id issue @@ -130,8 +127,9 @@ function computeCodeActionsForQuickFixFileStableId(opts: { const errorsOffset = map(nonStableIdFileIssues, (_) => _.offsetRange); const nonStableIdFileIssuesInfo = computeQuickFixStableIdInfo( - opts.xmlDocument, - errorsOffset + opts.context, + errorsOffset, + opts.existingIds ); const nonStableIdFileIssuesLSPInfo: QuickFixStableIdLSPInfo[] = map( nonStableIdFileIssuesInfo, diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 80b01e9d7..59f278749 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -11,6 +11,7 @@ import { DidChangeConfigurationNotification, FileEvent, InitializeResult, + Diagnostic, } from "vscode-languageserver/node"; import { URI } from "vscode-uri"; import { TextDocument } from "vscode-languageserver-textdocument"; @@ -27,7 +28,10 @@ import { import { commands } from "@ui5-language-assistant/user-facing-text"; import { ServerInitializationOptions } from "../api"; import { getCompletionItems } from "./completion-items"; -import { getXMLViewDiagnostics } from "./xml-view-diagnostics"; +import { + getXMLViewDiagnostics, + getXMLViewIdDiagnostics, +} from "./xml-view-diagnostics"; import { getHoverResponse } from "./hover"; import { initializeManifestData, @@ -37,6 +41,7 @@ import { reactOnManifestChange, reactOnCdsFileChange, reactOnXmlFileChange, + reactOnViewFileChange, reactOnPackageJson, isContext, } from "@ui5-language-assistant/context"; @@ -55,6 +60,8 @@ let ui5yamlStateInitialized: Promise | undefined = undefined; let initializationOptions: ServerInitializationOptions | undefined; let hasConfigurationCapability = false; +const documentsDiagnostics = new Map(); + connection.onInitialize( async (params: InitializeParams): Promise => { getLogger().info("`onInitialize` event", params); @@ -225,24 +232,12 @@ connection.onHover( } ); -const validateOpenDocuments = async (changes: FileEvent[]): Promise => { - const supportedDocs = [ - "manifest.json", - "ui5.yaml", - ".cds", - ".xml", - "package.json", - ]; - - const found = changes.find( - (change) => - !isXMLView(change.uri) && - supportedDocs.find((doc) => change.uri.endsWith(doc)) - ); - if (!found) { - return; - } - +/** + * Validate all open `.view.xml` and `.fragment.xml` documents + * + * @returns void + */ +const validateOpenDocuments = async (): Promise => { const allDocuments = documents.all(); for (const document of allDocuments) { const documentPath = URI.parse(document.uri).fsPath; @@ -261,6 +256,40 @@ const validateOpenDocuments = async (changes: FileEvent[]): Promise => { document, context, }); + documentsDiagnostics.set(document.uri, diagnostics); + getLogger().trace("computed diagnostics", { + diagnostics, + }); + connection.sendDiagnostics({ uri: document.uri, diagnostics }); + } +}; +/** + * Validate ids for all open `.view.xml` and `.fragment.xml` documents + * + * @returns void + */ +const validateIdsOfOpenDocuments = async (): Promise => { + const allDocuments = documents.all(); + for (const document of allDocuments) { + const documentPath = URI.parse(document.uri).fsPath; + const context = await getContext( + documentPath, + initializationOptions?.modelCachePath + ); + if (!isContext(context)) { + connection.sendNotification( + "UI5LanguageAssistant/context-error", + context + ); + return; + } + const idDiagnostics = getXMLViewIdDiagnostics({ + document, + context, + }); + let diagnostics = documentsDiagnostics.get(document.uri) ?? []; + diagnostics = diagnostics.concat(idDiagnostics); + getLogger().trace("computed diagnostics", { diagnostics, }); @@ -268,6 +297,29 @@ const validateOpenDocuments = async (changes: FileEvent[]): Promise => { } }; +async function validateOpenDocumentsOnDidChangeWatchedFiles( + changes: FileEvent[] +): Promise { + const supportedDocs = [ + "manifest.json", + "ui5.yaml", + ".cds", + ".xml", + "package.json", + ]; + + const found = changes.find( + (change) => + !isXMLView(change.uri) && + supportedDocs.find((doc) => change.uri.endsWith(doc)) + ); + if (!found) { + return; + } + await validateOpenDocuments(); + await validateIdsOfOpenDocuments(); +} + connection.onDidChangeWatchedFiles(async (changeEvent): Promise => { getLogger().debug("`onDidChangeWatchedFiles` event", { changeEvent, @@ -283,12 +335,13 @@ connection.onDidChangeWatchedFiles(async (changeEvent): Promise => { cdsFileEvents.push(change); } else if (uri.endsWith(".xml")) { await reactOnXmlFileChange(uri, change.type); + await reactOnViewFileChange(uri, change.type, validateIdsOfOpenDocuments); } else if (uri.endsWith("package.json")) { await reactOnPackageJson(uri, change.type); } }); await reactOnCdsFileChange(cdsFileEvents); - await validateOpenDocuments(changeEvent.changes); + await validateOpenDocumentsOnDidChangeWatchedFiles(changeEvent.changes); }); documents.onDidChangeContent(async (changeEvent): Promise => { @@ -310,7 +363,8 @@ documents.onDidChangeContent(async (changeEvent): Promise => { const documentPath = URI.parse(documentUri).fsPath; const context = await getContext( documentPath, - initializationOptions?.modelCachePath + initializationOptions?.modelCachePath, + document.getText() ); if (!isContext(context)) { connection.sendNotification( @@ -319,6 +373,7 @@ documents.onDidChangeContent(async (changeEvent): Promise => { ); return; } + const version = context.ui5Model.version; const framework = context.yamlDetails.framework; const isFallback = context.ui5Model.isFallback; @@ -335,11 +390,8 @@ documents.onDidChangeContent(async (changeEvent): Promise => { document, context, }); - getLogger().trace("computed diagnostics", { diagnostics }); - connection.sendDiagnostics({ - uri: changeEvent.document.uri, - diagnostics, - }); + documentsDiagnostics.set(document.uri, diagnostics); + await validateIdsOfOpenDocuments(); } }); @@ -355,12 +407,14 @@ connection.onCodeAction(async (params) => { const documentPath = URI.parse(docUri).fsPath; const context = await getContext( documentPath, - initializationOptions?.modelCachePath + initializationOptions?.modelCachePath, + textDocument.getText() ); if (!isContext(context)) { connection.sendNotification("UI5LanguageAssistant/context-error", context); return; } + const version = context.ui5Model.version; const framework = context.yamlDetails.framework; const isFallback = context.ui5Model.isFallback; @@ -449,6 +503,7 @@ documents.onDidClose((textDocumentChangeEvent) => { if (isXMLView(uri)) { // clear diagnostics for a closed file connection.sendDiagnostics({ uri, diagnostics: [] }); + documentsDiagnostics.delete(uri); } clearDocumentSettings(uri); }); diff --git a/packages/language-server/src/xml-view-diagnostics.ts b/packages/language-server/src/xml-view-diagnostics.ts index 12eb0386b..992075bdb 100644 --- a/packages/language-server/src/xml-view-diagnostics.ts +++ b/packages/language-server/src/xml-view-diagnostics.ts @@ -6,8 +6,6 @@ import { DiagnosticTag, } from "vscode-languageserver-types"; import { TextDocument } from "vscode-languageserver-textdocument"; -import { DocumentCstNode, parse } from "@xml-tools/parser"; -import { buildAst } from "@xml-tools/ast"; import { validations, DIAGNOSTIC_SOURCE, @@ -20,6 +18,7 @@ import { defaultValidators, validators, UI5ValidatorsConfig, + validateNonUniqueID, } from "@ui5-language-assistant/xml-views-validation"; import { defaultValidators as feValidators } from "@ui5-language-assistant/fe"; @@ -38,9 +37,7 @@ export function getXMLViewDiagnostics(opts: { document: TextDocument; context: Context; }): Diagnostic[] { - const documentText = opts.document.getText(); - const { cst, tokenVector } = parse(documentText); - const xmlDocAst = buildAst(cst as DocumentCstNode, tokenVector); + const xmlDocAst = opts.context.viewFiles[opts.context.documentPath]; const actualValidators = cloneDeep(defaultValidators); if (opts.context.manifestDetails.flexEnabled) { actualValidators.element.push(validators.validateNonStableId); @@ -62,6 +59,16 @@ export function getXMLViewDiagnostics(opts: { return diagnostics; } +export function getXMLViewIdDiagnostics(opts: { + document: TextDocument; + context: Context; +}): Diagnostic[] { + const { context } = opts; + const issues = validateNonUniqueID(context); + const diagnostics = validationIssuesToLspDiagnostics(issues, opts.document); + return diagnostics; +} + function mergeValidators( param: [ UI5ValidatorsConfig, @@ -86,7 +93,6 @@ function mergeValidators( } function baseDiagnostic( - document: TextDocument, currIssue: UI5XMLViewIssue, commonDiagnosticPros: Diagnostic ): Diagnostic { @@ -124,8 +130,8 @@ function baseDiagnostic( (_) => ({ message: validations.NON_UNIQUE_ID_RELATED_INFO.msg, location: { - uri: document.uri, - range: offsetRangeToLSPRange(_, document), + uri: _.uri, + range: _.range, }, }) ), @@ -151,7 +157,7 @@ function validationIssuesToLspDiagnostics( message: currIssue.message, }; if (currIssue.issueType === "base") { - return baseDiagnostic(document, currIssue, commonDiagnosticPros); + return baseDiagnostic(currIssue, commonDiagnosticPros); } if (currIssue.issueType === "annotation-issue") { return { diff --git a/packages/language-server/test/unit/__snapshots__/quick-fix.test.ts.snap b/packages/language-server/test/unit/__snapshots__/quick-fix.test.ts.snap index 9f05e3274..38008c6ce 100644 --- a/packages/language-server/test/unit/__snapshots__/quick-fix.test.ts.snap +++ b/packages/language-server/test/unit/__snapshots__/quick-fix.test.ts.snap @@ -17,13 +17,13 @@ Array [ "line": 5, }, }, - " id=\\"_IDGenButton1\\"", + " id=\\"_IDGenButton\\"", ], "command": "ui5_lang.quick_fix_stable_id", - "title": "Generate an ID", + "title": "Generate a unique ID", }, "kind": "quickfix", - "title": "Generate an ID", + "title": "Generate a unique ID", }, Object { "command": Object { @@ -82,7 +82,7 @@ Array [ }, }, Object { - "newText": " id=\\"_IDGenDialog1\\"", + "newText": " id=\\"_IDGenDialog\\"", "replaceRange": Object { "end": Object { "character": 23, @@ -97,10 +97,10 @@ Array [ ], ], "command": "ui5_lang.quick_fix_file_stable_id", - "title": "Generate IDs for the entire file", + "title": "Generate unique IDs for the entire file", }, "kind": "quickfix", - "title": "Generate IDs for the entire file", + "title": "Generate unique IDs for the entire file", }, Object { "command": Object { @@ -117,13 +117,13 @@ Array [ "line": 11, }, }, - " id=\\"_IDGenDialog1\\"", + " id=\\"_IDGenDialog\\"", ], "command": "ui5_lang.quick_fix_stable_id", - "title": "Generate an ID", + "title": "Generate a unique ID", }, "kind": "quickfix", - "title": "Generate an ID", + "title": "Generate a unique ID", }, Object { "command": Object { @@ -169,7 +169,7 @@ Array [ 0, Array [ Object { - "newText": " id=\\"_IDGenButton1\\"", + "newText": " id=\\"_IDGenButton\\"", "replaceRange": Object { "end": Object { "character": 19, @@ -197,10 +197,10 @@ Array [ ], ], "command": "ui5_lang.quick_fix_file_stable_id", - "title": "Generate IDs for the entire file", + "title": "Generate unique IDs for the entire file", }, "kind": "quickfix", - "title": "Generate IDs for the entire file", + "title": "Generate unique IDs for the entire file", }, ] `; diff --git a/packages/language-server/test/unit/completion-items-utils.ts b/packages/language-server/test/unit/completion-items-utils.ts index c1a3202a7..6fce7f23d 100644 --- a/packages/language-server/test/unit/completion-items-utils.ts +++ b/packages/language-server/test/unit/completion-items-utils.ts @@ -244,12 +244,15 @@ function assertFilterMatches( } export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { + const viewFiles = {}; + const controlIds = new Map(); + viewFiles[""] = {}; return { ui5Model, customViewId: "", manifestDetails: { appId: "", - manifestPath: "", + manifestPath: "manifest.json", flexEnabled: false, customViews: {}, mainServicePath: undefined, @@ -260,5 +263,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { framework: DEFAULT_UI5_FRAMEWORK, version: undefined, }, + viewFiles, + controlIds, + documentPath: "", }; }; diff --git a/packages/language-server/test/unit/hover.test.ts b/packages/language-server/test/unit/hover.test.ts index 215a1557f..a2c5dc45d 100644 --- a/packages/language-server/test/unit/hover.test.ts +++ b/packages/language-server/test/unit/hover.test.ts @@ -21,6 +21,8 @@ import { getHoverResponse } from "../../src/hover"; import { Context as AppContext } from "@ui5-language-assistant/context"; import { getDefaultContext } from "./completion-items-utils"; import { xmlSnippetToDocument } from "./testUtils"; +import { DocumentCstNode, parse } from "@xml-tools/parser"; +import { buildAst } from "@xml-tools/ast"; describe("the UI5 language assistant Hover Tooltip Service", () => { let ui5SemanticModel: UI5SemanticModel; @@ -36,6 +38,10 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { appContext = getDefaultContext(ui5SemanticModel); }); + afterEach(() => { + appContext = getDefaultContext(ui5SemanticModel); + }); + describe("hover on attribute key", () => { it("will get hover content UI5 property", () => { const xmlSnippet = ` { + appContext = getDefaultContext(ui5SemanticModel); + appContext.manifestDetails.flexEnabled = true; + }); + describe("Quick fix code actions", () => { it("no quick fixable diagnostics", () => { const xmlSnippet = ` { + it("execute Quick Fix Stable Id Command", () => { const response = executeQuickFixStableIdCommand({ documentUri: "dummyUri", documentVersion: 1, diff --git a/packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/options.js b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/options.js new file mode 100644 index 000000000..68184ff7f --- /dev/null +++ b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/options.js @@ -0,0 +1,24 @@ +const url = require("url"); + +const controlIds = new Map(); + +controlIds.set("OOPS", [ + { + uri: url.pathToFileURL("").toString(), + range: { + start: { line: 3, character: 11 }, + end: { line: 3, character: 17 }, + }, + offsetRange: { start: 79, end: 84 }, + }, + { + uri: url.pathToFileURL("").toString(), + range: { + start: { line: 5, character: 13 }, + end: { line: 5, character: 19 }, + }, + offsetRange: { start: 109, end: 114 }, + }, +]); + +module.exports = { flexEnabled: false, controlIds }; diff --git a/packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/output-lsp-response.json b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/output-lsp-response.json index 087ae5311..6a067df93 100644 --- a/packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/output-lsp-response.json +++ b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/non-unique-id/output-lsp-response.json @@ -7,18 +7,7 @@ "severity": 1, "source": "UI5 Language Assistant", "message": "Select a unique ID. The current \"OOPS\" ID has already been used.", - "relatedInformation": [ - { - "message": "An identical ID is also used here.", - "location": { - "uri": "snapshots/xml-view-diagnostics/non-unique-id/input.xml", - "range": { - "start": { "line": 5, "character": 13 }, - "end": { "line": 5, "character": 19 } - } - } - } - ] + "relatedInformation": [] }, { "range": { @@ -28,17 +17,6 @@ "severity": 1, "source": "UI5 Language Assistant", "message": "Select a unique ID. The current \"OOPS\" ID has already been used.", - "relatedInformation": [ - { - "message": "An identical ID is also used here.", - "location": { - "uri": "snapshots/xml-view-diagnostics/non-unique-id/input.xml", - "range": { - "start": { "line": 3, "character": 11 }, - "end": { "line": 3, "character": 17 } - } - } - } - ] + "relatedInformation": [] } ] diff --git a/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-diagnostics.test.ts b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-diagnostics.test.ts index e0e4856ab..0f267ae04 100644 --- a/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-diagnostics.test.ts +++ b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-diagnostics.test.ts @@ -31,7 +31,7 @@ describe(`The language server diagnostics capability`, () => { if (existsSync(optionsPath)) { options = require(optionsPath); } else { - options = { flexEnabled: false }; + options = { flexEnabled: false, controlIds: new Map() }; } it(`Can create diagnostic for ${dirName.replace(/-/g, " ")} (${relative( diff --git a/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-utils.ts b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-utils.ts index 40878a7d8..23caaa0d7 100644 --- a/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-utils.ts +++ b/packages/language-server/test/unit/snapshots/xml-view-diagnostics/snapshots-utils.ts @@ -1,4 +1,4 @@ -import { resolve, sep, relative, dirname } from "path"; +import { resolve, sep, relative, dirname, basename } from "path"; import { Diagnostic, Range } from "vscode-languageserver-types"; import { TextDocument } from "vscode-languageserver"; import { readJsonSync, readFileSync } from "fs-extra"; @@ -14,13 +14,21 @@ import { DEFAULT_UI5_FRAMEWORK, } from "@ui5-language-assistant/constant"; import { generate } from "@ui5-language-assistant/semantic-model"; -import { getXMLViewDiagnostics } from "../../../../src/xml-view-diagnostics"; +import { + getXMLViewDiagnostics, + getXMLViewIdDiagnostics, +} from "../../../../src/xml-view-diagnostics"; import { Context as AppContext } from "@ui5-language-assistant/context"; import { getDefaultContext } from "../../completion-items-utils"; +import { DocumentCstNode, parse } from "@xml-tools/parser"; +import { buildAst } from "@xml-tools/ast"; export const INPUT_FILE_NAME = "input.xml"; export const OUTPUT_LSP_RESPONSE_FILE_NAME = "output-lsp-response.json"; -export type LSPDiagnosticOptions = { flexEnabled: boolean }; +export type LSPDiagnosticOptions = { + flexEnabled: boolean; + controlIds: Map; +}; export async function snapshotTestLSPDiagnostic( testDir: string, @@ -122,6 +130,11 @@ export async function computeNewDiagnosticLSPResponse( testDir: string, options: LSPDiagnosticOptions ): Promise { + const xmlTextSnippet = readInputXMLSnippet(testDir); + const viewFiles = {}; + const { cst, tokenVector } = parse(xmlTextSnippet); + const ast = buildAst(cst as DocumentCstNode, tokenVector); + viewFiles[""] = ast; // No top level await ui5Model = await ui5ModelPromise; appContext = { @@ -134,19 +147,23 @@ export async function computeNewDiagnosticLSPResponse( mainServicePath: undefined, minUI5Version: undefined, }, + viewFiles, + controlIds: options?.controlIds ? options.controlIds : new Map(), }; - const xmlTextSnippet = readInputXMLSnippet(testDir); const xmlTextDoc = TextDocument.create( `file://${getInputXMLSnippetPath(testDir)}`, "xml", 0, xmlTextSnippet ); - - const actualDiagnostics = getXMLViewDiagnostics({ - document: xmlTextDoc, - context: appContext, - }); + const dirName = basename(testDir); + const actualDiagnostics = + dirName === "non-unique-id" + ? getXMLViewIdDiagnostics({ document: xmlTextDoc, context: appContext }) + : getXMLViewDiagnostics({ + document: xmlTextDoc, + context: appContext, + }); const diagnosticsForAssertions = cleanupLSPResponseForAssertions(actualDiagnostics); diff --git a/packages/logic-utils/api.d.ts b/packages/logic-utils/api.d.ts index 81173bf54..e69cddee3 100644 --- a/packages/logic-utils/api.d.ts +++ b/packages/logic-utils/api.d.ts @@ -284,6 +284,14 @@ export { getVersionInfoUrl, getVersionJsonUrl, getVersionsMap, + isKnownUI5Class, + isPossibleCustomClass, + locationToRange, + CORE_NS, + CUSTOM_DATA_NS, + SVG_NS, + TEMPLATING_NS, + XHTML_NS, } from "./src/api"; export { FetchResponse } from "./src/api"; diff --git a/packages/logic-utils/package.json b/packages/logic-utils/package.json index b3d03e14e..8d093d532 100644 --- a/packages/logic-utils/package.json +++ b/packages/logic-utils/package.json @@ -24,12 +24,14 @@ "lodash": "4.17.21", "node-fetch": "2.6.9", "https-proxy-agent": "5.0.1", - "proxy-from-env": "1.1.0" + "proxy-from-env": "1.1.0", + "chevrotain": "7.0.1" }, "devDependencies": { "@ui5-language-assistant/semantic-model": "4.0.18", "@ui5-language-assistant/test-utils": "4.0.16", - "@xml-tools/parser": "1.0.7" + "@xml-tools/parser": "1.0.7", + "vscode-languageserver-types": "3.17.2" }, "scripts": { "ci": "npm-run-all clean compile lint coverage", diff --git a/packages/logic-utils/src/api.ts b/packages/logic-utils/src/api.ts index f189352cd..ad3b259b1 100644 --- a/packages/logic-utils/src/api.ts +++ b/packages/logic-utils/src/api.ts @@ -51,3 +51,14 @@ export { getVersionJsonUrl, getVersionsMap, } from "./utils/ui5"; + +export { + CORE_NS, + CUSTOM_DATA_NS, + SVG_NS, + TEMPLATING_NS, + XHTML_NS, +} from "./utils/special-namespaces"; +export { isKnownUI5Class, isPossibleCustomClass } from "./utils/ui5-classes"; + +export { locationToRange } from "./utils/range"; diff --git a/packages/logic-utils/src/utils/range.ts b/packages/logic-utils/src/utils/range.ts new file mode 100644 index 000000000..f8b04a844 --- /dev/null +++ b/packages/logic-utils/src/utils/range.ts @@ -0,0 +1,32 @@ +import { CstNodeLocation } from "chevrotain"; +import { Range } from "vscode-languageserver-types"; + +const isNaNOrUndefined = (value: undefined | number): boolean => { + if (value === undefined) { + return true; + } + return isNaN(value); +}; +const isNumber = (value: undefined | number): value is number => { + const result = isNaNOrUndefined(value); + if (result) { + return false; + } + return true; +}; + +const createRange = (item: CstNodeLocation) => + Range.create( + isNumber(item.startLine) ? item.startLine - 1 : 0, + isNumber(item.startColumn) ? item.startColumn - 1 : 0, + isNumber(item.endLine) ? item.endLine - 1 : 0, + isNumber(item.endColumn) ? item.endColumn : 0 + ); + +export const locationToRange = (location?: CstNodeLocation): Range => { + if (!location) { + return Range.create({ line: 0, character: 0 }, { line: 0, character: 0 }); + } + const { start, end } = createRange(location); + return { start, end }; +}; diff --git a/packages/xml-views-validation/src/utils/special-namespaces.ts b/packages/logic-utils/src/utils/special-namespaces.ts similarity index 96% rename from packages/xml-views-validation/src/utils/special-namespaces.ts rename to packages/logic-utils/src/utils/special-namespaces.ts index e8b1c1031..cd5cc4310 100644 --- a/packages/xml-views-validation/src/utils/special-namespaces.ts +++ b/packages/logic-utils/src/utils/special-namespaces.ts @@ -1,9 +1,9 @@ /* istanbul ignore next - the constants are used but coverage is not reported for some reason */ export const TEMPLATING_NS = - "http://schemas.sap.com/sapui5/extension/sap.ui.core.template/1"; + "http://schemas.sap.com/sapui5/extension/sap.ui.core.template/1"; //NOSONAR /* istanbul ignore next - the constants are used but coverage is not reported for some reason */ export const CUSTOM_DATA_NS = - "http://schemas.sap.com/sapui5/extension/sap.ui.core.CustomData/1"; + "http://schemas.sap.com/sapui5/extension/sap.ui.core.CustomData/1"; //NOSONAR /* istanbul ignore next - the constants are used but coverage is not reported for some reason */ export const XHTML_NS = "http://www.w3.org/1999/xhtml"; /* istanbul ignore next - the constants are used but coverage is not reported for some reason */ diff --git a/packages/xml-views-validation/src/utils/ui5-classes.ts b/packages/logic-utils/src/utils/ui5-classes.ts similarity index 87% rename from packages/xml-views-validation/src/utils/ui5-classes.ts rename to packages/logic-utils/src/utils/ui5-classes.ts index b961054c1..fee56b584 100644 --- a/packages/xml-views-validation/src/utils/ui5-classes.ts +++ b/packages/logic-utils/src/utils/ui5-classes.ts @@ -1,10 +1,7 @@ import { XMLElement } from "@xml-tools/ast"; -import { - resolveXMLNS, - getUI5ClassByXMLElement, -} from "@ui5-language-assistant/logic-utils"; import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; -import { SVG_NS, TEMPLATING_NS, XHTML_NS } from "./special-namespaces"; +import { SVG_NS, TEMPLATING_NS, XHTML_NS, resolveXMLNS } from "../api"; +import { getUI5ClassByXMLElement } from "./xml-node-to-ui5-node"; // Heuristic to limit false positives by only checking tags starting with upper // case names, This would **mostly** limit the checks for things that can actually be diff --git a/packages/logic-utils/src/utils/xml-node-to-ui5-node.ts b/packages/logic-utils/src/utils/xml-node-to-ui5-node.ts index afc73189d..50d6ab1c2 100644 --- a/packages/logic-utils/src/utils/xml-node-to-ui5-node.ts +++ b/packages/logic-utils/src/utils/xml-node-to-ui5-node.ts @@ -8,7 +8,7 @@ import { splitQNameByNamespace, flattenEvents, flattenAssociations, -} from "@ui5-language-assistant/logic-utils"; +} from "../api"; import { XMLElement, XMLAttribute } from "@xml-tools/ast"; import { UI5Class, diff --git a/packages/logic-utils/test/unit/range.test.ts b/packages/logic-utils/test/unit/range.test.ts new file mode 100644 index 000000000..8237fb7ec --- /dev/null +++ b/packages/logic-utils/test/unit/range.test.ts @@ -0,0 +1,47 @@ +import { locationToRange } from "../../src/api"; +import { CstNodeLocation } from "chevrotain"; + +describe("locationToRange", () => { + it("should return a Range object with start and end positions set", () => { + // arrange + const location = { + startLine: 1, + startColumn: 1, + endLine: 2, + endColumn: 2, + } as CstNodeLocation; + // act + const result = locationToRange(location); + // assert + expect(result).toStrictEqual({ + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + }); + }); + + it("should return a Range object with default positions when location is undefined", () => { + // act + const result = locationToRange(undefined); + // assert + expect(result).toStrictEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }); + }); + + it("should return a Range object with adjusted start and end positions when some properties are undefined", () => { + // arrange + const location = { + startLine: 5, + startColumn: 10, + } as CstNodeLocation; + + // act + const result = locationToRange(location); + // assert + expect(result).toStrictEqual({ + start: { line: 4, character: 9 }, + end: { line: 0, character: 0 }, + }); + }); +}); diff --git a/packages/logic-utils/test/unit/ui5-classes.test.ts b/packages/logic-utils/test/unit/ui5-classes.test.ts new file mode 100644 index 000000000..a4876b261 --- /dev/null +++ b/packages/logic-utils/test/unit/ui5-classes.test.ts @@ -0,0 +1,66 @@ +import { parse, DocumentCstNode } from "@xml-tools/parser"; +import { buildAst, XMLElement } from "@xml-tools/ast"; +import { isPossibleCustomClass, isKnownUI5Class } from "../../src/api"; +import { + generateModel, + getFallbackPatchVersions, +} from "@ui5-language-assistant/test-utils"; +import { generate } from "@ui5-language-assistant/semantic-model"; +import { + DEFAULT_UI5_FRAMEWORK, + DEFAULT_UI5_VERSION, +} from "@ui5-language-assistant/constant"; +import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; + +type XMLElementWithName = XMLElement & { name: string }; +const getXmlElement = (content: string): XMLElementWithName => { + const { cst, tokenVector } = parse(content); + const ast = buildAst(cst as DocumentCstNode, tokenVector); + return ast.rootElement as XMLElementWithName; +}; + +describe("isPossibleCustomClass", () => { + it("should return true if the name of the XML element starts with an uppercase letter", () => { + const xmlElement = getXmlElement(""); + const result = isPossibleCustomClass(xmlElement); + expect(result).toBe(true); + }); + + it("should return false if the name of the XML element does not start with an uppercase letter", () => { + const xmlElement = getXmlElement(""); + const result = isPossibleCustomClass(xmlElement); + expect(result).toBe(false); + }); +}); + +describe("isKnownUI5Class", () => { + let model: UI5SemanticModel; + beforeAll(async () => { + model = await generateModel({ + framework: DEFAULT_UI5_FRAMEWORK, + version: ( + await getFallbackPatchVersions() + ).SAPUI5 as typeof DEFAULT_UI5_VERSION, + modelGenerator: generate, + }); + }); + it("should return true if the UI5 class for the XML element is known", () => { + const content = ` + + + + `; + const xmlElement = getXmlElement(content); + const result = isKnownUI5Class(xmlElement, model); + expect(result).toBe(true); + }); + + it("should return false if the UI5 class for the XML element is not known", () => { + const xmlElement = getXmlElement(""); + const result = isKnownUI5Class(xmlElement, model); + expect(result).toBe(false); + }); +}); diff --git a/packages/user-facing-text/src/commands.ts b/packages/user-facing-text/src/commands.ts index aa8f24f5d..009ce3275 100644 --- a/packages/user-facing-text/src/commands.ts +++ b/packages/user-facing-text/src/commands.ts @@ -7,10 +7,10 @@ import { Commands } from "../api"; export const commands: Commands = { QUICK_FIX_STABLE_ID_ERROR: { name: "ui5_lang.quick_fix_stable_id", - title: "Generate an ID", + title: "Generate a unique ID", }, QUICK_FIX_STABLE_ID_FILE_ERRORS: { name: "ui5_lang.quick_fix_file_stable_id", - title: "Generate IDs for the entire file", + title: "Generate unique IDs for the entire file", }, }; diff --git a/packages/xml-views-completion/test/unit/utils.ts b/packages/xml-views-completion/test/unit/utils.ts index b8a0822bc..d50c28f91 100644 --- a/packages/xml-views-completion/test/unit/utils.ts +++ b/packages/xml-views-completion/test/unit/utils.ts @@ -94,5 +94,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { framework: DEFAULT_UI5_FRAMEWORK, version: undefined, }, + viewFiles: {}, + controlIds: new Map(), + documentPath: "", }; }; diff --git a/packages/xml-views-definition/src/controller/index.ts b/packages/xml-views-definition/src/controller/index.ts index 04ee206ff..a54d61f58 100644 --- a/packages/xml-views-definition/src/controller/index.ts +++ b/packages/xml-views-definition/src/controller/index.ts @@ -1,8 +1,5 @@ import { Location, Position } from "vscode-languageserver-types"; import { DefinitionParams } from "vscode-languageserver"; -import { readFile } from "fs/promises"; -import { parse, DocumentCstNode } from "@xml-tools/parser"; -import { buildAst } from "@xml-tools/ast"; import { buildFileUri, getAttribute } from "../utils"; import { URI } from "vscode-uri"; import { isContext, getContext } from "@ui5-language-assistant/context"; @@ -31,9 +28,11 @@ export async function getControllerLocation( const { position, textDocument } = param; const documentUri = textDocument.uri; const documentPath = URI.parse(documentUri).fsPath; - const text = await readFile(documentPath, "utf-8"); - const { cst, tokenVector } = parse(text); - const ast = buildAst(cst as DocumentCstNode, tokenVector); + const context = await getContext(documentPath); + if (!isContext(context)) { + return []; + } + const ast = context.viewFiles[documentPath]; if (!ast.rootElement) { return []; } @@ -42,10 +41,6 @@ export async function getControllerLocation( return []; } - const context = await getContext(documentPath); - if (!isContext(context)) { - return []; - } // value must be present - otherwise getAttribute method returns undefined const value = attr.value as string; const id = context.manifestDetails.appId; diff --git a/packages/xml-views-definition/test/unit/controller/index.test.ts b/packages/xml-views-definition/test/unit/controller/index.test.ts index 261ab084c..26b15db00 100644 --- a/packages/xml-views-definition/test/unit/controller/index.test.ts +++ b/packages/xml-views-definition/test/unit/controller/index.test.ts @@ -17,6 +17,7 @@ describe("index", () => { let uri = ""; const pathSegments = ["src", "view", "App.view.xml"]; beforeEach(function () { + context.cache.reset(); const useConfig: Config = { projectInfo: { name: ProjectName.tsFreeStyle, @@ -35,7 +36,7 @@ describe("index", () => { // arrange const param: DefinitionParams = { position: {} as Position, - textDocument: { uri: "file:\\dummy" }, + textDocument: { uri }, }; jest.spyOn(fs.promises, "readFile").mockResolvedValue(""); // act diff --git a/packages/xml-views-quick-fix/api.d.ts b/packages/xml-views-quick-fix/api.d.ts index 125c6bdda..b6fa1e4cb 100644 --- a/packages/xml-views-quick-fix/api.d.ts +++ b/packages/xml-views-quick-fix/api.d.ts @@ -1,8 +1 @@ -import { XMLDocument } from "@xml-tools/ast"; -import { OffsetRange } from "@ui5-language-assistant/logic-utils"; -import { QuickFixStableIdInfo } from "./src/quick-fix-stable-id"; - -export declare function computeQuickFixStableIdInfo( - xmlDoc: XMLDocument, - errorOffset: OffsetRange[] -): QuickFixStableIdInfo[]; +export { computeQuickFixStableIdInfo } from "./src/api"; diff --git a/packages/xml-views-quick-fix/package.json b/packages/xml-views-quick-fix/package.json index 053c65aff..dad37a0b8 100644 --- a/packages/xml-views-quick-fix/package.json +++ b/packages/xml-views-quick-fix/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@ui5-language-assistant/logic-utils": "4.0.19", + "@ui5-language-assistant/context": "4.0.28", "@xml-tools/ast": "5.0.0", "@xml-tools/ast-position": "2.0.2", "lodash": "4.17.21" diff --git a/packages/xml-views-quick-fix/src/quick-fix-stable-id.ts b/packages/xml-views-quick-fix/src/quick-fix-stable-id.ts index c71fcf1a1..5d0e2536e 100644 --- a/packages/xml-views-quick-fix/src/quick-fix-stable-id.ts +++ b/packages/xml-views-quick-fix/src/quick-fix-stable-id.ts @@ -1,15 +1,9 @@ import { find, some, compact, map } from "lodash"; import { astPositionAtOffset } from "@xml-tools/ast-position"; -import { - XMLDocument, - accept, - XMLAstVisitor, - XMLAttribute, - XMLElement, -} from "@xml-tools/ast"; +import { XMLElement } from "@xml-tools/ast"; import { OffsetRange } from "@ui5-language-assistant/logic-utils"; +import type { Context } from "@ui5-language-assistant/context"; -const ID_PATTERN = /^_IDGen(.+)([1-9]\d*)$/; const ID_PREFIX_PATTERN = "_IDGen"; export type QuickFixStableIdInfo = { @@ -18,14 +12,11 @@ export type QuickFixStableIdInfo = { }; export function computeQuickFixStableIdInfo( - xmlDoc: XMLDocument, - errorOffset: OffsetRange[] + context: Context, + errorOffset: OffsetRange[], + existingIds: Record ): QuickFixStableIdInfo[] { - const biggestIdsByElementNameCollector = - new BiggestIdsByElementNameCollector(); - accept(xmlDoc, biggestIdsByElementNameCollector); - const biggestIdsByElementName = - biggestIdsByElementNameCollector.biggestIdsByElementName; + const xmlDoc = context.viewFiles[context.documentPath]; const quickFixStableIdInfo = compact( map(errorOffset, (_) => { const astNode = astPositionAtOffset(xmlDoc, _.start); @@ -48,7 +39,7 @@ export function computeQuickFixStableIdInfo( ); const newText = computeQuickFixIdSuggestion( - biggestIdsByElementName, + existingIds, xmlElement.name, hasIdAttribute ); @@ -62,42 +53,49 @@ export function computeQuickFixStableIdInfo( return quickFixStableIdInfo; } -// We collect the biggest id number for each element name. -// The element name should match the pattern: `_IDGen{ElementName}{idNumber}`. -class BiggestIdsByElementNameCollector implements XMLAstVisitor { - public biggestIdsByElementName: Record = Object.create(null); - visitXMLAttribute(xmlAttribute: XMLAttribute) { - if (xmlAttribute.key === "id" && xmlAttribute.value !== null) { - const match = ID_PATTERN.exec(xmlAttribute.value); - if (match === null) { - return; - } - - const className = match[1]; - const suffix = parseInt(match[2]); - if ( - this.biggestIdsByElementName[className] === undefined || - suffix > this.biggestIdsByElementName[className] - ) { - this.biggestIdsByElementName[className] = suffix; - } +/** + * Get unique ID. It creates unique incremented number suffix for each new unique id. + * + * @param existingIds all existing ids in all `.xml` files + * @param newId new suggested id + * @param suffix index suffix + * @returns unique id across `.xml` files + */ +function getUniqueId( + existingIds: Record, + newId: string, + suffix = 0 +): string { + if (existingIds[newId]) { + const lastChar = Number(newId.slice(-1)); + if (!isNaN(lastChar)) { + // last char is number + suffix = lastChar + 1; + // remove last char + newId = newId.slice(0, -1); + } else { + suffix = suffix + 1; } + return getUniqueId(existingIds, `${newId}${suffix}`); } + return newId; } function computeQuickFixIdSuggestion( - biggestIdsByElementName: Record, + existingIds: Record, elementName: string, hasIdAttribute: boolean ): string { - const suffix = biggestIdsByElementName[elementName] - ? biggestIdsByElementName[elementName] + 1 - : 1; - // Data structure supports multiple fixes in the same file without conflicts - biggestIdsByElementName[elementName] = suffix; - let newText = `id="${ID_PREFIX_PATTERN}${elementName}${suffix}"`; + const uniqueId = getUniqueId( + existingIds, + `${ID_PREFIX_PATTERN}${elementName}` + ); + // keep track of newly generated id + existingIds[uniqueId] = 1; + + let newText = `id="${uniqueId}"`; if (!hasIdAttribute) { - // We want extra space if there is no id key to seperate the new text from the tag name + // We want extra space if there is no id key to separate the new text from the tag name newText = " " + newText; } diff --git a/packages/xml-views-quick-fix/test/unit/quick-fix-stable-id.test.ts b/packages/xml-views-quick-fix/test/unit/quick-fix-stable-id.test.ts index a3c039f7d..1f99c60c0 100644 --- a/packages/xml-views-quick-fix/test/unit/quick-fix-stable-id.test.ts +++ b/packages/xml-views-quick-fix/test/unit/quick-fix-stable-id.test.ts @@ -3,7 +3,7 @@ import { parse, DocumentCstNode } from "@xml-tools/parser"; import { buildAst, XMLDocument } from "@xml-tools/ast"; import { expectExists } from "@ui5-language-assistant/test-utils"; import { computeQuickFixStableIdInfo } from "../../src/api"; - +import { Context } from "@ui5-language-assistant/context"; describe("the UI5 language assistant QuickFix Service", () => { describe("true positive scenarios", () => { it("will get quick fix info when class is missing id attribute key", () => { @@ -17,15 +17,19 @@ describe("the UI5 language assistant QuickFix Service", () => { const { document, quickFixStableIdTestInfo } = getXmlSnippet(testXmlSnippet); expect(quickFixStableIdTestInfo).not.toBeEmpty(); - const testXmlDoc = getXmlDocument(document); - const quickFixInfo = computeQuickFixStableIdInfo(testXmlDoc, [ - { - start: quickFixStableIdTestInfo[0].start, - end: quickFixStableIdTestInfo[0].end, - }, - ]); + const context = getContext(document); + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + ], + {} + ); expectExists(quickFixInfo, "Quick Fix Info"); - expect(quickFixInfo[0].newText).toEqual(' id="_IDGenList1"'); + expect(quickFixInfo[0].newText).toEqual(' id="_IDGenList"'); expect(quickFixInfo[0].replaceRange.start).toEqual( quickFixStableIdTestInfo[0].idStartOffest ); @@ -43,23 +47,27 @@ describe("the UI5 language assistant QuickFix Service", () => { const { document, quickFixStableIdTestInfo } = getXmlSnippet(testXmlSnippet); expect(quickFixStableIdTestInfo).not.toBeEmpty(); - const testXmlDoc = getXmlDocument(document); - const quickFixInfo = computeQuickFixStableIdInfo(testXmlDoc, [ - { - start: quickFixStableIdTestInfo[0].start, - end: quickFixStableIdTestInfo[0].end, - }, - { - start: quickFixStableIdTestInfo[1].start, - end: quickFixStableIdTestInfo[1].end, - }, - ]); + const context = getContext(document); + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + { + start: quickFixStableIdTestInfo[1].start, + end: quickFixStableIdTestInfo[1].end, + }, + ], + {} + ); expectExists(quickFixInfo, "Quick Fix Info"); - expect(quickFixInfo[0].newText).toEqual(' id="_IDGenList1"'); + expect(quickFixInfo[0].newText).toEqual(' id="_IDGenList"'); expect(quickFixInfo[0].replaceRange.start).toEqual( quickFixStableIdTestInfo[0].idStartOffest ); - expect(quickFixInfo[1].newText).toEqual(' id="_IDGenList2"'); + expect(quickFixInfo[1].newText).toEqual(' id="_IDGenList1"'); expect(quickFixInfo[1].replaceRange.start).toEqual( quickFixStableIdTestInfo[1].idStartOffest ); @@ -74,17 +82,23 @@ describe("the UI5 language assistant QuickFix Service", () => { <🢂List🢀$> `; - const expectedSuggestion = ' id="_IDGenList1"'; + const expectedSuggestion = ' id="_IDGenList"'; const { document, quickFixStableIdTestInfo } = getXmlSnippet(testXmlSnippet); expect(quickFixStableIdTestInfo).not.toBeEmpty(); - const testXmlDoc = getXmlDocument(document); - const quickFixInfo = computeQuickFixStableIdInfo(testXmlDoc, [ - { - start: quickFixStableIdTestInfo[0].start, - end: quickFixStableIdTestInfo[0].end, - }, - ]); + const context = getContext(document); + const existingIds = {}; + existingIds["dummy-id"] = 1; + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + ], + existingIds + ); expectExists(quickFixInfo, "Quick Fix Info"); expect(quickFixInfo[0].newText).toEqual(expectedSuggestion); expect(quickFixInfo[0].replaceRange.start).toEqual( @@ -97,22 +111,29 @@ describe("the UI5 language assistant QuickFix Service", () => { xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m"> - + <🢂List🢀$> `; - const expectedSuggestion = ' id="_IDGenList3"'; + const expectedSuggestion = ' id="_IDGenList2"'; const { document, quickFixStableIdTestInfo } = getXmlSnippet(testXmlSnippet); expect(quickFixStableIdTestInfo).not.toBeEmpty(); - const testXmlDoc = getXmlDocument(document); - const quickFixInfo = computeQuickFixStableIdInfo(testXmlDoc, [ - { - start: quickFixStableIdTestInfo[0].start, - end: quickFixStableIdTestInfo[0].end, - }, - ]); + const context = getContext(document); + const existingIds = {}; + existingIds["_IDGenList"] = 1; + existingIds["_IDGenList1"] = 1; + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + ], + existingIds + ); expectExists(quickFixInfo, "Quick Fix Info"); expect(quickFixInfo[0].newText).toEqual(expectedSuggestion); expect(quickFixInfo[0].replaceRange.start).toEqual( @@ -128,17 +149,21 @@ describe("the UI5 language assistant QuickFix Service", () => { <🢂List🢀 $id=""> `; - const expectedSuggestion = 'id="_IDGenList1"'; + const expectedSuggestion = 'id="_IDGenList"'; const { document, quickFixStableIdTestInfo } = getXmlSnippet(testXmlSnippet); expect(quickFixStableIdTestInfo).not.toBeEmpty(); - const testXmlDoc = getXmlDocument(document); - const quickFixInfo = computeQuickFixStableIdInfo(testXmlDoc, [ - { - start: quickFixStableIdTestInfo[0].start, - end: quickFixStableIdTestInfo[0].end, - }, - ]); + const context = getContext(document); + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + ], + {} + ); expectExists(quickFixInfo, "Quick Fix Info"); expect(quickFixInfo[0].newText).toEqual(expectedSuggestion); expect(quickFixInfo[0].replaceRange.start).toEqual( @@ -154,23 +179,79 @@ describe("the UI5 language assistant QuickFix Service", () => { <🢂List🢀 models="" $id="" footerText=""> `; - const expectedSuggestion = 'id="_IDGenList1"'; + const expectedSuggestion = 'id="_IDGenList"'; const { document, quickFixStableIdTestInfo } = getXmlSnippet(testXmlSnippet); expect(quickFixStableIdTestInfo).not.toBeEmpty(); - const testXmlDoc = getXmlDocument(document); - const quickFixInfo = computeQuickFixStableIdInfo(testXmlDoc, [ - { - start: quickFixStableIdTestInfo[0].start, - end: quickFixStableIdTestInfo[0].end, - }, - ]); + const context = getContext(document); + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + ], + {} + ); expectExists(quickFixInfo, "Quick Fix Info"); expect(quickFixInfo[0].newText).toEqual(expectedSuggestion); expect(quickFixInfo[0].replaceRange.start).toEqual( quickFixStableIdTestInfo[0].idStartOffest ); }); + + it("will get unique quick fix suggestions across multiple files", () => { + const testXmlSnippet01 = ` + + <🢂List🢀$> + <🢂List🢀$> + + `; + const testXmlSnippet02 = ` + + + + + `; + const { document, quickFixStableIdTestInfo } = + getXmlSnippet(testXmlSnippet01); + const { document: doc02 } = getXmlSnippet(testXmlSnippet02); + expect(quickFixStableIdTestInfo).not.toBeEmpty(); + + const context = getContext(document); + context.viewFiles["uri02"] = getXmlDocument(doc02); + const existingIds = {}; + existingIds["_IDGenList"] = 1; + existingIds["_IDGenList1"] = 1; + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + { + start: quickFixStableIdTestInfo[1].start, + end: quickFixStableIdTestInfo[1].end, + }, + ], + existingIds + ); + expectExists(quickFixInfo, "Quick Fix Info"); + expect(quickFixInfo[0].newText).toEqual(' id="_IDGenList2"'); + expect(quickFixInfo[0].replaceRange.start).toEqual( + quickFixStableIdTestInfo[0].idStartOffest + ); + expect(quickFixInfo[1].newText).toEqual(' id="_IDGenList3"'); + expect(quickFixInfo[1].replaceRange.start).toEqual( + quickFixStableIdTestInfo[1].idStartOffest + ); + }); }); describe("true negative scenarios", () => { @@ -184,13 +265,17 @@ describe("the UI5 language assistant QuickFix Service", () => { `; const { document, quickFixStableIdTestInfo } = getXmlSnippet(testXmlSnippet); - const testXmlDoc = getXmlDocument(document); - const quickFixInfo = computeQuickFixStableIdInfo(testXmlDoc, [ - { - start: quickFixStableIdTestInfo[0].start, - end: quickFixStableIdTestInfo[0].end, - }, - ]); + const context = getContext(document); + const quickFixInfo = computeQuickFixStableIdInfo( + context, + [ + { + start: quickFixStableIdTestInfo[0].start, + end: quickFixStableIdTestInfo[0].end, + }, + ], + {} + ); expect(quickFixInfo).toBeEmpty(); }); @@ -232,3 +317,28 @@ function getXmlSnippet(xmlSnippet: string): { function createTextDocument(languageId: string, content: string): TextDocument { return TextDocument.create("uri", languageId, 0, content); } + +function getContext(textDocument: TextDocument): Context { + const context = { + ui5Model: {}, + customViewId: "", + manifestDetails: { + appId: "", + manifestPath: "", + flexEnabled: false, + customViews: {}, + mainServicePath: undefined, + minUI5Version: undefined, + }, + services: {}, + yamlDetails: { + framework: "SAPUI5", + version: undefined, + }, + viewFiles: {}, + controlIds: new Map(), + documentPath: "uri", + } as Context; + context.viewFiles["uri"] = getXmlDocument(textDocument); + return context; +} diff --git a/packages/xml-views-tooltip/test/unit/utils.ts b/packages/xml-views-tooltip/test/unit/utils.ts index 96cc96697..2f42265b5 100644 --- a/packages/xml-views-tooltip/test/unit/utils.ts +++ b/packages/xml-views-tooltip/test/unit/utils.ts @@ -19,5 +19,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { framework: DEFAULT_UI5_FRAMEWORK, version: undefined, }, + viewFiles: {}, + controlIds: new Map(), + documentPath: "", }; }; diff --git a/packages/xml-views-validation/api.d.ts b/packages/xml-views-validation/api.d.ts index 560f9e5c4..e6e3a2969 100644 --- a/packages/xml-views-validation/api.d.ts +++ b/packages/xml-views-validation/api.d.ts @@ -2,6 +2,7 @@ import { XMLDocument, XMLElement, XMLAttribute } from "@xml-tools/ast"; import { OffsetRange } from "@ui5-language-assistant/logic-utils"; import { UI5ValidatorsConfig } from "./src/validate-xml-views"; import { Context } from "@ui5-language-assistant/context"; +import { Range } from "vscode-languageserver-types"; export function validateXMLView(opts: { validators: UI5ValidatorsConfig; @@ -101,7 +102,10 @@ export interface InvalidBooleanValueIssue extends BaseUI5XMLViewIssue { export interface NonUniqueIDIssue extends BaseUI5XMLViewIssue { kind: "NonUniqueIDIssue"; - identicalIDsRanges: OffsetRange[]; + identicalIDsRanges: { + range: Range; + uri: string; + }[]; } export interface NonStableIDIssue extends BaseUI5XMLViewIssue { @@ -113,7 +117,7 @@ type XMLAttributeValidator = ( context: Context ) => T[]; -type XMLDocumentValidator = (document: XMLDocument) => T[]; +type XMLDocumentValidator = (document: XMLDocument, context: Context) => T[]; type XMLElementValidator = (XMLElement: XMLElement, context: Context) => T[]; @@ -123,7 +127,6 @@ export type Validators = { validateBooleanValue: XMLAttributeValidator; validateUseOfDeprecatedAttribute: XMLAttributeValidator; validateUnknownAttributeKey: XMLAttributeValidator; - validateNonUniqueID: XMLDocumentValidator; validateUseOfDeprecatedAggregation: XMLElementValidator; validateUseOfDeprecatedClass: XMLElementValidator; validateUnknownTagName: XMLElementValidator; @@ -135,3 +138,5 @@ export type Validators = { export const validators: Validators; export function isPossibleBindingAttributeValue(value: string): boolean; + +export { validateNonUniqueID } from "./src/api"; diff --git a/packages/xml-views-validation/package.json b/packages/xml-views-validation/package.json index e8cbda98c..6243a50ca 100644 --- a/packages/xml-views-validation/package.json +++ b/packages/xml-views-validation/package.json @@ -22,9 +22,11 @@ "typings": "./api.d.ts", "dependencies": { "@ui5-language-assistant/constant": "0.0.1", + "@ui5-language-assistant/context": "4.0.28", "@ui5-language-assistant/logic-utils": "4.0.19", "@ui5-language-assistant/semantic-model-types": "4.0.11", "@ui5-language-assistant/user-facing-text": "4.0.8", + "vscode-languageserver-types": "3.17.2", "@xml-tools/ast": "5.0.0", "@xml-tools/common": "0.1.2", "deep-freeze-strict": "1.1.1", diff --git a/packages/xml-views-validation/src/api.ts b/packages/xml-views-validation/src/api.ts index 10f42e98b..b2210b74c 100644 --- a/packages/xml-views-validation/src/api.ts +++ b/packages/xml-views-validation/src/api.ts @@ -9,7 +9,6 @@ import { validateUseOfDeprecatedClass, validateUseOfDeprecatedAggregation, validateUseOfDeprecatedAttribute, - validateNonUniqueID, validateUnknownAttributeKey, validateUnknownTagName, validateExplicitAggregationCardinality, @@ -26,7 +25,6 @@ export const validators: Validators = { validateUseOfDeprecatedClass, validateUseOfDeprecatedAggregation, validateUseOfDeprecatedAttribute, - validateNonUniqueID, validateUnknownAttributeKey, validateUnknownTagName, validateExplicitAggregationCardinality, @@ -42,9 +40,11 @@ export function validateXMLView(opts: { const validatorVisitor = new ValidatorVisitor(opts.context, opts.validators); accept(opts.xmlView, validatorVisitor); const issues = validatorVisitor.collectedIssues; + return issues; } export type { UI5ValidatorsConfig } from "./validate-xml-views"; export { isPossibleBindingAttributeValue } from "../src/utils/is-binding-attribute-value"; +export { validateNonUniqueID } from "./validators/non-unique-id"; diff --git a/packages/xml-views-validation/src/validators/attributes/unknown-attribute-key.ts b/packages/xml-views-validation/src/validators/attributes/unknown-attribute-key.ts index e3d2eb71b..9f42f4a95 100644 --- a/packages/xml-views-validation/src/validators/attributes/unknown-attribute-key.ts +++ b/packages/xml-views-validation/src/validators/attributes/unknown-attribute-key.ts @@ -11,13 +11,12 @@ import { flattenAggregations, ui5NodeToFQN, splitQNameByNamespace, -} from "@ui5-language-assistant/logic-utils"; -import { UnknownAttributeKeyIssue } from "../../../api"; -import { TEMPLATING_NS, CUSTOM_DATA_NS, CORE_NS, -} from "../../utils/special-namespaces"; +} from "@ui5-language-assistant/logic-utils"; +import { UnknownAttributeKeyIssue } from "../../../api"; + import { Context } from "@ui5-language-assistant/context"; export function validateUnknownAttributeKey( diff --git a/packages/xml-views-validation/src/validators/document/non-unique-id.ts b/packages/xml-views-validation/src/validators/document/non-unique-id.ts deleted file mode 100644 index 7faa13a1e..000000000 --- a/packages/xml-views-validation/src/validators/document/non-unique-id.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { reject, flatMap, map, pickBy } from "lodash"; -import { - accept, - XMLAstVisitor, - XMLAttribute, - XMLDocument, - XMLToken, -} from "@xml-tools/ast"; -import { - validations, - buildMessage, -} from "@ui5-language-assistant/user-facing-text"; -import { NonUniqueIDIssue } from "../../../api"; -import { isPossibleCustomClass } from "../../utils/ui5-classes"; - -const { NON_UNIQUE_ID } = validations; - -export function validateNonUniqueID(xmlDoc: XMLDocument): NonUniqueIDIssue[] { - const idCollector = new IdsCollectorVisitor(); - accept(xmlDoc, idCollector); - const idsToXMLElements = idCollector.idsToXMLElements; - const duplicatedIdsRecords = pickBy(idsToXMLElements, (_) => _.length > 1); - - const allIDsIssues: NonUniqueIDIssue[] = flatMap( - duplicatedIdsRecords, - buildIssuesForSingleID - ); - - return allIDsIssues; -} - -function buildIssuesForSingleID( - duplicatedAttributes: DuplicatedIDXMLAttribute[], - id: string -): NonUniqueIDIssue[] { - const issuesForID = map( - duplicatedAttributes, - (currDupAttrib, currAttribIdx): NonUniqueIDIssue => { - const currDupIdValToken = currDupAttrib.syntax.value; - // Related issues must not include the "main" issue attribute - const relatedOtherDupIDAttribs = reject( - duplicatedAttributes, - (_, arrIdx) => arrIdx === currAttribIdx - ); - - return { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, id), - severity: "error", - offsetRange: { - start: currDupIdValToken.startOffset, - end: currDupIdValToken.endOffset, - }, - identicalIDsRanges: map(relatedOtherDupIDAttribs, (_) => ({ - start: _.syntax.value.startOffset, - end: _.syntax.value.endOffset, - })), - }; - } - ); - - return issuesForID; -} - -type DuplicatedIDXMLAttribute = XMLAttribute & { syntax: { value: XMLToken } }; - -class IdsCollectorVisitor implements XMLAstVisitor { - public idsToXMLElements: Record = - Object.create(null); - - visitXMLAttribute(attrib: XMLAttribute): void { - if ( - attrib.key === "id" && - attrib.value !== null && - attrib.value !== "" && - attrib.syntax.value !== undefined && - attrib.parent.name !== null && - // @ts-expect-error - we already checked that xmlElement.name is not null - isPossibleCustomClass(attrib.parent) - ) { - if (this.idsToXMLElements[attrib.value] === undefined) { - // @ts-expect-error - TSC does not understand: `attrib.syntax.value !== undefined` is a type guard - this.idsToXMLElements[attrib.value] = [attrib]; - } else { - // @ts-expect-error - TSC does not understand: `attrib.syntax.value !== undefined` is a type guard - this.idsToXMLElements[attrib.value].push(attrib); - } - } - } -} diff --git a/packages/xml-views-validation/src/validators/elements/non-stable-id.ts b/packages/xml-views-validation/src/validators/elements/non-stable-id.ts index 55b529a8b..11b0a75c7 100644 --- a/packages/xml-views-validation/src/validators/elements/non-stable-id.ts +++ b/packages/xml-views-validation/src/validators/elements/non-stable-id.ts @@ -1,16 +1,17 @@ import { some, includes } from "lodash"; import { XMLElement } from "@xml-tools/ast"; -import { resolveXMLNS } from "@ui5-language-assistant/logic-utils"; +import { + resolveXMLNS, + isPossibleCustomClass, + isKnownUI5Class, + CORE_NS, +} from "@ui5-language-assistant/logic-utils"; import { validations, buildMessage, } from "@ui5-language-assistant/user-facing-text"; import { NonStableIDIssue } from "../../../api"; -import { - isPossibleCustomClass, - isKnownUI5Class, -} from "../../utils/ui5-classes"; -import { CORE_NS } from "../../utils/special-namespaces"; + import { Context } from "@ui5-language-assistant/context"; const { NON_STABLE_ID } = validations; diff --git a/packages/xml-views-validation/src/validators/elements/unknown-tag-name.ts b/packages/xml-views-validation/src/validators/elements/unknown-tag-name.ts index 1591ac482..ba2ad25cd 100644 --- a/packages/xml-views-validation/src/validators/elements/unknown-tag-name.ts +++ b/packages/xml-views-validation/src/validators/elements/unknown-tag-name.ts @@ -13,12 +13,12 @@ import { isSameXMLNS, resolveXMLNS, getUI5KindByXMLElement, + CORE_NS, } from "@ui5-language-assistant/logic-utils"; import { validations, buildMessage, } from "@ui5-language-assistant/user-facing-text"; -import { CORE_NS } from "../../utils/special-namespaces"; import { Context } from "@ui5-language-assistant/context"; const { diff --git a/packages/xml-views-validation/src/validators/index.ts b/packages/xml-views-validation/src/validators/index.ts index d3a218125..b0c23b078 100644 --- a/packages/xml-views-validation/src/validators/index.ts +++ b/packages/xml-views-validation/src/validators/index.ts @@ -6,7 +6,6 @@ import { validateBooleanValue } from "./attributes/invalid-boolean-value"; import { validateUseOfDeprecatedClass } from "./elements/use-of-deprecated-class"; import { validateUseOfDeprecatedAggregation } from "./elements/use-of-depracated-aggregation"; import { validateUseOfDeprecatedAttribute } from "./attributes/use-of-depracated-attribute"; -import { validateNonUniqueID } from "./document/non-unique-id"; import { validateUnknownAttributeKey } from "./attributes/unknown-attribute-key"; import { validateUnknownTagName } from "./elements/unknown-tag-name"; import { validateExplicitAggregationCardinality } from "./elements/cardinality-of-aggregation"; @@ -18,7 +17,7 @@ export { validateBooleanValue } from "./attributes/invalid-boolean-value"; export { validateUseOfDeprecatedClass } from "./elements/use-of-deprecated-class"; export { validateUseOfDeprecatedAggregation } from "./elements/use-of-depracated-aggregation"; export { validateUseOfDeprecatedAttribute } from "./attributes/use-of-depracated-attribute"; -export { validateNonUniqueID } from "./document/non-unique-id"; +export { validateNonUniqueID } from "./non-unique-id"; export { validateUnknownAttributeKey } from "./attributes/unknown-attribute-key"; export { validateUnknownTagName } from "./elements/unknown-tag-name"; export { validateExplicitAggregationCardinality } from "./elements/cardinality-of-aggregation"; @@ -26,7 +25,7 @@ export { validateAggregationType } from "./elements/type-of-aggregation"; export { validateNonStableId } from "./elements/non-stable-id"; export const defaultValidators: UI5ValidatorsConfig = { - document: [validateNonUniqueID], + document: [], element: [ validateUseOfDeprecatedClass, validateUseOfDeprecatedAggregation, diff --git a/packages/xml-views-validation/src/validators/non-unique-id.ts b/packages/xml-views-validation/src/validators/non-unique-id.ts new file mode 100644 index 000000000..41a11e488 --- /dev/null +++ b/packages/xml-views-validation/src/validators/non-unique-id.ts @@ -0,0 +1,35 @@ +import { map } from "lodash"; +import { + validations, + buildMessage, +} from "@ui5-language-assistant/user-facing-text"; +import { NonUniqueIDIssue } from "../../api"; +import { Context } from "@ui5-language-assistant/context"; +import { pathToFileURL } from "url"; + +const { NON_UNIQUE_ID } = validations; + +export function validateNonUniqueID(context: Context): NonUniqueIDIssue[] { + const allIDsIssues: NonUniqueIDIssue[] = []; + const uri = pathToFileURL(context.documentPath).toString(); + for (const [key, value] of context.controlIds) { + if (value.length > 1) { + const currentDocIssues = value.filter((i) => i.uri === uri); + const otherDocsIssues = value.filter((i) => i.uri !== uri); + for (const currentDocIssue of currentDocIssues) { + allIDsIssues.push({ + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, key), + severity: "error", + offsetRange: currentDocIssue.offsetRange, + identicalIDsRanges: map(otherDocsIssues, (_) => ({ + range: _.range, + uri: _.uri, + })), + }); + } + } + } + return allIDsIssues; +} diff --git a/packages/xml-views-validation/test/unit/test-utils.ts b/packages/xml-views-validation/test/unit/test-utils.ts index 128cd7d9b..74f79265e 100644 --- a/packages/xml-views-validation/test/unit/test-utils.ts +++ b/packages/xml-views-validation/test/unit/test-utils.ts @@ -35,6 +35,7 @@ export function testValidationsScenario(opts: { const xmlTextNoMarkers = opts.xmlText.replace(rangeMarkersRegExp, ""); const { cst, tokenVector } = parse(xmlTextNoMarkers); const ast = buildAst(cst as DocumentCstNode, tokenVector); + opts.context.viewFiles[opts.context.documentPath] = ast; const issues = validateXMLView({ validators: { @@ -143,5 +144,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { framework: DEFAULT_UI5_FRAMEWORK, version: undefined, }, + viewFiles: {}, + controlIds: new Map(), + documentPath: "", }; }; diff --git a/packages/xml-views-validation/test/unit/validators/document/non-unique-id.test.ts b/packages/xml-views-validation/test/unit/validators/document/non-unique-id.test.ts deleted file mode 100644 index db2085771..000000000 --- a/packages/xml-views-validation/test/unit/validators/document/non-unique-id.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; -import { - DEFAULT_UI5_FRAMEWORK, - DEFAULT_UI5_VERSION, -} from "@ui5-language-assistant/constant"; -import { - generateModel, - getFallbackPatchVersions, -} from "@ui5-language-assistant/test-utils"; -import { generate } from "@ui5-language-assistant/semantic-model"; -import { - validations, - buildMessage, -} from "@ui5-language-assistant/user-facing-text"; -import { validators } from "../../../../src/api"; -import { NonUniqueIDIssue } from "../../../../api"; -import { - computeExpectedRanges, - getDefaultContext, - testValidationsScenario, -} from "../../test-utils"; -import { Context as AppContext } from "@ui5-language-assistant/context"; - -const { NON_UNIQUE_ID } = validations; - -describe("the use of non unique id validation", () => { - let ui5SemanticModel: UI5SemanticModel; - let appContext: AppContext; - let testNonUniqueIDScenario: (opts: { - xmlText: string; - assertion: (issues: NonUniqueIDIssue[]) => void; - }) => void; - - beforeAll(async () => { - ui5SemanticModel = await generateModel({ - framework: DEFAULT_UI5_FRAMEWORK, - version: ( - await getFallbackPatchVersions() - ).SAPUI5 as typeof DEFAULT_UI5_VERSION, - modelGenerator: generate, - }); - appContext = getDefaultContext(ui5SemanticModel); - testNonUniqueIDScenario = (opts): void => - testValidationsScenario({ - context: appContext, - validators: { - document: [validators.validateNonUniqueID], - }, - xmlText: opts.xmlText, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - assertion: opts.assertion as any, - }); - }); - - describe("true positive scenarios", () => { - it("will detect two duplicate ID in different controls", () => { - const xmlSnippet = ` - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toHaveLength(2); - - const expectedRanges = computeExpectedRanges(xmlSnippet); - - expect(issues).toIncludeAllMembers([ - { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), - severity: "error", - offsetRange: expectedRanges[0], - identicalIDsRanges: [expectedRanges[1]], - }, - { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), - severity: "error", - offsetRange: expectedRanges[1], - identicalIDsRanges: [expectedRanges[0]], - }, - ]); - }, - }); - }); - - it("will detect two duplicate ID in different custom controls", () => { - const xmlSnippet = ` - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toHaveLength(2); - - const expectedRanges = computeExpectedRanges(xmlSnippet); - - expect(issues).toIncludeAllMembers([ - { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), - severity: "error", - offsetRange: expectedRanges[0], - identicalIDsRanges: [expectedRanges[1]], - }, - { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), - severity: "error", - offsetRange: expectedRanges[1], - identicalIDsRanges: [expectedRanges[0]], - }, - ]); - }, - }); - }); - - it("will detect three duplicate ID in different controls", () => { - const xmlSnippet = ` - - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toHaveLength(3); - - const expectedRanges = computeExpectedRanges(xmlSnippet); - - expect(issues).toIncludeAllMembers([ - { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, "TRIPLICATE"), - severity: "error", - offsetRange: expectedRanges[0], - identicalIDsRanges: [expectedRanges[1], expectedRanges[2]], - }, - { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, "TRIPLICATE"), - severity: "error", - offsetRange: expectedRanges[1], - identicalIDsRanges: [expectedRanges[0], expectedRanges[2]], - }, - { - issueType: "base", - kind: "NonUniqueIDIssue", - message: buildMessage(NON_UNIQUE_ID.msg, "TRIPLICATE"), - severity: "error", - offsetRange: expectedRanges[2], - identicalIDsRanges: [expectedRanges[0], expectedRanges[1]], - }, - ]); - }, - }); - }); - }); - - describe("negative edge cases", () => { - it("will not detect issues for duplicate attribute keys that are not `id`", () => { - const xmlSnippet = ` - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toBeEmpty; - }, - }); - }); - - it("will not detect issues for attributes that do not have a value", () => { - const xmlSnippet = ` - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toBeEmpty; - }, - }); - }); - - it("will not detect issues for attributes that have an empty value", () => { - const xmlSnippet = ` - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toBeEmpty; - }, - }); - }); - - it("will not detect issues for id attributes under lowercase element tags", () => { - const xmlSnippet = ` - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toBeEmpty; - }, - }); - }); - - it("will not detect issues for id attributes under whitelisted (none UI5) namespaces", () => { - const xmlSnippet = ` - - - - `; - - testNonUniqueIDScenario({ - xmlText: xmlSnippet, - assertion: (issues) => { - expect(issues).toBeEmpty; - }, - }); - }); - }); -}); diff --git a/packages/xml-views-validation/test/unit/validators/index.test.ts b/packages/xml-views-validation/test/unit/validators/index.test.ts index 636bd6e0b..636495463 100644 --- a/packages/xml-views-validation/test/unit/validators/index.test.ts +++ b/packages/xml-views-validation/test/unit/validators/index.test.ts @@ -23,7 +23,9 @@ describe("The `allValidators` constant", () => { !_.endsWith("index.js") && // "non-stable-id" validation is not part of allValidators. // We use it only when `flexEnabled` is set to true. - !_.endsWith("non-stable-id.js") + !_.endsWith("non-stable-id.js") && + // non-unique-id validation is not part of allValidators. It is called explicity after all validation to collect non-unique ids cross all xml view files under webapp + !_.endsWith("non-unique-id.js") ); expect(validatorPaths).not.toBeEmpty(); diff --git a/packages/xml-views-validation/test/unit/validators/non-unique-id.test.ts b/packages/xml-views-validation/test/unit/validators/non-unique-id.test.ts new file mode 100644 index 000000000..b6b831c28 --- /dev/null +++ b/packages/xml-views-validation/test/unit/validators/non-unique-id.test.ts @@ -0,0 +1,394 @@ +import { + Config, + ProjectName, + ProjectType, + TestFramework, +} from "@ui5-language-assistant/test-framework"; +import { Context, getContext, cache } from "@ui5-language-assistant/context"; +import { join } from "path"; +import { validateNonUniqueID } from "../../../src/validators"; +import { + validations, + buildMessage, +} from "@ui5-language-assistant/user-facing-text"; +import { XMLAttribute, XMLDocument, XMLElement } from "@xml-tools/ast"; +import { + locationToRange, + OffsetRange, +} from "@ui5-language-assistant/logic-utils"; +import { Range } from "vscode-languageserver-types"; +import { pathToFileURL } from "url"; + +const { NON_UNIQUE_ID } = validations; +let testFramework: TestFramework; +const viewFilePathSegments = [ + "app", + "manage_travels", + "webapp", + "ext", + "main", + "Main.view.xml", +]; + +const getDocumentPath = () => + join(testFramework.getProjectRoot(), ...viewFilePathSegments); +const getOffsetRange = ( + attributes: XMLAttribute[], + key: string +): OffsetRange | undefined => { + const id = attributes.find((i) => i.key === key); + if (id) { + const offsetRange = { + start: id.syntax.value?.startOffset ?? 0, + end: id.syntax.value?.endOffset ?? 0, + }; + return offsetRange; + } + return; +}; + +function processOffsetRanges( + elements: XMLElement[], + offSetRanges: OffsetRange[], + key = "id" +) { + for (const el of elements) { + const offsetRange = getOffsetRange(el.attributes, key); + if (offsetRange) { + offSetRanges.push(offsetRange); + } + if (el.subElements.length) { + processOffsetRanges(el.subElements, offSetRanges, key); + } + } +} + +const getOffsetRanges = ( + context: Context, + key = "id" +): Record => { + const viewFiles = Object.keys(context.viewFiles); + const offsetRangesPerView = {}; + for (const view of viewFiles) { + const xmlDoc = context.viewFiles[view]; + if (xmlDoc.rootElement) { + const offsetRanges: OffsetRange[] = []; + const offsetRange = getOffsetRange(xmlDoc.rootElement?.attributes, key); + if (offsetRange) { + offsetRanges.push(offsetRange); + } + processOffsetRanges(xmlDoc.rootElement.subElements, offsetRanges, key); + offsetRangesPerView[view] = offsetRanges; + } + } + return offsetRangesPerView; +}; + +function getIdRanges(elements: XMLElement[], ranges: Range[] = []): Range[] { + for (const el of elements) { + const id = el.attributes.find((i) => i.key === "id"); + if (id) { + ranges.push(locationToRange(id.syntax.value)); + } + if (el.subElements.length) { + getIdRanges(el.subElements, ranges); + } + } + return ranges; +} + +async function getParam(xmlSnippet: string): Promise<{ + context: Context; + xmlView: XMLDocument; + offsetRanges: Record; + documentPath: string; +}> { + await testFramework.updateFile(viewFilePathSegments, xmlSnippet); + const documentPath = getDocumentPath(); + const context = (await getContext(documentPath)) as Context; + const xmlView = context.viewFiles[context.documentPath]; + const offsetRanges = getOffsetRanges(context); + return { context, xmlView, offsetRanges, documentPath }; +} +describe("the use of non unique id validation", () => { + beforeEach(function () { + const useConfig: Config = { + projectInfo: { + name: ProjectName.cap, + type: ProjectType.CAP, + npmInstall: false, + }, + }; + testFramework = new TestFramework(useConfig); + // reset cache to avoid side effect + cache.reset(); + }); + describe("true positive scenarios", () => { + it("will detect two duplicate ID in different controls", async () => { + // arrange + const xmlSnippet = ` + + + `; + const { context, offsetRanges, documentPath } = await getParam( + xmlSnippet + ); + const offset = offsetRanges[documentPath]; + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toEqual([ + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), + severity: "error", + offsetRange: offset[0], + identicalIDsRanges: [], + }, + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), + severity: "error", + offsetRange: offset[1], + identicalIDsRanges: [], + }, + ]); + }); + + it("will detect two duplicate ID in different custom controls", async () => { + // arrange + const xmlSnippet = ` + + + `; + + const { context, offsetRanges, documentPath } = await getParam( + xmlSnippet + ); + const offset = offsetRanges[documentPath]; + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toEqual([ + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), + severity: "error", + offsetRange: offset[0], + identicalIDsRanges: [], + }, + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), + severity: "error", + offsetRange: offset[1], + identicalIDsRanges: [], + }, + ]); + }); + it("will detect three duplicate ID in different controls", async () => { + // arrange + const xmlSnippet = ` + + + + `; + const { context, offsetRanges, documentPath } = await getParam( + xmlSnippet + ); + const offset = offsetRanges[documentPath]; + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toEqual([ + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "TRIPLICATE"), + severity: "error", + offsetRange: offset[0], + identicalIDsRanges: [], + }, + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "TRIPLICATE"), + severity: "error", + offsetRange: offset[1], + identicalIDsRanges: [], + }, + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "TRIPLICATE"), + severity: "error", + offsetRange: offset[2], + identicalIDsRanges: [], + }, + ]); + }); + + it("will detect duplicate IDs cross view files", async () => { + // arrange + const xmlSnippet = ` + + + + `; + // modify context + const xmlSnippet02 = ` + + + + `; + + const CustomSectionSegments = [ + "app", + "manage_travels", + "webapp", + "ext", + "fragment", + "CustomSection.fragment.xml", + ]; + await testFramework.updateFile(CustomSectionSegments, xmlSnippet02); + const customSectionPath = join( + testFramework.getProjectRoot(), + ...CustomSectionSegments + ); + + const { context, offsetRanges, documentPath } = await getParam( + xmlSnippet + ); + const offset = offsetRanges[documentPath]; + const ranges = getIdRanges( + context.viewFiles[customSectionPath].rootElement?.subElements ?? [] + ); + const customSectionUri = pathToFileURL(customSectionPath).toString(); + const identicalIDsRanges = ranges.map((range) => ({ + uri: customSectionUri, + range, + })); + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toEqual([ + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), + severity: "error", + offsetRange: offset[0], + identicalIDsRanges, + }, + { + issueType: "base", + kind: "NonUniqueIDIssue", + message: buildMessage(NON_UNIQUE_ID.msg, "DUPLICATE"), + severity: "error", + offsetRange: offset[1], + identicalIDsRanges, + }, + ]); + }); + }); + describe("negative edge cases", () => { + it("will not detect issues for duplicate attribute keys that are not `id`", async () => { + // arrange + const xmlSnippet = ` + + + `; + const { context } = await getParam(xmlSnippet); + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toBeEmpty(); + }); + it("will not detect issues for attributes that have an empty value", async () => { + // arrange + const xmlSnippet = ` + + + `; + const { context } = await getParam(xmlSnippet); + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toBeEmpty(); + }); + it("will not detect issues for id attributes under lowercase element tags", async () => { + // arrange + const xmlSnippet = ` + + + `; + const { context } = await getParam(xmlSnippet); + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toBeEmpty(); + }); + it("will not detect issues for id attributes under allow list (none UI5) namespaces", async () => { + // arrange + const xmlSnippet = ` + + + + `; + const { context } = await getParam(xmlSnippet); + // act + const result = validateNonUniqueID(context); + // assert + expect(result).toBeEmpty(); + }); + }); +}); diff --git a/scripts/merge-coverage.js b/scripts/merge-coverage.js deleted file mode 100644 index 33a0ff174..000000000 --- a/scripts/merge-coverage.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * based on https://github.com/istanbuljs/istanbuljs/blob/1fe490e51909607137ded25b1688581c9fd926cd/monorepo-merge-reports.js - */ -const { dirname, basename, join, resolve } = require("path"); -const { spawnSync } = require("child_process"); - -const rimraf = require("rimraf"); -const makeDir = require("make-dir"); -const glob = require("glob"); - -process.chdir(resolve(__dirname, "..")); -rimraf.sync(".nyc_output"); -makeDir.sync(".nyc_output"); - -console.log("Merging coverage from packages..."); - -// Merge coverage data from each package so we can generate a complete reports -glob.sync("packages/*/reports/test/unit/coverage").forEach((jestOutput) => { - const cwd = dirname(jestOutput); - const packageName = cwd.split("/")[1]; - console.log(packageName); - - const { status, stderr } = spawnSync( - resolve("node_modules", ".bin", "nyc"), - [ - "merge", - "coverage", - join(__dirname, "..", ".nyc_output", packageName + ".json"), - ], - { - encoding: "utf8", - shell: true, - cwd, - } - ); - - if (status !== 0) { - console.error(stderr); - process.exit(status); - } -}); - -const { status, stderr } = spawnSync( - resolve("node_modules", ".bin", "nyc"), - ["report", "--reporter=lcov"], - { - encoding: "utf8", - shell: true, - cwd: resolve(__dirname, ".."), - } -); - -if (status !== 0) { - console.error(stderr); - process.exit(status); -} diff --git a/test-packages/framework/package.json b/test-packages/framework/package.json index 1c9ef12f3..667dacf55 100644 --- a/test-packages/framework/package.json +++ b/test-packages/framework/package.json @@ -8,7 +8,7 @@ "api.d.ts", "src" ], - "main": "lib/api.js", + "main": "lib/src/api.js", "repository": "https://github.com/sap/ui5-language-assistant/", "license": "Apache-2.0", "typings": "./api.d.ts", @@ -27,7 +27,7 @@ "scripts": { "ci": "npm-run-all clean compile lint test", "clean": "rimraf ./lib ./coverage ./nyc_output ./.model-cache *.tsbuildinfo", - "compile": "yarn run clean && tsc -p tsconfig-build.json", + "compile": "yarn run clean && tsc -p .", "compile:watch": "tsc -p . --watch", "lint": "eslint . --ext .ts --max-warnings=0 --ignore-path=../../.gitignore", "test": "jest --ci --forceExit --detectOpenHandles --maxWorkers=1 --coverage=false" diff --git a/test-packages/framework/src/framework.ts b/test-packages/framework/src/framework.ts index f07378209..2697ea2a0 100644 --- a/test-packages/framework/src/framework.ts +++ b/test-packages/framework/src/framework.ts @@ -19,6 +19,7 @@ import { import { TestFrameworkAPI, ProjectInfo, Config, ReadFileResult } from "./types"; import { repeat } from "lodash"; import i18next, { i18n } from "i18next"; +import { getFrameworkRoot } from "./utils/project"; export const CURSOR_ANCHOR = "⇶"; @@ -50,8 +51,8 @@ export class TestFramework implements TestFrameworkAPI { * path to project folder */ private getProjectsSource(): string { - const dirname = __dirname; - return join(dirname, "..", "projects"); + const testFrameworkRoot = getFrameworkRoot(); + return join(testFrameworkRoot, "projects"); } private deleteProjectsCopy(): void { @@ -83,8 +84,8 @@ export class TestFramework implements TestFrameworkAPI { public getProjectRoot(): string { const { name } = this.projectInfo; - const dirname = __dirname; - return join(dirname, "..", "projects-copy", getPackageName(), name); + const testFrameworkRoot = getFrameworkRoot(); + return join(testFrameworkRoot, "projects-copy", getPackageName(), name); } public async initI18n(): Promise { diff --git a/test-packages/framework/src/utils/project.ts b/test-packages/framework/src/utils/project.ts index a4a7ff2a7..9aee51863 100644 --- a/test-packages/framework/src/utils/project.ts +++ b/test-packages/framework/src/utils/project.ts @@ -1,7 +1,7 @@ import { copySync, removeSync, pathExists, pathExistsSync } from "fs-extra"; import { execSync } from "child_process"; import { readdirSync } from "fs"; -import { join, sep } from "path"; +import { join, sep, dirname } from "path"; import { print } from "."; import { ProjectInfo } from "../api"; @@ -113,3 +113,15 @@ export const fileExits = (filePath: string): Promise => { export const fileExitsSync = (filePath: string): boolean => { return pathExistsSync(filePath); }; + +/** + * Retrieves the root directory of the test framework package. + * + * @returns The root directory of the test framework package. + */ +export function getFrameworkRoot(): string { + const pkgJsonPath = require.resolve( + "@ui5-language-assistant/test-framework/package.json" + ); + return dirname(pkgJsonPath); +} diff --git a/test-packages/framework/tsconfig-build.json b/test-packages/framework/tsconfig-build.json deleted file mode 100644 index 1d404643e..000000000 --- a/test-packages/framework/tsconfig-build.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["src/**/*.ts"], - "exclude": ["test/**/*.ts"], - "compilerOptions": { - "rootDir": "src" - } -} diff --git a/yarn.lock b/yarn.lock index 09639d108..03ec9f678 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,7 +32,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.7.tgz#61caffb60776e49a57ba61a88f02bedd8714f6bc" integrity sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.4.4", "@babel/core@^7.7.5": +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.4.4": version "7.21.8" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.8.tgz#2a8c7f0f53d60100ba4c32470ba0281c92aa9aa4" integrity sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ== @@ -2459,23 +2459,11 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" -append-transform@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12" - integrity sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg== - dependencies: - default-require-extensions "^3.0.0" - "aproba@^1.0.3 || ^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== -archy@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" - integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw== - are-we-there-yet@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz#679df222b278c64f2cdba1175cdc00b0d96164bd" @@ -2912,16 +2900,6 @@ cachedir@2.3.0: resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== -caching-transform@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-4.0.0.tgz#00d297a4206d71e2163c39eaffa8157ac0651f0f" - integrity sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA== - dependencies: - hasha "^5.0.0" - make-dir "^3.0.0" - package-hash "^4.0.0" - write-file-atomic "^3.0.0" - call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -3881,13 +3859,6 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== -default-require-extensions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-3.0.1.tgz#bfae00feeaeada68c2ae256c62540f60b80625bd" - integrity sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw== - dependencies: - strip-bom "^4.0.0" - defaults@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" @@ -4242,11 +4213,6 @@ es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@ es6-symbol "^3.1.3" next-tick "^1.1.0" -es6-error@^4.0.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" - integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== - es6-iterator@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" @@ -4694,15 +4660,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -find-cache-dir@^3.2.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" - integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== - dependencies: - commondir "^1.0.1" - make-dir "^3.0.2" - pkg-dir "^4.1.0" - find-node-modules@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/find-node-modules/-/find-node-modules-2.1.3.tgz#3c976cff2ca29ee94b4f9eafc613987fc4c0ee44" @@ -4804,14 +4761,6 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" -foreground-child@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" - integrity sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA== - dependencies: - cross-spawn "^7.0.0" - signal-exit "^3.0.2" - foreground-child@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" @@ -4852,11 +4801,6 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -fromentries@^1.2.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.3.2.tgz#e4bca6808816bf8f93b52750f1127f5a6fd86e3a" - integrity sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg== - fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -5372,14 +5316,6 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -hasha@^5.0.0: - version "5.2.2" - resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.2.2.tgz#a48477989b3b327aea3c04f53096d816d97522a1" - integrity sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ== - dependencies: - is-stream "^2.0.0" - type-fest "^0.8.0" - homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" @@ -5886,7 +5822,7 @@ is-typed-array@^1.1.10, is-typed-array@^1.1.9: gopd "^1.0.1" has-tostringtag "^1.0.0" -is-typedarray@^1.0.0, is-typedarray@~1.0.0: +is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== @@ -5908,7 +5844,7 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" -is-windows@^1.0.0, is-windows@^1.0.1, is-windows@^1.0.2: +is-windows@^1.0.0, is-windows@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== @@ -5950,23 +5886,6 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== -istanbul-lib-hook@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz#8f84c9434888cc6b1d0a9d7092a76d239ebf0cc6" - integrity sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ== - dependencies: - append-transform "^2.0.0" - -istanbul-lib-instrument@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" - integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== - dependencies: - "@babel/core" "^7.7.5" - "@istanbuljs/schema" "^0.1.2" - istanbul-lib-coverage "^3.0.0" - semver "^6.3.0" - istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" @@ -5978,18 +5897,6 @@ istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: istanbul-lib-coverage "^3.2.0" semver "^6.3.0" -istanbul-lib-processinfo@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz#366d454cd0dcb7eb6e0e419378e60072c8626169" - integrity sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg== - dependencies: - archy "^1.0.0" - cross-spawn "^7.0.3" - istanbul-lib-coverage "^3.2.0" - p-map "^3.0.0" - rimraf "^3.0.0" - uuid "^8.3.2" - istanbul-lib-report@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" @@ -6008,7 +5915,7 @@ istanbul-lib-source-maps@^4.0.0: istanbul-lib-coverage "^3.0.0" source-map "^0.6.1" -istanbul-reports@^3.0.2, istanbul-reports@^3.1.3: +istanbul-reports@^3.1.3: version "3.1.5" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae" integrity sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w== @@ -6995,11 +6902,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash.flattendeep@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" - integrity sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ== - lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" @@ -7144,7 +7046,7 @@ lru-queue@^0.1.0: dependencies: es5-ext "~0.10.2" -make-dir@3.1.0, make-dir@^3.0.0, make-dir@^3.0.2: +make-dir@3.1.0, make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -7680,13 +7582,6 @@ node-machine-id@1.1.12: resolved "https://registry.yarnpkg.com/node-machine-id/-/node-machine-id-1.1.12.tgz#37904eee1e59b320bb9c5d6c0a59f3b469cb6267" integrity sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ== -node-preload@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/node-preload/-/node-preload-0.2.1.tgz#c03043bb327f417a18fee7ab7ee57b408a144301" - integrity sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ== - dependencies: - process-on-spawn "^1.0.0" - node-releases@^2.0.8: version "2.0.10" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" @@ -7921,39 +7816,6 @@ nx@16.9.1, "nx@>=16.5.1 < 17": "@nx/nx-win32-arm64-msvc" "16.9.1" "@nx/nx-win32-x64-msvc" "16.9.1" -nyc@15.1.0: - version "15.1.0" - resolved "https://registry.yarnpkg.com/nyc/-/nyc-15.1.0.tgz#1335dae12ddc87b6e249d5a1994ca4bdaea75f02" - integrity sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A== - dependencies: - "@istanbuljs/load-nyc-config" "^1.0.0" - "@istanbuljs/schema" "^0.1.2" - caching-transform "^4.0.0" - convert-source-map "^1.7.0" - decamelize "^1.2.0" - find-cache-dir "^3.2.0" - find-up "^4.1.0" - foreground-child "^2.0.0" - get-package-type "^0.1.0" - glob "^7.1.6" - istanbul-lib-coverage "^3.0.0" - istanbul-lib-hook "^3.0.0" - istanbul-lib-instrument "^4.0.0" - istanbul-lib-processinfo "^2.0.2" - istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.0" - istanbul-reports "^3.0.2" - make-dir "^3.0.0" - node-preload "^0.2.1" - p-map "^3.0.0" - process-on-spawn "^1.0.0" - resolve-from "^5.0.0" - rimraf "^3.0.0" - signal-exit "^3.0.2" - spawn-wrap "^2.0.0" - test-exclude "^6.0.0" - yargs "^15.0.2" - oauth-sign@~0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" @@ -8152,13 +8014,6 @@ p-map@^2.0.0: resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== -p-map@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" - integrity sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ== - dependencies: - aggregate-error "^3.0.0" - p-pipe@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" @@ -8201,16 +8056,6 @@ p-waterfall@2.1.1: dependencies: p-reduce "^2.0.0" -package-hash@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/package-hash/-/package-hash-4.0.0.tgz#3537f654665ec3cc38827387fc904c163c54f506" - integrity sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ== - dependencies: - graceful-fs "^4.1.15" - hasha "^5.0.0" - lodash.flattendeep "^4.4.0" - release-zalgo "^1.0.0" - pacote@^15.2.0: version "15.2.0" resolved "https://registry.yarnpkg.com/pacote/-/pacote-15.2.0.tgz#0f0dfcc3e60c7b39121b2ac612bf8596e95344d3" @@ -8406,7 +8251,7 @@ pirates@^4.0.4: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== -pkg-dir@^4.1.0, pkg-dir@^4.2.0: +pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== @@ -8488,13 +8333,6 @@ process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -process-on-spawn@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/process-on-spawn/-/process-on-spawn-1.0.0.tgz#95b05a23073d30a17acfdc92a440efd2baefdc93" - integrity sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg== - dependencies: - fromentries "^1.2.0" - progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -8759,13 +8597,6 @@ regexpp@^3.1.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== -release-zalgo@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/release-zalgo/-/release-zalgo-1.0.0.tgz#09700b7e5074329739330e535c5a90fb67851730" - integrity sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA== - dependencies: - es6-error "^4.0.1" - remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" @@ -9252,18 +9083,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -spawn-wrap@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" - integrity sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg== - dependencies: - foreground-child "^2.0.0" - is-windows "^1.0.2" - make-dir "^3.0.0" - rimraf "^3.0.0" - signal-exit "^3.0.2" - which "^2.0.1" - spawndamnit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/spawndamnit/-/spawndamnit-2.0.0.tgz#9f762ac5c3476abb994b42ad592b5ad22bb4b0ad" @@ -10004,7 +9823,7 @@ type-fest@^0.6.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== -type-fest@^0.8.0, type-fest@^0.8.1: +type-fest@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== @@ -10036,13 +9855,6 @@ typed-rest-client@1.2.0: tunnel "0.0.4" underscore "1.8.3" -typedarray-to-buffer@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" - integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== - dependencies: - is-typedarray "^1.0.0" - typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" @@ -10582,16 +10394,6 @@ write-file-atomic@^2.4.2: imurmurhash "^0.1.4" signal-exit "^3.0.2" -write-file-atomic@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" - integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== - dependencies: - imurmurhash "^0.1.4" - is-typedarray "^1.0.0" - signal-exit "^3.0.2" - typedarray-to-buffer "^3.1.5" - write-file-atomic@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" @@ -10724,7 +10526,7 @@ yargs@16.2.0, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" -yargs@^15.0.2, yargs@^15.1.0: +yargs@^15.1.0: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==