diff --git a/packages/language-server/src/completion-items.ts b/packages/language-server/src/completion-items.ts index c8ec593ab..b46e2fe89 100644 --- a/packages/language-server/src/completion-items.ts +++ b/packages/language-server/src/completion-items.ts @@ -27,6 +27,7 @@ import { } from "@ui5-language-assistant/xml-views-completion"; import { ui5NodeToFQN } from "@ui5-language-assistant/logic-utils"; import { getNodeDocumentation, getNodeDetail } from "./documentation"; +import { assertNever } from "assert-never"; export function getCompletionItems( model: UI5SemanticModel, @@ -103,8 +104,7 @@ export function computeLSPKind( case "BooleanValueInXMLAttributeValue": return CompletionItemKind.Constant; default: - // TODO: we probably need a logging solution to highlight edge cases we - // do not handle... + assertNever(suggestion, true); return CompletionItemKind.Text; } } @@ -179,11 +179,20 @@ function createTextEdits( // Tag name case "UI5AggregationsInXMLTagName": { range = getXMLTagNameRange(suggestion.astNode) ?? range; - const tagName = suggestion.ui5Node.name; + let parentNS: string | undefined = undefined; + /* istanbul ignore else - defensive programming (aggregation suggestions wil not be returned in the root tag) */ + if (suggestion.astNode.parent.type === "XMLElement") { + parentNS = suggestion.astNode.parent.ns; + if (parentNS !== undefined && parentNS !== "") { + parentNS += ":"; + } + } + const tagName = `${parentNS ?? ""}${suggestion.ui5Node.name}`; + filterText = `${parentNS ?? ""}${suggestion.ui5Node.name}`; // Auto-close tag /* istanbul ignore else */ if (shouldCloseXMLElement(suggestion.astNode)) { - newText += `>\${0}`; + newText = `${tagName}>\${0}`; } else { additionalTextEdits.push( ...getClosingTagTextEdits(suggestion.astNode, tagName) diff --git a/packages/language-server/test/completion-items-classes-spec.ts b/packages/language-server/test/completion-items-classes-spec.ts index bedd7f66a..246c850f4 100644 --- a/packages/language-server/test/completion-items-classes-spec.ts +++ b/packages/language-server/test/completion-items-classes-spec.ts @@ -263,9 +263,9 @@ describe("the UI5 language assistant Code Completion Services - classes", () => it("will not insert the namespace when selecting completion for class in inner tag and namespace is already defined", () => { assertClassesCompletions({ xmlSnippet: ` - + + `, expected: [ { @@ -282,9 +282,9 @@ describe("the UI5 language assistant Code Completion Services - classes", () => it("will insert the namespace when selecting completion for class in inner tag and namespace is not defined", () => { assertClassesCompletions({ xmlSnippet: ` - + + `, expected: [ { @@ -383,7 +383,7 @@ describe("the UI5 language assistant Code Completion Services - classes", () => xmlSnippet: ` - it("will replace the class closing tag name when the tag is closed and has the same name as the opening tag", () => { assertClassesCompletions({ xmlSnippet: ` - + - + `, expected: [ { @@ -435,9 +435,9 @@ describe("the UI5 language assistant Code Completion Services - classes", () => it("will not replace the class closing tag name when the tag is closed and has a different name from the opening tag", () => { assertClassesCompletions({ xmlSnippet: ` - + - + `, expected: [ { @@ -459,9 +459,9 @@ describe("the UI5 language assistant Code Completion Services - classes", () => it("will not replace the class closing tag name when the tag is closed and the opening tag doesn't have a name", () => { assertClassesCompletions({ xmlSnippet: ` - + <⇶> - + `, expected: [ { @@ -477,9 +477,9 @@ describe("the UI5 language assistant Code Completion Services - classes", () => it("will replace the class closing tag name when the tag is closed and does not have a name", () => { assertClassesCompletions({ xmlSnippet: ` - + ⭲⭰ - + `, expected: [ { @@ -511,9 +511,9 @@ describe("the UI5 language assistant Code Completion Services - classes", () => it("will replace the class closing tag name when also inserting the namespace", () => { assertClassesCompletions({ xmlSnippet: ` - + - + `, expected: [ { diff --git a/packages/language-server/test/completion-items-spec.ts b/packages/language-server/test/completion-items-spec.ts index 6ccc5e70b..472215dbe 100644 --- a/packages/language-server/test/completion-items-spec.ts +++ b/packages/language-server/test/completion-items-spec.ts @@ -224,7 +224,7 @@ describe("the UI5 language assistant Code Completion Services", () => { expect(suggestionKinds).to.deep.equal([CompletionItemKind.Reference]); }); - it("will get completion values for UI5 aggregation", () => { + it("will get completion values for UI5 aggregation in the default namespace", () => { const xmlSnippet = ` @@ -232,6 +232,31 @@ describe("the UI5 language assistant Code Completion Services", () => { const suggestions = getSuggestions(xmlSnippet, ui5SemanticModel); const suggestionsDetails = map(suggestions, (suggestion) => ({ label: suggestion.label, + tagName: getTagName(suggestion.textEdit), + replacedText: getTextInRange(xmlSnippet, suggestion.textEdit?.range), + })); + const suggestionKinds = uniq( + map(suggestions, (suggestion) => suggestion.kind) + ); + + expect(suggestionsDetails).to.deep.equalInAnyOrder([ + { label: "contextMenu", tagName: "contextMenu", replacedText: "te" }, + { label: "items", tagName: "items", replacedText: "te" }, + { label: "swipeContent", tagName: "swipeContent", replacedText: "te" }, + ]); + + expect(suggestionKinds).to.deep.equal([CompletionItemKind.Field]); + }); + + it("will get completion values for UI5 aggregation in a non-default namespace", () => { + const xmlSnippet = ` + ({ + label: suggestion.label, + tagName: getTagName(suggestion.textEdit), replacedText: getTextInRange(xmlSnippet, suggestion.textEdit?.range), })); const suggestionKinds = uniq( @@ -239,15 +264,15 @@ describe("the UI5 language assistant Code Completion Services", () => { ); expect(suggestionsDetails).to.deep.equalInAnyOrder([ - { label: "contextMenu", replacedText: "te" }, - { label: "items", replacedText: "te" }, - { label: "swipeContent", replacedText: "te" }, + { label: "contextMenu", tagName: "m:contextMenu", replacedText: "te" }, + { label: "items", tagName: "m:items", replacedText: "te" }, + { label: "swipeContent", tagName: "m:swipeContent", replacedText: "te" }, ]); expect(suggestionKinds).to.deep.equal([CompletionItemKind.Field]); }); - it("will get completion values for UI5 aggregation when the cursor is in the middle of a name", () => { + it("will get completion values for UI5 aggregation when the cursor is in the middle of a name in the default namespace", () => { const xmlSnippet = ` @@ -275,7 +300,39 @@ describe("the UI5 language assistant Code Completion Services", () => { expect(suggestionKinds).to.deep.equal([CompletionItemKind.Field]); }); - it("will replace the aggregation closing tag name when the tag is closed and has the same name as the opening tag", () => { + it("will get completion values for UI5 aggregation when the cursor is in the middle of a name in a non-default namespace", () => { + const xmlSnippet = ` + ({ + label: suggestion.label, + replacedText: getTextInRange(xmlSnippet, suggestion.textEdit?.range), + tagName: getTagName(suggestion.textEdit), + })); + const suggestionKinds = uniq( + map(suggestions, (suggestion) => suggestion.kind) + ); + + expect(suggestionsDetails).to.deep.equalInAnyOrder([ + { + label: "contextMenu", + replacedText: "teMenu", + tagName: "m:contextMenu", + }, + { label: "items", replacedText: "teMenu", tagName: "m:items" }, + { + label: "swipeContent", + replacedText: "teMenu", + tagName: "m:swipeContent", + }, + ]); + + expect(suggestionKinds).to.deep.equal([CompletionItemKind.Field]); + }); + + it("will replace the aggregation closing tag name when the tag is closed and has the same name as the opening tag in the default namespace", () => { const xmlSnippet = ` @@ -336,7 +393,68 @@ describe("the UI5 language assistant Code Completion Services", () => { expect(suggestionKinds).to.deep.equal([CompletionItemKind.Field]); }); - it("will not replace the class closing tag name when the tag is closed and has a different name from the opening tag", () => { + it("will replace the aggregation closing tag name when the tag is closed and has the same name as the opening tag in a non-default namespace", () => { + const xmlSnippet = ` + + + + `; + const suggestions = getSuggestions(xmlSnippet, ui5SemanticModel); + const suggestionsDetails = map(suggestions, (suggestion) => ({ + label: suggestion.label, + tagName: getTagName(suggestion.textEdit), + additionalTextEdits: suggestion.additionalTextEdits, + replacedText: getTextInRange(xmlSnippet, suggestion.textEdit?.range), + })); + const suggestionKinds = uniq( + map(suggestions, (suggestion) => suggestion.kind) + ); + + const ranges = getRanges(xmlSnippet); + expect(ranges, "additional text edits ranges").to.have.lengthOf(1); + + expect(suggestionsDetails).to.deep.equalInAnyOrder([ + { + label: "contextMenu", + tagName: "contextMenu", + additionalTextEdits: [ + { + range: ranges[0], + newText: `m:contextMenu`, + }, + ], + replacedText: "m:te", + }, + { + label: "items", + tagName: "items", + additionalTextEdits: [ + { + range: ranges[0], + newText: `m:items`, + }, + ], + replacedText: "m:te", + }, + { + label: "swipeContent", + tagName: "swipeContent", + additionalTextEdits: [ + { + range: ranges[0], + newText: `m:swipeContent`, + }, + ], + replacedText: "m:te", + }, + ]); + + expect(suggestionKinds).to.deep.equal([CompletionItemKind.Field]); + }); + + it("will not replace the aggregation closing tag name when the tag is closed and has a different name from the opening tag", () => { const xmlSnippet = ` @@ -382,7 +500,7 @@ describe("the UI5 language assistant Code Completion Services", () => { expect(suggestionKinds).to.deep.equal([CompletionItemKind.Field]); }); - it("will replace the class closing tag name when the tag is closed and does not have a name", () => { + it("will replace the aggregation closing tag name when the tag is closed and does not have a name", () => { const xmlSnippet = ` diff --git a/packages/language-server/test/hover-spec.ts b/packages/language-server/test/hover-spec.ts index 3ce8fa775..25ec53810 100644 --- a/packages/language-server/test/hover-spec.ts +++ b/packages/language-server/test/hover-spec.ts @@ -27,9 +27,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -70,9 +70,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expect(response).to.not.exist; @@ -84,9 +84,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -100,9 +100,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -118,9 +118,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -134,9 +134,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -150,9 +150,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -166,9 +166,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -181,7 +181,7 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expectExists(response, "Hover item"); @@ -195,9 +195,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const response = getHoverItem(xmlSnippet, ui5SemanticModel); expect(response).to.not.exist; diff --git a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/input.xml b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/input.xml index d17fe1d47..55311988b 100644 --- a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/input.xml +++ b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/input.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/output-lsp-response.json b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/output-lsp-response.json index 356e66f47..28cdacee9 100644 --- a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/output-lsp-response.json +++ b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-attribute-key/output-lsp-response.json @@ -10,8 +10,8 @@ }, { "range": { - "start": { "line": 1, "character": 13 }, - "end": { "line": 1, "character": 23 } + "start": { "line": 1, "character": 17 }, + "end": { "line": 1, "character": 27 } }, "severity": 1, "source": "UI5 Language Assistant", diff --git a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/input.xml b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/input.xml index 553b1ecaa..8d0c26393 100644 --- a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/input.xml +++ b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/input.xml @@ -2,7 +2,7 @@ - + <🢂Button_TYPO🢀 /> <🢂content_TYPO🢀> @@ -10,7 +10,7 @@ <🢂mvc:TYPO🢀> - + <🢂Button2_TYPO🢀 /> \ No newline at end of file diff --git a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/output-lsp-response.json b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/output-lsp-response.json index 8f5d82860..ad6c2d6f8 100644 --- a/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/output-lsp-response.json +++ b/packages/language-server/test/snapshots/xml-view-diagnostics/unknown-tag-name/output-lsp-response.json @@ -42,6 +42,6 @@ }, "severity": 1, "source": "UI5 Language Assistant", - "message": "The \"Button2_TYPO\" name is neither a class name nor an aggregation in the \"sap.ui.core.mvc.View\" class" + "message": "The \"Button2_TYPO\" name is neither a class name in the \"sap.m\" namespace nor an aggregation of the \"sap.ui.core.mvc.View\" class" } ] diff --git a/packages/logic-utils/api.d.ts b/packages/logic-utils/api.d.ts index ef2ea739f..78abb6a1d 100644 --- a/packages/logic-utils/api.d.ts +++ b/packages/logic-utils/api.d.ts @@ -172,3 +172,54 @@ export function convertJSDocToMarkdown( * @param link */ export function getLink(model: UI5SemanticModel, link: string): string; + +/** + * Split possibly qualified XML Tag or XML Attribute name to prefix and local name. + * If there is no prefix in the qualified name, the returned prefix will be undefined. + * @param qName + */ +export function splitQNameByNamespace( + qName: string +): { prefix: string | undefined; localName: string }; + +/** + * Return the xml namespace defined for the xml element prefix (ns), or undefined if not found + * @param xmlElement + */ +export function resolveXMLNS(xmlElement: XMLElement): string | undefined; + +/** + * Return the xml namespace defined for this prefix, or undefined if not found. + * The defined namespaces are taken from the xml element. + * @param prefix + * @param xmlElement + */ +export function resolveXMLNSFromPrefix( + prefix: string | undefined, + xmlElement: XMLElement +): string | undefined; + +/** + * Check if the xml element namespace prefixes (ns) reference the same namespace + * @param xmlElement1 + * @param xmlElement2 + */ +export function isSameXMLNS( + xmlElement1: XMLElement, + xmlElement2: XMLElement +): boolean; + +/** + * Check if the xml namespace prefixes reference the same namespace. + * The defined namespaces are taken from the respective xml elements. + * @param prefix1 + * @param xmlElement1 + * @param prefix2 + * @param xmlElement2 + */ +export function isSameXMLNSFromPrefix( + prefix1: string | undefined, + xmlElement1: XMLElement, + prefix2: string | undefined, + xmlElement2: XMLElement +): boolean; diff --git a/packages/logic-utils/src/api.ts b/packages/logic-utils/src/api.ts index d7bc7fe17..1564026b5 100644 --- a/packages/logic-utils/src/api.ts +++ b/packages/logic-utils/src/api.ts @@ -23,3 +23,10 @@ export { convertJSDocToMarkdown, getLink, } from "./utils/documentation"; +export { splitQNameByNamespace } from "./utils/split-qname"; +export { + resolveXMLNS, + resolveXMLNSFromPrefix, + isSameXMLNS, + isSameXMLNSFromPrefix, +} from "./utils/xml-namespaces"; diff --git a/packages/logic-utils/src/utils/split-qname.ts b/packages/logic-utils/src/utils/split-qname.ts new file mode 100644 index 000000000..b82f3a471 --- /dev/null +++ b/packages/logic-utils/src/utils/split-qname.ts @@ -0,0 +1,17 @@ +import { includes } from "lodash"; + +export function splitQNameByNamespace( + qName: string +): { prefix: string | undefined; localName: string } { + if (!includes(qName, ":")) { + return { prefix: undefined, localName: qName }; + } + const match = qName.match(/(?[^:]*)(:(?.*))?/); + // There will always be a match because qName always contains a colon at this point + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const matchGroups = match!.groups!; + return { + prefix: matchGroups.ns, + localName: matchGroups.name, + }; +} diff --git a/packages/logic-utils/src/utils/xml-namespaces.ts b/packages/logic-utils/src/utils/xml-namespaces.ts new file mode 100644 index 000000000..d6da8f873 --- /dev/null +++ b/packages/logic-utils/src/utils/xml-namespaces.ts @@ -0,0 +1,51 @@ +import { XMLElement, DEFAULT_NS } from "@xml-tools/ast"; + +export function resolveXMLNS(xmlElement: XMLElement): string | undefined { + return resolveXMLNSFromPrefix(xmlElement.ns, xmlElement); +} + +export function resolveXMLNSFromPrefix( + prefix: string | undefined, + xmlElement: XMLElement +): string | undefined { + // If no NS is explicitly defined try the default one + const prefixXmlns = prefix ?? DEFAULT_NS; + const resolvedXmlns = xmlElement.namespaces[prefixXmlns]; + return resolvedXmlns; +} + +export function isSameXMLNS( + xmlElement1: XMLElement, + xmlElement2: XMLElement +): boolean { + return isSameXMLNSFromPrefix( + xmlElement1.ns, + xmlElement1, + xmlElement2.ns, + xmlElement2 + ); +} + +export function isSameXMLNSFromPrefix( + prefix1: string | undefined, + xmlElement1: XMLElement, + prefix2: string | undefined, + xmlElement2: XMLElement +): boolean { + // It's possible to re-define namespaces, so we can't rely on the namespace prefix to check this. + // It's also possible to define several prefixes for the same namespace. + + // If the prefixes are resolved to the same namespace, they are the same + const ns1 = resolveXMLNSFromPrefix(prefix1, xmlElement1); + const ns2 = resolveXMLNSFromPrefix(prefix2, xmlElement2); + if (ns1 === ns2 && ns1 !== undefined) { + return true; + } + + // If both prefixes are not defined but they are the same string we also consider them the same + if (ns1 === undefined && ns2 === undefined && prefix1 === prefix2) { + return true; + } + + return false; +} 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 7c947ac76..acfbe9d5f 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 @@ -2,8 +2,10 @@ import { xmlToFQN, flattenProperties, flattenAggregations, + isSameXMLNS, + resolveXMLNS, } from "@ui5-language-assistant/logic-utils"; -import { XMLElement, XMLAttribute, DEFAULT_NS } from "@xml-tools/ast"; +import { XMLElement, XMLAttribute } from "@xml-tools/ast"; import { UI5Class, UI5SemanticModel, @@ -30,8 +32,9 @@ export function getUI5AggregationByXMLElement( if (element.parent.type === "XMLDocument") { return undefined; } - // Aggregations don't have a namesapce - if (element.ns !== undefined) { + // Aggregations must be in the same namespace as their parent + // https://sapui5.hana.ondemand.com/#/topic/19eabf5b13214f27b929b9473df3195b + if (!isSameXMLNS(element, element.parent)) { return undefined; } const ui5Class = getUI5ClassByXMLElement(element.parent, model); @@ -66,7 +69,7 @@ export function getUI5NodeFromXMLElementNamespace( isXmlnsDefined: boolean; } { const isDefault = xmlElement.ns === undefined; - const xmlNamespace = xmlElement.namespaces[xmlElement.ns ?? DEFAULT_NS]; + const xmlNamespace = resolveXMLNS(xmlElement); if (xmlNamespace === undefined) { return { namespace: undefined, diff --git a/packages/logic-utils/src/utils/xml-to-fqn.ts b/packages/logic-utils/src/utils/xml-to-fqn.ts index 12effef91..83b789f45 100644 --- a/packages/logic-utils/src/utils/xml-to-fqn.ts +++ b/packages/logic-utils/src/utils/xml-to-fqn.ts @@ -1,11 +1,10 @@ -import { DEFAULT_NS, XMLElement } from "@xml-tools/ast"; +import { XMLElement } from "@xml-tools/ast"; +import { resolveXMLNS } from "../api"; export function xmlToFQN(astElement: XMLElement): string { // TODO: is this the optimal way to handle nameless elements? - const baseName = astElement.name ? astElement.name : ""; - // if no NS is explicitly defined try the default one - const prefixXmlns = astElement.ns ? astElement.ns : DEFAULT_NS; - const resolvedXmlns = astElement.namespaces[prefixXmlns]; + const baseName = astElement.name ?? ""; + const resolvedXmlns = resolveXMLNS(astElement); if (resolvedXmlns !== undefined) { // Note that adding the 'dot' seems to be a UI5 semantic, not xmlns semantics diff --git a/packages/logic-utils/test/utils/split-qname-spec.ts b/packages/logic-utils/test/utils/split-qname-spec.ts new file mode 100644 index 000000000..6092b7f6e --- /dev/null +++ b/packages/logic-utils/test/utils/split-qname-spec.ts @@ -0,0 +1,50 @@ +import { expect } from "chai"; +import { splitQNameByNamespace } from "../../src/api"; + +describe("The @ui5-language-assistant/logic-utils function", () => { + it("returns the local name with undefined prefix if it's not qualified", () => { + const { prefix, localName } = splitQNameByNamespace("some.name"); + expect(prefix).to.be.undefined; + expect(localName).to.equal("some.name"); + }); + + it("returns the prefix and local name for qualified name", () => { + const { prefix, localName } = splitQNameByNamespace( + "thenamespace:some.name" + ); + expect(prefix).to.equal("thenamespace"); + expect(localName).to.equal("some.name"); + }); + + it("returns empty local name if qname ends with :", () => { + const { prefix, localName } = splitQNameByNamespace("thenamespace:"); + expect(prefix).to.equal("thenamespace"); + expect(localName).to.equal(""); + }); + + it("returns empty prefix if qname starts with :", () => { + const { prefix, localName } = splitQNameByNamespace(":some.name"); + expect(prefix).to.equal(""); + expect(localName).to.equal("some.name"); + }); + + it("returns empty local name and prefix if qname is :", () => { + const { prefix, localName } = splitQNameByNamespace(":"); + expect(prefix).to.equal(""); + expect(localName).to.equal(""); + }); + + it("returns empty local name and undefined prefix if qname is an empty string", () => { + const { prefix, localName } = splitQNameByNamespace(""); + expect(prefix).to.be.undefined; + expect(localName).to.equal(""); + }); + + it("splits on the first :", () => { + const { prefix, localName } = splitQNameByNamespace( + "thenamespace:some:name" + ); + expect(prefix).to.equal("thenamespace"); + expect(localName).to.equal("some:name"); + }); +}); diff --git a/packages/logic-utils/test/utils/xml-namespaces-spec.ts b/packages/logic-utils/test/utils/xml-namespaces-spec.ts new file mode 100644 index 000000000..b3b4f264d --- /dev/null +++ b/packages/logic-utils/test/utils/xml-namespaces-spec.ts @@ -0,0 +1,504 @@ +import { expect } from "chai"; +import { + resolveXMLNS, + resolveXMLNSFromPrefix, + isSameXMLNS, + isSameXMLNSFromPrefix, +} from "../../src/api"; +import { XMLElement, buildAst } from "@xml-tools/ast"; +import { parse, DocumentCstNode } from "@xml-tools/parser"; +import { expectExists } from "@ui5-language-assistant/test-utils"; + +describe("The @ui5-language-assistant/logic-utils function", () => { + context("prefix exists", () => { + it("returns the namespace for defined prefix", () => { + const rootElement = getRootElement( + `` + ); + expect(resolveXMLNSFromPrefix("a", rootElement)).to.equal("a.ns"); + }); + + it("returns the correct namespace when it's redefined", () => { + const rootElement = getRootElement(` + + + `); + expect(rootElement.subElements[0]).to.exist; + expect(resolveXMLNSFromPrefix("a", rootElement.subElements[0])).to.equal( + "redefined.a" + ); + }); + }); + + context("prefix is not sent", () => { + it("returns the default namespace when it's defined", () => { + const rootElement = getRootElement( + `` + ); + expect(resolveXMLNSFromPrefix(undefined, rootElement)).to.equal( + "default.ns" + ); + }); + + it("returns undefined when the default namespace is not defined", () => { + const rootElement = getRootElement(``); + expect(resolveXMLNSFromPrefix(undefined, rootElement)).to.be.undefined; + }); + }); + + context("prefix is not defined", () => { + it("returns undefined", () => { + const rootElement = getRootElement(``); + expect(resolveXMLNSFromPrefix("x", rootElement)).to.be.undefined; + }); + + it("returns undefined when there is a default namespace", () => { + const rootElement = getRootElement( + `` + ); + expect(resolveXMLNSFromPrefix("x", rootElement)).to.be.undefined; + }); + }); +}); + +describe("The @ui5-language-assistant/logic-utils function", () => { + context("element prefix exists", () => { + it("returns the namespace for defined prefix", () => { + const rootElement = getRootElement(``); + expect(resolveXMLNS(rootElement)).to.equal("a.ns"); + }); + + it("returns the correct namespace when it's redefined", () => { + const rootElement = getRootElement(` + + + `); + expect(rootElement.subElements[0]).to.exist; + expect(resolveXMLNS(rootElement.subElements[0])).to.equal("redefined.a"); + }); + }); + + context("element doesn't have a prefix", () => { + it("returns the default namespace when it's defined", () => { + const rootElement = getRootElement(``); + expect(resolveXMLNS(rootElement)).to.equal("default.ns"); + }); + + it("returns undefined when the default namespace is not defined", () => { + const rootElement = getRootElement(``); + expect(resolveXMLNS(rootElement)).to.be.undefined; + }); + }); + + context("element prefix is not defined", () => { + it("returns undefined", () => { + const rootElement = getRootElement(``); + expect(resolveXMLNS(rootElement)).to.be.undefined; + }); + + it("returns undefined when there is a default namespace", () => { + const rootElement = getRootElement(``); + expect(resolveXMLNS(rootElement)).to.be.undefined; + }); + }); +}); + +describe("The @ui5-language-assistant/logic-utils function", () => { + context("bothe prefixes are defined", () => { + it("returns true when it's the same prefix", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "a", + rootElement.subElements[0], + "a", + rootElement.subElements[1] + ) + ).to.be.true; + }); + + it("returns true when the prefixes are different but they are resolved to the same namespace", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "x", + rootElement.subElements[0], + "y", + rootElement.subElements[1] + ) + ).to.be.true; + }); + + it("returns false when the prefixes are resolved to different namespaces", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "x", + rootElement.subElements[0], + "y", + rootElement.subElements[1] + ) + ).to.be.false; + }); + + it("returns false when the prefixes are the same but one of them is redefined to a different namespace", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "a", + rootElement.subElements[0], + "a", + rootElement.subElements[1] + ) + ).to.be.false; + }); + }); + + context("only one of the prefixes is defined", () => { + it("returns false when default namespace is defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "a", + rootElement.subElements[0], + "x", + rootElement.subElements[1] + ) + ).to.be.false; + expect( + isSameXMLNSFromPrefix( + "x", + rootElement.subElements[0], + "a", + rootElement.subElements[1] + ) + ).to.be.false; + }); + + it("returns false when default namespace is not defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "a", + rootElement.subElements[0], + "x", + rootElement.subElements[1] + ) + ).to.be.false; + expect( + isSameXMLNSFromPrefix( + "x", + rootElement.subElements[0], + "a", + rootElement.subElements[1] + ) + ).to.be.false; + }); + + it("returns false when the other is not sent and the default namespace is defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "a", + rootElement.subElements[0], + undefined, + rootElement.subElements[1] + ) + ).to.be.false; + expect( + isSameXMLNSFromPrefix( + undefined, + rootElement.subElements[0], + "a", + rootElement.subElements[1] + ) + ).to.be.false; + }); + + it("returns false when the other is not sent and the default namespace is not defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "a", + rootElement.subElements[0], + undefined, + rootElement.subElements[1] + ) + ).to.be.false; + expect( + isSameXMLNSFromPrefix( + undefined, + rootElement.subElements[0], + "a", + rootElement.subElements[1] + ) + ).to.be.false; + }); + }); + + context("both prefixes are not defined", () => { + it("returns true when it's the same prefix", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "x", + rootElement.subElements[0], + "x", + rootElement.subElements[1] + ) + ).to.be.true; + }); + + it("returns false when it's not the same prefix and the default namespace is not defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "x", + rootElement.subElements[0], + "y", + rootElement.subElements[1] + ) + ).to.be.false; + }); + + it("returns false when it's not the same prefix and the default namespace is defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNSFromPrefix( + "x", + rootElement.subElements[0], + "y", + rootElement.subElements[1] + ) + ).to.be.false; + }); + }); +}); + +describe("The @ui5-language-assistant/logic-utils function", () => { + context("bothe prefixes are defined", () => { + it("returns true when it's the same prefix", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.true; + }); + + it("returns true when the prefixes are different but they are resolved to the same namespace", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.true; + }); + + it("returns false when the prefixes are resolved to different namespaces", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + }); + + it("returns false when the prefixes are the same but one of them is redefined to a different namespace", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + }); + }); + + context("only one of the prefixes is defined", () => { + it("returns false when default namespace is defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + expect( + isSameXMLNS(rootElement.subElements[1], rootElement.subElements[0]) + ).to.be.false; + }); + + it("returns false when default namespace is not defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + expect( + isSameXMLNS(rootElement.subElements[1], rootElement.subElements[0]) + ).to.be.false; + }); + + it("returns false when the other doesn't have a prefix and the default namespace is not defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + expect( + isSameXMLNS(rootElement.subElements[1], rootElement.subElements[0]) + ).to.be.false; + }); + + it("returns false when the other doesn't have a prefix and the default namespace is defined to a different namespace", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + expect( + isSameXMLNS(rootElement.subElements[1], rootElement.subElements[0]) + ).to.be.false; + }); + + it("returns true when the other doesn't have a prefix and the default namespace is defined to the same namespace", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.true; + expect( + isSameXMLNS(rootElement.subElements[1], rootElement.subElements[0]) + ).to.be.true; + }); + }); + + context("both prefixes are not defined", () => { + it("returns true when it's the same prefix", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.true; + }); + + it("returns false when it's not the same prefix and the default namespace is not defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + }); + + it("returns false when it's not the same prefix and the default namespace is defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.false; + }); + + it("returns true when there is no prefix and the default namespace is not defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.true; + }); + + it("returns true when there is no prefix and the default namespace is defined", () => { + const rootElement = getRootElement(` + + + + `); + expect( + isSameXMLNS(rootElement.subElements[0], rootElement.subElements[1]) + ).to.be.true; + }); + }); +}); + +function getRootElement(xmlText: string): XMLElement { + const { cst, tokenVector } = parse(xmlText); + const ast = buildAst(cst as DocumentCstNode, tokenVector); + expectExists(ast.rootElement, "ast root element"); + return ast.rootElement; +} diff --git a/packages/logic-utils/test/utils/xml-node-to-ui5-node-spec.ts b/packages/logic-utils/test/utils/xml-node-to-ui5-node-spec.ts index 5e92c917b..c96ccab42 100644 --- a/packages/logic-utils/test/utils/xml-node-to-ui5-node-spec.ts +++ b/packages/logic-utils/test/utils/xml-node-to-ui5-node-spec.ts @@ -94,6 +94,34 @@ describe("The @ui5-language-assistant/logic-utils { + const xmlText = ` + + + `; + const element = getRootElementChild(xmlText); + + const ui5Aggregation = getUI5AggregationByXMLElement(element, ui5Model); + expectExists(ui5Aggregation, "ui5 aggregation"); + expect(ui5NodeToFQN(ui5Aggregation)).to.equal( + "sap.ui.core.mvc.View.content" + ); + }); + + it("returns the aggregation for known aggregation under a class tag with a different prefix that references the same namespace", () => { + const xmlText = ` + + + `; + const element = getRootElementChild(xmlText); + + const ui5Aggregation = getUI5AggregationByXMLElement(element, ui5Model); + expectExists(ui5Aggregation, "ui5 aggregation"); + expect(ui5NodeToFQN(ui5Aggregation)).to.equal( + "sap.ui.core.mvc.View.content" + ); + }); + it("returns undefined for unknown aggregation under a class tag", () => { const xmlText = ` @@ -127,7 +155,7 @@ describe("The @ui5-language-assistant/logic-utils { + it("returns undefined for tag with known namespace under a class tag without namespace", () => { const xmlText = ` @@ -138,6 +166,39 @@ describe("The @ui5-language-assistant/logic-utils { + const xmlText = ` + + + `; + const element = getRootElementChild(xmlText); + + const ui5Aggregation = getUI5AggregationByXMLElement(element, ui5Model); + expect(ui5Aggregation, "ui5 aggregation").to.be.undefined; + }); + + it("returns undefined for tag with unknown namespace under a class tag with a different namespace", () => { + const xmlText = ` + + + `; + const element = getRootElementChild(xmlText); + + const ui5Aggregation = getUI5AggregationByXMLElement(element, ui5Model); + expect(ui5Aggregation, "ui5 aggregation").to.be.undefined; + }); + + it("returns undefined for tag with empty namespace under a class tag without a namespace", () => { + const xmlText = ` + + <:content> + `; + const element = getRootElementChild(xmlText); + + const ui5Aggregation = getUI5AggregationByXMLElement(element, ui5Model); + expect(ui5Aggregation, "ui5 aggregation").to.be.undefined; + }); + it("returns undefined for non-aggregation node under a class tag", () => { const xmlText = ` @@ -149,7 +210,7 @@ describe("The @ui5-language-assistant/logic-utils { + it("returns undefined for root tag", () => { const xmlText = ` `; const element = getRootElement(xmlText); diff --git a/packages/vscode-ui5-language-assistant/test/suite/extension-spec.ts b/packages/vscode-ui5-language-assistant/test/suite/extension-spec.ts index 47d25b5a3..b941f8059 100644 --- a/packages/vscode-ui5-language-assistant/test/suite/extension-spec.ts +++ b/packages/vscode-ui5-language-assistant/test/suite/extension-spec.ts @@ -64,7 +64,7 @@ describe("the Language Server Client Integration Tests", () => { const xmlSnippet = ` - + _.name)) ); + const existingAggregationsWithoutCurrent = + xmlElement.name === null + ? existingAggregations + : reject(existingAggregations, (name) => name === xmlElement.name); const uniquePrefixMatchingAggregations = filterMembersForSuggestion( flattenAggregations(parentUI5Class), - prefix, - existingAggregations + prefixParts.localName, + existingAggregationsWithoutCurrent ); return map(uniquePrefixMatchingAggregations, (_) => ({ diff --git a/packages/xml-views-completion/test/providers/elementName/aggregation-spec.ts b/packages/xml-views-completion/test/providers/elementName/aggregation-spec.ts index 12a56779e..888997ddd 100644 --- a/packages/xml-views-completion/test/providers/elementName/aggregation-spec.ts +++ b/packages/xml-views-completion/test/providers/elementName/aggregation-spec.ts @@ -112,6 +112,32 @@ describe("The ui5-language-assistant xml-views-completion", () => { }); }); + it("will suggest the current aggregation", () => { + const xmlSnippet = ` + + + + + + + `; + + testSuggestionsScenario({ + model: REAL_UI5_MODEL, + xmlText: xmlSnippet, + providers: { + elementName: [aggregationSuggestions], + }, + assertion: (suggestions) => { + const suggestedNames = map(suggestions, (_) => _.ui5Node.name); + expect(suggestedNames).to.include.members(["footer"]); + expectAggregationsSuggestions(suggestions, "Page"); + }, + }); + }); + it("will filter suggestions on prefix (true prefix)", () => { const xmlSnippet = ` { }, }); }); + + it("will return suggestions when namespace is the same as parent", () => { + const xmlSnippet = ` + + + + `; + + testSuggestionsScenario({ + model: REAL_UI5_MODEL, + xmlText: xmlSnippet, + providers: { + elementName: [aggregationSuggestions], + }, + assertion: (suggestions) => { + const suggestedNames = map(suggestions, (_) => _.ui5Node.name); + expect(suggestedNames).to.include.members([ + "customData", + "customHeader", + ]); + expect(suggestedNames).to.not.include.members([ + "content", + "dependents", + "dragDropConfig", + "footer", + "headerContent", + "landmarkInfo", + "layoutData", + "subHeader", + "tooltip", + ]); + expectAggregationsSuggestions(suggestions, "Page"); + }, + }); + }); + + it("will return suggestions when namespace prefix is different but referenced namespace is the same as parent", () => { + const xmlSnippet = ` + + + + `; + + testSuggestionsScenario({ + model: REAL_UI5_MODEL, + xmlText: xmlSnippet, + providers: { + elementName: [aggregationSuggestions], + }, + assertion: (suggestions) => { + const suggestedNames = map(suggestions, (_) => _.ui5Node.name); + expect(suggestedNames).to.include.members([ + "customData", + "customHeader", + ]); + expect(suggestedNames).to.not.include.members([ + "content", + "dependents", + "dragDropConfig", + "footer", + "headerContent", + "landmarkInfo", + "layoutData", + "subHeader", + "tooltip", + ]); + expectAggregationsSuggestions(suggestions, "Page"); + }, + }); + }); + + it("will return suggestions when parent has namespace and prefix doesn't", () => { + const xmlSnippet = ` + + + + `; + + testSuggestionsScenario({ + model: REAL_UI5_MODEL, + xmlText: xmlSnippet, + providers: { + elementName: [aggregationSuggestions], + }, + assertion: (suggestions) => { + const suggestedNames = map(suggestions, (_) => _.ui5Node.name); + expect(suggestedNames).to.include.members([ + "customData", + "customHeader", + ]); + expect(suggestedNames).to.not.include.members([ + "content", + "dependents", + "dragDropConfig", + "footer", + "headerContent", + "landmarkInfo", + "layoutData", + "subHeader", + "tooltip", + ]); + expectAggregationsSuggestions(suggestions, "Page"); + }, + }); + }); + + it("will return suggestions when prefix only contains the namespace and it is the same as parent", () => { + const xmlSnippet = ` + + + + `; + + testSuggestionsScenario({ + model: REAL_UI5_MODEL, + xmlText: xmlSnippet, + providers: { + elementName: [aggregationSuggestions], + }, + assertion: (suggestions) => { + const suggestedNames = map(suggestions, (_) => _.ui5Node.name); + expect(suggestedNames).to.include.members([ + "customData", + "dependents", + "dragDropConfig", + "layoutData", + "tooltip", + "content", + "customHeader", + "footer", + "headerContent", + "landmarkInfo", + "subHeader", + ]); + expectAggregationsSuggestions(suggestions, "Page"); + }, + }); + }); }); context("none applicable scenarios", () => { @@ -263,6 +440,50 @@ describe("The ui5-language-assistant xml-views-completion", () => { }, }); }); + + it("will not suggest when namespace is not the same as parent", () => { + const xmlSnippet = ` + + + + `; + + testSuggestionsScenario({ + model: REAL_UI5_MODEL, + xmlText: xmlSnippet, + providers: { + elementName: [aggregationSuggestions], + }, + assertion: (suggestions) => { + expect(suggestions).to.be.empty; + }, + }); + }); + + it("will not suggest when prefix has namespace and parent doesn't", () => { + const xmlSnippet = ` + + + + `; + + testSuggestionsScenario({ + model: REAL_UI5_MODEL, + xmlText: xmlSnippet, + providers: { + elementName: [aggregationSuggestions], + }, + assertion: (suggestions) => { + expect(suggestions).to.be.empty; + }, + }); + }); }); }); }); diff --git a/packages/xml-views-completion/test/providers/elementName/classes-spec.ts b/packages/xml-views-completion/test/providers/elementName/classes-spec.ts index 1b882fd20..74935390a 100644 --- a/packages/xml-views-completion/test/providers/elementName/classes-spec.ts +++ b/packages/xml-views-completion/test/providers/elementName/classes-spec.ts @@ -211,9 +211,9 @@ describe("The ui5-language-assistant xml-views-completion", () => { - + <⇶ - + `; testSuggestionsScenario({ @@ -262,9 +262,9 @@ describe("The ui5-language-assistant xml-views-completion", () => { - + + `; testSuggestionsScenario({ @@ -299,9 +299,9 @@ describe("The ui5-language-assistant xml-views-completion", () => { xmlns="sap.m" xmlns:forms="sap.ui.commons.form" > - + + `; testSuggestionsScenario({ @@ -335,9 +335,9 @@ describe("The ui5-language-assistant xml-views-completion", () => { xmlns="sap.m" xmlns:forms="sap.ui.commons.form" > - + + `; testSuggestionsScenario({ @@ -372,9 +372,9 @@ describe("The ui5-language-assistant xml-views-completion", () => { - + + `; testSuggestionsScenario({ @@ -400,11 +400,11 @@ describe("The ui5-language-assistant xml-views-completion", () => { - + <⇶ - + `; testSuggestionsScenario({ @@ -441,7 +441,29 @@ describe("The ui5-language-assistant xml-views-completion", () => { }); }); - it("will offer no suggestion when an aggregation's parent tag does not start with upper case character", () => { + it("will offer no suggestions when under a tag with only namespace", () => { + const xmlSnippet = ` + + + <⇶ + + `; + + testSuggestionsScenario({ + model: ui5Model, + xmlText: xmlSnippet, + providers: { + elementName: [classesSuggestions], + }, + assertion: (suggestions) => { + expect(suggestions).to.be.empty; + }, + }); + }); + + it("will offer no suggestion when the parent tag is not a recognized class or aggreation", () => { const xmlSnippet = ` { - + <⇶ - + `; testSuggestionsScenario({ @@ -490,9 +512,31 @@ describe("The ui5-language-assistant xml-views-completion", () => { - + + <⇶ + + `; + + testSuggestionsScenario({ + model: ui5Model, + xmlText: xmlSnippet, + providers: { + elementName: [classesSuggestions], + }, + assertion: (suggestions) => { + expect(suggestions).to.be.empty; + }, + }); + }); + + it("will offer no suggestions inside an explicit aggregation when the aggregation namespace is not recognized", () => { + const xmlSnippet = ` + + <⇶ - + `; testSuggestionsScenario({ @@ -511,9 +555,9 @@ describe("The ui5-language-assistant xml-views-completion", () => { const xmlSnippet = ` - + + `; testSuggestionsScenario({ @@ -544,9 +588,9 @@ describe("The ui5-language-assistant xml-views-completion", () => { - + <⇶ - + `; testSuggestionsScenario({ diff --git a/packages/xml-views-tooltip/src/tooltip.ts b/packages/xml-views-tooltip/src/tooltip.ts index 58a487198..1a976916d 100644 --- a/packages/xml-views-tooltip/src/tooltip.ts +++ b/packages/xml-views-tooltip/src/tooltip.ts @@ -1,6 +1,6 @@ -import { find, includes } from "lodash"; +import { find } from "lodash"; import { assertNever } from "assert-never"; -import { XMLAttribute, XMLElement, DEFAULT_NS } from "@xml-tools/ast"; +import { XMLAttribute, XMLElement } from "@xml-tools/ast"; import { isXMLNamespaceKey } from "@xml-tools/common"; import { XMLElementOpenName, @@ -16,6 +16,9 @@ import { flattenProperties, flattenEvents, flattenAssociations, + splitQNameByNamespace, + isSameXMLNSFromPrefix, + resolveXMLNSFromPrefix, } from "@ui5-language-assistant/logic-utils"; import { UI5Class, @@ -73,16 +76,26 @@ function findUI5NodeByElement( } // openName or closeName cannot be undefined here because otherwise the ast position visitor wouldn't return their types - const nameByKind = isOpenName + const tagQName = isOpenName ? /* istanbul ignore next */ astNode.syntax.openName?.image : /* istanbul ignore next */ astNode.syntax.closeName?.image; + /* istanbul ignore if */ + if (tagQName === undefined) { + return undefined; + } - return nameByKind !== undefined - ? findAggragationByName(parentElementClass, nameByKind) - : /* istanbul ignore next */ - undefined; + // Aggregations must be in the same namespace as their parent + // https://sapui5.hana.ondemand.com/#/topic/19eabf5b13214f27b929b9473df3195b + const { prefix, localName } = splitQNameByNamespace(tagQName); + if ( + !isSameXMLNSFromPrefix(prefix, astNode, astNode.parent.ns, astNode.parent) + ) { + return undefined; + } + + return findAggragationByName(parentElementClass, localName); } function findAggragationByName( @@ -98,38 +111,18 @@ function findAggragationByName( return ui5Aggregation; } -function splitQNameByNamespace( - qName: string -): { ns: string | undefined; name: string } { - if (!includes(qName, ":")) { - return { name: qName, ns: undefined }; - } - const match = qName.match(/(?[^:]*)(:(?.*))?/); - // There will always be a match because qName always contains a colon at this point - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const matchGroups = match!.groups!; - return { - ns: matchGroups.ns, - name: - matchGroups.name ?? - /* istanbul ignore next */ - "", - }; -} - function elementClosingTagToFQN(xmlElement: XMLElement): string { //the closeName can't be undefined here because otherwise the ast position visitor wouldn't return its type /* istanbul ignore next */ const qName = xmlElement.syntax.closeName?.image ?? ""; - const { ns, name } = splitQNameByNamespace(qName); - const prefixXmlns = ns ?? DEFAULT_NS; - const resolvedXmlns = xmlElement.namespaces[prefixXmlns]; + const { prefix, localName } = splitQNameByNamespace(qName); + const resolvedXmlns = resolveXMLNSFromPrefix(prefix, xmlElement); if (resolvedXmlns !== undefined) { - return resolvedXmlns + "." + name; + return resolvedXmlns + "." + localName; } - return name; + return localName; } function findUI5NodeByXMLAttributeKey( diff --git a/packages/xml-views-tooltip/test/tooltip-spec.ts b/packages/xml-views-tooltip/test/tooltip-spec.ts index 72803252d..bdb9f82e9 100644 --- a/packages/xml-views-tooltip/test/tooltip-spec.ts +++ b/packages/xml-views-tooltip/test/tooltip-spec.ts @@ -24,9 +24,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -38,24 +38,24 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); - expect(ui5Node).to.not.exist; + expect(ui5Node).to.be.undefined; }); it("will get hover content UI5 property in unknown tag", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); - expect(ui5Node).to.not.exist; + expect(ui5Node).to.be.undefined; }); }); @@ -64,9 +64,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -78,33 +78,33 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); - expect(ui5Node).to.not.exist; + expect(ui5Node).to.be.undefined; }); it("will get hover content UI5 property - incorrect enum", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); - expect(ui5Node).to.not.exist; + expect(ui5Node).to.be.undefined; }); it("will get hover content UI5 namespace", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -118,9 +118,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -132,9 +132,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -142,27 +142,79 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { expect(ui5Node.kind).equal("UI5Aggregation"); }); - it("will get hover content of unknown tag with unknown parent tag", () => { + it("will get hover content UI5 Aggregation with a different namespace prefix that references the same namespace", () => { const xmlSnippet = ` - - - + + + + `; + const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); + expectExists(ui5Node, "UI5Node"); + expect(ui5Node.name).to.equal("content"); + expect(ui5Node.kind).equal("UI5Aggregation"); + }); + + it("will get hover content UI5 Aggregation in the default namespace", () => { + const xmlSnippet = ` + + + + `; + const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); + expectExists(ui5Node, "UI5Node"); + expect(ui5Node.name).to.equal("content"); + expect(ui5Node.kind).equal("UI5Aggregation"); + }); + + it("will not get hover content for UI5 Aggregation in the wrong namespace", () => { + const xmlSnippet = ` + + + + `; + const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); + expect(ui5Node).to.be.undefined; + }); + + it("will not get hover content UI5 Aggregation when only the aggregation doesn't have a namespace", () => { + const xmlSnippet = ` + + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); - expect(ui5Node).to.not.exist; + expect(ui5Node).to.be.undefined; }); }); context("hover on element close tag name", () => { + it("will get hover content of unknown tag with unknown parent tag", () => { + const xmlSnippet = ` + + + + `; + const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); + expect(ui5Node).to.be.undefined; + }); + it("will get hover content UI5 class", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -174,9 +226,9 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -184,6 +236,35 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { expect(ui5Node.kind).equal("UI5Aggregation"); }); + it("will get hover content UI5 Aggregation with a different namespace prefix that references the same namespace", () => { + const xmlSnippet = ` + + + + `; + const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); + expectExists(ui5Node, "UI5Node"); + expect(ui5Node.name).to.equal("content"); + expect(ui5Node.kind).equal("UI5Aggregation"); + }); + + it("will get hover content UI5 Aggregation in the default namespace", () => { + const xmlSnippet = ` + + + + `; + const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); + expectExists(ui5Node, "UI5Node"); + expect(ui5Node.name).to.equal("content"); + expect(ui5Node.kind).equal("UI5Aggregation"); + }); + it("will get hover content UI5 class with default namespace", () => { const xmlSnippet = ` { xmlns="sap.m"> `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); - expect(ui5Node).to.not.exist; + expect(ui5Node).to.be.undefined; }); it("will get hover content UI5 class when open and close tag are different", () => { const xmlSnippet = ` - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); expectExists(ui5Node, "UI5Node"); @@ -221,12 +302,12 @@ describe("the UI5 language assistant Hover Tooltip Service", () => { const xmlSnippet = ` - + - + `; const ui5Node = getUI5Node(xmlSnippet, ui5SemanticModel); - expect(ui5Node).to.not.exist; + expect(ui5Node).to.be.undefined; }); }); diff --git a/packages/xml-views-validation/src/utils/messages.ts b/packages/xml-views-validation/src/utils/messages.ts index 0547063ce..c1204cc5f 100644 --- a/packages/xml-views-validation/src/utils/messages.ts +++ b/packages/xml-views-validation/src/utils/messages.ts @@ -4,7 +4,11 @@ export const UNKNOWN_CLASS_IN_NS = `The "{0}" class does not exist in the "{1}" namespace`; export const UNKNOWN_CLASS_WITHOUT_NS = `The "{0}" class does not exist, please specify a namespace`; export const UNKNOWN_AGGREGATION_IN_CLASS = `The "{0}" aggregation does not exist in the "{1}" class`; -export const UNKNOWN_TAG_NAME_IN_CLASS = `The "{0}" name is neither a class name nor an aggregation in the "{1}" class`; +export const UNKNOWN_AGGREGATION_IN_CLASS_DIFF_NAMESPACE = `The "{0}" aggregation must have the same namespace as the "{1}" class`; +export const UNKNOWN_TAG_NAME_IN_CLASS = `The "{0}" name is neither a class name nor an aggregation of the "{1}" class`; +export const UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS = `The "{0}" name is neither a class name in the "{1}" namespace nor an aggregation of the "{2}" class`; +export const UNKNOWN_TAG_NAME_IN_NS = `The "{0}" name is neither a class name in the "{1}" namespace nor an aggregation of its parent tag`; +export const UNKNOWN_TAG_NAME_NO_NS = `The "{0}" name is neither a class name nor an aggregation of its parent tag, please specify a namespace`; export function getMessage(message: string, ...params: string[]): string { let result = message; 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 8bf890a2a..4f3aa04c7 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 @@ -13,6 +13,7 @@ import { flattenAssociations, flattenAggregations, ui5NodeToFQN, + splitQNameByNamespace, } from "@ui5-language-assistant/logic-utils"; import { UnknownAttributeKeyIssue } from "../../../api"; import { TEMPLATING_NS, CUSTOM_DATA_NS } from "../../utils/special-namespaces"; @@ -183,17 +184,15 @@ function isValidUI5ClassAttribute( function splitAttributeByNamespace( attribute: XMLAttribute & { key: string } -): { ns: string | undefined; name: string | undefined } { - if (!includes(attribute.key, ":")) { - return { name: attribute.key, ns: undefined }; +): { ns: string | undefined; name: string } { + const { prefix, localName } = splitQNameByNamespace(attribute.key); + if (prefix === undefined) { + return { ns: prefix, name: localName }; } - const match = attribute.key.match(/(?[^:]*)(:(?.*))?/); - // There will always be a match because the attribute key always contains a colon at this point - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const matchGroups = match!.groups!; - const resolvedNS = attribute.parent.namespaces[matchGroups.ns]; + // Can't use resolveXMLNSFromPrefix here because attributes don't use the default namespace + const resolvedNS = attribute.parent.namespaces[prefix]; return { ns: resolvedNS, - name: matchGroups.name, + name: localName, }; } 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 c7f30b71f..e7ab45ea0 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 @@ -1,18 +1,27 @@ import { UnknownTagNameIssue } from "../../../api"; -import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; +import { + UI5SemanticModel, + BaseUI5Node, + UI5Class, +} from "@ui5-language-assistant/semantic-model-types"; import { XMLElement, XMLToken } from "@xml-tools/ast"; import { getUI5ClassByXMLElement, ui5NodeToFQN, getUI5NodeFromXMLElementNamespace, getUI5AggregationByXMLElement, + isSameXMLNS, } from "@ui5-language-assistant/logic-utils"; import { getMessage, UNKNOWN_CLASS_IN_NS, UNKNOWN_CLASS_WITHOUT_NS, UNKNOWN_AGGREGATION_IN_CLASS, + UNKNOWN_AGGREGATION_IN_CLASS_DIFF_NAMESPACE, UNKNOWN_TAG_NAME_IN_CLASS, + UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS, + UNKNOWN_TAG_NAME_IN_NS, + UNKNOWN_TAG_NAME_NO_NS, } from "../../utils/messages"; export function validateUnknownTagName( @@ -59,7 +68,7 @@ function validateTagWithNamespace( // To avoid false positives, we assume unrecognized namespaces are user-defined so we don't validate tags in them // (they could be user-defined class tags). - // They could also be valid special namespaces like xhtml. + // They could also be valid special namespaces like xhtml or template. // TODO There should be an error in xml-tools if the namespace is not defined in an xmlns attribute const { namespace: ui5Namespace } = getUI5NodeFromXMLElementNamespace( xmlElement, @@ -69,20 +78,43 @@ function validateTagWithNamespace( return []; } - // Check if it's a known class (aggregations don't have namespaces) - const ui5Class = getUI5ClassByXMLElement(xmlElement, model); - if (ui5Class !== undefined) { + // Check if it's a known class or aggregaion + if ( + getUI5ClassByXMLElement(xmlElement, model) !== undefined || + getUI5AggregationByXMLElement(xmlElement, model) !== undefined + ) { return []; } + // This is an unrecognized element on a non-custom namespace. + // Try to find out what this element was supposed to be to give the most accurate error message. + + // Check if it could be an aggregation: + // Aggregations cannot be the root element and cannot be under another aggregation. + // Aggregations are always in the parent element (class) namespace. + // + if ( + xmlElement.parent.type === "XMLElement" && + isSameXMLNS(xmlElement.parent, xmlElement) && + getUI5AggregationByXMLElement(xmlElement.parent, model) === undefined + ) { + return [ + { + ...issueDefaults, + message: getUnknownTagNameMessage( + xmlElement.name, + ui5Namespace, + getUI5ClassByXMLElement(xmlElement.parent, model) + ), + }, + ]; + } + + // If it's not an aggregation it can only be a class return [ { ...issueDefaults, - message: getMessage( - UNKNOWN_CLASS_IN_NS, - xmlElement.name, - ui5NodeToFQN(ui5Namespace) - ), + message: getUnknownClassMessage(xmlElement.name, ui5Namespace), }, ]; } @@ -119,23 +151,14 @@ function validateTagWithoutNamespace( } // This is an unrecognized element on a non-custom (or undefined) default namespace. - // Try to find out what this element can be. + // Try to find out what this element was supposed to be to give the most accurate error message. + + // If it's the root tag, it can only be a class if (xmlElement.parent.type === "XMLDocument") { - // If it's the root tag, it can only be a class - let message: string; - if (ui5Namespace !== undefined) { - message = getMessage( - UNKNOWN_CLASS_IN_NS, - xmlElement.name, - ui5NodeToFQN(ui5Namespace) - ); - } else { - message = getMessage(UNKNOWN_CLASS_WITHOUT_NS, xmlElement.name); - } return [ { ...issueDefaults, - message, + message: getUnknownClassMessage(xmlElement.name, ui5Namespace), }, ]; } @@ -143,26 +166,28 @@ function validateTagWithoutNamespace( // Check if the parent is a recognized class const parentUI5Class = getUI5ClassByXMLElement(xmlElement.parent, model); if (parentUI5Class !== undefined) { - // If the parent class has a default aggregation, this could be a class. Otherwise it must be an aggregation. + // If the parent class doesn't have a default aggregation, it can only contain aggregations under it if (parentUI5Class.defaultAggregation === undefined) { return [ { ...issueDefaults, - message: getMessage( - UNKNOWN_AGGREGATION_IN_CLASS, + message: getUnknownAggregationMessage( xmlElement.name, - ui5NodeToFQN(parentUI5Class) + xmlElement.ns, + parentUI5Class, + xmlElement.parent.ns ), }, ]; } else { + // It could be a class or an aggregation return [ { ...issueDefaults, - message: getMessage( - UNKNOWN_TAG_NAME_IN_CLASS, + message: getUnknownTagNameMessage( xmlElement.name, - ui5NodeToFQN(parentUI5Class) + ui5Namespace, + parentUI5Class ), }, ]; @@ -176,24 +201,78 @@ function validateTagWithoutNamespace( ); if (parentUI5Aggregation !== undefined) { // Only classes can appear under aggregations - let message: string; - if (ui5Namespace !== undefined) { - message = getMessage( - UNKNOWN_CLASS_IN_NS, - xmlElement.name, - ui5NodeToFQN(ui5Namespace) - ); - } else { - message = getMessage(UNKNOWN_CLASS_WITHOUT_NS, xmlElement.name); - } return [ { ...issueDefaults, - message, + message: getUnknownClassMessage(xmlElement.name, ui5Namespace), }, ]; } - // It might be an aggregation name under a custom class, so we don't give an error to avoid false positives - return []; + // We don't know what the parent tag is. + // Since the tag doesn't have a namespace, and there is either a recognized default namespace or no default namespace, + // we can conclude that this tag is not a custom class, and that it's not an aggregation of a custom class + // (since aggregations must have the same namespace as their parent tag, and we know that the namespace is not custom). + return [ + { + ...issueDefaults, + message: getUnknownTagNameMessage( + xmlElement.name, + ui5Namespace, + undefined + ), + }, + ]; +} + +function getUnknownClassMessage( + name: string, + ui5Namespace: BaseUI5Node | undefined +): string { + if (ui5Namespace !== undefined) { + return getMessage(UNKNOWN_CLASS_IN_NS, name, ui5NodeToFQN(ui5Namespace)); + } + return getMessage(UNKNOWN_CLASS_WITHOUT_NS, name); +} + +function getUnknownAggregationMessage( + name: string, + ns: string | undefined, + ui5Class: UI5Class, + classNS: string | undefined +): string { + // Aggregations must be in the same namespace as the class + if (ns !== classNS) { + return getMessage( + UNKNOWN_AGGREGATION_IN_CLASS_DIFF_NAMESPACE, + name, + ui5NodeToFQN(ui5Class) + ); + } + return getMessage(UNKNOWN_AGGREGATION_IN_CLASS, name, ui5NodeToFQN(ui5Class)); +} + +function getUnknownTagNameMessage( + name: string, + ui5Namespace: BaseUI5Node | undefined, + parentUI5Class: UI5Class | undefined +): string { + if (parentUI5Class !== undefined && ui5Namespace !== undefined) { + return getMessage( + UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS, + name, + ui5NodeToFQN(ui5Namespace), + ui5NodeToFQN(parentUI5Class) + ); + } else if (parentUI5Class !== undefined && ui5Namespace === undefined) { + return getMessage( + UNKNOWN_TAG_NAME_IN_CLASS, + name, + ui5NodeToFQN(parentUI5Class) + ); + } else if (parentUI5Class === undefined && ui5Namespace !== undefined) { + return getMessage(UNKNOWN_TAG_NAME_IN_NS, name, ui5NodeToFQN(ui5Namespace)); + } else { + return getMessage(UNKNOWN_TAG_NAME_NO_NS, name); + } } diff --git a/packages/xml-views-validation/test/validators/attributes/unknown-attribute-key-spec.ts b/packages/xml-views-validation/test/validators/attributes/unknown-attribute-key-spec.ts index 5071aa8f1..76932273a 100644 --- a/packages/xml-views-validation/test/validators/attributes/unknown-attribute-key-spec.ts +++ b/packages/xml-views-validation/test/validators/attributes/unknown-attribute-key-spec.ts @@ -72,9 +72,9 @@ describe("the unknown attribute name validation", () => { ` - + - + `, "Unknown attribute key: TYPO" ); @@ -96,9 +96,9 @@ describe("the unknown attribute name validation", () => { ` - + - + `, "Unknown attribute key: TYPO" ); @@ -130,9 +130,9 @@ describe("the unknown attribute name validation", () => { ` - + - + `, "Unknown attribute key: stashed" ); @@ -143,9 +143,9 @@ describe("the unknown attribute name validation", () => { ` - + - + `, "Unknown attribute key: binding" ); @@ -156,9 +156,9 @@ describe("the unknown attribute name validation", () => { ` - + - + `, "Unknown attribute key: class" ); @@ -186,9 +186,9 @@ describe("the unknown attribute name validation", () => { ` - + - + `, "Unknown attribute key: _select" ); @@ -227,9 +227,9 @@ describe("the unknown attribute name validation", () => { - + - + `); }); @@ -300,9 +300,9 @@ describe("the unknown attribute name validation", () => { - - - + + + `); }); @@ -310,9 +310,9 @@ describe("the unknown attribute name validation", () => { assertNoIssues(` - + - + `); }); @@ -320,9 +320,9 @@ describe("the unknown attribute name validation", () => { assertNoIssues(` - + - + `); }); }); @@ -334,8 +334,8 @@ describe("the unknown attribute name validation", () => { - - + + `); }); @@ -344,7 +344,7 @@ describe("the unknown attribute name validation", () => { - + `); }); @@ -353,7 +353,7 @@ describe("the unknown attribute name validation", () => { - + `); }); @@ -362,7 +362,7 @@ describe("the unknown attribute name validation", () => { - + `); }); @@ -370,8 +370,8 @@ describe("the unknown attribute name validation", () => { assertNoIssues(` - - + + `); }); @@ -379,8 +379,8 @@ describe("the unknown attribute name validation", () => { assertNoIssues(` - - + + `); }); }); @@ -391,8 +391,8 @@ describe("the unknown attribute name validation", () => { - - + + `); }); @@ -400,9 +400,9 @@ describe("the unknown attribute name validation", () => { assertNoIssues(` - + - + `); }); diff --git a/packages/xml-views-validation/test/validators/element/unknown-tag-name-spec.ts b/packages/xml-views-validation/test/validators/element/unknown-tag-name-spec.ts index 98d17d584..5e16fc97b 100644 --- a/packages/xml-views-validation/test/validators/element/unknown-tag-name-spec.ts +++ b/packages/xml-views-validation/test/validators/element/unknown-tag-name-spec.ts @@ -1,17 +1,24 @@ +import { expect } from "chai"; import { partial } from "lodash"; import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; import { generateModel } from "@ui5-language-assistant/test-utils"; import { assertNoIssues as assertNoIssuesBase, assertSingleIssue as assertSingleIssueBase, + testValidationsScenario, + computeExpectedRanges, } from "../../test-utils"; import { validateUnknownTagName } from "../../../src/validators/elements/unknown-tag-name"; import { getMessage, UNKNOWN_CLASS_IN_NS, UNKNOWN_CLASS_WITHOUT_NS, - UNKNOWN_TAG_NAME_IN_CLASS, UNKNOWN_AGGREGATION_IN_CLASS, + UNKNOWN_AGGREGATION_IN_CLASS_DIFF_NAMESPACE, + UNKNOWN_TAG_NAME_IN_CLASS, + UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS, + UNKNOWN_TAG_NAME_IN_NS, + UNKNOWN_TAG_NAME_NO_NS, } from "../../../src/utils/messages"; describe("the unknown tag name validation", () => { @@ -68,7 +75,12 @@ describe("the unknown tag name validation", () => { `, - getMessage(UNKNOWN_CLASS_IN_NS, "Button_TYPO", "sap.m") + getMessage( + UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS, + "Button_TYPO", + "sap.m", + "sap.m.SplitApp" + ) ); }); @@ -77,14 +89,101 @@ describe("the unknown tag name validation", () => { ` - + <🢂m:Button_TYPO🢀> - + `, getMessage(UNKNOWN_CLASS_IN_NS, "Button_TYPO", "sap.m") ); }); + + it("will detect an invalid aggregation when it's in the wrong namespace", () => { + assertSingleIssue( + ` + <🢂m:content🢀> + + `, + getMessage(UNKNOWN_CLASS_IN_NS, "content", "sap.m") + ); + }); + + it("will detect an invalid class name under aggregation in the same namespace", () => { + assertSingleIssue( + ` + + <🢂mvc:Button_TYPO🢀> + + + `, + getMessage(UNKNOWN_CLASS_IN_NS, "Button_TYPO", "sap.ui.core.mvc") + ); + }); + + it("will detect an invalid aggregation name under known class tag without default aggregation", () => { + assertSingleIssue( + ` + + <🢂m:content_TYPO🢀> + + + `, + getMessage( + UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS, + "content_TYPO", + "sap.m", + "sap.m.SplitApp" + ) + ); + }); + + it("will detect an issue for unknown name under unknown class in a known namespace", () => { + const xmlSnippet = ` + + <🢂m:SplitApp_TYPO🢀> + <🢂m:Button_TYPO🢀> + + + `; + const expectedRanges = computeExpectedRanges(xmlSnippet); + + testValidationsScenario({ + model: ui5SemanticModel, + xmlText: xmlSnippet, + validators: { element: [validateUnknownTagName] }, + assertion: (issues) => { + expect(issues).to.deep.equalInAnyOrder([ + { + kind: "UnknownTagName", + message: getMessage( + UNKNOWN_CLASS_IN_NS, + "SplitApp_TYPO", + "sap.m" + ), + offsetRange: expectedRanges[0], + severity: "error", + }, + { + kind: "UnknownTagName", + message: getMessage( + UNKNOWN_TAG_NAME_IN_NS, + "Button_TYPO", + "sap.m" + ), + offsetRange: expectedRanges[1], + severity: "error", + }, + ]); + }, + }); + }); }); context("tag without namespace", () => { @@ -101,9 +200,9 @@ describe("the unknown tag name validation", () => { assertSingleIssue( ` - + <🢂List🢀> - + `, getMessage(UNKNOWN_CLASS_WITHOUT_NS, "List") ); @@ -123,23 +222,83 @@ describe("the unknown tag name validation", () => { ); }); - it("will detect an invalid aggregation name under known class tag without default aggregation", () => { + it("will detect an invalid aggregation namespace under known class tag without default aggregation", () => { assertSingleIssue( ` - <🢂content_TYPO🢀> - + <🢂content🢀> + `, getMessage( - UNKNOWN_AGGREGATION_IN_CLASS, - "content_TYPO", + UNKNOWN_AGGREGATION_IN_CLASS_DIFF_NAMESPACE, + "content", "sap.m.SplitApp" ) ); }); + + it("will detect an issue for unknown name under unknown class in non-default non-ui5 namespace when name starts with uppercase", () => { + assertSingleIssue( + ` + + <🢂Button_TYPO🢀> + + + `, + getMessage(UNKNOWN_TAG_NAME_NO_NS, "Button_TYPO") + ); + }); + }); + + context("when default namespace is a ui5 namespace", () => { + it("will detect an issue for unknown name under unknown class in the default namespace", () => { + const xmlSnippet = ` + + <🢂SplitApp_TYPO🢀> + <🢂Button_TYPO🢀> + + + `; + const expectedRanges = computeExpectedRanges(xmlSnippet); + + testValidationsScenario({ + model: ui5SemanticModel, + xmlText: xmlSnippet, + validators: { element: [validateUnknownTagName] }, + assertion: (issues) => { + expect(issues).to.deep.equalInAnyOrder([ + { + kind: "UnknownTagName", + message: getMessage( + UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS, + "SplitApp_TYPO", + "sap.m", + "sap.ui.core.mvc.View" + ), + offsetRange: expectedRanges[0], + severity: "error", + }, + { + kind: "UnknownTagName", + message: getMessage( + UNKNOWN_TAG_NAME_IN_NS, + "Button_TYPO", + "sap.m" + ), + offsetRange: expectedRanges[1], + severity: "error", + }, + ]); + }, + }); + }); }); }); @@ -172,8 +331,9 @@ describe("the unknown tag name validation", () => { <🢂List_TYPO🢀> `, getMessage( - UNKNOWN_TAG_NAME_IN_CLASS, + UNKNOWN_TAG_NAME_IN_NS_UNDER_CLASS, "List_TYPO", + "sap.ui.core.mvc", "sap.ui.core.mvc.View" ) ); @@ -181,14 +341,14 @@ describe("the unknown tag name validation", () => { it("will detect an invalid aggregation name under known class tag without default aggregation", () => { assertSingleIssue( - ` - + ` + <🢂content_TYPO🢀> - - `, + + `, getMessage( UNKNOWN_AGGREGATION_IN_CLASS, "content_TYPO", @@ -246,6 +406,17 @@ describe("the unknown tag name validation", () => { ); }); + it("will not detect an issue for known aggregation in a different namespace prefix that references the same namespace", () => { + assertNoIssues( + ` + + + ` + ); + }); + it("will not detect an issue for known class under class that has default aggregation", () => { assertNoIssues( ` { ` - + - + ` ); }); @@ -328,10 +499,10 @@ describe("the unknown tag name validation", () => { ` - + - + ` ); }); @@ -366,8 +537,8 @@ describe("the unknown tag name validation", () => { ` - - + + ` ); }); @@ -403,8 +574,8 @@ describe("the unknown tag name validation", () => { xmlns:typo="sap.m_TYPO" xmlns="sap.m"> - - + + ` ); @@ -425,10 +596,10 @@ describe("the unknown tag name validation", () => { ` - + - + ` ); }); @@ -500,21 +671,8 @@ describe("the unknown tag name validation", () => { xmlns:mvc="sap.ui.core.mvc" xmlns:typo="sap.m_TYPO"> - - - - ` - ); - }); - - it("will not detect an issue for unknown name under unknown class in non-default non-ui5 namespace when name starts with uppercase", () => { - assertNoIssues( - ` - - - + + ` ); diff --git a/packages/xml-views-validation/test/validators/element/use-of-deprecated-class-spec.ts b/packages/xml-views-validation/test/validators/element/use-of-deprecated-class-spec.ts index daebe6437..d33edf956 100644 --- a/packages/xml-views-validation/test/validators/element/use-of-deprecated-class-spec.ts +++ b/packages/xml-views-validation/test/validators/element/use-of-deprecated-class-spec.ts @@ -144,8 +144,8 @@ describe("the use of deprecated class validation", () => { xmlns:mvc="sap.ui.core.mvc" xmlns="sap.ui.commons"> - - + + `; testValidationsScenario({