From f8b575995d585c0c9050f7790a7a4e0b6e3f6003 Mon Sep 17 00:00:00 2001 From: Yossi Saadi Date: Mon, 15 Apr 2024 11:34:48 +0300 Subject: [PATCH 1/2] feat(autocomplete): make autocomplete better, separate utils to different utils file --- src/codemirror/extensions/autocomplete.ts | 92 ++++++---------- src/codemirror/utils/autocomplete-utils.ts | 121 +++++++++++++++++++++ 2 files changed, 156 insertions(+), 57 deletions(-) create mode 100644 src/codemirror/utils/autocomplete-utils.ts diff --git a/src/codemirror/extensions/autocomplete.ts b/src/codemirror/extensions/autocomplete.ts index 676e43c..0c29d1c 100644 --- a/src/codemirror/extensions/autocomplete.ts +++ b/src/codemirror/extensions/autocomplete.ts @@ -7,10 +7,12 @@ import { } from "@codemirror/autocomplete"; import { getEditorStateInfo } from "../utils/extensions-utils"; import { AutocompletionsMetadata } from "@/types"; - -function isNewTagContext(lineText: string): boolean { - return /<\w*$/.test(lineText); -} +import { + getNewTagContext, + shouldTriggerPropSuggestions, + extractAlreadyUsedProps, + isInsideAttribute, +} from "../utils/autocomplete-utils"; function generateComponentNameCompletions( partialName: string, @@ -41,37 +43,11 @@ function generateComponentNameCompletions( })); } -function extractAlreadyUsedProps( - fullLineText: string, - cursorPos: number -): Set { - const tagStart = fullLineText.lastIndexOf("<", cursorPos); - let tagEnd = fullLineText.indexOf(">", cursorPos); - if (tagEnd === -1) { - // tags that aren't closed yet - tagEnd = fullLineText.length; - } - - const tagContent = fullLineText.substring(tagStart, tagEnd); - - // props in the format propName="value", propName={value}, and just propName (flag-boolean) - const propRegex = /(\w+)(?=\s*=|\s*\/>|>|$)/g; - const usedProps = new Set(); - let match; - - while ((match = propRegex.exec(tagContent))) { - usedProps.add(match[1]); - } - - return usedProps; -} - function generatePropCompletions( componentName: keyof AutocompletionsMetadata, options: AutocompletionsMetadata, partialPropName: string, - usedProps: Set, - lineTextUpToCursor: string + usedProps: Set ): Completion[] { const componentProps = options[componentName]; if (!componentProps?.length) { @@ -98,17 +74,16 @@ function generatePropCompletions( section: `${componentName}'s props`, // group props by component name type: required ? "required" : "property", // property won't have * next to it apply: (view, _completion, from, to) => { - const applyText = type === "string" ? `${name}=""` : `${name}={}`; - const applyPrefix = lineTextUpToCursor.endsWith(" ") ? "" : " "; - const textToInsert = applyPrefix + applyText; + const textToInsert = type === "string" ? `${name}=""` : `${name}={}`; + const replaceFrom = from - partialPropName.length; const transaction = view.state.update({ changes: { - from, + from: replaceFrom, to, insert: textToInsert, }, - selection: { anchor: from + textToInsert.length - 1 }, + selection: { anchor: replaceFrom + textToInsert.length - 1 }, }); view.dispatch(transaction); // Dispatch the transaction to apply changes and set cursor }, @@ -126,33 +101,35 @@ function playgroundAutocompletion( let completions: Completion[] = []; let from = cursorPos; - if (isNewTagContext(lineTextUpToCursor)) { + if (isInsideAttribute(fullLineText, cursorPos)) { + // never show suggestions from any kind inside attributes + return null; + } + + const newTagName = getNewTagContext(lineTextUpToCursor); + if (newTagName !== null) { // if the cursor is in a new tag context, generate component name completions - const partialComponentName = - /<(\w*)$/.exec(lineTextUpToCursor)?.[1] || null; - if (partialComponentName) { - from -= partialComponentName.length; - completions = generateComponentNameCompletions( - partialComponentName, - options - ); - } else if (lineTextUpToCursor.endsWith("<")) { - completions = generateComponentNameCompletions("", options); + completions = generateComponentNameCompletions(newTagName, options); + if (newTagName !== "") { + from -= newTagName.length; } } else { // otherwise, generate prop completions - const usedProps = extractAlreadyUsedProps(fullLineText, cursorPos); + if (!shouldTriggerPropSuggestions(fullLineText, cursorPos)) { + return null; + } const match = lineTextUpToCursor.match(/<(\w+)\s[\s\S]*?(\w*)$/); - if (match) { - const [, componentName, partialPropName] = match; - completions = generatePropCompletions( - componentName, - options, - partialPropName, - usedProps, - lineTextUpToCursor - ); + if (!match) { + return null; } + const usedProps = extractAlreadyUsedProps(fullLineText, cursorPos); + const [, componentName, partialPropName] = match; + completions = generatePropCompletions( + componentName, + options, + partialPropName, + usedProps + ); } if (completions.length === 0) { @@ -163,6 +140,7 @@ function playgroundAutocompletion( from, to: cursorPos, options: completions, + validFor: /^[\w-]*$/, }; } diff --git a/src/codemirror/utils/autocomplete-utils.ts b/src/codemirror/utils/autocomplete-utils.ts new file mode 100644 index 0000000..4d85b69 --- /dev/null +++ b/src/codemirror/utils/autocomplete-utils.ts @@ -0,0 +1,121 @@ +export function getNewTagContext(textUpToCursor: string): string { + // typing `<` or `)$/.test( + textUpToCursor + ) && + /\/>$/.test(textUpToCursor) + ) { + return false; + } + + // Check if the cursor is just before a closing tag, which we don't want to match + if (/<\/[A-Za-z]+>$/.test(textUpToCursor)) { + return false; + } + + // Check for an open tag being typed + return /<[A-Za-z]+[^>]*$/.test(textUpToCursor); +} + +export function isInsideAttribute( + fullLineText: string, + cursorPos: number +): boolean { + const textUpToCursor = fullLineText.substring(0, cursorPos); + + // Matches any JSX attribute including complex expressions within braces + // This also matches incomplete attributes (missing closing quote/brace) + const insideComplexExpression = /([A-Za-z-]+)\s*=\s*\{\s*[^}]*$/.test( + textUpToCursor + ); + if (insideComplexExpression) { + return true; + } + + // Matches inside quotes (single or double) that are not closed yet or have content before closing quote + const insideQuotes = /([A-Za-z-]+)\s*=\s*["'][^"']*$/; + if (insideQuotes.test(textUpToCursor)) { + return true; + } + + // Matches at the beginning of an attribute's value, immediately after '=' + const atBeginningOfValue = /([A-Za-z-]+)\s*=\s*$/; + if (atBeginningOfValue.test(textUpToCursor)) { + return true; + } + + // Checks if cursor is right after a complete attribute's value but within it + // This covers both quoted values and complex expressions that are correctly closed + const afterCompleteValue = /([A-Za-z-]+)\s*=\s*(["'][^"']*["']|\{[^}]*})\s+$/; + const justAfterCompleteValue = + /([A-Za-z-]+)\s*=\s*(["'][^"']*["']|\{[^}]*})$/; + if ( + justAfterCompleteValue.test(textUpToCursor) && + !afterCompleteValue.test(textUpToCursor) + ) { + // If just after a complete value without trailing spaces, we're still inside the attribute + return true; + } + + // Handles cases where the cursor is within a complete attribute's value but not at the end + const withinCompleteValue = + /([A-Za-z-]+)\s*=\s*["']([^"']+)["']\s+[A-Za-z-]+\s*=/; + if (withinCompleteValue.test(fullLineText.substring(0, cursorPos + 1))) { + return false; + } + + return false; +} + +export function shouldTriggerPropSuggestions( + fullLineText: string, + cursorPos: number +) { + const isAfterSpaceOrTagName = /[ >]\s*$/.test( + fullLineText.substring(0, cursorPos) + ); + + return isInsideOpenTag(fullLineText, cursorPos) && isAfterSpaceOrTagName; +} + +export function extractAlreadyUsedProps( + fullLineText: string, + cursorPos: number +): Set { + const regex = /<\w+\s+([^>]+)>?/g; + const textUpToCursor = fullLineText.substring(0, cursorPos); + let match; + const usedProps = new Set(); + + while ((match = regex.exec(textUpToCursor))) { + match[1].split(/\s+/).forEach((prop) => { + const [propName] = prop.split("="); + if ( + propName && + !["!", "<", ">", "/"].some((char) => propName.includes(char)) + ) { + usedProps.add(propName); + } + }); + } + + return usedProps; +} From 544ac64e10c9293428fb11a5178bdaadb600f276 Mon Sep 17 00:00:00 2001 From: Yossi Saadi Date: Mon, 15 Apr 2024 11:35:10 +0300 Subject: [PATCH 2/2] test(autocomplete): tests for autocomplete utils --- .../__tests__/autocomplete-utils.test.ts | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 src/codemirror/utils/__tests__/autocomplete-utils.test.ts diff --git a/src/codemirror/utils/__tests__/autocomplete-utils.test.ts b/src/codemirror/utils/__tests__/autocomplete-utils.test.ts new file mode 100644 index 0000000..bdd5765 --- /dev/null +++ b/src/codemirror/utils/__tests__/autocomplete-utils.test.ts @@ -0,0 +1,333 @@ +import { + extractAlreadyUsedProps, + isInsideAttribute, + isInsideOpenTag, + getNewTagContext, + shouldTriggerPropSuggestions, +} from "../autocomplete-utils"; +import { describe } from "vitest"; + +describe("autocomplete-utils", () => { + describe("getNewTagContext", () => { + it('should return an empty string if just "<" is typed', () => { + expect(getNewTagContext("<")).toBe(""); + }); + + it("should return the partial tag name if in the middle of typing a tag", () => { + expect(getNewTagContext(" { + expect(getNewTagContext("Just some random text")).toBe(null); + }); + + it("should handle multiple tags correctly and return the latest context if cursor is at the end", () => { + expect(getNewTagContext("
{ + expect(getNewTagContext("
")).toBe(null); + }); + + it("should return null if tag is completed and closed with matching tag", () => { + expect(getNewTagContext("
")).toBe(null); + }); + + it("should return null if complete tag contains attributes", () => { + expect(getNewTagContext("
")).toBe(null); + }); + + it("should return null if incomplete tag contains attributes", () => { + expect(getNewTagContext("
{ + expect(getNewTagContext(""; + const cursorPos = text.indexOf(">"); + expect(isInsideOpenTag(text, cursorPos)).toBeTruthy(); + }); + + it("should return false if cursor is at tag children position", () => { + const text = ""; + const cursorPos = text.indexOf(">") + 1; + expect(isInsideOpenTag(text, cursorPos)).toBeFalsy(); + }); + + it("should return true if cursor is within the open tag that has a space after the tag name and before a close bracket", () => { + const text = "