From 14487b94e1654e2d7f372305500e9f45f1bc1525 Mon Sep 17 00:00:00 2001 From: Maruf Rasully Date: Thu, 16 May 2024 09:12:50 +0300 Subject: [PATCH 1/2] fix: validate binding after expression binding --- packages/binding-parser/src/constant.ts | 3 + packages/binding-parser/src/types/index.ts | 7 + .../binding-parser/src/utils/expression.ts | 331 +++++++++++++++--- .../test/unit/utils/expression.test.ts | 54 ++- .../property-binding-info-validator.test.ts | 12 + 5 files changed, 344 insertions(+), 63 deletions(-) diff --git a/packages/binding-parser/src/constant.ts b/packages/binding-parser/src/constant.ts index 934ba71b3..33c34b630 100644 --- a/packages/binding-parser/src/constant.ts +++ b/packages/binding-parser/src/constant.ts @@ -23,3 +23,6 @@ export const VALUE = "value"; export const LEXER_ERROR = "lexer-error"; export const PARSE_ERROR = "parse-error"; + +export const END_OF_LINE = /\r|\n|\r\n/; +export const WHITE_SPACE_REG = /[ \t\f]+/; diff --git a/packages/binding-parser/src/types/index.ts b/packages/binding-parser/src/types/index.ts index b0c4daca4..f7b59b3e4 100644 --- a/packages/binding-parser/src/types/index.ts +++ b/packages/binding-parser/src/types/index.ts @@ -1,3 +1,4 @@ +import { LEFT_CURLY, RIGHT_CURLY } from "../constant"; export * as BindingParserTypes from "./binding-parser"; export interface ExtractBindingSyntax { @@ -5,3 +6,9 @@ export interface ExtractBindingSyntax { endIndex: number; expression: string; } + +export interface Token { + type: typeof LEFT_CURLY | typeof RIGHT_CURLY; + start: number; + end: number; +} diff --git a/packages/binding-parser/src/utils/expression.ts b/packages/binding-parser/src/utils/expression.ts index ecf9a6a04..e71680cd9 100644 --- a/packages/binding-parser/src/utils/expression.ts +++ b/packages/binding-parser/src/utils/expression.ts @@ -1,5 +1,11 @@ -import { isPrimitiveValue } from "../api"; -import { ExtractBindingSyntax } from "../types"; +import { + END_OF_LINE, + LEFT_CURLY, + RIGHT_CURLY, + WHITE_SPACE_REG, + isPrimitiveValue, +} from "../api"; +import { ExtractBindingSyntax, Token } from "../types"; import type { ParseResultErrors, StructureValue, @@ -163,65 +169,280 @@ export const isBindingAllowed = ( }; /** - * Regular expression to extract binding syntax. + * Check if character is whitespace. * - * Also handles escaping of '{' and '}'. + * @param character character to check + * @returns boolean */ -// eslint-disable-next-line no-useless-escape -const start = /(\\[\\\{\}])|(\{)/g; -// eslint-disable-next-line no-useless-escape -const end = /(\\[\\\{\}])|(\})/g; - -export const extractBindingSyntax = (input: string): ExtractBindingSyntax[] => { - const result: ExtractBindingSyntax[] = []; - let startRegResult: RegExpExecArray | null; - let endRegResult: RegExpExecArray | null; - // resetting - start.lastIndex = 0; - let startIndex = 0; - let lastIndex = 0; - let endIndex = 0; - const text = input; - if (text.trim() === "") { - return [{ startIndex, endIndex, expression: input }]; - } - while ((startRegResult = start.exec(input)) !== null) { - // scape special chars - if (startRegResult[1]) { - continue; +function isWhitespace(character: string | undefined): boolean { + if (!character) { + return false; + } + return WHITE_SPACE_REG.test(character); +} + +/** + * Check if character is escape char. + * + * @param character character to check + * @returns boolean + */ +function isEscape(character: string | undefined): boolean { + return character === "\\"; +} + +/** + * Check if character is end of line. + * + * @param character character to check + * @returns boolean + */ +function isEndOfLine(character: string | undefined): boolean { + if (!character) { + return false; + } + + return END_OF_LINE.test(character); +} + +/** + * Check if input character is left curly bracket. + * + * @param character input character + * @returns boolean + */ +function isLeftCurlyBracket(character: string | undefined): boolean { + return character === "{"; +} +/** + * Check if input character is right curly bracket. + * + * @param character input character + * @returns boolean + */ +function isRightCurlyBracket(character: string | undefined): boolean { + return character === "}"; +} + +class ExtractBinding { + private offset: number; + private text: string; + private tokens: Token[]; + private tokenIdx: number; + private expressions: ExtractBindingSyntax[] = []; + /** + * Class constructor. + * + * @param text text to be tokenized + * @returns void + */ + constructor(text: string) { + this.text = text; + this.offset = 0; + this.tokens = []; + this.tokenIdx = 0; + this.expressions = []; + } + /** + * Peek token. + * + * @param count number of token to peek + * @returns token or undefined + */ + peekToken(count: number): Token | undefined { + return this.tokens[count]; + } + /** + * Get next token. + * + * @param count number of token to increment + * @returns token or undefined + */ + nextToken(count = 1): Token | undefined { + const tokenIdx = this.tokenIdx + count; + this.tokenIdx = tokenIdx; + return this.tokens[tokenIdx]; + } + /** + * Peek character. + * + * @param count number of character to peek + * @returns undefine or string + */ + peek(count = 0): undefined | string { + if (this.offset + count >= this.text.length) { + return undefined; } - const startInput = input.slice(startRegResult.index); - // collect all closing bracket(s) - end.lastIndex = 0; - while ((endRegResult = end.exec(startInput)) !== null) { - // scape special chars - if (endRegResult[1]) { - break; + + return this.text.charAt(this.offset + count); + } + + /** + * Get next char and increment offset. + * + * @param count amount characters to increment offset. By default one char + * @returns undefine or string + */ + next(count = 1): undefined | string { + if (this.offset >= this.text.length) { + return undefined; + } + // increment offset + this.offset = this.offset + count; + return this.text.charAt(count); + } + + /** + * Get image. + * + * @param start start of offset + * @param end end of offset + * @returns image for given offset + */ + getImage(start: number, end: number): string { + return this.text.substring(start, end); + } + /** + * Create tokens for left and right curly bracket. + */ + tokenize(): void { + while (this.peek()) { + const character = this.peek(); + if (isWhitespace(character) || isEndOfLine(character)) { + this.next(); + continue; + } + if (isEscape(character)) { + this.next(2); + continue; + } + if (isLeftCurlyBracket(character)) { + this.tokens.push({ + start: this.offset, + end: this.offset + 1, + type: LEFT_CURLY, + }); } - lastIndex = endRegResult.index; + + if (isRightCurlyBracket(character)) { + this.tokens.push({ + start: this.offset, + end: this.offset + 1, + type: RIGHT_CURLY, + }); + } + this.next(); } - if (lastIndex === startRegResult.index) { - // missing closing bracket - const expression = startInput.slice(0, input.length); - result.push({ - startIndex: startRegResult.index, - endIndex: input.length, - expression, + } + /** + * Get binding expressions. + * + * @returns binding expressions + */ + getExpressions() { + return this.expressions; + } + /** + * Extract start and end of brackets and add it to expressions. + * + * @returns void + */ + extract() { + if (this.text.trim() === "") { + // empty + this.expressions.push({ + startIndex: 0, + endIndex: 0, + expression: this.text, + }); + return; + } + let leftCurly: Token[] = []; + let rightCurly: Token[] = []; + while (this.peekToken(this.tokenIdx)) { + const token = this.peekToken(this.tokenIdx) as Token; + if (token.type === LEFT_CURLY) { + leftCurly.push(token); + this.nextToken(); + continue; + } + if (token.type === RIGHT_CURLY) { + while (this.peekToken(this.tokenIdx)) { + const token = this.peekToken(this.tokenIdx) as Token; + if (token.type === RIGHT_CURLY) { + rightCurly.push(token); + this.nextToken(); + continue; + } + if (token.type === LEFT_CURLY) { + break; + } + } + // valid syntax + if (leftCurly.length === rightCurly.length) { + const start = leftCurly[0].start; + const end = rightCurly[rightCurly.length - 1].end; + this.expressions.push({ + expression: this.getImage(start, end), + endIndex: end, + startIndex: start, + }); + // reset + leftCurly = []; + rightCurly = []; + continue; + } + // miss match left curly bracket + if (leftCurly.length < rightCurly.length) { + // take last right curly bracket + const start = leftCurly[0].start; + const end = rightCurly[rightCurly.length - 1].end; + this.expressions.push({ + expression: this.getImage(start, end), + endIndex: end, + startIndex: start, + }); + // reset + leftCurly = []; + rightCurly = []; + continue; + } + // miss match right curly bracket + if (leftCurly.length > rightCurly.length) { + continue; + } + } + } + if (leftCurly.length > 0 && rightCurly.length === 0) { + // handle missing right curly bracket + const start = leftCurly[0].start; + const end = this.offset - start; + this.expressions.push({ + startIndex: start, + endIndex: end, + expression: this.getImage(start, end), }); - input = startInput.slice(input.length); - } else { - const expression = startInput.slice(0, lastIndex + 1); - startIndex = endIndex + startRegResult.index; - endIndex = startIndex + lastIndex + 1; - result.push({ - startIndex, - endIndex, - expression, + } else if (leftCurly.length > rightCurly.length) { + // handle miss match right curly bracket + const start = leftCurly[0].start; + const end = rightCurly[rightCurly.length - 1].end; + this.expressions.push({ + startIndex: start, + endIndex: end, + expression: this.getImage(start, end), }); - input = startInput.slice(lastIndex + 1); - // resetting - start.lastIndex = 0; } } - return result; -}; +} + +/** + * Extract binding syntax. + * + * Also handles escaping of '{' or '}'. + */ +export function extractBindingSyntax(input: string): ExtractBindingSyntax[] { + const binding = new ExtractBinding(input); + binding.tokenize(); + binding.extract(); + return binding.getExpressions(); +} diff --git a/packages/binding-parser/test/unit/utils/expression.test.ts b/packages/binding-parser/test/unit/utils/expression.test.ts index 6e04857e3..95c79e1bf 100644 --- a/packages/binding-parser/test/unit/utils/expression.test.ts +++ b/packages/binding-parser/test/unit/utils/expression.test.ts @@ -143,11 +143,16 @@ describe("expression", () => { const result = extractBindingSyntax(input); expect(result).toStrictEqual([ { - endIndex: 109, + endIndex: 65, expression: - "{\n events: {\n key01: \"abc\",\n }\n\t\t\t}\n {\n path: 'some/value',\n }", + '{\n events: {\n key01: "abc",\n }\n\t\t\t}', startIndex: 7, }, + { + endIndex: 109, + expression: "{\n path: 'some/value',\n }", + startIndex: 72, + }, ]); }); it("two property binding info [missing brackets]", () => { @@ -165,11 +170,16 @@ describe("expression", () => { const result = extractBindingSyntax(input); expect(result).toStrictEqual([ { - endIndex: 116, + endIndex: 64, expression: - "{\n events: \n key01: \"abc\",\n }\n\t\t\t}\n {\n path: '',\n events: {\n }", + '{\n events: \n key01: "abc",\n }\n\t\t\t}', startIndex: 7, }, + { + endIndex: 116, + expression: "{\n path: '',\n events: {\n }", + startIndex: 71, + }, ]); }); it("two property binding info", () => { @@ -177,10 +187,15 @@ describe("expression", () => { const result = extractBindingSyntax(input); expect(result).toStrictEqual([ { - endIndex: 26, - expression: "{path:'' } , {events: { }}", + endIndex: 10, + expression: "{path:'' }", startIndex: 0, }, + { + endIndex: 26, + expression: "{events: { }}", + startIndex: 13, + }, ]); }); it("two property binding info [with special chars]", () => { @@ -188,10 +203,33 @@ describe("expression", () => { const result = extractBindingSyntax(input); expect(result).toStrictEqual([ { - endIndex: 35, - expression: "{parts: [' ']} $$ {path: '###'}", + endIndex: 18, + expression: "{parts: [' ']}", startIndex: 4, }, + { + endIndex: 35, + expression: "{path: '###'}", + startIndex: 22, + }, + ]); + }); + it("expression binding and property binding info", () => { + const input = + "{= ${/actionButtonsInfo/midColumn/closeColumn} !== null } {parts: [' ']}"; + const result = extractBindingSyntax(input); + expect(result).toStrictEqual([ + { + endIndex: 57, + expression: + "{= ${/actionButtonsInfo/midColumn/closeColumn} !== null }", + startIndex: 0, + }, + { + endIndex: 72, + expression: "{parts: [' ']}", + startIndex: 58, + }, ]); }); }); diff --git a/packages/binding/test/unit/services/diagnostics/validators/property-binding-info-validator.test.ts b/packages/binding/test/unit/services/diagnostics/validators/property-binding-info-validator.test.ts index bc60317a3..61cb0a986 100644 --- a/packages/binding/test/unit/services/diagnostics/validators/property-binding-info-validator.test.ts +++ b/packages/binding/test/unit/services/diagnostics/validators/property-binding-info-validator.test.ts @@ -706,6 +706,18 @@ describe("property-binding-info-validator", () => { ] `); }); + it("do not ignore validation after expression binding", async () => { + const snippet = ` + `; + const result = await validateView(snippet); + expect(result.map((item) => issueToSnapshot(item))) + .toMatchInlineSnapshot(` + Array [ + "kind: NotAllowedProperty; text: One of these elements [parts, path] are allowed; severity:info; range:9:78-9:83", + "kind: NotAllowedProperty; text: One of these elements [parts, path] are allowed; severity:info; range:9:92-9:96", + ] + `); + }); }); describe("quotes", () => { it("no wrong diagnostic for quotes", async () => { From 263c32bbb18d3cd8151aae96e6a44073b81140a1 Mon Sep 17 00:00:00 2001 From: Maruf Rasully Date: Thu, 16 May 2024 11:31:42 +0300 Subject: [PATCH 2/2] fix: change set --- .changeset/lemon-vans-run.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/lemon-vans-run.md diff --git a/.changeset/lemon-vans-run.md b/.changeset/lemon-vans-run.md new file mode 100644 index 000000000..3f4014a81 --- /dev/null +++ b/.changeset/lemon-vans-run.md @@ -0,0 +1,8 @@ +--- +"@ui5-language-assistant/vscode-ui5-language-assistant-bas-ext": patch +"vscode-ui5-language-assistant": patch +"@ui5-language-assistant/binding-parser": patch +"@ui5-language-assistant/binding": patch +--- + +fix: validate binding after expression binding