diff --git a/src/core/classDetector.ts b/src/core/classDetector.ts index a0224d7..331075c 100644 --- a/src/core/classDetector.ts +++ b/src/core/classDetector.ts @@ -12,6 +12,21 @@ export interface ClassRange { range: vscode.Range } +const warnedInvalidSupportedFunctionPatterns = new Set() + +function warnInvalidSupportedFunctionPattern(pattern: string, error: unknown) { + if (warnedInvalidSupportedFunctionPatterns.has(pattern)) { + return + } + + warnedInvalidSupportedFunctionPatterns.add(pattern) + + const suffix = error instanceof Error && error.message ? `: ${error.message}` : "" + vscode.window.showWarningMessage( + `Tailwind Stash: invalid supportedFunctions regex "${pattern}" was ignored${suffix}`, + ) +} + /** * Detects Tailwind class strings in a document, including those wrapped * in utility functions like cn(), clsx(), cva(), twMerge(), etc. @@ -53,6 +68,14 @@ export function detectClassRanges( // Strip ^/$ anchors — they don't work inside a group in a larger regex. // Use \b boundaries instead for correct matching. const cleaned = regexMatch[1].replace(/^\^/, "").replace(/\$$/, "") + try { + // Validate the pattern compiles; the constructor throws SyntaxError on invalid syntax. + // oxlint-disable-next-line no-new -- constructed only to validate; result intentionally unused + new RegExp(cleaned) + } catch (error) { + warnInvalidSupportedFunctionPattern(fn, error) + continue + } regexPatterns.push(`\\b${cleaned}`) } else { literalNames.push(escapeRegex(fn)) diff --git a/test/__mocks__/vscode.ts b/test/__mocks__/vscode.ts index 398198a..5ddb9c9 100644 --- a/test/__mocks__/vscode.ts +++ b/test/__mocks__/vscode.ts @@ -410,6 +410,7 @@ export const window = { onDidChangeVisibleTextEditors: makeEventEmitter("onDidChangeVisibleTextEditors"), showInformationMessage(_msg: string) {}, showTextDocument() {}, + showWarningMessage(_msg: string) {}, visibleTextEditors: [] as unknown[], } diff --git a/test/core/classDetector.test.ts b/test/core/classDetector.test.ts index 133bbda..470dcb5 100644 --- a/test/core/classDetector.test.ts +++ b/test/core/classDetector.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" -import { createMockDocument } from "../__mocks__/vscode" +import { createMockDocument, window } from "../__mocks__/vscode" import { detectClassRanges } from "../../src/core/classDetector" const defaultFunctions = [ @@ -14,6 +14,10 @@ const defaultFunctions = [ "classnames", ] +afterEach(() => { + vi.restoreAllMocks() +}) + function detect(text: string, opts?: { functions?: string[]; minClasses?: number }) { const doc = createMockDocument(text) return detectClassRanges(doc, opts?.functions ?? defaultFunctions, opts?.minClasses ?? 4) @@ -432,6 +436,36 @@ describe("empty and invalid function patterns", () => { expect(results[0].element).toBe("div") }) + it("warns once and keeps valid detections when a regex pattern is invalid", () => { + const warningSpy = vi.spyOn(window, "showWarningMessage") + const text = ` +const a = cn("flex items-center p-4 rounded"); +const b = getButtonClasses("grid gap-4 p-6 bg-white"); +
+` + + const results = detect(text, { + functions: ["cn", "/[invalid(/", "/^get.*Classes$/"], + }) + + expect(results).toHaveLength(3) + expect(warningSpy).toHaveBeenCalledTimes(1) + expect(warningSpy.mock.calls[0]?.[0]).toContain("/[invalid(/") + }) + + it("does not repeat the warning for the same invalid regex pattern", () => { + const warningSpy = vi.spyOn(window, "showWarningMessage") + + detect('
', { + functions: ["/[still-invalid(/"], + }) + detect('
', { + functions: ["/[still-invalid(/"], + }) + + expect(warningSpy).toHaveBeenCalledTimes(1) + }) + it("returns results gracefully when regex pattern is invalid", () => { const results = detect('
', { functions: ["/[invalid(/"],