diff --git a/.changeset/light-queens-listen.md b/.changeset/light-queens-listen.md new file mode 100644 index 000000000..e2a052dc9 --- /dev/null +++ b/.changeset/light-queens-listen.md @@ -0,0 +1,12 @@ +--- +"@ui5-language-assistant/vscode-ui5-language-assistant-bas-ext": patch +"vscode-ui5-language-assistant": patch +"@ui5-language-assistant/xml-views-completion": patch +"@ui5-language-assistant/xml-views-definition": patch +"@ui5-language-assistant/xml-views-validation": patch +"@ui5-language-assistant/xml-views-tooltip": patch +"@ui5-language-assistant/language-server": patch +"@ui5-language-assistant/context": patch +--- + +Enable go to controller's definition from XML view file diff --git a/packages/context/src/manifest.ts b/packages/context/src/manifest.ts index 0efa86665..29621fd51 100644 --- a/packages/context/src/manifest.ts +++ b/packages/context/src/manifest.ts @@ -219,7 +219,8 @@ function collectViewsCustomTemplates( * @param manifest manifest of an app */ async function extractManifestDetails( - manifest: Manifest + manifest: Manifest, + manifestPath: string ): Promise { const customViews = {}; const targets = manifest["sap.ui5"]?.routing?.targets || {}; @@ -258,6 +259,8 @@ async function extractManifestDetails( customViews, flexEnabled, minUI5Version, + appId: manifest["sap.app"]?.id ?? "", + manifestPath, }; } @@ -271,6 +274,8 @@ export async function getManifestDetails( const manifestPath = await findManifestPath(documentPath); if (!manifestPath) { return { + appId: "", + manifestPath: "", flexEnabled: false, customViews: {}, mainServicePath: undefined, @@ -280,13 +285,15 @@ export async function getManifestDetails( const manifest = await getUI5Manifest(manifestPath); if (!manifest) { return { + appId: "", + manifestPath: "", flexEnabled: false, customViews: {}, mainServicePath: undefined, minUI5Version: undefined, }; } - return extractManifestDetails(manifest); + return extractManifestDetails(manifest, manifestPath); } /** diff --git a/packages/context/src/types.ts b/packages/context/src/types.ts index ad6b257bd..29b1115de 100644 --- a/packages/context/src/types.ts +++ b/packages/context/src/types.ts @@ -46,12 +46,16 @@ export interface ServiceDetails { * @param minUI5Version minimum version of UI5 * @param mainServicePath path to a main OData service * @param customViews record of views id and their entity set + * @param appId application id under `sap.app` namespace + * @param manifestPath path to manifest.json file */ export type ManifestDetails = { flexEnabled: boolean; minUI5Version: string | undefined; mainServicePath: string | undefined; customViews: { [name: string]: { entitySet?: string; contextPath?: string } }; + appId: string; + manifestPath: string; }; /** * @param framework UI5 framework diff --git a/packages/context/test/unit/api.test.ts b/packages/context/test/unit/api.test.ts index 03c9452ba..21b9715ec 100644 --- a/packages/context/test/unit/api.test.ts +++ b/packages/context/test/unit/api.test.ts @@ -16,6 +16,8 @@ describe("context", () => { const getManifestDetailsStub = jest .spyOn(manifest, "getManifestDetails") .mockResolvedValue({ + appId: "", + manifestPath: "", mainServicePath: "/", customViews: {}, flexEnabled: false, @@ -54,6 +56,8 @@ describe("context", () => { const getManifestDetailsStub = jest .spyOn(manifest, "getManifestDetails") .mockResolvedValue({ + appId: "", + manifestPath: "", mainServicePath: "/", customViews: {}, flexEnabled: false, diff --git a/packages/context/test/unit/manifest.test.ts b/packages/context/test/unit/manifest.test.ts index b7f6247ae..5525a5e3e 100644 --- a/packages/context/test/unit/manifest.test.ts +++ b/packages/context/test/unit/manifest.test.ts @@ -97,6 +97,8 @@ describe("manifest", () => { mainServicePath: "/processor/", flexEnabled: true, minUI5Version: "1.108.26", + appId: "sap.fe.demo.managetravels", + manifestPath: join(appRoot, "manifest.json"), }); }); @@ -173,8 +175,11 @@ describe("manifest", () => { }); try { const result = await getManifestDetails(docPath); + // adapt manifestPath + result.manifestPath = result.manifestPath.split(appRoot).join("."); expect(result).toMatchInlineSnapshot(` Object { + "appId": "", "customViews": Object { "template1": Object { "contextPath": "/Incidents/to_Customer", @@ -211,6 +216,7 @@ describe("manifest", () => { }, "flexEnabled": false, "mainServicePath": "//", + "manifestPath": "./manifest.json", "minUI5Version": undefined, } `); @@ -256,6 +262,8 @@ describe("manifest", () => { mainServicePath: "/processor/", flexEnabled: true, minUI5Version: "1.108.26", + appId: "sap.fe.demo.managetravels", + manifestPath: join(appRoot, "manifest.json"), }); } finally { mock.restore(); @@ -284,6 +292,8 @@ describe("manifest", () => { flexEnabled: false, mainServicePath: undefined, minUI5Version: undefined, + appId: "", + manifestPath: "", }); } finally { cacheSpy.mockRestore(); @@ -309,6 +319,8 @@ describe("manifest", () => { flexEnabled: false, mainServicePath: undefined, minUI5Version: undefined, + appId: "", + manifestPath: "", }); } finally { cacheGetSpy.mockRestore(); diff --git a/packages/context/test/unit/manifest_with_mock.test.ts b/packages/context/test/unit/manifest_with_mock.test.ts index d6d93ad1f..6daa6a192 100644 --- a/packages/context/test/unit/manifest_with_mock.test.ts +++ b/packages/context/test/unit/manifest_with_mock.test.ts @@ -35,9 +35,11 @@ describe("manifest", () => { const result = await getManifestDetails(docPath); expect(result).toMatchInlineSnapshot(` Object { + "appId": "", "customViews": Object {}, "flexEnabled": false, "mainServicePath": undefined, + "manifestPath": "", "minUI5Version": undefined, } `); diff --git a/packages/language-server/package.json b/packages/language-server/package.json index e118056b7..5465b90fc 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -25,6 +25,7 @@ "node": ">=10.0.0" }, "dependencies": { + "@ui5-language-assistant/xml-views-definition": "0.0.1", "@ui5-language-assistant/binding": "1.0.27", "@sap/swa-for-sapbas-vsx": "1.1.9", "@ui5-language-assistant/logger": "0.0.1", diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 00902c69a..e852a1c43 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -48,6 +48,7 @@ import { initSwa } from "./swa"; import { getLogger, setLogLevel } from "./logger"; import { initI18n } from "./i18n"; import { isXMLView } from "@ui5-language-assistant/logic-utils"; +import { getDefinition } from "@ui5-language-assistant/xml-views-definition"; const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); @@ -91,6 +92,7 @@ connection.onInitialize( triggerCharacters: ['"', "'", ":", "<", "/"], }, hoverProvider: true, + definitionProvider: true, codeActionProvider: true, // Each command executes a different code action scenario executeCommandProvider: { @@ -104,6 +106,8 @@ connection.onInitialize( } ); +connection.onDefinition(getDefinition); + connection.onInitialized(async (): Promise => { getLogger().info("`onInitialized` event"); if (hasConfigurationCapability) { diff --git a/packages/language-server/test/unit/completion-items-utils.ts b/packages/language-server/test/unit/completion-items-utils.ts index ed1e5ac87..0de57e58f 100644 --- a/packages/language-server/test/unit/completion-items-utils.ts +++ b/packages/language-server/test/unit/completion-items-utils.ts @@ -247,6 +247,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { ui5Model, customViewId: "", manifestDetails: { + appId: "", + manifestPath: "", flexEnabled: false, customViews: {}, mainServicePath: undefined, 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 56c116a97..e18662649 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 @@ -124,6 +124,8 @@ export async function computeNewDiagnosticLSPResponse( appContext = { ...getDefaultContext(ui5Model), manifestDetails: { + appId: "", + manifestPath: "", flexEnabled: options ? options.flexEnabled : false, customViews: {}, mainServicePath: undefined, diff --git a/packages/xml-views-completion/test/unit/utils.ts b/packages/xml-views-completion/test/unit/utils.ts index 03791022f..1d2828238 100644 --- a/packages/xml-views-completion/test/unit/utils.ts +++ b/packages/xml-views-completion/test/unit/utils.ts @@ -81,6 +81,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { ui5Model, customViewId: "", manifestDetails: { + appId: "", + manifestPath: "", flexEnabled: false, customViews: {}, mainServicePath: undefined, diff --git a/packages/xml-views-definition/CHANGELOG.md b/packages/xml-views-definition/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/xml-views-definition/CONTRIBUTING.md b/packages/xml-views-definition/CONTRIBUTING.md new file mode 100644 index 000000000..84eb94476 --- /dev/null +++ b/packages/xml-views-definition/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contribution Guide + +This package does not have any unique development flows. +Please see the top level [Contribution Guide](../../CONTRIBUTING.md). diff --git a/packages/xml-views-definition/README.md b/packages/xml-views-definition/README.md new file mode 100644 index 000000000..202e2f270 --- /dev/null +++ b/packages/xml-views-definition/README.md @@ -0,0 +1,39 @@ +[![npm (scoped)](https://img.shields.io/npm/v/@ui5-language-assistant/xml-views-definition.svg)](https://www.npmjs.com/package/@ui5-language-assistant/xml-views-definition) + +# @ui5-language-assistant/xml-views-definition + +Logic for goto definition of Language Server Protocol (LSP). + +## Supported scenarios: + +### From XML to controllers' definition: + +It supports dot or object notation for following XML attributes. + +- "controllerName" +- "template:require" +- "core:require" + +It resolves controllers' definition as follows: + +1. It tries to load `.controller.js` +2. It tries to load `.js` +3. It tries to load `.controller.ts` +4. It tries to load `.ts` + +## Usage + +This package only exposes programmatic APIs, import the package and use the exported APIs +defined in [api.d.ts](./api.d.ts). + +## Support + +Please open [issues](https://github.com/SAP/ui5-language-assistant/issues) on github. + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md). + +## Licensing + +Copyright 2022 SAP SE. Please see our [LICENSE](../../LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/SAP/ui5-language-assistant). diff --git a/packages/xml-views-definition/api.d.ts b/packages/xml-views-definition/api.d.ts new file mode 100644 index 000000000..01ed9b392 --- /dev/null +++ b/packages/xml-views-definition/api.d.ts @@ -0,0 +1 @@ +export { getDefinition } from "./src/api"; diff --git a/packages/xml-views-definition/jest.config.js b/packages/xml-views-definition/jest.config.js new file mode 100644 index 000000000..406cef575 --- /dev/null +++ b/packages/xml-views-definition/jest.config.js @@ -0,0 +1,15 @@ +const { join } = require("path"); +const defaultConfig = require("../../jest.config"); + +module.exports = { + ...defaultConfig, + globals: { + "ts-jest": { + tsconfig: join(__dirname, "tsconfig-test.json"), + diagnostics: { + // warnOnly: true, + exclude: /\.(spec|test)\.ts$/, + }, + }, + }, +}; diff --git a/packages/xml-views-definition/package.json b/packages/xml-views-definition/package.json new file mode 100644 index 000000000..e7b7a9909 --- /dev/null +++ b/packages/xml-views-definition/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ui5-language-assistant/xml-views-definition", + "version": "0.0.1", + "private": true, + "description": "Definition logic for UI5 XML-Views", + "keywords": [], + "files": [ + ".reuse", + "LICENSES", + "lib/src", + "api.d.ts", + "src" + ], + "main": "lib/src/api.js", + "repository": "https://github.com/sap/ui5-language-assistant/", + "license": "Apache-2.0", + "typings": "./api.d.ts", + "dependencies": { + "vscode-languageserver": "8.0.2", + "@xml-tools/ast": "5.0.0", + "vscode-languageserver-textdocument": "1.0.1", + "vscode-uri": "2.1.2", + "@ui5-language-assistant/context": "4.0.24", + "@xml-tools/parser": "1.0.7", + "@ui5-language-assistant/binding-parser": "1.0.7" + }, + "devDependencies": { + "vscode-languageserver-types": "3.17.2", + "@ui5-language-assistant/test-framework": "4.0.12" + }, + "scripts": { + "ci": "npm-run-all clean compile lint coverage", + "clean": "rimraf ./lib ./coverage ./nyc_output", + "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", + "coverage": "jest --ci --forceExit --detectOpenHandles --maxWorkers=1 --coverage=true" + } +} diff --git a/packages/xml-views-definition/src/api.ts b/packages/xml-views-definition/src/api.ts new file mode 100644 index 000000000..141d0eee6 --- /dev/null +++ b/packages/xml-views-definition/src/api.ts @@ -0,0 +1,16 @@ +import { Location } from "vscode-languageserver-types"; +import { DefinitionParams } from "vscode-languageserver"; +import { getControllerLocation } from "./controller"; + +/** + * Get definition location(s). This method implements `onDefinition` request of LSP (Language Server Protocol) + * + * @param param definition param + * @returns definition location(s) + */ +export async function getDefinition( + param: DefinitionParams +): Promise { + const ctrLoc = await getControllerLocation(param); + return [...ctrLoc]; +} diff --git a/packages/xml-views-definition/src/controller/index.ts b/packages/xml-views-definition/src/controller/index.ts new file mode 100644 index 000000000..f2aaa57e2 --- /dev/null +++ b/packages/xml-views-definition/src/controller/index.ts @@ -0,0 +1,99 @@ +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"; +import { + parseBinding, + positionInside, +} from "@ui5-language-assistant/binding-parser"; + +const allowedAttrs = new Set([ + "controllerName", + "template:require", + "core:require", +]); +const exts = [".controller.js", ".js", ".controller.ts", ".ts"]; + +/** + * Get controller location. + * It searches local file system for file with `.controller.js`, `.js`, `.controller.ts` or `.ts` extension. + * + * @param param definition param + * @returns array of location + */ +export async function getControllerLocation( + param: DefinitionParams +): Promise { + 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); + if (!ast.rootElement) { + return []; + } + const attr = getAttribute(ast.rootElement, position, allowedAttrs); + if (!attr) { + 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; + const manifestPath = context.manifestDetails.manifestPath; + + if (value.indexOf("/") !== -1) { + // handle object notation + /* istanbul ignore next */ + const character = attr.syntax.value?.startColumn ?? 0; + /* istanbul ignore next */ + const line = (attr.syntax.value && attr.syntax.value.startLine - 1) ?? 0; // zero based index + const pos: Position = { character, line }; + const result = parseBinding(value, pos); + /* istanbul ignore next */ + const el = result.ast.bindings[0]?.elements.find((i) => + /* istanbul ignore next */ + positionInside(i.value?.range, position) + ); + if (!el) { + return []; + } + /* istanbul ignore next */ + if (el.value?.type !== "string-value") { + return []; + } + const text = el.value.text.split("/").join(".").replace(/['"]/g, ""); + + const fileUri = await buildFileUri(id, text, manifestPath, exts); + if (!fileUri) { + return []; + } + return [ + { + uri: fileUri, + range: { start: position, end: position }, + }, + ]; + } + + // handle dot notation + const fileUri = await buildFileUri(id, value, manifestPath, exts); + if (!fileUri) { + return []; + } + return [ + { + uri: fileUri, + range: { start: position, end: position }, + }, + ]; +} diff --git a/packages/xml-views-definition/src/utils/file.ts b/packages/xml-views-definition/src/utils/file.ts new file mode 100644 index 000000000..3039134b8 --- /dev/null +++ b/packages/xml-views-definition/src/utils/file.ts @@ -0,0 +1,50 @@ +import { access, constants } from "fs"; +import { join, dirname } from "path"; + +/** + * Check if path exists on file system. + * + * @param filePath absolute path to a file on file system. + * @returns boolean + */ +export async function pathExists(filePath: string): Promise { + return new Promise((resolve) => { + access(filePath, constants.F_OK, (err) => { + if (err) { + resolve(false); + } else { + resolve(true); + } + }); + }); +} + +/** + * Build absolute file uri based on manifest path and value without namespace. + * + * @param namespace app id or app namespace + * @param value value with namespace + * @param manifestPath path to manifest + * @param exts file extension + * @returns file uri or undefined + */ +export async function buildFileUri( + namespace: string, + value: string, + manifestPath: string, + exts: string[] +): Promise { + /* istanbul ignore next */ + const parts = value.split(namespace); + const valueWithoutNS = parts.filter((i) => !!i).join("."); + /* istanbul ignore next */ + const withoutNSParts = valueWithoutNS.split("."); + const absolutePath = join(dirname(manifestPath), ...withoutNSParts); + + for (const ext of exts) { + const filePath = `${absolutePath}${ext}`; + if (await pathExists(filePath)) { + return filePath; + } + } +} diff --git a/packages/xml-views-definition/src/utils/index.ts b/packages/xml-views-definition/src/utils/index.ts new file mode 100644 index 000000000..31a3f125d --- /dev/null +++ b/packages/xml-views-definition/src/utils/index.ts @@ -0,0 +1,62 @@ +import { SourcePosition, XMLAttribute, XMLElement } from "@xml-tools/ast"; +import { Position } from "vscode-languageserver-textdocument"; +export { buildFileUri, pathExists } from "./file"; + +/** + * Check if cursor position is contained in source position. + * + * @param sourcePos source position + * @param cursorPosition cursor position + * @returns boolean + */ +export function positionContained( + sourcePos: SourcePosition, + cursorPosition: Position +): boolean { + if (sourcePos.startLine !== cursorPosition.line + 1) { + return false; + } + if ( + sourcePos.startColumn <= cursorPosition.character && + sourcePos.endColumn >= cursorPosition.character + ) { + return true; + } + return false; +} + +/** + * Get attribute from xml element. + * + * @param element xml element + * @param position cursor position + * @param allowedAttrs allowed xml attributes + * @returns xml attribute or undefined + */ +export function getAttribute( + element: XMLElement, + position: Position, + allowedAttrs: Set +): XMLAttribute | undefined { + for (const attr of element.attributes) { + if (allowedAttrs.has(attr.key ?? "")) { + const attrPositionContain = positionContained(attr.position, position); + if (attrPositionContain && attr.syntax.value) { + const valuePositionContain = positionContained( + attr.syntax.value, + position + ); + if (valuePositionContain) { + return attr; + } + } + } + } + const subElements = element.subElements || []; + for (const subElement of subElements) { + const result = getAttribute(subElement, position, allowedAttrs); + if (result) { + return result; + } + } +} diff --git a/packages/xml-views-definition/test/unit/api.test.ts b/packages/xml-views-definition/test/unit/api.test.ts new file mode 100644 index 000000000..bb3e424e3 --- /dev/null +++ b/packages/xml-views-definition/test/unit/api.test.ts @@ -0,0 +1,20 @@ +import { DefinitionParams, Range, Position } from "vscode-languageserver"; +import * as controller from "../../src/controller"; +import { getDefinition } from "../../src/api"; +describe("api", () => { + test("getDefinition", async () => { + // arrange + const parma = {} as DefinitionParams; + const data: Position = { character: 0, line: 0 }; + const range: Range = { start: data, end: data }; + const returnData = [{ range, uri: "file-uri" }]; + const spyController = jest + .spyOn(controller, "getControllerLocation") + .mockResolvedValue(returnData); + // act + const result = await getDefinition(parma); + // assert + expect(result).toEqual(returnData); + expect(spyController).toHaveBeenNthCalledWith(1, parma); + }); +}); diff --git a/packages/xml-views-definition/test/unit/controller/index.test.ts b/packages/xml-views-definition/test/unit/controller/index.test.ts new file mode 100644 index 000000000..f9ed18919 --- /dev/null +++ b/packages/xml-views-definition/test/unit/controller/index.test.ts @@ -0,0 +1,296 @@ +import { + Config, + ProjectName, + ProjectType, + TestFramework, + CURSOR_ANCHOR, +} from "@ui5-language-assistant/test-framework"; +import { DefinitionParams, Position } from "vscode-languageserver"; +import * as fs from "fs"; +import * as context from "@ui5-language-assistant/context"; +import { getControllerLocation } from "../../../src/controller"; +import { join } from "path"; + +describe("index", () => { + let testFramework: TestFramework; + let uri = ""; + const pathSegments = ["src", "view", "App.view.xml"]; + beforeEach(function () { + const useConfig: Config = { + projectInfo: { + name: ProjectName.tsFreeStyle, + type: ProjectType.UI5, + npmInstall: false, + }, + }; + testFramework = new TestFramework(useConfig); + uri = testFramework.getFileUri(pathSegments); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe("getControllerLocation", () => { + test("rootElement undefined - empty location", async () => { + // arrange + const param: DefinitionParams = { + position: {} as Position, + textDocument: { uri: "file:\\dummy" }, + }; + jest.spyOn(fs.promises, "readFile").mockResolvedValue(""); + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([]); + }); + test("wrong position - empty location", async () => { + // arrange + const param: DefinitionParams = { + position: { line: 0, character: 0 }, + textDocument: { uri }, + }; + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([]); + }); + test("getContext error - empty location", async () => { + // arrange + const content = ` + + + `; + const { offset } = await testFramework.updateFile(pathSegments, content); + const { textDocumentPosition } = testFramework.toVscodeTextDocument( + uri, + content, + offset + ); + const position = textDocumentPosition.position; + const param: DefinitionParams = { + position, + textDocument: { uri }, + }; + jest + .spyOn(context, "getContext") + .mockResolvedValue(new Error("error-raised")); + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([]); + }); + describe("object notation", () => { + test("wrong position - empty location", async () => { + // arrange + const content = ` + + + `; + const { offset } = await testFramework.updateFile( + pathSegments, + content + ); + const { textDocumentPosition } = testFramework.toVscodeTextDocument( + uri, + content, + offset + ); + + const param: DefinitionParams = { + position: textDocumentPosition.position, + textDocument: { uri }, + }; + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([]); + }); + test("can not build file uri - empty location", async () => { + // arrange + const content = ` + + + `; + const { offset } = await testFramework.updateFile( + pathSegments, + content + ); + const { textDocumentPosition } = testFramework.toVscodeTextDocument( + uri, + content, + offset + ); + + const param: DefinitionParams = { + position: textDocumentPosition.position, + textDocument: { uri }, + }; + // remove file + await fs.promises.unlink( + join( + testFramework.getProjectRoot(), + "src", + "controller", + "App.controller.ts" + ) + ); + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([]); + }); + test("correct position", async () => { + // arrange + const content = ` + + + `; + const { offset } = await testFramework.updateFile( + pathSegments, + content + ); + const { textDocumentPosition } = testFramework.toVscodeTextDocument( + uri, + content, + offset + ); + const position = textDocumentPosition.position; + const param: DefinitionParams = { + position, + textDocument: { uri }, + }; + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([ + { + uri: join( + testFramework.getProjectRoot(), + "src", + "controller", + "App.controller.ts" + ), + range: { start: position, end: position }, + }, + ]); + }); + }); + describe("dot notation", () => { + test("wrong position - empty location", async () => { + // arrange + const content = ` + + + `; + const { offset } = await testFramework.updateFile( + pathSegments, + content + ); + const { textDocumentPosition } = testFramework.toVscodeTextDocument( + uri, + content, + offset + ); + + const param: DefinitionParams = { + position: textDocumentPosition.position, + textDocument: { uri }, + }; + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([]); + }); + test("can not build file uri - empty location", async () => { + // arrange + const content = ` + + + `; + const { offset } = await testFramework.updateFile( + pathSegments, + content + ); + const { textDocumentPosition } = testFramework.toVscodeTextDocument( + uri, + content, + offset + ); + + const param: DefinitionParams = { + position: textDocumentPosition.position, + textDocument: { uri }, + }; + // remove file + await fs.promises.unlink( + join( + testFramework.getProjectRoot(), + "src", + "controller", + "App.controller.ts" + ) + ); + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([]); + }); + test("correct position", async () => { + // arrange + const content = ` + + + `; + const { offset } = await testFramework.updateFile( + pathSegments, + content + ); + const { textDocumentPosition } = testFramework.toVscodeTextDocument( + uri, + content, + offset + ); + const position = textDocumentPosition.position; + const param: DefinitionParams = { + position, + textDocument: { uri }, + }; + // act + const result = await getControllerLocation(param); + // assert + expect(result).toEqual([ + { + uri: join( + testFramework.getProjectRoot(), + "src", + "controller", + "App.controller.ts" + ), + range: { start: position, end: position }, + }, + ]); + }); + }); + }); +}); diff --git a/packages/xml-views-definition/test/unit/data/controller/App.controller.js b/packages/xml-views-definition/test/unit/data/controller/App.controller.js new file mode 100644 index 000000000..9a5fa5bad --- /dev/null +++ b/packages/xml-views-definition/test/unit/data/controller/App.controller.js @@ -0,0 +1 @@ +// dummy for test diff --git a/packages/xml-views-definition/test/unit/data/controller/AppHelper.js b/packages/xml-views-definition/test/unit/data/controller/AppHelper.js new file mode 100644 index 000000000..9a5fa5bad --- /dev/null +++ b/packages/xml-views-definition/test/unit/data/controller/AppHelper.js @@ -0,0 +1 @@ +// dummy for test diff --git a/packages/xml-views-definition/test/unit/data/controller/Handler.ts b/packages/xml-views-definition/test/unit/data/controller/Handler.ts new file mode 100644 index 000000000..9a5fa5bad --- /dev/null +++ b/packages/xml-views-definition/test/unit/data/controller/Handler.ts @@ -0,0 +1 @@ +// dummy for test diff --git a/packages/xml-views-definition/test/unit/data/controller/Helper.controller.ts b/packages/xml-views-definition/test/unit/data/controller/Helper.controller.ts new file mode 100644 index 000000000..9a5fa5bad --- /dev/null +++ b/packages/xml-views-definition/test/unit/data/controller/Helper.controller.ts @@ -0,0 +1 @@ +// dummy for test diff --git a/packages/xml-views-definition/test/unit/utils/file.test.ts b/packages/xml-views-definition/test/unit/utils/file.test.ts new file mode 100644 index 000000000..4c48e5d03 --- /dev/null +++ b/packages/xml-views-definition/test/unit/utils/file.test.ts @@ -0,0 +1,127 @@ +import { pathExists, buildFileUri } from "../../../src/utils"; +import { join } from "path"; + +describe("file", () => { + describe("pathExists", () => { + test("returns true if path exists", async () => { + // arrange + const filePath = join(__dirname, "index.test.ts"); + // act + const result = await pathExists(filePath); + // assert + expect(result).toBe(true); + }); + + test("returns false if path does not exist", async () => { + // act + const result = await pathExists("path-does-not-exits"); + // assert + expect(result).toBe(false); + }); + }); + describe("buildFileUri", () => { + const exts = [".controller.js", ".js", ".controller.ts", ".ts"]; + const mockManifestPath = join(__dirname, "..", "data", "manifest.json"); + + test("file uri when matching controller file exists with .controller.js extension", async () => { + // arrange + const namespace = "sap.ui.demo.walkthrough"; + const value = "sap.ui.demo.walkthrough.controller.App"; + const expectedFileUri = join( + __dirname, + "..", + "data", + "controller", + "App.controller.js" + ); + // act + const result = await buildFileUri( + namespace, + value, + mockManifestPath, + exts + ); + // assert + expect(result).toBe(expectedFileUri); + }); + test("file uri when matching controller file exists with .js extension", async () => { + // arrange + const namespace = "sap.ui.demo.walkthrough"; + const value = "sap.ui.demo.walkthrough.controller.AppHelper"; + const expectedFileUri = join( + __dirname, + "..", + "data", + "controller", + "AppHelper.js" + ); + // act + const result = await buildFileUri( + namespace, + value, + mockManifestPath, + exts + ); + // assert + expect(result).toBe(expectedFileUri); + }); + + test("file uri when matching controller file exists with .controller.ts extension", async () => { + // arrange + const namespace = "sap.ui.demo.walkthrough"; + const value = "sap.ui.demo.walkthrough.controller.Helper"; + const expectedFileUri = join( + __dirname, + "..", + "data", + "controller", + "Helper.controller.ts" + ); + // act + const result = await buildFileUri( + namespace, + value, + mockManifestPath, + exts + ); + // assert + expect(result).toBe(expectedFileUri); + }); + test("file uri when matching controller file exists with .ts extension", async () => { + // arrange + const namespace = "sap.ui.demo.walkthrough"; + const value = "sap.ui.demo.walkthrough.controller.Handler"; + const expectedFileUri = join( + __dirname, + "..", + "data", + "controller", + "Handler.ts" + ); + // act + const result = await buildFileUri( + namespace, + value, + mockManifestPath, + exts + ); + // assert + expect(result).toBe(expectedFileUri); + }); + + test("undefined when no matching controller file exists", async () => { + // arrange + const namespace = "sap.ui.demo.walkthrough"; + const value = "sap.ui.demo.walkthrough.controller.Abc"; + // act + const result = await buildFileUri( + namespace, + value, + mockManifestPath, + exts + ); + // assert + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/xml-views-definition/test/unit/utils/index.test.ts b/packages/xml-views-definition/test/unit/utils/index.test.ts new file mode 100644 index 000000000..04b19a093 --- /dev/null +++ b/packages/xml-views-definition/test/unit/utils/index.test.ts @@ -0,0 +1,135 @@ +import { Position } from "vscode-languageserver-textdocument"; +import { SourcePosition, XMLElement } from "@xml-tools/ast"; +import { getAttribute, positionContained } from "../../../src/utils"; +import { parse, DocumentCstNode } from "@xml-tools/parser"; +import { buildAst } from "@xml-tools/ast"; + +function getXmlElement(text: string): XMLElement { + const { cst, tokenVector } = parse(text); + const ast = buildAst(cst as DocumentCstNode, tokenVector); + return ast.rootElement as XMLElement; +} +const allowedAttrs = new Set([ + "controllerName", + "template:require", + "core:require", +]); + +describe("index", () => { + describe("getAttribute", () => { + test("no attributes - undefined", () => { + // arrange + const element = { attributes: [] } as unknown as XMLElement; + const position = {} as Position; + // act + const result = getAttribute(element, position, allowedAttrs); + // assert + expect(result).toBeUndefined(); + }); + test("for controllerName attribute", () => { + // arrange + const text = ` + + + `; + const element = getXmlElement(text); + const position: Position = { line: 3, character: 32 }; + // act + const result = getAttribute(element, position, allowedAttrs); + // assert + expect(result?.value).toEqual("sap.ui.demo.walkthrough.controller.Main"); + }); + test("for template:require attribute", () => { + // arrange + const text = ` + + + `; + const element = getXmlElement(text); + const position: Position = { line: 2, character: 32 }; + // act + const result = getAttribute(element, position, allowedAttrs); + // assert + expect(result?.value).toEqual( + "sap.ui.demo.walkthrough.controller.Helper" + ); + }); + test("for core:require attribute", () => { + // arrange + const text = ` + + + + `; + const element = getXmlElement(text); + const position: Position = { line: 6, character: 92 }; + // act + const result = getAttribute(element, position, allowedAttrs); + // assert + expect(result?.value).toEqual( + "{ MessageToast: 'sap/m/MessageToast', helper: 'sap/ui/demo/walkthrough/controller/Helper' }" + ); + }); + }); + describe("positionContained", () => { + test("true - cursor position is contained in source position", () => { + // arrange + const sourcePos: SourcePosition = { + startLine: 1, + startColumn: 2, + endColumn: 4, + endLine: 2, + endOffset: 20, + startOffset: 0, + }; + const cursorPosition = { line: 0, character: 3 }; + // act + const result = positionContained(sourcePos, cursorPosition); + // assert + expect(result).toBe(true); + }); + + test("false - cursor position is not contained in source position", () => { + // arrange + const sourcePos = { + startLine: 1, + startColumn: 2, + endColumn: 4, + endLine: 2, + endOffset: 20, + startOffset: 0, + } as SourcePosition; + const cursorPosition = { line: 0, character: 1 }; + // act + const result = positionContained(sourcePos, cursorPosition); + // assert + expect(result).toBe(false); + }); + test("false - not on same line", () => { + // arrange + const sourcePos = { + startLine: 1, + startColumn: 2, + endColumn: 4, + endLine: 2, + endOffset: 20, + startOffset: 0, + } as SourcePosition; + const cursorPosition = { line: 5, character: 1 }; + // act + const result = positionContained(sourcePos, cursorPosition); + // assert + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/xml-views-definition/tsconfig-test.json b/packages/xml-views-definition/tsconfig-test.json new file mode 100644 index 000000000..fb1f25450 --- /dev/null +++ b/packages/xml-views-definition/tsconfig-test.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/xml-views-definition/tsconfig.json b/packages/xml-views-definition/tsconfig.json new file mode 100644 index 000000000..7ecd7b544 --- /dev/null +++ b/packages/xml-views-definition/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "lib", + "baseUrl": "." + }, + "include": ["src/**/*", "test/**/*", "api.d.ts"] +} diff --git a/packages/xml-views-tooltip/test/unit/utils.ts b/packages/xml-views-tooltip/test/unit/utils.ts index 552a58b11..ea6af434d 100644 --- a/packages/xml-views-tooltip/test/unit/utils.ts +++ b/packages/xml-views-tooltip/test/unit/utils.ts @@ -6,6 +6,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { ui5Model, customViewId: "", manifestDetails: { + appId: "", + manifestPath: "", flexEnabled: false, customViews: {}, mainServicePath: undefined, diff --git a/packages/xml-views-validation/test/unit/test-utils.ts b/packages/xml-views-validation/test/unit/test-utils.ts index f060eece5..4bcaa95a7 100644 --- a/packages/xml-views-validation/test/unit/test-utils.ts +++ b/packages/xml-views-validation/test/unit/test-utils.ts @@ -130,6 +130,8 @@ export const getDefaultContext = (ui5Model: UI5SemanticModel): Context => { ui5Model, customViewId: "", manifestDetails: { + appId: "", + manifestPath: "", flexEnabled: false, customViews: {}, mainServicePath: undefined, diff --git a/sonar-project.properties b/sonar-project.properties index 7d7d51116..ecfb85bff 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -20,7 +20,8 @@ sonar.javascript.lcov.reportPaths=packages/binding/reports/test/unit/coverage/lc packages/xml-views-completion/reports/test/unit/coverage/lcov.info, \ packages/xml-views-quick-fix/reports/test/unit/coverage/lcov.info, \ packages/xml-views-tooltip/reports/test/unit/coverage/lcov.info, \ - packages/xml-views-validation/reports/test/unit/coverage/lcov.info + packages/xml-views-validation/reports/test/unit/coverage/lcov.info, \ + packages/xml-views-definition/reports/test/unit/coverage/lcov.info sonar.testExecutionReportPaths=packages/binding/reports/test/unit/coverage/sonar-report.xml, \ packages/binding-parser/reports/test/unit/coverage/sonar-report.xml, \ @@ -36,4 +37,5 @@ sonar.testExecutionReportPaths=packages/binding/reports/test/unit/coverage/sonar packages/xml-views-completion/reports/test/unit/coverage/sonar-report.xml, \ packages/xml-views-quick-fix/reports/test/unit/coverage/sonar-report.xml, \ packages/xml-views-tooltip/reports/test/unit/coverage/sonar-report.xml, \ - packages/xml-views-validation/reports/test/unit/coverage/sonar-report.xml + packages/xml-views-validation/reports/test/unit/coverage/sonar-report.xml, \ + packages/xml-views-definition/reports/test/unit/coverage/sonar-report.xml diff --git a/tsconfig.base.json b/tsconfig.base.json index 6bb1e6964..e93ccac4f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,7 +11,7 @@ "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitAny": false, - "noImplicitReturns": true, + "noImplicitReturns": false, "esModuleInterop": true, "skipLibCheck": true, "types": ["node", "jest", "jest-extended"] diff --git a/tsconfig.json b/tsconfig.json index de0290af0..d50540f55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,9 @@ { "path": "./packages/xml-views-validation" }, + { + "path": "./packages/xml-views-definition" + }, { "path": "./test-packages/test-utils" },