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/__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 = "