diff --git a/.gitignore b/.gitignore index b5cb5ef..c3cd5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /dist node_modules/ coverage/ +scratch/ yarn-error.log diff --git a/README.md b/README.md index 9cefc3f..18acd8b 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ const project = new Project("/Users/user/path/to/project") const controllers = project.controllerDefinitions const controller = controllers[0] -console.log(controller.methods) +console.log(controller.actionNames) // => ["connect", "click", "disconnect"] -console.log(controller.targets) +console.log(controller.targetNames) // => ["name", "output"] -console.log(controller.classes) +console.log(controller.classNames) // => ["loading"] console.log(controller.values) diff --git a/package.json b/package.json index c85b305..dd773ef 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,15 @@ "dev": "yarn watch", "clean": "rimraf dist", "prerelease": "yarn build", + "install:fixtures": "node scripts/setupFixtures.mjs", + "postinstall": "yarn install:fixtures", + "pretest": "yarn install:fixtures", "test": "vitest" }, "dependencies": { "@hotwired/stimulus-webpack-helpers": "^1.0.1", "@typescript-eslint/typescript-estree": "^7.0.1", + "@typescript-eslint/visitor-keys": "^7.0.2", "acorn-walk": "^8.3.1", "astring": "^1.8.6", "fs": "^0.0.1-security", @@ -37,6 +41,6 @@ "ts-node": "^10.9.2", "tslib": "^2.6.2", "typescript": "^5.3.3", - "vitest": "^1.0.4" + "vitest": "^1.2.2" } } diff --git a/playground/.gitignore b/playground/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/playground/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/playground/app.mjs b/playground/app.mjs new file mode 100644 index 0000000..7fc2023 --- /dev/null +++ b/playground/app.mjs @@ -0,0 +1,34 @@ +import express from "express" +import { Project, SourceFile, ClassDeclaration, ControllerDefinition, ExportDeclaration, ImportDeclaration } from "stimulus-parser" + +const headers = { "Content-Type": "application/json" } + +const app = express() +app.use(express.json()) + +function replacer(key, value) { + if (key === "project") return undefined + if (this instanceof SourceFile && key === "content") return undefined + if (this instanceof ImportDeclaration && key === "sourceFile") return undefined + if (this instanceof ExportDeclaration && key === "sourceFile") return undefined + if (this instanceof ClassDeclaration && key === "sourceFile") return undefined + if (this instanceof ControllerDefinition && key === "classDeclaration") return undefined + + return value +} + +app.post("/api/analyze", (request, response) => { + try { + const project = new Project("playground") + const sourceFile = new SourceFile(project, "playground_controller.js", request.body?.controller) + + sourceFile.initialize() + sourceFile.analyze() + + response.status(200).set(headers).end(JSON.stringify({ simple: sourceFile.inspect, full: sourceFile }, replacer)) + } catch(e) { + response.status(500).set(headers).end(JSON.stringify({ error: e.message })) + } +}); + +export { app } diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..fd19b0a --- /dev/null +++ b/playground/index.html @@ -0,0 +1,93 @@ + + + + + + + Stimulus Parser Playground + + + + + + + + +
+ + +
+
+
+
+
Stimulus Controller
+ +
+ + + +
+
+ +
+ +
+
+ +
+
+
Parse Result
+ +
+ + + +
+
+ +
+ + +
+
+
+
+
+ + + + diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 0000000..fbf6eec --- /dev/null +++ b/playground/package.json @@ -0,0 +1,26 @@ +{ + "name": "stimulus-parser-playground", + "type": "module", + "scripts": { + "dev": "cross-env NODE_ENV=development node server.dev.mjs", + "build": "npm run build:client", + "build:client": "vite build --outDir dist/client", + "preview": "cross-env NODE_ENV=production node server.prod.mjs", + "serve": "cross-env NODE_ENV=production node server.prod.mjs" + }, + "dependencies": { + "@alenaksu/json-viewer": "^2.0.1", + "@hotwired/stimulus": "^3.2.2", + "dedent": "^1.5.1", + "express": "^4.18.2", + "lz-string": "^1.5.0", + "stimulus-parser": "link:../" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.5", + "cross-env": "^7.0.3", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } +} diff --git a/playground/server.dev.mjs b/playground/server.dev.mjs new file mode 100644 index 0000000..6e875a5 --- /dev/null +++ b/playground/server.dev.mjs @@ -0,0 +1,34 @@ +import fs from "fs" +import path from "path" + +import { fileURLToPath } from "url" +import { createServer as createViteServer } from "vite" +import { app } from "./app.mjs" + +const PORT = 5173 +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const vite = await createViteServer({ + server: { + middlewareMode: { + server: app + } + }, + appType: "custom" +}) + +app.use(vite.middlewares) + +app.use("*", (request, response, next) => { + try { + const template = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf-8") + response.status(200).set({ "Content-Type": "text/html" }).end(template) + } catch (e) { + vite.ssrFixStacktrace(e) + next(e) + } +}) + +app.listen(PORT, () => { + console.log(`Listing on http://localhost:${PORT}`) +}) diff --git a/playground/server.prod.mjs b/playground/server.prod.mjs new file mode 100644 index 0000000..55aa874 --- /dev/null +++ b/playground/server.prod.mjs @@ -0,0 +1,18 @@ +import fs from "fs" +import path from "path" +import express from "express" + +import { fileURLToPath } from "url" +import { app } from "./app.mjs" + +app.use(express.static(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "dist/client"), { index: false })) + +const template = fs.readFileSync("./dist/client/index.html", "utf-8") + +app.use("*", (request, response, next) => { + response.status(200).set({ "Content-Type": "text/html" }).end(template) +}) + +app.listen(5173, () => { + console.log("Listing on http://localhost:5173") +}) diff --git a/playground/src/controllers/index.ts b/playground/src/controllers/index.ts new file mode 100644 index 0000000..176c53d --- /dev/null +++ b/playground/src/controllers/index.ts @@ -0,0 +1,6 @@ +import { Application } from "@hotwired/stimulus" +import PlaygroundController from "./playground_controller" + +const application = Application.start() + +application.register("playground", PlaygroundController) diff --git a/playground/src/controllers/playground_controller.js b/playground/src/controllers/playground_controller.js new file mode 100644 index 0000000..97863ab --- /dev/null +++ b/playground/src/controllers/playground_controller.js @@ -0,0 +1,168 @@ +import lz from "lz-string" +import dedent from "dedent" + +import { Controller } from "@hotwired/stimulus" + +const exampleController = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static targets = ["name", "output"] + + greet() { + this.outputTarget.textContent = \`Hello, \${this.nameTarget.value}!\` + } + } +` + +export default class extends Controller { + static targets = ["input", "simpleViewer", "fullViewer", "viewerButton"] + + connect() { + this.restoreInput() + this.analyze() + } + + updateURL() { + window.location.hash = this.compressedValue + } + + async insert(event) { + if (this.inputTarget.value !== "" && !confirm("Do you want to overwrite the current controller?")) { + return + } + + this.inputTarget.value = exampleController + + const button = this.getClosestButton(event.target) + + button.querySelector(".fa-file").classList.add("hidden") + button.querySelector(".fa-circle-check").classList.remove("hidden") + + setTimeout(() => { + button.querySelector(".fa-file").classList.remove("hidden") + button.querySelector(".fa-circle-check").classList.add("hidden") + }, 1000) + } + + async share(event) { + const button = this.getClosestButton(event.target) + + try { + await navigator.clipboard.writeText(window.location.href) + + button.querySelector(".fa-circle-check").classList.remove("hidden") + } catch (error) { + button.querySelector(".fa-circle-xmark").classList.remove("hidden") + } + + button.querySelector(".fa-copy").classList.add("hidden") + + setTimeout(() => { + button.querySelector(".fa-copy").classList.remove("hidden") + button.querySelector(".fa-circle-xmark").classList.add("hidden") + button.querySelector(".fa-circle-check").classList.add("hidden") + }, 1000) + } + + restoreInput() { + if (window.location.hash && this.inputTarget.value === "") { + this.inputTarget.value = this.decompressedValue + } + } + + getClosestButton(element) { + return (element instanceof HTMLButtonElement) ? element : element.closest("button") + } + + selectViewer(event) { + const button = this.getClosestButton(event.target) + + this.viewerButtonTargets.forEach(button => button.dataset.active = false) + button.dataset.active = true + + if (button.dataset.viewer === "simple") { + this.simpleViewerTarget.classList.remove("hidden") + this.fullViewerTarget.classList.add("hidden") + } else { + this.simpleViewerTarget.classList.add("hidden") + this.fullViewerTarget.classList.remove("hidden") + } + } + + async analyze(){ + this.updateURL() + + let response + let json + + try { + response = await fetch("/api/analyze", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + controller: this.inputTarget.value + }) + }) + } catch(error) { + this.simpleViewerTarget.data = { error: error, message: error.message } + this.fullViewerTarger.data = { error: error, message: error.message } + } + + if (response.ok) { + try { + json = await response.json() + + if (this.hasSimpleViewerTarget) { + const isEmpty = !this.simpleViewerTarget.data + + this.simpleViewerTarget.data = { sourceFile: json.simple } + + if (isEmpty) { + if (json.simple.errors.length > 0) { + this.simpleViewerTarget.expand("sourceFile.errors") + } else if (json.simple.controllerDefinitions.length > 0) { + this.simpleViewerTarget.expand("sourceFile.controllerDefinitions.*") + } else if (json.simple.classDeclarations.length > 0) { + this.simpleViewerTarget.expand("sourceFile.classDeclarations") + } else { + this.simpleViewerTarget.expand("sourceFile") + } + } + } + + if (this.hasFullViewerTarget) { + const isEmpty = !this.fullViewerTarget.data + + this.fullViewerTarget.data = { sourceFile: json.full } + + if (isEmpty) { + if (json.full.errors.length > 0) { + this.fullViewerTarget.expand("sourceFile.errors") + } else if (json.full.classDeclarations.length > 0) { + this.fullViewerTarget.expand("sourceFile.classDeclarations") + } else { + this.fullViewerTarget.expand("sourceFile") + } + } + } + } catch (error) { + this.simpleViewerTarget.data = { error: "Server didn't return JSON", response: error.message } + this.fullViewerTarget.data = { error: "Server didn't return JSON", response: error.message } + } + } else { + this.simpleViewerTarget.data = { error: "Server didn't respond with a 200 response" } + this.fullViewerTarget.data = { error: "Server didn't respond with a 200 response" } + } + } + + get compressedValue() { + return lz.compressToEncodedURIComponent(this.inputTarget.value) + } + + get decompressedValue() { + return lz.decompressFromEncodedURIComponent(window.location.hash.slice(1)) + } +} diff --git a/playground/src/entry-client.ts b/playground/src/entry-client.ts new file mode 100644 index 0000000..323dbb5 --- /dev/null +++ b/playground/src/entry-client.ts @@ -0,0 +1,3 @@ +import "@alenaksu/json-viewer" +import "./controllers" +import "./style.css" diff --git a/playground/src/style.css b/playground/src/style.css new file mode 100644 index 0000000..ff8c768 --- /dev/null +++ b/playground/src/style.css @@ -0,0 +1,9 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; +} + +json-viewer::part(key) { + margin-right: 10px; +} diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000..3e3bcf5 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/playground/yarn.lock b/playground/yarn.lock new file mode 100644 index 0000000..58ac5f2 --- /dev/null +++ b/playground/yarn.lock @@ -0,0 +1,1385 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@alenaksu/json-viewer@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@alenaksu/json-viewer/-/json-viewer-2.0.1.tgz#f8332b8cd560989090ebabc765df64adf1c0f7e1" + integrity sha512-M6rN1bcuSGfar6ND9fFASBkez0UcWOUxMiwm2i9jlPBrpjOHOz0/utMgZhfrsgfyFPZ1H1gzfU8auJkYO1mq/g== + dependencies: + lit "^2.3.1" + +"@esbuild/aix-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" + integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA== + +"@esbuild/android-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4" + integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA== + +"@esbuild/android-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" + integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w== + +"@esbuild/android-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d" + integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew== + +"@esbuild/darwin-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e" + integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g== + +"@esbuild/darwin-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd" + integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A== + +"@esbuild/freebsd-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487" + integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA== + +"@esbuild/freebsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c" + integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg== + +"@esbuild/linux-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b" + integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA== + +"@esbuild/linux-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef" + integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w== + +"@esbuild/linux-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601" + integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA== + +"@esbuild/linux-loong64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" + integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA== + +"@esbuild/linux-mips64el@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec" + integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w== + +"@esbuild/linux-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8" + integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg== + +"@esbuild/linux-riscv64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf" + integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg== + +"@esbuild/linux-s390x@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8" + integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg== + +"@esbuild/linux-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78" + integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg== + +"@esbuild/netbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b" + integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA== + +"@esbuild/openbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0" + integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw== + +"@esbuild/sunos-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30" + integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA== + +"@esbuild/win32-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae" + integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A== + +"@esbuild/win32-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67" + integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ== + +"@esbuild/win32-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae" + integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA== + +"@hotwired/stimulus-webpack-helpers@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus-webpack-helpers/-/stimulus-webpack-helpers-1.0.1.tgz#4cd74487adeca576c9865ac2b9fe5cb20cef16dd" + integrity sha512-wa/zupVG0eWxRYJjC1IiPBdt3Lruv0RqGN+/DTMmUWUyMAEB27KXmVY6a8YpUVTM7QwVuaLNGW4EqDgrS2upXQ== + +"@hotwired/stimulus@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" + integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.0.tgz#353ce4a76c83fadec272ea5674ede767650762fd" + integrity sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g== + +"@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.3.tgz#25b4eece2592132845d303e091bad9b04cdcfe03" + integrity sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.0.0" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@rollup/rollup-android-arm-eabi@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz#38c3abd1955a3c21d492af6b1a1dca4bb1d894d6" + integrity sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w== + +"@rollup/rollup-android-arm64@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz#3822e929f415627609e53b11cec9a4be806de0e2" + integrity sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ== + +"@rollup/rollup-darwin-arm64@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz#6c082de71f481f57df6cfa3701ab2a7afde96f69" + integrity sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ== + +"@rollup/rollup-darwin-x64@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz#c34ca0d31f3c46a22c9afa0e944403eea0edcfd8" + integrity sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg== + +"@rollup/rollup-linux-arm-gnueabihf@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz#48e899c1e438629c072889b824a98787a7c2362d" + integrity sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA== + +"@rollup/rollup-linux-arm64-gnu@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz#788c2698a119dc229062d40da6ada8a090a73a68" + integrity sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA== + +"@rollup/rollup-linux-arm64-musl@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz#3882a4e3a564af9e55804beeb67076857b035ab7" + integrity sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ== + +"@rollup/rollup-linux-riscv64-gnu@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz#0c6ad792e1195c12bfae634425a3d2aa0fe93ab7" + integrity sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw== + +"@rollup/rollup-linux-x64-gnu@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz#9d62485ea0f18d8674033b57aa14fb758f6ec6e3" + integrity sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA== + +"@rollup/rollup-linux-x64-musl@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz#50e8167e28b33c977c1f813def2b2074d1435e05" + integrity sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw== + +"@rollup/rollup-win32-arm64-msvc@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz#68d233272a2004429124494121a42c4aebdc5b8e" + integrity sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw== + +"@rollup/rollup-win32-ia32-msvc@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz#366ca62221d1689e3b55a03f4ae12ae9ba595d40" + integrity sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA== + +"@rollup/rollup-win32-x64-msvc@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz#9ffdf9ed133a7464f4ae187eb9e1294413fab235" + integrity sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg== + +"@types/body-parser@*": + version "1.19.5" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" + integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/estree@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +"@types/express-serve-static-core@^4.17.33": + version "4.17.43" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz#10d8444be560cb789c4735aea5eac6e5af45df54" + integrity sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@^4.17.21": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/http-errors@*": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" + integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== + +"@types/mime@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45" + integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw== + +"@types/mime@^1": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" + integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== + +"@types/node@*", "@types/node@^20.10.5": + version "20.11.19" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.19.tgz#b466de054e9cb5b3831bee38938de64ac7f81195" + integrity sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ== + dependencies: + undici-types "~5.26.4" + +"@types/qs@*": + version "6.9.11" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.11.tgz#208d8a30bc507bd82e03ada29e4732ea46a6bbda" + integrity sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ== + +"@types/range-parser@*": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" + integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== + +"@types/send@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" + integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.5" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" + integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ== + dependencies: + "@types/http-errors" "*" + "@types/mime" "*" + "@types/node" "*" + +"@types/trusted-types@^2.0.2": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + +"@typescript-eslint/types@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.0.2.tgz#b6edd108648028194eb213887d8d43ab5750351c" + integrity sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA== + +"@typescript-eslint/typescript-estree@^7.0.1": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz#3c6dc8a3b9799f4ef7eca0d224ded01974e4cb39" + integrity sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw== + dependencies: + "@typescript-eslint/types" "7.0.2" + "@typescript-eslint/visitor-keys" "7.0.2" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "9.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/visitor-keys@7.0.2", "@typescript-eslint/visitor-keys@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz#2899b716053ad7094962beb895d11396fc12afc7" + integrity sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ== + dependencies: + "@typescript-eslint/types" "7.0.2" + eslint-visitor-keys "^3.4.1" + +accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-walk@^8.3.1: + version "8.3.2" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" + integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +array-flatten@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" + integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +astring@^1.8.6: + version "1.8.6" + resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731" + integrity sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +body-parser@1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" + integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +content-disposition@0.5.4: + version "0.5.4" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" + integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== + dependencies: + safe-buffer "5.2.1" + +content-type@~1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie-signature@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" + integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== + +cookie@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^7.0.0, cross-spawn@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +dedent@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.1.tgz#4f3fc94c8b711e9bb2800d185cd6ad20f2a90aff" + integrity sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg== + +define-data-property@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +esbuild@^0.19.3: + version "0.19.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" + integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.19.12" + "@esbuild/android-arm" "0.19.12" + "@esbuild/android-arm64" "0.19.12" + "@esbuild/android-x64" "0.19.12" + "@esbuild/darwin-arm64" "0.19.12" + "@esbuild/darwin-x64" "0.19.12" + "@esbuild/freebsd-arm64" "0.19.12" + "@esbuild/freebsd-x64" "0.19.12" + "@esbuild/linux-arm" "0.19.12" + "@esbuild/linux-arm64" "0.19.12" + "@esbuild/linux-ia32" "0.19.12" + "@esbuild/linux-loong64" "0.19.12" + "@esbuild/linux-mips64el" "0.19.12" + "@esbuild/linux-ppc64" "0.19.12" + "@esbuild/linux-riscv64" "0.19.12" + "@esbuild/linux-s390x" "0.19.12" + "@esbuild/linux-x64" "0.19.12" + "@esbuild/netbsd-x64" "0.19.12" + "@esbuild/openbsd-x64" "0.19.12" + "@esbuild/sunos-x64" "0.19.12" + "@esbuild/win32-arm64" "0.19.12" + "@esbuild/win32-ia32" "0.19.12" + "@esbuild/win32-x64" "0.19.12" + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +eslint-visitor-keys@^3.4.1: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +express@^4.18.2: + version "4.18.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" + integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + dependencies: + accepts "~1.3.8" + array-flatten "1.1.1" + body-parser "1.20.1" + content-disposition "0.5.4" + content-type "~1.0.4" + cookie "0.5.0" + cookie-signature "1.0.6" + debug "2.6.9" + depd "2.0.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + finalhandler "1.2.0" + fresh "0.5.2" + http-errors "2.0.0" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "2.4.1" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.7" + qs "6.11.0" + range-parser "~1.2.1" + safe-buffer "5.2.1" + send "0.18.0" + serve-static "1.15.0" + setprototypeof "1.2.0" + statuses "2.0.1" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +fast-glob@^3.2.9: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fastq@^1.6.0: + version "1.17.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" + integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + dependencies: + reusify "^1.0.4" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "2.4.1" + parseurl "~1.3.3" + statuses "2.0.1" + unpipe "~1.0.0" + +foreground-child@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" + integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" + integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== + +fs@^0.0.1-security: + version "0.0.1-security" + resolved "https://registry.yarnpkg.com/fs/-/fs-0.0.1-security.tgz#8a7bd37186b6dddf3813f23858b57ecaaf5e41d4" + integrity sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^10.3.10: + version "10.3.10" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" + integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== + dependencies: + foreground-child "^3.1.0" + jackspeak "^2.3.5" + minimatch "^9.0.1" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry "^1.10.1" + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +has-property-descriptors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +hasown@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa" + integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA== + dependencies: + function-bind "^1.1.2" + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore@^5.2.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" + integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== + +inherits@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^2.3.5: + version "2.3.6" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" + integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +lit-element@^3.3.0: + version "3.3.3" + resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.3.3.tgz#10bc19702b96ef5416cf7a70177255bfb17b3209" + integrity sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.1.0" + "@lit/reactive-element" "^1.3.0" + lit-html "^2.8.0" + +lit-html@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.8.0.tgz#96456a4bb4ee717b9a7d2f94562a16509d39bffa" + integrity sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q== + dependencies: + "@types/trusted-types" "^2.0.2" + +lit@^2.3.1: + version "2.8.0" + resolved "https://registry.yarnpkg.com/lit/-/lit-2.8.0.tgz#4d838ae03059bf9cafa06e5c61d8acc0081e974e" + integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA== + dependencies: + "@lit/reactive-element" "^1.6.0" + lit-element "^3.3.0" + lit-html "^2.8.0" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +"lru-cache@^9.1.1 || ^10.0.0": + version "10.2.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-descriptors@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" + integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +methods@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" + integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== + +micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +minimatch@9.0.3, minimatch@^9.0.1: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0": + version "7.0.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" + integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +object-inspect@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.10.1: + version "1.10.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698" + integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ== + dependencies: + lru-cache "^9.1.1 || ^10.0.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" + integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +postcss@^8.4.35: + version "8.4.35" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7" + integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +proxy-addr@~2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" + integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rollup@^4.2.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.12.0.tgz#0b6d1e5f3d46bbcf244deec41a7421dc54cc45b5" + integrity sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.12.0" + "@rollup/rollup-android-arm64" "4.12.0" + "@rollup/rollup-darwin-arm64" "4.12.0" + "@rollup/rollup-darwin-x64" "4.12.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.12.0" + "@rollup/rollup-linux-arm64-gnu" "4.12.0" + "@rollup/rollup-linux-arm64-musl" "4.12.0" + "@rollup/rollup-linux-riscv64-gnu" "4.12.0" + "@rollup/rollup-linux-x64-gnu" "4.12.0" + "@rollup/rollup-linux-x64-musl" "4.12.0" + "@rollup/rollup-win32-arm64-msvc" "4.12.0" + "@rollup/rollup-win32-ia32-msvc" "4.12.0" + "@rollup/rollup-win32-x64-msvc" "4.12.0" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +safe-buffer@5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver@^7.5.4: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== + dependencies: + lru-cache "^6.0.0" + +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + +serve-static@1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" + integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.18.0" + +set-function-length@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.1.tgz#47cc5945f2c771e2cf261c6737cf9684a2a5e425" + integrity sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g== + dependencies: + define-data-property "^1.1.2" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.1" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.5.tgz#9a84546599b48909fb6af1211708d23b1946221b" + integrity sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ== + dependencies: + call-bind "^1.0.6" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +"stimulus-parser@link:..": + version "0.0.0" + uid "" + +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: + name string-width-cjs + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +ts-api-utils@^1.0.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.2.1.tgz#f716c7e027494629485b21c0df6180f4d08f5e8b" + integrity sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typescript@^5.3.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +vite@^5.0.10: + version "5.1.3" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.3.tgz#dd072653a80225702265550a4700561740dfde55" + integrity sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew== + dependencies: + esbuild "^0.19.3" + postcss "^8.4.35" + rollup "^4.2.0" + optionalDependencies: + fsevents "~2.3.3" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== diff --git a/scripts/setupFixtures.mjs b/scripts/setupFixtures.mjs new file mode 100644 index 0000000..d82dccd --- /dev/null +++ b/scripts/setupFixtures.mjs @@ -0,0 +1,13 @@ +import path from "path" +import { glob } from "glob" +import { execSync } from "child_process" + +const fixtures = await glob("test/fixtures/**/package.json", { ignore: "**/**/node_modules/**" }) + +fixtures.forEach(async fixturesPath => { + const fixtureFolder = path.dirname(fixturesPath) + + console.log(`Installing packages for fixture: ${fixtureFolder}`) + + execSync(`cd ${fixtureFolder} && yarn install && cd -`) +}) diff --git a/src/application_file.ts b/src/application_file.ts new file mode 100644 index 0000000..d2d8b28 --- /dev/null +++ b/src/application_file.ts @@ -0,0 +1,33 @@ +import type { Project } from "./project" +import type { SourceFile } from "./source_file" +import type { RegisteredController } from "./registered_controller" +import type { ApplicationType } from "./types" +import type { ImportDeclaration } from "./import_declaration" +import type { ExportDeclaration } from "./export_declaration" + +export class ApplicationFile { + public readonly project: Project + public readonly registeredControllers: RegisteredController[] = [] + public readonly sourceFile: SourceFile + public readonly mode: ApplicationType + + constructor(project: Project, sourceFile: SourceFile, mode: ApplicationType = "esbuild"){ + this.project = project + this.sourceFile = sourceFile + this.mode = mode + } + + get path() { + return this.sourceFile.path + } + + get applicationImport(): ImportDeclaration | undefined { + return this.sourceFile.stimulusApplicationImport + } + + get exportDeclaration(): ExportDeclaration | undefined { + // TODO: this should trace from the application import, to the variable declaration to the export + // return this.sourceFile.exportDeclarations.find(declaration => declaration.localName === this.applicationImport?.localName) + return this.sourceFile.exportDeclarations[0] + } +} diff --git a/src/class_declaration.ts b/src/class_declaration.ts new file mode 100644 index 0000000..81bdb22 --- /dev/null +++ b/src/class_declaration.ts @@ -0,0 +1,234 @@ +import * as ast from "./util/ast" +import * as decorators from "./util/decorators" +import * as properties from "./util/properties" +import { walk } from "./util/walk" + +import { ParseError } from "./parse_error" +import { SourceFile } from "./source_file" +import { ControllerDefinition } from "./controller_definition" +import { ImportDeclaration } from "./import_declaration" +import { ExportDeclaration } from "./export_declaration" +import { MethodDefinition } from "./controller_property_definition" + +import type * as Acorn from "acorn" +import type { TSESTree } from "@typescript-eslint/typescript-estree" +import type { Project } from "./project" +import type { ClassDeclarationNode } from "./types" + +export class ClassDeclaration { + public readonly isStimulusClassDeclaration: boolean = false + public readonly sourceFile: SourceFile + public readonly className?: string + public readonly node?: ClassDeclarationNode + + public isAnalyzed: boolean = false + public importDeclaration?: ImportDeclaration // TODO: technically a class can be imported more than once + public exportDeclaration?: ExportDeclaration // TODO: technically a class can be exported more than once + public controllerDefinition?: ControllerDefinition + + constructor(sourceFile: SourceFile, className?: string, node?: ClassDeclarationNode) { + this.sourceFile = sourceFile + this.className = className + this.node = node + } + + get shouldParse() { + return this.isStimulusDescendant + } + + get superClassNode(): Acorn.Expression | undefined | null { + return this.node?.superClass + } + + get superClassName(): string | undefined { + if (this.superClassNode?.type !== "Identifier") return + + return this.superClassNode.name + } + + get superClass(): ClassDeclaration | undefined { + if (!this.superClassName) return + + const classDeclaration = this.sourceFile.classDeclarations.find(i => i.className === this.superClassName) + const importDeclaration = this.sourceFile.importDeclarations.find(i => i.localName === this.superClassName) + const stimulusController = (importDeclaration && importDeclaration?.isStimulusImport) ? new StimulusControllerClassDeclaration(this.sourceFile.project, importDeclaration) : undefined + + return ( + classDeclaration || importDeclaration?.nextResolvedClassDeclaration || stimulusController + ) + } + + get isStimulusDescendant() { + return !!this.highestAncestor.superClass?.importDeclaration?.isStimulusImport + } + + get isExported(): boolean { + return !!this.exportDeclaration + } + + // TODO: check if this is right and makes sense + // get exportDeclaration(): ExportDeclaration | undefined { + // return this.sourceFile.exportDeclarations.find(exportDeclaration => exportDeclaration.exportedClassDeclaration === this); + // } + + get highestAncestor(): ClassDeclaration { + return this.ancestors.reverse()[0] + } + + get ancestors(): ClassDeclaration[] { + if (!this.nextResolvedClassDeclaration) { + return [this] + } + + return [this, ...this.nextResolvedClassDeclaration.ancestors] + } + + get nextResolvedClassDeclaration(): ClassDeclaration | undefined { + if (this.superClass) { + if (this.superClass.importDeclaration) { + return this.superClass.nextResolvedClassDeclaration + } + return this.superClass + } + + if (this.importDeclaration) { + return this.importDeclaration.nextResolvedClassDeclaration + } + + return + } + + analyze() { + if (!this.shouldParse) return + if (this.isAnalyzed) return + + this.controllerDefinition = new ControllerDefinition(this.sourceFile.project, this) + + this.analyzeStaticPropertiesExpressions() + this.analyzeClassDecorators() + this.analyzeMethods() + this.analyzeDecorators() + this.analyzeStaticProperties() + + this.validate() + + this.isAnalyzed = true + } + + analyzeStaticPropertiesExpressions() { + if (!this.controllerDefinition) return + + this.sourceFile.analyzeStaticPropertiesExpressions(this.controllerDefinition) + } + + analyzeClassDecorators() { + if (!this.node) return + if (!this.controllerDefinition) return + + this.controllerDefinition.isTyped = !!decorators.extractDecorators(this.node).find(decorator => + (decorator.expression.type === "Identifier") ? decorator.expression.name === "TypedController" : false + ) + } + + analyzeMethods() { + if (!this.node) return + + walk(this.node, { + MethodDefinition: node => { + if (!this.controllerDefinition) return + if (node.kind !== "method") return + if (node.key.type !== "Identifier" && node.key.type !== "PrivateIdentifier") return + + const tsNode = (node as unknown as TSESTree.MethodDefinition) + const methodName = ast.extractIdentifier(node.key) as string + const isPrivate = node.key.type === "PrivateIdentifier" || tsNode.accessibility === "private" + const name = isPrivate ? `#${methodName}` : methodName + + this.controllerDefinition.methodDefinitions.push(new MethodDefinition(name, node, node.loc, "static")) + }, + + PropertyDefinition: node => { + if (!this.controllerDefinition) return + if (node.key.type !== "Identifier") return + if (!node.value || node.value.type !== "ArrowFunctionExpression") return + + this.controllerDefinition.methodDefinitions.push(new MethodDefinition(node.key.name, node, node.loc, "static")) + }, + }) + } + + analyzeStaticProperties() { + if (!this.node) return + + walk(this.node, { + PropertyDefinition: node => { + if (!node.value) return + if (!node.static) return + if (node.key.type !== "Identifier") return + + properties.parseStaticControllerProperties(this.controllerDefinition, node.key, node.value) + } + }) + } + + analyzeDecorators() { + if (!this.node) return + + walk(this.node, { + PropertyDefinition: _node => { + const node = _node as unknown as TSESTree.PropertyDefinition + + decorators.extractDecorators(_node).forEach(decorator => { + if (node.key.type !== "Identifier") return + + decorators.parseDecorator(this.controllerDefinition, node.key.name, decorator, node) + }) + } + }) + } + + public validate() { + if (!this.controllerDefinition) return + + if (this.controllerDefinition.anyDecorator && !this.controllerDefinition.isTyped) { + this.controllerDefinition.errors.push( + new ParseError("LINT", "Controller needs to be decorated with @TypedController in order to use decorators.", this.node?.loc), + ) + } + + if (!this.controllerDefinition.anyDecorator && this.controllerDefinition.isTyped) { + this.controllerDefinition.errors.push( + new ParseError("LINT", "Controller was decorated with @TypedController but Controller didn't use any decorators.", this.node?.loc), + ) + } + } + + get inspect(): object { + return { + className: this.className, + superClass: this.superClass?.inspect, + isStimulusDescendant: this.isStimulusDescendant, + isExported: this.isExported, + sourceFile: this.sourceFile?.path, + hasControllerDefinition: !!this.controllerDefinition, + controllerDefinition: this.controllerDefinition?.inspect + } + } +} + +export class StimulusControllerClassDeclaration extends ClassDeclaration { + public readonly isStimulusClassDeclaration: boolean = true + + constructor(project: Project, importDeclaration: ImportDeclaration) { + super(new SourceFile(project, "stimulus/controller.js"), importDeclaration.localName || "Controller") + this.importDeclaration = importDeclaration + } + + get isStimulusDescendant() { + return true + } + + get nextResolvedClassDeclaration() { + return undefined + } +} diff --git a/src/controller_definition.ts b/src/controller_definition.ts index 9395560..70a12e0 100644 --- a/src/controller_definition.ts +++ b/src/controller_definition.ts @@ -1,104 +1,258 @@ import path from "path" +import { identifierForContextKey } from "@hotwired/stimulus-webpack-helpers" + import { Project } from "./project" +import { ClassDeclaration } from "./class_declaration" import { ParseError } from "./parse_error" - -import { identifierForContextKey } from "@hotwired/stimulus-webpack-helpers" import { MethodDefinition, ValueDefinition, ClassDefinition, TargetDefinition } from "./controller_property_definition" -type ParentController = { - controllerFile?: string - constant: string - identifier?: string - definition?: ControllerDefinition - package?: string - parent?: ParentController - type: "default" | "application" | "package" | "import" | "unknown" -} +import { dasherize, uncapitalize, camelize } from "./util/string" +import type { RegisteredController } from "./registered_controller" export class ControllerDefinition { - readonly path: string readonly project: Project - parent?: ParentController + readonly classDeclaration: ClassDeclaration - isTyped: boolean = false - anyDecorator: boolean = false - - readonly _methods: Array = [] - readonly _targets: Array = [] - readonly _classes: Array = [] - readonly _values: { [key: string]: ValueDefinition } = {} + public isTyped: boolean = false + public anyDecorator: boolean = false readonly errors: ParseError[] = [] + readonly methodDefinitions: Array = [] + readonly targetDefinitions: Array = [] + readonly classDefinitions: Array = [] + readonly valueDefinitions: Array = [] - static controllerPathForIdentifier(identifier: string, fileending: string = "js"): string { + static controllerPathForIdentifier(identifier: string, fileExtension: string = "js"): string { const path = identifier.replace(/--/g, "/").replace(/-/g, "_") - return `${path}_controller.${fileending}` + return `${path}_controller.${fileExtension}` } - constructor(project: Project, path: string) { + constructor(project: Project, classDeclaration: ClassDeclaration) { this.project = project - this.path = path + this.classDeclaration = classDeclaration } get hasErrors() { return this.errors.length > 0 } - get methods() { - return this._methods.map((method) => method.name) + get sourceFile() { + return this.classDeclaration.sourceFile + } + + get path() { + return this.sourceFile.path + } + + // Actions + + get actions(): MethodDefinition[] { + return this.classDeclaration.ancestors.flatMap(klass => + klass.controllerDefinition?.methodDefinitions || [] + ) + } + + get actionNames(): string[] { + return this.actions.map(action => action.name) + } + + get localActions(): MethodDefinition[] { + return this.methodDefinitions + } + + get localActionNames(): string[] { + return this.localActions.map(method => method.name) + } + + // Targets + + get targets(): TargetDefinition[] { + return this.classDeclaration.ancestors.flatMap(klass => + klass.controllerDefinition?.targetDefinitions || [] + ) + } + + get targetNames(): string[] { + return this.targets.map(target => target.name) + } + + get localTargets(): TargetDefinition[] { + return this.targetDefinitions + } + + get localTargetNames(): string[] { + return this.localTargets.map(target => target.name) + } + + // Classes + + get classes(): ClassDefinition[] { + return this.classDeclaration.ancestors.flatMap(klass => + klass.controllerDefinition?.classDefinitions || [] + ) } - get targets() { - return this._targets.map((method) => method.name) + get classNames(): string[] { + return this.classes.map(klass => klass.name) } - get classes() { - return this._classes.map((method) => method.name) + get localClasses(): ClassDefinition[] { + return this.classDefinitions } + get localClassNames(): string[] { + return this.localClasses.map(klass => klass.name) + } + + // Values + get values() { - return Object.fromEntries(Object.entries(this._values).map(([key, def]) => [key, def.valueDef])) + return this.classDeclaration.ancestors.flatMap(klass => + klass.controllerDefinition?.valueDefinitions || [] + ) + } + + get valueNames() { + return this.values.map(value => value.name) + } + + get localValues() { + return this.valueDefinitions + } + + get localValueNames() { + return this.localValues.map(value => value.name) + } + + get valueDefinitionsMap() { + return Object.fromEntries(this.values.map(definition => [definition.name, definition.definition])) + } + + get localValueDefinitionsMap() { + return Object.fromEntries(this.localValues.map(definition => [definition.name, definition.definition])) + } + + get controllerRoot(): string { + return this.project.controllerRootForPath(this.path) } get controllerPath() { return this.project.relativeControllerPath(this.path) } - get identifier() { + get guessedControllerPath() { + return this.project.guessedRelativeControllerPath(this.path) + } + + get isExported(): boolean { + return this.classDeclaration.isExported + } + + get registeredControllers(): RegisteredController[] { + return this.project.registeredControllers.filter(controller => controller.controllerDefinition === this) + } + + get registeredIdentifiers(): string[] { + return this.registeredControllers.map(controller => controller.identifier) + } + + get guessedIdentifier() { + const className = this.classDeclaration?.className + const hasMoreThanOneController = this.classDeclaration?.sourceFile.classDeclarations.filter(klass => klass.isStimulusDescendant).length > 1 + const isProjectFile = this.path.includes("node_modules") + + if (className && ((isProjectFile && hasMoreThanOneController) || (!isProjectFile))) { + return dasherize(uncapitalize(className.replace("Controller", ""))) + } + const folder = path.dirname(this.controllerPath) const extension = path.extname(this.controllerPath) const file = path.basename(this.controllerPath) const filename = path.basename(this.controllerPath, extension) + const toControllerIdentifier = (file: string): string => { + const identifier = dasherize(camelize(path.basename(path.dirname(file)))) + + if (["dist", "src", "index", "out"].includes(identifier)) { + return toControllerIdentifier(path.dirname(file)) + } + + return identifier + } + if (file === `controller${extension}`) { return identifierForContextKey(`${folder}_${file}${extension}`) || "" + } else if (this.path.includes("node_modules")) { + const identifier = dasherize(camelize(path.basename(this.path, path.extname(this.path)))) + + return (identifier === "index") ? toControllerIdentifier(path.dirname(this.path)) : identifier } else if (!filename.endsWith("controller")) { return identifierForContextKey(`${folder}/${filename}_controller${extension}`) || "" } else { - return identifierForContextKey(this.controllerPath) || "" + return identifierForContextKey(this.guessedControllerPath) || "" } } - get isNamespaced() { - return this.identifier.includes("--") + get isNamespaced(): boolean { + return this.guessedIdentifier.includes("--") } get namespace() { - const splits = this.identifier.split("--") + const splits = this.guessedIdentifier.split("--") return splits.slice(0, splits.length - 1).join("--") } get type() { const splits = this.path.split(".") - const ending = splits[splits.length - 1] + const extension = splits[splits.length - 1] - if (Project.javascriptEndings.includes(ending)) return "javascript" - if (Project.typescriptEndings.includes(ending)) return "typescript" + if (Project.javascriptExtensions.includes(extension)) return "javascript" + if (Project.typescriptExtensions.includes(extension)) return "typescript" return "javascript" } + + addTargetDefinition(targetDefinition: TargetDefinition): void { + if (this.localTargetNames.includes(targetDefinition.name)) { + this.errors.push(new ParseError("LINT", `Duplicate definition of Stimulus Target "${targetDefinition.name}"`, targetDefinition.loc)) + } else if (this.targetNames.includes(targetDefinition.name)) { + this.errors.push(new ParseError("LINT", `Duplicate definition of Stimulus Target "${targetDefinition.name}". A parent controller already defines this Target.`, targetDefinition.loc)) + } + + this.targetDefinitions.push(targetDefinition) + } + + addClassDefinition(classDefinition: ClassDefinition) { + if (this.localClassNames.includes(classDefinition.name)) { + this.errors.push(new ParseError("LINT", `Duplicate definition of Stimulus Class "${classDefinition.name}"`, classDefinition.loc)) + } else if (this.classNames.includes(classDefinition.name)) { + this.errors.push(new ParseError("LINT", `Duplicate definition of Stimulus Class "${classDefinition.name}". A parent controller already defines this Class.`, classDefinition.loc)) + } + + this.classDefinitions.push(classDefinition) + } + + addValueDefinition(valueDefinition: ValueDefinition) { + if (this.localValueNames.includes(valueDefinition.name)) { + this.errors.push(new ParseError("LINT", `Duplicate definition of Stimulus Value "${valueDefinition.name}"`, valueDefinition.loc)) + } else if (this.valueNames.includes(valueDefinition.name)) { + this.errors.push(new ParseError("LINT", `Duplicate definition of Stimulus Value "${valueDefinition.name}". A parent controller already defines this Value.`, valueDefinition.loc)) + } + + this.valueDefinitions.push(valueDefinition) + } + + get inspect() { + return { + guessedIdentifier: this.guessedIdentifier, + targets: this.targetNames, + values: this.valueDefinitions, + classes: this.classNames, + actions: this.actionNames, + } + } } diff --git a/src/controller_property_definition.ts b/src/controller_property_definition.ts index 4fdb39e..939d1fd 100644 --- a/src/controller_property_definition.ts +++ b/src/controller_property_definition.ts @@ -1,23 +1,39 @@ -import { SourceLocation } from "acorn" +import type * as Acorn from "acorn" -type ValueDefinitionValue = Array | boolean | number | object | string | undefined +import type { ValueDefinitionValue, ValueDefinition as ValueDefinitionType } from "./types" + +// TODO: ArrayExpression and ObjectExpression shoudl probably be PropertyDefinition as well +// AssignmentExpression | PropertyDefinition +// +// maybe the ControllerPropertyDefinition superclass should be Acorn.Node, but the subclasses themselves can narrow down the type +type Node = Acorn.MethodDefinition | Acorn.PropertyDefinition | Acorn.ArrayExpression | Acorn.ObjectExpression export abstract class ControllerPropertyDefinition { constructor( public readonly name: string, - public readonly loc?: SourceLocation, - public readonly definitionType: "decorator" | "static" = "decorator", + public readonly node: Node, + public readonly loc?: Acorn.SourceLocation | null, + public readonly definitionType: "decorator" | "static" = "static", ) {} } export class ValueDefinition extends ControllerPropertyDefinition { constructor( name: string, - public readonly valueDef: { type: string; default: ValueDefinitionValue }, - loc?: SourceLocation, - definitionType: "decorator" | "static" = "decorator", + public readonly definition: ValueDefinitionType, + node: Node, + loc?: Acorn.SourceLocation | null, + definitionType: "decorator" | "static" = "static", ) { - super(name, loc, definitionType) + super(name, node, loc, definitionType) + } + + get type() { + return this.definition.type + } + + get default() { + return this.definition.default } public static defaultValuesForType = { diff --git a/src/controllers_index_file.ts b/src/controllers_index_file.ts new file mode 100644 index 0000000..c90b7ca --- /dev/null +++ b/src/controllers_index_file.ts @@ -0,0 +1,235 @@ +import path from "path" + +import { RegisteredController } from "./registered_controller" + +import { glob } from "glob" +import { walk } from "./util/walk" +import { hasDepedency } from "./util/npm" + +import type { Project } from "./project" +import type { SourceFile } from "./source_file" +import type { ImportDeclaration } from "./import_declaration" +import type { ControllerLoadMode } from "./types" + +export class ControllersIndexFile { + public readonly project: Project + public readonly registeredControllers: RegisteredController[] = [] + public readonly sourceFile: SourceFile + + public readonly fallbackPath: string = "app/javascript/controllers/index.js" + + constructor(project: Project, sourceFile: SourceFile){ + this.project = project + this.sourceFile = sourceFile + } + + get path() { + return this.sourceFile.path + } + + get applicationImport(): ImportDeclaration | undefined { + return this.sourceFile.importDeclarations.find(declaration => + declaration.originalName === this.project.applicationFile?.exportDeclaration?.exportedName + ) + } + + async analyze() { + this.analyzeApplicationRegisterCalls() + this.analyzeApplicationLoadCalls() + await this.analyzeStimulusLoadingCalls() + await this.analyzeEsbuildRails() + await this.analyzeStimulusViteHelpers() + await this.analyzeStimulusWebpackHelpers() + } + + analyzeApplicationRegisterCalls() { + walk(this.sourceFile.ast, { + CallExpression: node => { + const { callee } = node + + if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.property.type === "Identifier") { + const { object, property } = callee + + const objectName = object.name + const propertyName = property.name + + if (objectName !== this.applicationImport?.localName) return + + if (propertyName !== "register") return + + const [identifierNode, controllerNode] = node.arguments.slice(0, 2) + const identifier = (identifierNode.type === "Literal") ? identifierNode.value?.toString() : null + const controllerName = (controllerNode.type === "Identifier") ? controllerNode.name : null + + if (!identifier || !controllerName) return // TODO: probably should add an error here + + const importDeclaration = this.sourceFile.findImport(controllerName) + if (!importDeclaration) return // TODO: probably should add an error here + + const classDeclaration = importDeclaration.resolvedClassDeclaration + if (!classDeclaration) return // TODO: probably should add an error here + + const controller = classDeclaration.controllerDefinition + if (!controller) return // TODO: probably should add an error here + + this.project._controllerRoots.add(this.project.relativePath(path.dirname(this.sourceFile.path))) + + this.registeredControllers.push(new RegisteredController(identifier, controller, "register")) + } + } + }) + } + + analyzeApplicationLoadCalls() { + walk(this.sourceFile.ast, { + CallExpression: node => { + const { callee } = node + + if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.property.type === "Identifier") { + const { object, property } = callee + + const objectName = object.name + const propertyName = property.name + + if (objectName !== this.applicationImport?.localName) return + + if (propertyName === "load") { + // TODO + } + } + } + }) + } + + async analyzeStimulusLoadingCalls() { + let controllersGlob + let type: ControllerLoadMode = "stimulus-loading-eager" + + walk(this.sourceFile.ast, { + CallExpression: node => { + if (node.callee.type === "Identifier" && ["eagerLoadControllersFrom", "lazyLoadControllersFrom"].includes(node.callee.name)) { + const [pathNode, applicationNode] = node.arguments.slice(0, 2) + const controllersPath = (pathNode.type === "Literal") ? pathNode.value?.toString() : null + const application = (applicationNode.type === "Identifier") ? applicationNode.name : null + + type = node.callee.name === "eagerLoadControllersFrom" ? "stimulus-loading-eager" : "stimulus-loading-lazy" + + if (!controllersPath || !application) return + + const base = this.project.relativePath(path.dirname(path.dirname(this.sourceFile.path))) + const controllerRoot = path.join(this.project.projectPath, base, controllersPath) + + this.project._controllerRoots.add(this.project.relativePath(controllerRoot)) + + controllersGlob = path.join(controllerRoot, `**/*.{${this.project.extensionsGlob}}`) + } + } + }) + + + if (!controllersGlob) return + + await this.evaluateControllerGlob(controllersGlob, type) + } + + async analyzeEsbuildRails() { + const hasEsbuildRails = await hasDepedency(this.project.projectPath, "esbuild-rails") + + if (!hasEsbuildRails) return + + const imports = this.sourceFile.importDeclarations.find(declaration => declaration.source.includes("*")) + + if (!imports) return + + const controllersGlob = imports.resolvedRelativePath + + if (!controllersGlob) return + + this.project._controllerRoots.add(this.project.relativePath(path.dirname(this.sourceFile.path))) + + await this.evaluateControllerGlob(controllersGlob, "esbuild-rails") + } + + async analyzeStimulusViteHelpers() { + const hasViteHelpers = await hasDepedency(this.project.projectPath, "stimulus-vite-helpers") + + if (!hasViteHelpers) return + + let controllersGlob + + walk(this.sourceFile.ast, { + CallExpression: node => { + if (node.callee.type === "MemberExpression" && node.callee.object.type === "MetaProperty" && node.callee.property.type === "Identifier") { + const [pathNode] = node.arguments.slice(0, 1) + const importGlob = (pathNode.type === "Literal") ? pathNode.value?.toString() : null + + if (!importGlob) return + + const controllerRoot = path.dirname(this.sourceFile.path) + this.project._controllerRoots.add(this.project.relativePath(controllerRoot)) + + controllersGlob = path.join(controllerRoot, importGlob) + } + } + }) + + if (controllersGlob) { + await this.evaluateControllerGlob(controllersGlob, "stimulus-vite-helpers") + } + } + + async analyzeStimulusWebpackHelpers() { + const hasWebpackHelpers = await hasDepedency(this.project.projectPath, "@hotwired/stimulus-webpack-helpers") + + if (!hasWebpackHelpers) return + + let controllersGlob + let definitionsFromContextCalled = false + + walk(this.sourceFile.ast, { + CallExpression: node => { + if (node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.property.type === "Identifier") { + + if (node.callee.object.name === "require" && node.callee.property.name === "context") { + const [folder, _arg, pattern] = node.arguments.map(m => m.type === "Literal" ? m.value : undefined).filter(c => c).slice(0, 3) + + const controllerRoot = path.join(path.dirname(path.dirname(this.sourceFile.path)), folder?.toString() || "") + this.project._controllerRoots.add(this.project.relativePath(controllerRoot)) + + if (pattern instanceof RegExp) { + controllersGlob = path.join(controllerRoot, `**/*${pattern.source.replace("$", "").replace("\\.", ".")}`) + } + } + } + } + }) + + walk(this.sourceFile.ast, { + CallExpression: node => { + if (node.callee.type === "Identifier" && ["definitionsFromContext"].includes(node.callee.name)) { + const [contextNode] = node.arguments.slice(0, 1) + const context = (contextNode.type === "Identifier") ? contextNode.name : null + + if (context) { + definitionsFromContextCalled = true + } + } + } + }) + + + if (controllersGlob && definitionsFromContextCalled) { + await this.evaluateControllerGlob(controllersGlob, "stimulus-webpack-helpers") + } + } + + private async evaluateControllerGlob(controllersGlob: string, type: ControllerLoadMode) { + const controllerFiles = (await glob(controllersGlob)).map(path => this.project.relativePath(path)) + const sourceFiles = this.project.projectFiles.filter(file => controllerFiles.includes(this.project.relativePath(file.path))) + const controllerDefinitions = sourceFiles.flatMap(file => file.defaultExportControllerDefinition || []) + + controllerDefinitions.forEach(controller => { + this.registeredControllers.push(new RegisteredController(controller.guessedIdentifier, controller, type)) + }) + } +} diff --git a/src/export_declaration.ts b/src/export_declaration.ts new file mode 100644 index 0000000..a67cc09 --- /dev/null +++ b/src/export_declaration.ts @@ -0,0 +1,181 @@ +import path from "path" + +import type * as Acorn from "acorn" +import type { SourceFile } from "./source_file" +import type { NodeModule } from "./node_module" +import type { ClassDeclaration } from "./class_declaration" +import type { ControllerDefinition } from "./controller_definition" + +export class ExportDeclaration { + public readonly sourceFile: SourceFile + public readonly exportedName?: string + public readonly localName?: string + public readonly source?: string + public readonly type: "default" | "named" | "namespace" + public readonly node: Acorn.ExportNamedDeclaration | Acorn.ExportAllDeclaration | Acorn.ExportDefaultDeclaration + + constructor(sourceFile: SourceFile, args: { exportedName?: string, localName?: string, source?: string, type: "default" | "named" | "namespace", node: Acorn.ExportNamedDeclaration | Acorn.ExportAllDeclaration | Acorn.ExportDefaultDeclaration}) { + this.sourceFile = sourceFile + this.exportedName = args.exportedName + this.localName = args.localName + this.source = args.source + this.type = args.type + this.node = args.node + } + + get project() { + return this.sourceFile.project + } + + get isStimulusExport(): boolean { + return this.exportedClassDeclaration?.isStimulusDescendant || false + } + + get highestAncestor() { + return this.ancestors.reverse()[0] + } + + get ancestors(): ExportDeclaration[] { + if (!this.nextResolvedExportDeclaration) { + return [this] + } + + return [this, ...this.nextResolvedExportDeclaration.ancestors] + } + + get isRelativeExport(): boolean { + if (!this.source) return false + + return this.source.startsWith(".") + } + + get isNodeModuleExport() { + return !this.isRelativeExport + } + + get exportedClassDeclaration(): ClassDeclaration | undefined { + return ( + this.sourceFile.classDeclarations.find(klass => klass.exportDeclaration === this) || + this.sourceFile.importDeclarations.find(declaration => declaration.localName === this.localName)?.nextResolvedClassDeclaration || + this.nextResolvedExportDeclaration?.exportedClassDeclaration + ) + } + + get resolvedRelativePath(): string | undefined { + if (this.isRelativeExport && this.source) { + const thisFolder = path.dirname(this.sourceFile.path) + const folder = path.dirname(this.source) + let file = path.basename(this.source) + + if (!file.endsWith(this.sourceFile.fileExtension)) { + file += this.sourceFile.fileExtension + } + + return path.join(thisFolder, folder, file) + } + + return undefined + } + + get resolvedRelativeSourceFile(): SourceFile | undefined { + if (!this.resolvedRelativePath) return + + return this.project.allSourceFiles.find(file => file.path === this.resolvedRelativePath) + } + + get resolvedNodeModule(): NodeModule | undefined { + if (this.resolvedRelativePath) return undefined + + // TODO: account for exportmaps + const nodeModule = this.project.detectedNodeModules.find(node => node.name === this.source) + + if (nodeModule) return nodeModule + + return undefined + } + + get resolvedNodeModuleSourceFile(): SourceFile | undefined { + return this.resolvedNodeModule?.resolvedSourceFile + } + + get resolvedPath(): string | undefined { + return this.resolvedClassDeclaration?.sourceFile.path + } + + get nextResolvedPath() { + if (this.resolvedRelativePath) return this.resolvedRelativePath + if (this.resolvedNodeModule) return this.resolvedNodeModule.resolvedPath + + return undefined + } + + get resolvedSourceFile(): SourceFile | undefined { + return this.resolvedClassDeclaration?.highestAncestor.sourceFile + } + + get nextResolvedSourceFile(): SourceFile | undefined { + if (this.resolvedRelativePath) return this.resolvedRelativeSourceFile + if (this.resolvedNodeModule) return this.resolvedNodeModuleSourceFile + + return undefined + } + + get resolvedExportDeclaration(): ExportDeclaration | undefined { + return this.resolvedClassDeclaration?.highestAncestor.exportDeclaration + } + + get nextResolvedExportDeclaration(): ExportDeclaration | undefined { + const sourceFile = this.nextResolvedSourceFile + + if (!sourceFile) return undefined + + // Re-exports + if (this.source) { + if (this.type === "default" && this.localName) { + return sourceFile.exportDeclarations.find(declaration => declaration.exportedName === this.localName) + } else if (this.type === "default") { + return sourceFile.defaultExport + } else if (this.type === "named" && this.localName === undefined) { + return sourceFile.defaultExport + } else if (this.type === "named") { + return sourceFile.exportDeclarations.find(declaration => declaration.type === "named" && declaration.exportedName === this.exportedName) + } else if (this.type === "namespace"){ + throw new Error("Tried to resolve namespace re-export") + } + } + + // Regular exports + if (this.type === "default") { + return sourceFile.defaultExport + } else if (this.type === "named") { + return sourceFile.exportDeclarations.find(declaration => declaration.type === "named" && declaration.exportedName === this.exportedName) + } else if (this.type === "namespace"){ + throw new Error("Tried to resolve namespace export") + } + } + + get resolvedClassDeclaration(): ClassDeclaration | undefined { + return this.nextResolvedClassDeclaration?.highestAncestor + } + + get nextResolvedClassDeclaration(): ClassDeclaration | undefined { + if (this.exportedClassDeclaration) return this.exportedClassDeclaration + if (this.nextResolvedExportDeclaration) return this.nextResolvedExportDeclaration.nextResolvedClassDeclaration + + return undefined + } + + get resolvedControllerDefinition(): ControllerDefinition | undefined { + return this.resolvedClassDeclaration?.controllerDefinition + } + + get inspect(): object { + return { + type: this.type, + source: this.source, + localName: this.localName, + exportedName: this.exportedName, + exportedFrom: this.sourceFile?.path, + } + } +} diff --git a/src/import_declaration.ts b/src/import_declaration.ts new file mode 100644 index 0000000..32c01c9 --- /dev/null +++ b/src/import_declaration.ts @@ -0,0 +1,148 @@ +import path from "path" + +import type * as Acorn from "acorn" +import type { NodeModule } from "./node_module" +import type { SourceFile } from "./source_file" +import type { ClassDeclaration } from "./class_declaration" +import type { ExportDeclaration } from "./export_declaration" +import type { ControllerDefinition} from "./controller_definition" + +export type ImportDeclarationType = "default" | "named" | "namespace" + +type ImportDeclarationArgs = { + type: ImportDeclarationType + originalName?: string + localName: string + source: string + isStimulusImport: boolean // TODO: check if this really needs to be in the args on initialization + node: Acorn.ImportDeclaration +} + +export class ImportDeclaration { + public readonly sourceFile: SourceFile + public readonly originalName?: string + public readonly localName: string + public readonly source: string + public readonly type: ImportDeclarationType + public readonly isStimulusImport: boolean + public readonly node: Acorn.ImportDeclaration + + constructor(sourceFile: SourceFile, args: ImportDeclarationArgs) { + this.sourceFile = sourceFile + this.originalName = args.originalName + this.localName = args.localName + this.source = args.source + this.isStimulusImport = args.isStimulusImport + this.node = args.node + this.type = args.type + } + + get project() { + return this.sourceFile.project + } + + get isRenamedImport(): boolean { + if (this.type !== "named") return false + + return this.originalName !== this.localName + } + + get isNodeModuleImport() { + return !this.isRelativeImport + } + + get isRelativeImport() { + return this.source.startsWith(".") + } + + get resolvedRelativePath(): string | undefined { + if (this.isRelativeImport) { + const thisFolder = path.dirname(this.sourceFile.path) + const folder = path.dirname(this.source) + let file = path.basename(this.source) + + if (!file.endsWith(this.sourceFile.fileExtension)) { + file += this.sourceFile.fileExtension + } + + return path.join(thisFolder, folder, file) + } + + return undefined + } + + get resolvedNodeModule(): NodeModule | undefined { + if (this.resolvedRelativePath) return + + // TODO: account for exportmaps + const nodeModule = this.project.detectedNodeModules.find(node => node.name === this.source) + + if (nodeModule) return nodeModule + + return undefined + } + + get resolvedNodeModuleSourceFile(): SourceFile | undefined { + return this.resolvedNodeModule?.resolvedSourceFile + } + + get nextResolvedPath() { + if (this.resolvedRelativePath) return this.resolvedRelativePath + if (this.resolvedNodeModule) return this.resolvedNodeModule.resolvedPath + + return undefined + } + + get resolvedPath() { + return this.resolvedClassDeclaration?.sourceFile.path + } + + get resolvedSourceFile(): SourceFile | undefined { + return this.resolvedClassDeclaration?.sourceFile + } + + get nextResolvedSourceFile(): SourceFile | undefined { + if (!this.nextResolvedPath) return + + return this.project.allSourceFiles.find(file => file.path === this.nextResolvedPath) + } + + get resolvedExportDeclaration(): ExportDeclaration | undefined { + return this.nextResolvedExportDeclaration?.highestAncestor + } + + get nextResolvedExportDeclaration(): ExportDeclaration | undefined { + const sourceFile = this.nextResolvedSourceFile + + if (!sourceFile) return + + const exports = sourceFile.exportDeclarations + + if (this.type === "default") return sourceFile.defaultExport + if (this.type === "namespace") throw new Error("Implement namespace imports") + + return exports.find(declaration => declaration.exportedName === this.originalName) + } + + get resolvedClassDeclaration(): ClassDeclaration | undefined { + return this.nextResolvedClassDeclaration?.highestAncestor + } + + get nextResolvedClassDeclaration(): ClassDeclaration | undefined { + return this.nextResolvedExportDeclaration?.exportedClassDeclaration + } + + get resolvedControllerDefinition(): ControllerDefinition | undefined { + return this.resolvedClassDeclaration?.controllerDefinition + } + + get inspect(): object { + return { + type: this.type, + localName: this.localName, + originalName: this.originalName, + source: this.source, + importedFrom: this.sourceFile?.path, + } + } +} diff --git a/src/index.ts b/src/index.ts index 9c698dd..55238f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,14 @@ +export * from "./application_file" +export * from "./class_declaration" export * from "./controller_definition" +export * from "./controllers_index_file" +export * from "./export_declaration" +export * from "./import_declaration" +export * from "./node_module" +export * from "./parse_error" export * from "./parser" export * from "./project" +export * from "./registered_controller" +export * from "./source_file" export * from "./types" +export * from "./util" diff --git a/src/node_module.ts b/src/node_module.ts new file mode 100644 index 0000000..e24f5e7 --- /dev/null +++ b/src/node_module.ts @@ -0,0 +1,96 @@ +import { SourceFile } from "./source_file" + +import { nodeModuleForPackageName } from "./util/npm" +import { shouldIgnore } from "./packages" + +import type { Project } from "./project" +import type { ControllerDefinition } from "./controller_definition" + +interface NodeModuleArgs { + name: string + path: string + entrypoint: string + controllerRoots: string[] + files: string[] + type: "main" | "module" | "source" +} + +export class NodeModule { + public readonly project: Project + public readonly name: string + public readonly path: string + public readonly entrypoint: string + public readonly controllerRoots: string[] + public readonly sourceFiles: SourceFile[] = [] + public readonly type: "main" | "module" | "source" + + static async forProject(project: Project, name: string) { + return await nodeModuleForPackageName(project, name) + } + + constructor(project: Project, args: NodeModuleArgs) { + this.project = project + this.name = args.name + this.path = args.path + this.entrypoint = args.entrypoint + this.controllerRoots = args.controllerRoots + this.type = args.type + + // TODO: files should be refreshable resp. the NodeModule class should know how to fetch its files + this.sourceFiles = args.files.map(path => new SourceFile(this.project, path)) + } + + async initialize() { + if (shouldIgnore(this.name)) return + + await Promise.allSettled(this.sourceFiles.map(sourceFile => sourceFile.initialize())) + } + + async analyze() { + if (shouldIgnore(this.name)) return + + const referencedFilePaths = this.sourceFiles.flatMap(s => s.importDeclarations.filter(i => i.isRelativeImport).map(i => i.resolvedRelativePath)) + const referencedSourceFiles = this.sourceFiles.filter(s => referencedFilePaths.includes(s.path)) + + await Promise.allSettled(referencedSourceFiles.map(sourceFile => sourceFile.analyze())) + await Promise.allSettled(this.sourceFiles.map(sourceFile => sourceFile.analyze())) + } + + async refresh() { + await Promise.allSettled(this.sourceFiles.map(sourceFile => sourceFile.refresh())) + } + + get resolvedPath() { + return this.entrypoint + } + + get entrypointSourceFile(): SourceFile | undefined { + return this.sourceFiles.find(file => file.path === this.entrypoint) + } + + get resolvedSourceFile(): SourceFile | undefined { + return this.entrypointSourceFile + } + + get classDeclarations() { + return this.sourceFiles.flatMap(file => file.classDeclarations) + } + + get controllerDefinitions(): ControllerDefinition[] { + return this.classDeclarations.map(klass => klass.controllerDefinition).filter(controller => controller) as ControllerDefinition[] + } + + get files(): string[] { + return this.sourceFiles.map(file => file.path) + } + + get inspect(): object { + return { + name: this.name, + type: this.type, + entrypoint: this.entrypoint, + files: this.files.length, + controllerDefinitions: this.controllerDefinitions.map(c => c?.guessedIdentifier), + } + } +} diff --git a/src/packages.ts b/src/packages.ts new file mode 100644 index 0000000..47cbc7e --- /dev/null +++ b/src/packages.ts @@ -0,0 +1,114 @@ +import path from "path" +import { glob } from "glob" + +import { Project } from "./project" + +import { findNodeModulesPath, parsePackageJSON, nodeModuleForPackageJSONPath, hasDepedency } from "./util/npm" + +export const helperPackages = [ + "@hotwired/stimulus-loading", + "@hotwired/stimulus-webpack-helpers", + "bun-stimulus-plugin", + "esbuild-plugin-stimulus", + "stimulus-vite-helpers", + "vite-plugin-stimulus-hmr", +] + +export const ignoredPackageNamespaces = [ + "@angular", + "@babel", + "@date-fns", + "@rollup", + "@types", +] + +export const ignoredPackages = [ + ...helperPackages, + "@hotwired/stimulus", + "@rails/webpacker", + "axios", + "babel-core", + "boostrap", + "tailwindcss", + "babel-eslint", + "babel-loader", + "babel-runtime", + "bun", + "chai", + "compression-webpack-plugin", + "core-js", + "esbuild-rails", + "esbuild", + "eslint", + "hotkeys-js", + "jquery", + "laravel-vite-plugin", + "lodash", + "mitt", + "mocha", + "moment", + "postcss", + "react", + "rollup", + "shakapacker", + "terser-webpack-plugin", + "typescript", + "vite-plugin-rails", + "vite-plugin-ruby", + "vite", + "vue", + "webpack-assets-manifest", + "webpack-cli", + "webpack-merge", + "webpack", +] + +export async function analyzePackage(project: Project, name: string) { + const nodeModulesPath = await findNodeModulesPath(project.projectPath) + + if (!nodeModulesPath) return + if (!await hasDepedency(project.projectPath, name)) return + + const packagePath = path.join(nodeModulesPath, name, "package.json") + + return await anylzePackagePath(project, packagePath) +} + +export function shouldIgnore(name: string): boolean { + if (ignoredPackages.includes(name)) return true + + return ignoredPackageNamespaces.some(namespace => name.includes(namespace)) +} + +export async function anylzePackagePath(project: Project, packagePath: string) { + const packageJSON = await parsePackageJSON(packagePath) + const packageName = packageJSON.name + + if (shouldIgnore(packageName)) return + + const nodeModule = await nodeModuleForPackageJSONPath(project, packagePath) + + if (nodeModule && !project.detectedNodeModules.map(nodeModule => nodeModule.name).includes(packageName)) { + project.detectedNodeModules.push(nodeModule) + + return nodeModule + } + + return undefined +} + +export async function analyzeAll(project: Project) { + const nodeModulesPath = await findNodeModulesPath(project.projectPath) + + if (!nodeModulesPath) return + + const packages = [ + ...await glob(`${nodeModulesPath}/*stimulus*/package.json`), // for libraries like stimulus-in-library-name + ...await glob(`${nodeModulesPath}/*stimulus*/*/package.json`), // for libraries like @stimulus-in-namespace-name/some-library + ...await glob(`${nodeModulesPath}/*/*stimulus*/package.json`), // for libraries like @some-namespace/stimulus-in-library-name + ] + + await Promise.allSettled( + packages.map(packagePath => anylzePackagePath(project, packagePath)) + ) +} diff --git a/src/parse_error.ts b/src/parse_error.ts index bbb4c9a..668add5 100644 --- a/src/parse_error.ts +++ b/src/parse_error.ts @@ -1,8 +1,17 @@ +import type { SourceLocation } from "acorn" + export class ParseError { constructor( public readonly severity: "LINT" | "FAIL", public readonly message: string, - public readonly loc?: any, - public readonly cause?: any, + public readonly loc?: SourceLocation | null, + public readonly cause?: Error, ) {} + + get inspect(): object { + return { + severity: this.severity, + message: this.message, + } + } } diff --git a/src/parser.ts b/src/parser.ts index 9a50d12..e5fca32 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,324 +1,30 @@ import * as ESLintParser from "@typescript-eslint/typescript-estree" -import { simple } from "acorn-walk" - import { Project } from "./project" -import { ParseError } from "./parse_error" -import { ControllerDefinition } from "./controller_definition" -import { ControllerPropertyDefinition, MethodDefinition, ValueDefinition, ClassDefinition, TargetDefinition } from "./controller_property_definition" -import { NodeElement, PropertyValue } from "./types" -type ImportStatement = { - originalName?: string - importedName: string - source: string -} - -type ClassDeclaration = { - className: string - superClass?: string - isStimulusClass: boolean -} - -type NestedArray = T | NestedArray[] -type NestedObject = { - [k: string]: T | NestedObject -} - -// TODO: Support decorator + reflect-metadata value definitions -// TODO: error or multiple classes +import type { ParserOptions } from "./types" export class Parser { - private readonly project: Project - private parser: typeof ESLintParser + readonly project: Project + + private parser = ESLintParser + private readonly parserOptions: ParserOptions = { + loc: true, + range: true, + tokens: true, + comment: true, + sourceType: "module", + ecmaVersion: "latest" + } constructor(project: Project) { this.project = project - this.parser = ESLintParser } parse(code: string, filename?: string) { return this.parser.parse(code, { - loc: true, - range: true, - tokens: true, - comment: true, - sourceType: "module", - ecmaVersion: "latest", + ...this.parserOptions, filePath: filename }) } - - parseController(code: string, filename: string) { - try { - const importStatements: ImportStatement[] = [] - const classDeclarations: ClassDeclaration[] = [] - - const ast = this.parse(code, filename) - const controller = new ControllerDefinition(this.project, filename) - - const parser = this - - simple(ast as any, { - ImportDeclaration(node: any): void { - node.specifiers.map((specifier: any) => { - importStatements.push({ - originalName: specifier.imported?.name, - importedName: specifier.local.name, - source: node.source.value - }) - }) - }, - - ClassDeclaration(node: any): void { - const className = node.id?.name - const superClass = node.superClass.name - const importStatement = importStatements.find(i => i.importedName === superClass) - - // TODO: this needs to be recursive - const isStimulusClass = importStatement ? (importStatement.source === "@hotwired/stimulus" && importStatement.originalName === "Controller") : false - - classDeclarations.push({ - className, - superClass, - isStimulusClass - }) - - if (importStatement) { - controller.parent = { - constant: superClass, - package: importStatement.source, - type: isStimulusClass ? "default" : "import", - } - } else { - controller.parent = { - constant: node.superClass.name, - type: "unknown", - } - } - - if ("decorators" in node && Array.isArray(node.decorators)) { - controller.isTyped = !!node.decorators.find( - (decorator: any) => decorator.expression.name === "TypedController", - ) - } - }, - - MethodDefinition(node: any): void { - if (node.kind === "method") { - const methodName = node.key.name - const isPrivate = node.accessibility === "private" || node.key.type === "PrivateIdentifier" - const name = isPrivate ? `#${methodName}` : methodName - - controller._methods.push(new MethodDefinition(name, node.loc, "static")) - } - }, - - ExpressionStatement(node: any): void { - const left = node.expression.left - - if (node.expression.type === "AssignmentExpression" && left.type === "MemberExpression" && left.object.type === "Identifier") { - const classDeclaration = classDeclarations.find(c => c.className === left.object.name) - - if (classDeclaration && classDeclaration.isStimulusClass) { - parser.parseProperty(controller, node.expression) - } - } - }, - - PropertyDefinition(node: any): void { - if ("decorators" in node && Array.isArray(node.decorators) && node.decorators.length > 0) { - return parser.parseDecoratedProperty(controller, node) - } - - if (node.value?.type === "ArrowFunctionExpression") { - controller._methods.push(new MethodDefinition(node.key.name, node.loc, "static")) - } - - if (!node.static) return - - parser.parseProperty(controller, node) - }, - }) - - this.validate(controller) - - return controller - } catch (error: any) { - console.error(`Error while parsing controller in '${filename}': ${error.message}`) - - const controller = new ControllerDefinition(this.project, filename) - - controller.errors.push(new ParseError("FAIL", "Error parsing controller", null, error)) - - return controller - } - } - - public validate(controller: ControllerDefinition) { - if (controller.anyDecorator && !controller.isTyped) { - controller.errors.push( - new ParseError("LINT", "You need to decorate the controller with @TypedController to use decorators"), - ) - } - - if (!controller.anyDecorator && controller.isTyped) { - controller.errors.push( - new ParseError("LINT", "You decorated the controller with @TypedController to use decorators"), - ) - } - - this.uniqueErrorGenerator(controller, "target", controller._targets) - this.uniqueErrorGenerator(controller, "class", controller._classes) - // values are reported at the time of parsing since we're storing them as an object - } - - private uniqueErrorGenerator(controller: ControllerDefinition, type: string, items: ControllerPropertyDefinition[]) { - const errors: string[] = [] - - items.forEach((item, index) => { - if (errors.includes(item.name)) return - - items.forEach((item2, index2) => { - if (index2 === index) return - - if (item.name === item2.name) { - errors.push(item.name) - controller.errors.push(new ParseError("LINT", `Duplicate definition of ${type}:${item.name}`, item2.loc)) - } - }) - }) - } - - public parseDecoratedProperty(controller: ControllerDefinition, node: any) { - const { name } = node.key - - node.decorators.forEach((decorator: any) => { - switch (decorator.expression.name || decorator.expression.callee.name) { - case "Target": - case "Targets": - controller.anyDecorator = true - return controller._targets.push( - new TargetDefinition(this.stripDecoratorSuffix(name, "Target"), node.loc, "decorator"), - ) - case "Class": - case "Classes": - controller.anyDecorator = true - return controller._classes.push( - new ClassDefinition(this.stripDecoratorSuffix(name, "Class"), node.loc, "decorator"), - ) - case "Value": - controller.anyDecorator = true - if (decorator.expression.name !== undefined || decorator.expression.arguments.length !== 1) { - throw new Error("We dont support reflected types yet") - } - - const key = this.stripDecoratorSuffix(name, "Value") - const type = decorator.expression.arguments[0].name - - if (controller._values[key]) { - controller.errors.push(new ParseError("LINT", `Duplicate definition of value:${key}`, node.loc)) - } - - const defaultValue = { - type, - default: node.value ? this.getDefaultValueFromNode(node) : ValueDefinition.defaultValuesForType[type], - } - - controller._values[key] = new ValueDefinition(key, defaultValue, node.loc, "decorator") - } - }) - } - - private stripDecoratorSuffix(name: string, type: string) { - return name.slice(0, name.indexOf(type)) - } - - public parseProperty(controller: ControllerDefinition, node: any) { - const name = node?.key?.name || node?.left?.property?.name - const elements = node?.value?.elements || node?.right?.elements - const properties = node?.value?.properties || node?.right?.properties - - switch (name) { - case "targets": - return controller._targets.push( - ...elements.map((element: any) => new TargetDefinition(element.value, node.loc, "static")), - ) - case "classes": - return controller._classes.push( - ...elements.map((element: any) => new ClassDefinition(element.value, node.loc, "static")), - ) - case "values": - properties.forEach((property: NodeElement) => { - if (controller._values[property.key.name]) { - controller.errors.push( - new ParseError("LINT", `Duplicate definition of value:${property.key.name}`, node.loc), - ) - } - - controller._values[property.key.name] = new ValueDefinition( - property.key.name, - this.parseValuePropertyDefinition(property), - node.loc, - "static", - ) - }) - } - } - - private parseValuePropertyDefinition(property: NodeElement): { type: any; default: any } { - const { value } = property - if (value.name && typeof value.name === "string") { - return { - type: value.name, - default: ValueDefinition.defaultValuesForType[value.name], - } - } - - const properties = property.value.properties - - const typeProperty = properties.find((property) => property.key.name === "type") - const defaultProperty = properties.find((property) => property.key.name === "default") - - return { - type: typeProperty?.value.name || "", - default: this.getDefaultValueFromNode(defaultProperty), - } - } - - private convertArrayExpression(value: NodeElement | PropertyValue): NestedArray { - return value.elements.map((node) => { - if (node.type === "ArrayExpression") { - return this.convertArrayExpression(node) - } else { - return node.value - } - }) - } - - private convertObjectExpression(value: PropertyValue): NestedObject { - return Object.fromEntries( - value.properties.map((property) => { - const isObjectExpression = property.value.type === "ObjectExpression" - const value = isObjectExpression ? this.convertObjectExpression(property.value) : property.value.value - - return [property.key.name, value] - }) - ) - } - - private getDefaultValueFromNode(node: any) { - if ("value" in node.value) { - return node.value.value - } - - const value = node.value - - switch (value.type) { - case "ArrayExpression": - return this.convertArrayExpression(value) - case "ObjectExpression": - return this.convertObjectExpression(value) - } - } } diff --git a/src/project.ts b/src/project.ts index 48e3de4..f1c652f 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,49 +1,34 @@ +import { glob } from "glob" + +import { ApplicationFile } from "./application_file" import { ControllerDefinition } from "./controller_definition" +import { ControllersIndexFile } from "./controllers_index_file" +import { ExportDeclaration } from "./export_declaration" +import { ImportDeclaration } from "./import_declaration" import { Parser } from "./parser" +import { SourceFile } from "./source_file" -import { promises as fs } from "fs" -import { glob } from "glob" - -const fileExists = (path: string) => { - return new Promise((resolve, reject) => - fs.stat(path).then(() => resolve(path)).catch(() => reject()) - ) -} +import { analyzeAll, analyzePackage } from "./packages" +import { resolvePathWhenFileExists, nestedFolderSort } from "./util/fs" +import { calculateControllerRoots } from "./util/project" -interface ControllerFile { - filename: string - content: string -} +import type { NodeModule } from "./node_module" +import type { RegisteredController } from "./registered_controller" export class Project { readonly projectPath: string readonly controllerRootFallback = "app/javascript/controllers" - static readonly javascriptEndings = ["js", "mjs", "cjs", "jsx"] - static readonly typescriptEndings = ["ts", "mts", "tsx"] - public controllerDefinitions: ControllerDefinition[] = [] + static readonly javascriptExtensions = ["js", "mjs", "cjs", "jsx"] + static readonly typescriptExtensions = ["ts", "mts", "tsx"] - private controllerFiles: Array = [] - private parser: Parser = new Parser(this) - - static calculateControllerRoots(filenames: string[]) { - const controllerRoots: string[] = []; - - filenames.forEach(filename => { - const splits = filename.split("/") - const controllersIndex = splits.indexOf("controllers") - - if (controllersIndex !== -1) { - const controllerRoot = splits.slice(0, controllersIndex + 1).join("/") - - if (!controllerRoots.includes(controllerRoot)) { - controllerRoots.push(controllerRoot) - } - } - }) - - return controllerRoots.sort(); - } + public detectedNodeModules: Array = [] + public referencedNodeModules: Set = new Set() + public projectFiles: Array = [] + public _controllerRoots: Set = new Set() + public parser: Parser = new Parser(this) + public applicationFile?: ApplicationFile + public controllersFile?: ControllersIndexFile constructor(projectPath: string) { this.projectPath = projectPath @@ -59,65 +44,217 @@ export class Project { return this.relativePath(path).replace(`${controllerRoot}/`, "") } + guessedRelativeControllerPath(path: string) { + const controllerRoot = this.guessedControllerRootForPath(path) + + return this.relativePath(path).replace(`${controllerRoot}/`, "") + } + possibleControllerPathsForIdentifier(identifier: string): string[] { - const endings = Project.javascriptEndings.concat(Project.typescriptEndings) + const extensions = Project.javascriptExtensions.concat(Project.typescriptExtensions) - return this.controllerRoots.flatMap(root => endings.map( - ending => `${root}/${ControllerDefinition.controllerPathForIdentifier(identifier, ending)}` - )) + return this.guessedControllerRoots.flatMap(root => extensions.map( + extension => `${root}/${ControllerDefinition.controllerPathForIdentifier(identifier, extension)}` + )).sort(nestedFolderSort) } async findControllerPathForIdentifier(identifier: string): Promise { const possiblePaths = this.possibleControllerPathsForIdentifier(identifier) - const promises = possiblePaths.map((path: string) => fileExists(`${this.projectPath}/${path}`)) - const possiblePath = await Promise.any(promises).catch(() => null) + const resolvedPaths = await Promise.all(possiblePaths.map(path => resolvePathWhenFileExists(`${this.projectPath}/${path}`))) + const resolvedPath = resolvedPaths.find(resolvedPath => resolvedPath) + + return resolvedPath ? this.relativePath(resolvedPath) : null + } + + controllerRootForPath(filePath: string) { + const relativePath = this.relativePath(filePath) + const relativeRoots = Array.from(this.controllerRoots).map(root => this.relativePath(root)) - return (possiblePath) ? this.relativePath(possiblePath) : null + return this.relativePath(relativeRoots.find(root => relativePath === root) || relativeRoots.find(root => relativePath.startsWith(root)) || this.controllerRootFallback) + } + + guessedControllerRootForPath(filePath: string) { + const relativePath = this.relativePath(filePath) + const relativeRoots = this.guessedControllerRoots.map(root => this.relativePath(root)) + + return this.relativePath(relativeRoots.find(root => relativePath === root) || relativeRoots.find(root => relativePath.startsWith(root)) || this.controllerRootFallback) + } + + get controllerDefinitions(): ControllerDefinition[] { + return this.projectFiles.flatMap(file => file.exportedControllerDefinitions) + } + + get allProjectControllerDefinitions(): ControllerDefinition[] { + return this.projectFiles.flatMap(file => file.controllerDefinitions) + } + + // TODO: this should be coming from the nodeModules + get allControllerDefinitions(): ControllerDefinition[] { + return this.allSourceFiles.flatMap(file => file.controllerDefinitions) + } + + get allSourceFiles() { + return this.projectFiles.concat( + ...this.detectedNodeModules.flatMap(module => module.sourceFiles) + ) } get controllerRoot() { - return this.controllerRoots[0] || this.controllerRootFallback + return Array.from(this.controllerRoots)[0] || this.controllerRootFallback } get controllerRoots() { - const roots = Project.calculateControllerRoots( - this.controllerFiles.map(file => this.relativePath(file.filename)) - ) + return Array.from(this._controllerRoots) + } + + get guessedControllerRoots() { + const controllerFiles = this.allSourceFiles.filter(file => file.controllerDefinitions.length > 0) + const relativePaths = controllerFiles.map(file => this.relativePath(file.path)) + const roots = calculateControllerRoots(relativePaths).sort(nestedFolderSort) return (roots.length > 0) ? roots : [this.controllerRootFallback] } + get registeredControllers(): RegisteredController[] { + if (!this.controllersFile) return [] + + return this.controllersFile.registeredControllers + } + + get referencedNodeModulesLazy() { + return this.projectFiles + .flatMap(file => file.importDeclarations) + .filter(declaration => declaration.isNodeModuleImport) + .map(declaration => declaration.source) + } + + findProjectFile(path: string) { + return this.projectFiles.find(file => file.path == path) + } + + registerReferencedNodeModule(declaration: ImportDeclaration|ExportDeclaration) { + if (!declaration.source) return + + if (declaration instanceof ExportDeclaration && !declaration.isNodeModuleExport) return + if (declaration instanceof ImportDeclaration && !declaration.isNodeModuleImport) return + + this.referencedNodeModules.add(declaration.source) + } + + async initialize() { + await this.searchProjectFiles() + await this.analyze() + } + + async refresh() { + await this.searchProjectFiles() + await this.refreshProjectFiles() + await this.analyze() + } + async analyze() { - this.controllerFiles = [] - this.controllerDefinitions = [] + await this.initializeProjectFiles() + await this.analyzeReferencedModules() + await this.analyzeProjectFiles() + await this.analyzeStimulusApplicationFile() + await this.analyzeStimulusControllersIndexFile() + } - await this.readControllerFiles() + async reset() { + this.projectFiles = [] + this.detectedNodeModules = [] + this.referencedNodeModules = new Set() - this.controllerFiles.forEach((file: ControllerFile) => { - this.controllerDefinitions.push(this.parser.parseController(file.content, file.filename)) + await this.initialize() + } + + async refreshFile(path: string) { + const projectFile = this.findProjectFile(path) + + if (!projectFile) return + + await projectFile.refresh() + } + + + async initializeProjectFiles() { + await Promise.allSettled(this.projectFiles.map(file => file.initialize())) + } + + private async analyzeProjectFiles() { + await Promise.allSettled(this.projectFiles.map(file => file.analyze())) + } + + private async refreshProjectFiles() { + await Promise.allSettled(this.projectFiles.map(file => file.refresh())) + } + + async analyzeReferencedModules() { + const referencesModules = Array.from(this.referencedNodeModules).map(async packageName => { + const nodeModule = ( + this.detectedNodeModules.find(module => module.name === packageName) ||  + await analyzePackage(this, packageName) + ) + + if (nodeModule) { + await nodeModule.initialize() + } }) + + await Promise.allSettled(referencesModules) + await Promise.allSettled(this.detectedNodeModules.map(nodeModule => nodeModule.analyze())) + } + + async detectAvailablePackages() { + await analyzeAll(this) + } + + async analyzeAllDetectedModules() { + await Promise.allSettled(this.detectedNodeModules.map(module => module.initialize())) + await Promise.allSettled(this.detectedNodeModules.map(module => module.analyze())) } - private controllerRootForPath(path: string) { - const relativePath = this.relativePath(path) - const relativeRoots = this.controllerRoots.map(root => this.relativePath(root)) + async analyzeStimulusApplicationFile() { + let applicationFile = this.projectFiles.find(file => !!file.stimulusApplicationImport) - return relativeRoots.find(root => relativePath.startsWith(root)) || this.controllerRootFallback + if (applicationFile) { + this.applicationFile = new ApplicationFile(this, applicationFile) + } else { + // TODO: we probably want to add an error to the project + } } - private async readControllerFiles() { - const endings = `${Project.javascriptEndings.join(",")},${Project.typescriptEndings.join(",")}` + async analyzeStimulusControllersIndexFile() { + let controllersFile = this.projectFiles.find(file => file.isStimulusControllersIndex) - const controllerFiles = await glob(`${this.projectPath}/**/*_controller.{${endings}}`, { - ignore: `${this.projectPath}/node_modules/**/*`, + if (controllersFile) { + this.controllersFile = new ControllersIndexFile(this, controllersFile) + + await this.controllersFile.analyze() + } else { + // TODO: we probably want to add an error to the project + } + } + + private async searchProjectFiles() { + const paths = await this.getProjectFilePaths() + + paths.forEach(path => { + const file = this.findProjectFile(path) + + if (!file) { + this.projectFiles.push(new SourceFile(this, path)) + } }) + } - await Promise.allSettled( - controllerFiles.map(async (filename: string) => { - const content = await fs.readFile(filename, "utf8") + private async getProjectFilePaths(): Promise { + return await glob(`${this.projectPath}/**/*.{${this.extensionsGlob}}`, { + ignore: `${this.projectPath}/**/node_modules/**/*`, + }) + } - this.controllerFiles.push({ filename, content }) - }) - ) + get extensionsGlob() { + return Project.javascriptExtensions.concat(Project.typescriptExtensions).join(",") } } diff --git a/src/registered_controller.ts b/src/registered_controller.ts new file mode 100644 index 0000000..a23349c --- /dev/null +++ b/src/registered_controller.ts @@ -0,0 +1,37 @@ +import { ControllerDefinition} from "./controller_definition" + +import type { ControllerLoadMode } from "./types" + +export class RegisteredController { + public readonly controllerDefinition: ControllerDefinition + public readonly identifier: string + public readonly loadMode: ControllerLoadMode + + constructor(identifier: string, controllerDefinition: ControllerDefinition, loadMode: ControllerLoadMode){ + this.identifier = identifier + this.controllerDefinition = controllerDefinition + this.loadMode = loadMode + } + + get path() { + return this.sourceFile.path + } + + get sourceFile() { + return this.controllerDefinition.sourceFile + } + + get classDeclaration() { + return this.controllerDefinition.classDeclaration + } + + get isNamespaced(): boolean { + return this.identifier.includes("--") || false + } + + get namespace() { + const splits = this.identifier.split("--") + + return splits.slice(0, splits.length - 1).join("--") + } +} diff --git a/src/source_file.ts b/src/source_file.ts new file mode 100644 index 0000000..2f292d4 --- /dev/null +++ b/src/source_file.ts @@ -0,0 +1,379 @@ +import path from "path" + +import * as ast from "./util/ast" +import * as properties from "./util/properties" +import * as fs from "./util/fs" + +import { ParseError } from "./parse_error" +import { Project } from "./project" +import { ControllerDefinition } from "./controller_definition" +import { ClassDeclaration } from "./class_declaration" +import { ImportDeclaration } from "./import_declaration" +import { ExportDeclaration } from "./export_declaration" + +import { walk } from "./util/walk" +import { helperPackages } from "./packages" + +import type * as Acorn from "acorn" +import type { AST } from "@typescript-eslint/typescript-estree" +import type { ParserOptions } from "./types" +import type { ImportDeclarationType } from "./import_declaration" + +export class SourceFile { + public hasSyntaxError: boolean = false + public isAnalyzed: boolean = false + public content?: string + readonly path: string + readonly project: Project + + public ast?: AST + + public errors: ParseError[] = [] + public importDeclarations: ImportDeclaration[] = [] + public exportDeclarations: ExportDeclaration[] = [] + public classDeclarations: ClassDeclaration[] = [] + + get controllerDefinitions(): ControllerDefinition[] { + return this + .classDeclarations + .map(classDeclaration => classDeclaration.controllerDefinition) + .filter(controllerDefinition => controllerDefinition) as ControllerDefinition[] + } + + get exportedControllerDefinitions(): ControllerDefinition[] { + return this.controllerDefinitions.filter(controllerDefinition => controllerDefinition.classDeclaration.isExported) + } + + get defaultExportControllerDefinition(): ControllerDefinition | undefined { + return this.defaultExport?.exportedClassDeclaration?.controllerDefinition + } + + get hasErrors() { + return this.errors.length > 0 + } + + get hasContent() { + return this.content !== undefined + } + + get isParsed() { + return !!this.ast + } + + get isProjectFile() { + return this.project.projectFiles.includes(this) + } + + get fileExtension() { + return path.extname(this.path) + } + + get defaultExport() { + return this.exportDeclarations.find(declaration => declaration.type === "default") + } + + get resolvedClassDeclarations(): ClassDeclaration[] { + return this.exportDeclarations + .flatMap(declaration => declaration.resolvedClassDeclaration) + .filter(dec => dec !== undefined) as ClassDeclaration[] + } + + get resolvedControllerDefinitions() { + return this.resolvedClassDeclarations.filter(klass => klass.controllerDefinition) + } + + get stimulusApplicationImport() { + return this.importDeclarations.find(declaration => + declaration.source === "@hotwired/stimulus" && declaration.originalName === "Application" + ) + } + + get hasHelperPackage() { + return this.importDeclarations.some(declaration => helperPackages.includes(declaration.source)) + } + + get hasStimulusApplicationImport() { + return !!this.importDeclarations.find(declaration => this.project.applicationFile?.path == declaration.resolvedRelativePath) + } + + get isStimulusControllersIndex() { + if (this.hasHelperPackage) return true + if (this.hasStimulusApplicationImport) return true + + return false + } + + findClass(className: string) { + return this.classDeclarations.find(klass => klass.className === className) + } + + findImport(localName: string) { + return this.importDeclarations.find(declaration => declaration.localName === localName) + } + + findExport(localName: string) { + return this.exportDeclarations.find(declaration => declaration.localName === localName) + } + + constructor(project: Project, path: string, content?: string) { + this.project = project + this.path = path + this.content = content + } + + async initialize() { + if (!this.hasContent) { + await this.read() + } + + if (!this.isParsed && !this.hasSyntaxError) { + this.parse() + } + + this.analyzeImportsAndExports() + } + + async refresh() { + this.isAnalyzed = false + this.hasSyntaxError = false + + this.content = undefined + this.ast = undefined + + this.errors = [] + this.importDeclarations = [] + this.exportDeclarations = [] + this.classDeclarations = [] + + await this.read() + + this.parse() + this.analyzeImportsAndExports() + this.analyze() + } + + async read() { + this.content = undefined + this.ast = undefined + + try { + this.content = await fs.readFile(this.path) + } catch (error: any) { + this.errors.push(new ParseError("FAIL", "Error reading file", null, error)) + } + } + + parse() { + if (this.isParsed) return + + if (this.content === undefined) { + this.errors.push(new ParseError("FAIL", "File content hasn't been read yet")) + return + } + + this.ast = undefined + this.hasSyntaxError = false + + try { + this.ast = this.project.parser.parse(this.content, this.path) + } catch(error: any) { + this.hasSyntaxError = true + this.errors.push(new ParseError("FAIL", `Error parsing controller: ${error.message}`, null, error)) + } + } + + analyze() { + if (!this.isParsed) return + if (this.isAnalyzed) return + + try { + this.analyzeClassDeclarations() + this.analyzeClassExports() + this.analyzeControllers() + + this.isAnalyzed = true + } catch(error: any) { + this.errors.push(new ParseError("FAIL", `Error while analyzing file: ${error.message}`, null, error)) + } + } + + analyzeImportsAndExports() { + this.analyzeImportDeclarations() + this.analyzeExportDeclarations() + } + + analyzeControllers() { + this.classDeclarations.forEach((classDeclaration) => classDeclaration.analyze()) + } + + analyzeImportDeclarations() { + if (!this.ast) return + + walk(this.ast, { + ImportDeclaration: node => { + node.specifiers.forEach(specifier => { + const original = (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier") ? specifier.imported.name : undefined + const originalName = (original === "default") ? undefined : original + const localName = specifier.local.name + const source = ast.extractLiteral(node.source) as string + const isStimulusImport = (originalName === "Controller" && source === "@hotwired/stimulus") + + let type: ImportDeclarationType = "default" + + if (specifier.type === "ImportSpecifier") type = "named" + if (specifier.type === "ImportDefaultSpecifier") type = "default" + if (specifier.type === "ImportNamespaceSpecifier") type = "namespace" + if (original === "default") type = "default" + + const declaration = new ImportDeclaration(this, { originalName, localName, source, isStimulusImport, node, type }) + + this.importDeclarations.push(declaration) + this.project.registerReferencedNodeModule(declaration) + }) + }, + }) + } + + analyzeExportDeclarations() { + if (!this.ast) return + + walk(this.ast, { + ExportNamedDeclaration: node => { + const { specifiers, declaration } = node + const type = "named" + + specifiers.forEach(specifier => { + const exportedName = ast.extractIdentifier(specifier.exported) + const localName = ast.extractIdentifier(specifier.local) + const source = ast.extractLiteralAsString(node.source) + let exportDeclaration + + if (exportedName === "default") { + exportDeclaration = new ExportDeclaration(this, { localName: (localName === "default" ? undefined : localName), source, type: "default", node }) + } else if (localName === "default") { + exportDeclaration = new ExportDeclaration(this, { exportedName: (exportedName === "default" ? undefined : exportedName), source, type: exportedName === "default" ? "default" : "named", node }) + } else { + exportDeclaration = new ExportDeclaration(this, { exportedName, localName, source, type, node }) + } + + this.exportDeclarations.push(exportDeclaration) + this.project.registerReferencedNodeModule(exportDeclaration) + }) + + if (!declaration) return + + if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") { + const exportedName = declaration.id.name + const localName = declaration.id.name + const exportDeclaration = new ExportDeclaration(this, { exportedName, localName, type, node }) + + this.exportDeclarations.push(exportDeclaration) + } + + if (declaration.type === "VariableDeclaration") { + declaration.declarations.forEach(declaration => { + const exportedName = ast.extractIdentifier(declaration.id) + const localName = ast.extractIdentifier(declaration.id) + const exportDeclaration = new ExportDeclaration(this, { exportedName, localName, type, node }) + + this.exportDeclarations.push(exportDeclaration) + }) + } + }, + + ExportDefaultDeclaration: node => { + const type = "default" + const name = ast.extractIdentifier(node.declaration) + const nameFromId = ast.extractIdentifier((node.declaration as Acorn.ClassDeclaration | Acorn.FunctionDeclaration).id) + const nameFromAssignment = ast.extractIdentifier((node.declaration as Acorn.AssignmentExpression).left) + + const localName = name || nameFromId || nameFromAssignment + const exportDeclaration = new ExportDeclaration(this, { localName, type, node }) + + this.exportDeclarations.push(exportDeclaration) + }, + + ExportAllDeclaration: node => { + const type = "namespace" + const exportedName = ast.extractIdentifier(node.exported) + const source = ast.extractLiteralAsString(node.source) + + const exportDeclaration = new ExportDeclaration(this, { exportedName, source, type, node }) + + this.exportDeclarations.push(exportDeclaration) + this.project.registerReferencedNodeModule(exportDeclaration) + }, + + }) + } + + analyzeClassDeclarations() { + walk(this.ast, { + ClassDeclaration: node => { + const className = ast.extractIdentifier(node.id) + const classDeclaration = new ClassDeclaration(this, className, node) + + this.classDeclarations.push(classDeclaration) + }, + + VariableDeclaration: node => { + node.declarations.forEach(declaration => { + if (declaration.type !== "VariableDeclarator") return + if (declaration.id.type !== "Identifier") return + if (!declaration.init || declaration.init.type !== "ClassExpression") return + + const className = ast.extractIdentifier(declaration.id) + const classDeclaration = new ClassDeclaration(this, className, declaration.init) + + this.classDeclarations.push(classDeclaration) + }) + } + }) + } + + // this function is called from the ClassDeclaration class + analyzeStaticPropertiesExpressions(controllerDefinition: ControllerDefinition) { + walk(this.ast, { + AssignmentExpression: expression => { + if (expression.left.type !== "MemberExpression") return + + const object = expression.left.object + const property = expression.left.property + + if (property.type !== "Identifier") return + if (object.type !== "Identifier") return + if (object.name !== controllerDefinition.classDeclaration.className) return + + properties.parseStaticControllerProperties(controllerDefinition, property, expression.right) + }, + }) + } + + // TODO: check if we still need this + analyzeClassExports() { + this.classDeclarations.forEach(classDeclaration => { + const exportDeclaration = this.exportDeclarations.find(exportDeclaration => exportDeclaration.localName === classDeclaration.className) + + if (exportDeclaration) { + classDeclaration.exportDeclaration = exportDeclaration + } + }) + } + + get inspect() { + return { + path: this.path, + hasContent: !!this.content, + hasErrors: this.hasErrors, + hasSyntaxError: this.hasSyntaxError, + hasAst: !!this.ast, + isParsed: this.isParsed, + isAnalyzed: this.isAnalyzed, + imports: this.importDeclarations.map(i => i.inspect), + exports: this.exportDeclarations.map(e => e.inspect), + classDeclarations: this.classDeclarations.map(c => c.inspect), + controllerDefinitions: this.controllerDefinitions.map(c => c.inspect), + errors: this.errors.map(e => e.inspect), + } + } +} diff --git a/src/types.ts b/src/types.ts index 22fb68b..934bfc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,22 +1,49 @@ -export interface NodeElement { - key: { name: string } - value: PropertyValue - properties: PropertyElement[] - elements: NodeElement[] - type: string +import type * as Acorn from "acorn" + +export type NestedArray = T | NestedArray[] + +export type NestedObject = { + [k: string]: T | NestedObject } -export interface PropertyValue { - name: string - value: PropertyValue - raw: string - properties: PropertyElement[] - elements: NodeElement[] +export type ValueDefinitionValue = string | number | bigint | boolean | object | null | undefined + +export type ValueDefinition = { type: string + default: ValueDefinitionValue } -export interface PropertyElement { - key: { name: string } - value: PropertyValue - properties: PropertyElement[] +export type ValueDefinitionObject = { [key: string]: ValueDefinition } + +export type ParserOptions = { + loc: boolean + range?: boolean + tokens?: boolean + comment?: boolean + sourceType: string + ecmaVersion: string + filePath?: string } + +export type ClassDeclarationNode = Acorn.ClassDeclaration | Acorn.AnonymousClassDeclaration | Acorn.ClassExpression + +export type ApplicationType = + "esbuild" | + "esbuild-rails" | + "vite" | + "vite-rails" | + "vite-ruby" | + "rollup" | + "webpack" | + "webpacker" | + "shakapacker" | + "importmap-rails" + +export type ControllerLoadMode = + "load" | + "register" | + "stimulus-loading-lazy" |  + "stimulus-loading-eager" | + "esbuild-rails" |  + "stimulus-vite-helpers" | + "stimulus-webpack-helpers" diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 1e02fbb..0000000 --- a/src/util.ts +++ /dev/null @@ -1,20 +0,0 @@ -import path from "path" - -export function camelize(string: string) { - return string.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase()) -} - -export function dasherize(value: string) { - return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`) -} - -export function nestedFolderSort(a: string, b: string) { - const aLength = a.split("/").length - const bLength = b.split("/").length - - if (aLength == bLength) { - return a.localeCompare(b) - } else { - return (aLength > bLength) ? 1 : -1 - } -} diff --git a/src/util/ast.ts b/src/util/ast.ts new file mode 100644 index 0000000..55b2536 --- /dev/null +++ b/src/util/ast.ts @@ -0,0 +1,130 @@ +import { ValueDefinition } from "../controller_property_definition" + +import type * as Acorn from "acorn" +import type { NestedObject, ValueDefinitionValue, ValueDefinition as ValueDefinitionType } from "../types" + +export function findPropertyInProperties(_properties: (Acorn.Property | Acorn.SpreadElement)[], propertyName: string): Acorn.Property | undefined { + const properties = _properties.filter(property => property.type === "Property") as Acorn.Property[] + + return properties.find(property => + ((property.key.type === "Identifier") ? property.key.name : undefined) === propertyName + ) +} + +export function convertArrayExpression(value: Acorn.ArrayExpression): Array { + return value.elements.map(node => { + if (!node) return + + switch (node.type) { + case "ArrayExpression": return convertArrayExpression(node) + case "Literal": return node.value?.toString() + case "SpreadElement": return // TODO: implement support for spreads + default: return + } + }).filter(value => value !== undefined) as string[] +} + +export function convertObjectExpression(value: Acorn.ObjectExpression): NestedObject { + const properties = value.properties.map(property => { + if (property.type === "SpreadElement") return [] // TODO: implement support for spreads + if (property.key.type !== "Identifier") return [] + + const value = + property.value.type === "ObjectExpression" + ? convertObjectExpression(property.value) + : extractLiteral(property.value) + + return [property.key.name, value] + }).filter(property => property !== undefined) + + return Object.fromEntries(properties) +} + +export function convertObjectExpressionToValueDefinitions(objectExpression: Acorn.ObjectExpression): [string, ValueDefinitionType][] { + const definitions: [string, ValueDefinitionType][] = [] + + objectExpression.properties.map(property => { + if (property.type !== "Property") return + if (property.key.type !== "Identifier") return + + const definition = convertPropertyToValueDefinition(property) + + if (definition) { + definitions.push([property.key.name, definition]) + } + }) + + return definitions +} + +export function convertObjectExpressionToValueDefinition(objectExpression: Acorn.ObjectExpression): ValueDefinitionType | undefined { + const typeProperty = findPropertyInProperties(objectExpression.properties, "type") + const defaultProperty = findPropertyInProperties(objectExpression.properties, "default") + + let type = undefined + + switch (typeProperty?.value?.type) { + case "Identifier": + type = typeProperty.value.name + break; + + case "Literal": + type = typeProperty.value?.toString() + break + } + + if (!type) return + + let defaultValue = getDefaultValueFromNode(defaultProperty?.value) + + return { + type, + default: defaultValue, + } +} + +export function convertPropertyToValueDefinition(property: Acorn.Property): ValueDefinitionType | undefined { + switch (property.value.type) { + case "Identifier": + return { + type: property.value.name, + default: ValueDefinition.defaultValuesForType[property.value.name] + } + case "ObjectExpression": + return convertObjectExpressionToValueDefinition(property.value) + } +} + +export function getDefaultValueFromNode(node?: Acorn.Expression | null) { + if (!node) return + + switch (node.type) { + case "ArrayExpression": + return convertArrayExpression(node) + case "ObjectExpression": + return convertObjectExpression(node) + case "Literal": + return node.value + default: + throw new Error(`node type ${node?.type}`) + } +} + +export function extractIdentifier(node?: Acorn.AnyNode | null): string | undefined { + if (!node) return undefined + if (!(node.type === "Identifier" || node.type === "PrivateIdentifier")) return undefined + + return node.name +} + +type AcornLiteral = string | number | bigint | boolean | RegExp | null | undefined + +export function extractLiteral(node?: Acorn.Expression | null): AcornLiteral { + if (node?.type !== "Literal") return undefined + + return node.value +} + +export function extractLiteralAsString(node?: Acorn.Expression | null): string | undefined { + return extractLiteral(node)?.toString() +} diff --git a/src/util/decorators.ts b/src/util/decorators.ts new file mode 100644 index 0000000..33e8820 --- /dev/null +++ b/src/util/decorators.ts @@ -0,0 +1,94 @@ +import { ValueDefinition, ClassDefinition, TargetDefinition } from "../controller_property_definition" +import { ControllerDefinition } from "../controller_definition" + +import type * as Acorn from "acorn" +import type { TSESTree } from "@typescript-eslint/typescript-estree" +import type { ValueDefinition as ValueDefinitionType, ValueDefinitionValue } from "../types" + +import * as ast from "./ast" + +export function stripDecoratorSuffix(name: string, type: string) { + return name.slice(0, name.indexOf(type)) +} + +export function extractDecorators(node: Acorn.AnyNode): TSESTree.Decorator[] { + if ("decorators" in node && Array.isArray(node.decorators)) { + return node.decorators + } else { + return [] + } +} + +export function parseDecorator(controllerDefinition: ControllerDefinition | undefined, name: string, decorator: TSESTree.Decorator, node: TSESTree.PropertyDefinition): void { + if (!controllerDefinition) return + + const identifierName = (decorator.expression.type === "Identifier") ? decorator.expression.name : undefined + const calleeName = (decorator.expression.type === "CallExpression" && decorator.expression.callee.type === "Identifier") ? decorator.expression.callee.name : undefined + + const decoratorName = identifierName || calleeName + + switch (decoratorName) { + case "Target": + case "Targets": + parseTargetDecorator(controllerDefinition, name, node) + break + + case "Class": + case "Classes": + parseClassDecorator(controllerDefinition, name, node) + break + + case "Value": + parseValueDecorator(controllerDefinition, name, decorator, node) + + break + } +} + +export function parseTargetDecorator(controllerDefinition: ControllerDefinition, name: string, node: TSESTree.PropertyDefinition): void { + controllerDefinition.anyDecorator = true + + const targetDefinition = new TargetDefinition(stripDecoratorSuffix(name, "Target"), node as any, node.loc, "decorator") + + controllerDefinition.addTargetDefinition(targetDefinition) +} + +export function parseClassDecorator(controllerDefinition: ControllerDefinition, name: string, node: TSESTree.PropertyDefinition): void { + controllerDefinition.anyDecorator = true + + const classDefinition = new ClassDefinition(stripDecoratorSuffix(name, "Class"), node as any, node.loc, "decorator") + + controllerDefinition.addClassDefinition(classDefinition) +} + +export function parseValueDecorator(controllerDefinition: ControllerDefinition, name: string, decorator: TSESTree.Decorator, node: TSESTree.PropertyDefinition): void { + controllerDefinition.anyDecorator = true + + const isIdentifier = (decorator.expression.type === "Identifier" && decorator.expression.name !== undefined) + const hasOneArgument = (decorator.expression.type === "CallExpression" && decorator.expression.arguments.length === 1) + + if (isIdentifier || !hasOneArgument) { + // TODO: Support decorator + reflect-metadata value definitions + throw new Error("We dont support reflected types yet") + } + + if (decorator.expression.type !== "CallExpression") return + + const key = stripDecoratorSuffix(name, "Value") + const type = decorator.expression.arguments[0] + + if (type.type !== "Identifier") return + + const defaultValue: ValueDefinitionValue = node.value ? + ast.getDefaultValueFromNode(node.value as unknown as Acorn.Expression) + : ValueDefinition.defaultValuesForType[type.name] + + const definition: ValueDefinitionType = { + type: type.name, + default: defaultValue + } + + const valueDefinition = new ValueDefinition(key, definition, node as any, node.loc, "decorator") + + controllerDefinition.addValueDefinition(valueDefinition) +} diff --git a/src/util/fs.ts b/src/util/fs.ts new file mode 100644 index 0000000..715953e --- /dev/null +++ b/src/util/fs.ts @@ -0,0 +1,35 @@ +import { promises as fs } from "fs" + +export async function resolvePathWhenFileExists(path: string): Promise { + const exists = await folderExists(path) + + return exists ? path : null +} + +export async function readFile(path: string): Promise { + return await fs.readFile(path, "utf8") +} + +export async function fileExists(path: string): Promise { + return folderExists(path) +} + +export async function folderExists(path: string): Promise { + return new Promise(resolve => + fs + .stat(path) + .then(() => resolve(true)) + .catch(() => resolve(false)) + ) +} + +export function nestedFolderSort(a: string, b: string) { + const aLength = a.split("/").length + const bLength = b.split("/").length + + if (aLength == bLength) { + return a.localeCompare(b) + } else { + return (aLength > bLength) ? 1 : -1 + } +} diff --git a/src/util/index.ts b/src/util/index.ts index e3a8b26..91e75d9 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1 +1,9 @@ export * as builder from "./ast_builder" +export * from "./ast" +export * from "./decorators" +export * from "./fs" +export * from "./npm" +export * from "./project" +export * from "./properties" +export * from "./string" +export * from "./walk" diff --git a/src/util/npm.ts b/src/util/npm.ts new file mode 100644 index 0000000..8c4a6e2 --- /dev/null +++ b/src/util/npm.ts @@ -0,0 +1,98 @@ +import path from "path" +import { glob } from "glob" + +import { NodeModule } from "../node_module" + +import { readFile, folderExists } from "../util/fs" + +import type { Project } from "../project" + +export async function findPackagePath(startPath: string, packageName: string): Promise { + const nodeModulesPath = await findNodeModulesPath(startPath) + + if (!nodeModulesPath) return null + + return path.join(nodeModulesPath, packageName) +} + +export async function findNodeModulesPath(startPath: string): Promise { + const findFolder = async (splits: string[]): Promise => { + if (splits.length == 0) return null + + let possiblePath = path.join(...splits, "node_modules") + + if (!possiblePath.startsWith("/")) possiblePath = `/${possiblePath}` + + const exists = await folderExists(possiblePath) + + if (exists) return possiblePath + + return findFolder(splits.slice(0, splits.length - 1)) + } + + return await findFolder(startPath.split("/")) +} + +export function nodeModulesPathFor(nodeModulesPath: string, packageName: string): string { + return path.join(nodeModulesPath, packageName) +} + +export async function hasDepedency(projectPath: string, packageName: string): Promise { + const nodeModulesPath = await findNodeModulesPath(projectPath) + + if (!nodeModulesPath) return false + + const packagePath = nodeModulesPathFor(nodeModulesPath, packageName) + + return await folderExists(packagePath) +} + +export async function parsePackageJSON(path: string) { + const packageJSON = await readFile(path) + return JSON.parse(packageJSON) +} + +export function getTypesForPackageJSON(packageJSON: any): { type: "main"|"module"|"source", entrypoint: string|undefined }[]{ + return [ + { type: "source", entrypoint: packageJSON.source }, + { type: "module", entrypoint: packageJSON.module }, + { type: "main", entrypoint: packageJSON.main } + ] +} + +export function getTypeFromPackageJSON(packageJSON: any) { + return getTypesForPackageJSON(packageJSON).find(({ entrypoint }) => !!entrypoint) || { type: undefined, entrypoint: undefined } +} + +export async function nodeModuleForPackageName(project: Project, name: string): Promise { + const packageJSON = await findPackagePath(project.projectPath, name) + + if (!packageJSON) return + + return nodeModuleForPackageJSONPath(project, path.join(packageJSON, "package.json")) +} + +export async function nodeModuleForPackageJSONPath(project: Project, packageJSONPath: string): Promise { + const packageJSON = await parsePackageJSON(packageJSONPath) + const packageName = packageJSON.name + + const { type, entrypoint } = getTypeFromPackageJSON(packageJSON) + + if (entrypoint && type) { + const rootFolder = path.dirname(packageJSONPath) + const entrypointRoot = path.dirname(entrypoint) + const absoluteEntrypointRoot = path.join(rootFolder, entrypointRoot) + const files = await glob(`${absoluteEntrypointRoot}/**/*.{js,mjs,cjs}`) + + return new NodeModule(project, { + name: packageName, + path: rootFolder, + entrypoint: path.join(rootFolder, entrypoint), + controllerRoots: [absoluteEntrypointRoot], + type, + files, + }) + } + + return +} diff --git a/src/util/project.ts b/src/util/project.ts new file mode 100644 index 0000000..46ba117 --- /dev/null +++ b/src/util/project.ts @@ -0,0 +1,48 @@ +import path from "path" + +import { nestedFolderSort } from "./fs" + +export function calculateControllerRoots(filenames: string[]) { + let controllerRoots: string[] = []; + + filenames = filenames.sort(nestedFolderSort) + + const findClosest = (basename: string) => { + const splits = basename.split("/") + + for (let i = 0; i < splits.length + 1; i++) { + const possbilePath = splits.slice(0, i).join("/") + + if (controllerRoots.includes(possbilePath) && possbilePath !== basename) { + return possbilePath + } + } + } + + filenames.forEach(filename => { + const splits = path.dirname(filename).split("/") + const controllersIndex = splits.indexOf("controllers") + + if (controllersIndex !== -1) { + const controllerRoot = splits.slice(0, controllersIndex + 1).join("/") + + if (!controllerRoots.includes(controllerRoot)) { + controllerRoots.push(controllerRoot) + } + } else { + const controllerRoot = splits.slice(0, splits.length).join("/") + const found = findClosest(controllerRoot) + + if (found) { + const index = controllerRoots.indexOf(controllerRoot) + if (index !== -1) controllerRoots.splice(index, 1) + } else { + if (!controllerRoots.includes(controllerRoot)) { + controllerRoots.push(controllerRoot) + } + } + } + }) + + return controllerRoots.sort(nestedFolderSort) +} diff --git a/src/util/properties.ts b/src/util/properties.ts new file mode 100644 index 0000000..14138f3 --- /dev/null +++ b/src/util/properties.ts @@ -0,0 +1,37 @@ +import { ValueDefinition, ClassDefinition, TargetDefinition } from "../controller_property_definition" +import { ControllerDefinition } from "../controller_definition" + +import type * as Acorn from "acorn" +import * as ast from "./ast" + +export function parseStaticControllerProperties(controllerDefinition: ControllerDefinition | undefined, left: Acorn.Identifier, right: Acorn.Expression): void { + if (!controllerDefinition) return + + if (right.type === "ArrayExpression") { + if (left.name === "targets") { + ast.convertArrayExpression(right).map(element => + controllerDefinition.addTargetDefinition( + new TargetDefinition(element, right, right.loc, "static") + ) + ) + } + + if (left.name === "classes") { + ast.convertArrayExpression(right).map(element => + controllerDefinition.addClassDefinition( + new ClassDefinition(element, right, right.loc, "static") + ) + ) + } + } + + if (right.type === "ObjectExpression" && left.name === "values") { + const definitions = ast.convertObjectExpressionToValueDefinitions(right) + + definitions.forEach(([name, valueDefinition]) => { + controllerDefinition.addValueDefinition( + new ValueDefinition(name, valueDefinition, right, right.loc, "static") + ) + }) + } +} diff --git a/src/util/string.ts b/src/util/string.ts new file mode 100644 index 0000000..08c6e9c --- /dev/null +++ b/src/util/string.ts @@ -0,0 +1,15 @@ +export function camelize(string: string) { + return string.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase()) +} + +export function dasherize(value: string) { + return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`) +} + +export function capitalize(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +export function uncapitalize(value: string) { + return value.charAt(0).toLowerCase() + value.slice(1) +} diff --git a/src/util/walk.ts b/src/util/walk.ts new file mode 100644 index 0000000..26f7aea --- /dev/null +++ b/src/util/walk.ts @@ -0,0 +1,20 @@ +import { base, simple } from "acorn-walk" +import { visitorKeys } from "@typescript-eslint/visitor-keys" + +import type * as Acorn from "acorn" +import type * as Walk from "acorn-walk" + +import type { ParserOptions } from "../types" +import type { AST } from "@typescript-eslint/typescript-estree" +import type { TSESTree } from "@typescript-eslint/typescript-estree" + +const ignoredNodes = Object.keys(visitorKeys).filter(key => key.startsWith("TS")) + +// @ts-ignore +ignoredNodes.forEach(node => base[node] = () => {}) + +type Node = Acorn.Node | TSESTree.Node | AST | undefined + +export function walk(node: Node, visitors: Walk.SimpleVisitors) { + return simple(node as any, visitors, base) +} diff --git a/test/class_declaration/highestAncestor.test.ts b/test/class_declaration/highestAncestor.test.ts new file mode 100644 index 0000000..f7bfa27 --- /dev/null +++ b/test/class_declaration/highestAncestor.test.ts @@ -0,0 +1,141 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("ClassDeclaration", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("highestAncestor", () => { + test("regular class", async () => { + const code = dedent` + class Child {} + ` + + const sourceFile = new SourceFile(project, "something.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(1) + + const klass = sourceFile.classDeclarations[0] + + expect(klass.highestAncestor).toEqual(klass) + expect(klass.superClass).toBeUndefined() + }) + + test("with super class", async () => { + const code = dedent` + class Parent {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "something.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(2) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + + expect(child.superClass).toEqual(parent) + expect(parent.superClass).toEqual(undefined) + + expect(child.highestAncestor).toEqual(parent) + expect(parent.highestAncestor).toEqual(parent) + }) + + test("with two super classes", async () => { + const code = dedent` + class Grandparent {} + class Parent extends Grandparent {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "something.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(3) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + const grandparent = sourceFile.findClass("Grandparent") + + expect(child.superClass).toEqual(parent) + expect(parent.superClass).toEqual(grandparent) + expect(grandparent.superClass).toEqual(undefined) + + expect(child.highestAncestor).toEqual(grandparent) + expect(parent.highestAncestor).toEqual(grandparent) + expect(grandparent.highestAncestor).toEqual(grandparent) + }) + + test("with two super classes and one anonymous class", async () => { + const code = dedent` + class Grandparent {} + class Parent extends Grandparent {} + class Child extends Parent {} + + export default class extends Child {} + ` + + const sourceFile = new SourceFile(project, "something.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(4) + + const exported = sourceFile.findClass(undefined) + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + const grandparent = sourceFile.findClass("Grandparent") + + expect(exported.superClass).toEqual(child) + expect(child.superClass).toEqual(parent) + expect(parent.superClass).toEqual(grandparent) + expect(grandparent.superClass).toBeUndefined() + + expect(exported.highestAncestor).toEqual(grandparent) + expect(child.highestAncestor).toEqual(grandparent) + expect(parent.highestAncestor).toEqual(grandparent) + expect(grandparent.highestAncestor).toEqual(grandparent) + }) + + test("with two classes with shared super class", async () => { + const code = dedent` + class Parent {} + class FirstChild extends Parent {} + class SecondChild extends Parent {} + ` + + const sourceFile = new SourceFile(project, "something.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(3) + + const firstChild = sourceFile.findClass("FirstChild") + const secondChild = sourceFile.findClass("SecondChild") + const parent = sourceFile.findClass("Parent") + + expect(firstChild.superClass).toEqual(parent) + expect(secondChild.superClass).toEqual(parent) + expect(parent.superClass).toBeUndefined() + + expect(firstChild.highestAncestor).toEqual(parent) + expect(secondChild.highestAncestor).toEqual(parent) + expect(parent.highestAncestor).toEqual(parent) + }) + }) +}) diff --git a/test/class_declaration/isStimulusDescendant.test.ts b/test/class_declaration/isStimulusDescendant.test.ts new file mode 100644 index 0000000..c7d5f65 --- /dev/null +++ b/test/class_declaration/isStimulusDescendant.test.ts @@ -0,0 +1,113 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("ClassDeclaration", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("isStimulusDescendant", () => { + test("regular class", async () => { + const code = dedent` + class Child {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(1) + + const klass = sourceFile.classDeclarations[0] + + expect(klass.isStimulusDescendant).toEqual(false) + }) + + test("with super class", async () => { + const code = dedent` + class Parent {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(2) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + + expect(child.isStimulusDescendant).toEqual(false) + expect(parent.isStimulusDescendant).toEqual(false) + }) + + test("with Stimulus Controller super class", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + class Child extends Controller {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(1) + + const child = sourceFile.findClass("Child") + + expect(child.isStimulusDescendant).toEqual(true) + }) + + test("with Stimulus Controller super class via second class", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Parent extends Controller {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(2) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + + expect(child.isStimulusDescendant).toEqual(true) + expect(parent.isStimulusDescendant).toEqual(true) + }) + + test("with super class called Controller", async () => { + const code = dedent` + import { Controller } from "something-else" + + class Parent extends Controller {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(2) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + + expect(child.isStimulusDescendant).toEqual(false) + expect(parent.isStimulusDescendant).toEqual(false) + }) + }) +}) diff --git a/test/class_declaration/methods.test.ts b/test/class_declaration/methods.test.ts new file mode 100644 index 0000000..2f66dc4 --- /dev/null +++ b/test/class_declaration/methods.test.ts @@ -0,0 +1,89 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("ClassDeclaration", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("non stimulus classes", () => { + test("regular class", async () => { + const code = dedent` + class Something { + connect() {} + method() {} + disconnect() {} + } + ` + + const sourceFile = new SourceFile(project, "something.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(1) + expect(sourceFile.classDeclarations[0].className).toEqual("Something") + expect(sourceFile.classDeclarations[0].isStimulusClassDeclaration).toEqual(false) + expect(sourceFile.controllerDefinitions).toEqual([]) + expect(sourceFile.errors).toHaveLength(0) + }) + + test("imports controller from somewhere", async () => { + const code = dedent` + import { Controller } from "somewhere" + + class Something extends Controller { + connect() {} + method() {} + disconnect() {} + } + ` + + const sourceFile = new SourceFile(project, "something.js", code) + + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeUndefined() + expect(something.superClassName).toEqual("Controller") + expect(sourceFile.errors).toHaveLength(0) + expect(sourceFile.controllerDefinitions).toEqual([]) + }) + }) + + describe("extends Stimulus Controller class", () => { + test("imports and extends controller from Stimulus", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Something extends Controller { + connect() {} + method() {} + disconnect() {} + } + ` + + const sourceFile = new SourceFile(project, "something.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.controllerDefinitions[0].actionNames).toEqual(["connect", "method", "disconnect"]) + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.isStimulusClassDeclaration).toBeTruthy() + expect(sourceFile.errors).toHaveLength(0) + }) + }) +}) diff --git a/test/class_declaration/superClass.test.ts b/test/class_declaration/superClass.test.ts new file mode 100644 index 0000000..964c51a --- /dev/null +++ b/test/class_declaration/superClass.test.ts @@ -0,0 +1,320 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile, ClassDeclaration, StimulusControllerClassDeclaration } from "../../src" + +let project = new Project(process.cwd()) + +describe("ClassDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("superClass", () => { + test("regular class", async () => { + const code = dedent` + class Child {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(1) + + const klass = sourceFile.classDeclarations[0] + + expect(klass.superClass).toBeUndefined() + }) + + test("with super class", async () => { + const code = dedent` + class Parent {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(2) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + + expect(child.superClass).toEqual(parent) + expect(child.superClass).toBeInstanceOf(ClassDeclaration) + expect(child.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + + expect(parent.superClass).toBeUndefined() + }) + + test("with Stimulus Controller super class", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Child extends Controller {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(1) + + const child = sourceFile.findClass("Child") + + expect(child.superClass).toBeDefined() + expect(child.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("with Stimulus Controller super class via second class", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Parent extends Controller {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(2) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + + expect(child.superClass).toEqual(parent) + expect(child.superClass).toBeInstanceOf(ClassDeclaration) + expect(child.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + + expect(parent.superClass).toBeDefined() + expect(parent.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("with super class called Controller", async () => { + const code = dedent` + import { Controller } from "something-else" + + class Parent extends Controller {} + class Child extends Parent {} + ` + + const sourceFile = new SourceFile(project, "child.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.classDeclarations.length).toEqual(2) + + const child = sourceFile.findClass("Child") + const parent = sourceFile.findClass("Parent") + + expect(child.superClass).toEqual(parent) + expect(child.superClass).toBeInstanceOf(ClassDeclaration) + expect(child.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + + expect(parent).toBeDefined() + expect(parent.className).toEqual("Parent") + expect(parent.superClass).toBeUndefined() + }) + + test("with super class name imported from other file", async () => { + const parentCode = dedent` + import { Controller } from "@hotwired/stimulus" + + export class ParentController extends Controller {} + ` + const childCode = dedent` + import { ParentController } from "./parent_controller" + + export class ChildController extends ParentController {} + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(childFile.classDeclarations.length).toEqual(1) + + const child = childFile.findClass("ChildController") + const parent = parentFile.findClass("ParentController") + + expect(child.className).toEqual("ChildController") + expect(child.superClass).toBeDefined() + expect(child.superClass.className).toEqual("ParentController") + expect(child.superClass).toEqual(parent) + expect(child.superClass).toBeInstanceOf(ClassDeclaration) + expect(child.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + + expect(parent.className).toEqual("ParentController") + expect(parent.superClass).toBeDefined() + expect(parent.superClass.className).toEqual("Controller") + expect(parent.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("with super class name imported from other file independent of file order", async () => { + const parentCode = dedent` + import { Controller } from "@hotwired/stimulus" + + export class ParentController extends Controller {} + ` + const childCode = dedent` + import { ParentController } from "./parent_controller" + + export class ChildController extends ParentController {} + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(childFile.classDeclarations.length).toEqual(1) + + const child = childFile.findClass("ChildController") + const parent = parentFile.findClass("ParentController") + + expect(child.className).toEqual("ChildController") + expect(child.superClass).toBeDefined() + expect(child.superClass.className).toEqual("ParentController") + expect(child.superClass).toEqual(parent) + expect(child.superClass).toBeInstanceOf(ClassDeclaration) + expect(child.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + + expect(parent.className).toEqual("ParentController") + expect(parent.superClass).toBeDefined() + expect(parent.superClass.className).toEqual("Controller") + expect(parent.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("with super class default imported from other file", async () => { + const parentCode = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller {} + ` + const childCode = dedent` + import ParentController from "./parent_controller" + + export class ChildController extends ParentController {} + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(childFile.classDeclarations.length).toEqual(1) + + const child = childFile.findClass("ChildController") + const parent = parentFile.exportDeclarations.find(exportDecl => exportDecl.type === "default").exportedClassDeclaration + + expect(child.className).toEqual("ChildController") + expect(child.superClass).toBeDefined() + expect(child.superClass.className).toEqual(undefined) + expect(child.superClass).toEqual(parent) + expect(child.superClass).toBeInstanceOf(ClassDeclaration) + expect(child.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + + expect(parent.className).toEqual(undefined) + expect(parent.superClass).toBeDefined() + expect(parent.superClass.className).toEqual("Controller") + expect(parent.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("adds errors when it cannot resolve named import", async () => { + const parentCode = dedent` + import { Controller } from "@hotwired/stimulus" + + export class ParentControllerOops extends Controller {} + ` + const childCode = dedent` + import { ParentController } from "./parent_controller" + + export class ChildController extends ParentController {} + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(childFile.classDeclarations.length).toEqual(1) + + const child = childFile.findClass("ChildController") + const parent = parentFile.findClass("ParentControllerOops") + + expect(child.className).toEqual("ChildController") + expect(child.superClass).toBeUndefined() + + expect(parent).toBeDefined() + expect(parentFile.errors.length).toEqual(0) + expect(parent.className).toEqual("ParentControllerOops") + expect(parent.superClass).toBeDefined() + expect(parent.superClass.className).toEqual("Controller") + expect(parent.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("adds errors when it cannot resolve import", async () => { + const parentCode = dedent` + import { Controller } from "@hotwired/stimulus" + + export class ParentController extends Controller {} + ` + const childCode = dedent` + import { ParentControllerOops } from "./parent_controller_oops" + + export class ChildController extends ParentController {} + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(childFile.classDeclarations.length).toEqual(1) + + const child = childFile.findClass("ChildController") + const parent = parentFile.findClass("ParentController") + + expect(child.className).toEqual("ChildController") + expect(child.superClass).toBeUndefined() + + expect(parent).toBeDefined() + expect(parent.className).toEqual("ParentController") + expect(parent.superClass).toBeDefined() + expect(parent.superClass.className).toEqual("Controller") + expect(parent.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + }) +}) diff --git a/test/controller_definition.test.ts b/test/controller_definition.test.ts deleted file mode 100644 index e812f4d..0000000 --- a/test/controller_definition.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { expect, test } from "vitest" -import { Project, ControllerDefinition } from "../src" - -const project = new Project(process.cwd()) - -test("absolute path", () => { - const controller = new ControllerDefinition( - project, - `${process.cwd()}/app/javascript/controllers/some_controller.js` - ) - - expect(controller.identifier).toEqual("some") - expect(controller.controllerPath).toEqual("some_controller.js") -}) - -test("relative project path", () => { - const controller = new ControllerDefinition(project, "app/javascript/controllers/some_controller.js") - - expect(controller.identifier).toEqual("some") - expect(controller.controllerPath).toEqual("some_controller.js") -}) - -test("relative controller path", () => { - const controller = new ControllerDefinition(project, "some_controller.js") - - expect(controller.identifier).toEqual("some") - expect(controller.controllerPath).toEqual("some_controller.js") -}) - -test("isNamespaced", () => { - const controller1 = new ControllerDefinition(project, "some_controller.js") - const controller2 = new ControllerDefinition(project, "some_underscored_controller.js") - const controller3 = new ControllerDefinition(project, "namespaced/some_controller.js") - const controller4 = new ControllerDefinition(project, "nested/namespaced/some_controller.js") - - expect(controller1.isNamespaced).toBeFalsy() - expect(controller2.isNamespaced).toBeFalsy() - expect(controller3.isNamespaced).toBeTruthy() - expect(controller4.isNamespaced).toBeTruthy() -}) - -test("namespace", () => { - const controller1 = new ControllerDefinition(project, "some_controller.js") - const controller2 = new ControllerDefinition(project, "some_underscored_controller.js") - const controller3 = new ControllerDefinition(project, "namespaced/some_controller.js") - const controller4 = new ControllerDefinition(project, "nested/namespaced/some_controller.js") - - expect(controller1.namespace).toEqual("") - expect(controller2.namespace).toEqual("") - expect(controller3.namespace).toEqual("namespaced") - expect(controller4.namespace).toEqual("nested--namespaced") -}) - -test("controllerPathForIdentifier", () => { - expect(ControllerDefinition.controllerPathForIdentifier("some")).toEqual("some_controller.js") - expect(ControllerDefinition.controllerPathForIdentifier("some-dasherized")).toEqual("some_dasherized_controller.js") - expect(ControllerDefinition.controllerPathForIdentifier("some_underscored")).toEqual("some_underscored_controller.js") - expect(ControllerDefinition.controllerPathForIdentifier("namespaced--some")).toEqual("namespaced/some_controller.js") - expect(ControllerDefinition.controllerPathForIdentifier("nested--namespaced--some")).toEqual( - "nested/namespaced/some_controller.js" - ) -}) - -test("controllerPathForIdentifier with fileending", () => { - expect(ControllerDefinition.controllerPathForIdentifier("some", "mjs")).toEqual("some_controller.mjs") - expect(ControllerDefinition.controllerPathForIdentifier("namespaced--some", "ts")).toEqual("namespaced/some_controller.ts") -}) - -test("type", () => { - expect(new ControllerDefinition(project, "some_controller.js").type).toEqual("javascript") - expect(new ControllerDefinition(project, "some_underscored.mjs").type).toEqual("javascript") - expect(new ControllerDefinition(project, "some_underscored.cjs").type).toEqual("javascript") - expect(new ControllerDefinition(project, "some_underscored.jsx").type).toEqual("javascript") - expect(new ControllerDefinition(project, "some_underscored.ts").type).toEqual("typescript") - expect(new ControllerDefinition(project, "some_underscored.mts").type).toEqual("typescript") - expect(new ControllerDefinition(project, "some_underscored.tsx").type).toEqual("typescript") -}) diff --git a/test/controller_definition/controller_definition.test.ts b/test/controller_definition/controller_definition.test.ts new file mode 100644 index 0000000..8e61cc3 --- /dev/null +++ b/test/controller_definition/controller_definition.test.ts @@ -0,0 +1,74 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { ControllerDefinition } from "../../src" +import { setupProject, controllerDefinitionFor } from "../helpers/setup" + +let project = setupProject("app") + +describe("ControllerDefinition", () => { + beforeEach(async () => { + project = setupProject("app") + }) + + test("relative project path", async () => { + const controller = await controllerDefinitionFor(project, "app/javascript/controllers/some_controller.js") + + expect(controller.guessedIdentifier).toEqual("some") + expect(controller.controllerPath).toEqual("some_controller.js") + }) + + test("relative controller path", async () => { + const controller = await controllerDefinitionFor(project, "some_controller.js") + + expect(controller.guessedIdentifier).toEqual("some") + expect(controller.controllerPath).toEqual("some_controller.js") + }) + + test("isNamespaced", async () => { + const controller1 = await controllerDefinitionFor(project, "app/javascript/controllers/some_controller.js") + const controller2 = await controllerDefinitionFor(project, "app/javascript/controllers/some_underscored_controller.js") + const controller3 = await controllerDefinitionFor(project, "app/javascript/controllers/namespaced/some_controller.js") + const controller4 = await controllerDefinitionFor(project, "app/javascript/controllers/nested/namespaced/some_controller.js") + + expect(controller1.isNamespaced).toBeFalsy() + expect(controller2.isNamespaced).toBeFalsy() + expect(controller3.isNamespaced).toBeTruthy() + expect(controller4.isNamespaced).toBeTruthy() + }) + + test("namespace", async () => { + const controller1 = await controllerDefinitionFor(project, "app/javascript/controllers/some_controller.js") + const controller2 = await controllerDefinitionFor(project, "app/javascript/controllers/some_underscored_controller.js") + const controller3 = await controllerDefinitionFor(project, "app/javascript/controllers/namespaced/some_controller.js") + const controller4 = await controllerDefinitionFor(project, "app/javascript/controllers/nested/namespaced/some_controller.js") + + expect(controller1.namespace).toEqual("") + expect(controller2.namespace).toEqual("") + expect(controller3.namespace).toEqual("namespaced") + expect(controller4.namespace).toEqual("nested--namespaced") + }) + + test("controllerPathForIdentifier", async () => { + expect(ControllerDefinition.controllerPathForIdentifier("some")).toEqual("some_controller.js") + expect(ControllerDefinition.controllerPathForIdentifier("some-dasherized")).toEqual("some_dasherized_controller.js") + expect(ControllerDefinition.controllerPathForIdentifier("some_underscored")).toEqual("some_underscored_controller.js") + expect(ControllerDefinition.controllerPathForIdentifier("namespaced--some")).toEqual("namespaced/some_controller.js") + expect(ControllerDefinition.controllerPathForIdentifier("nested--namespaced--some")).toEqual( + "nested/namespaced/some_controller.js" + ) + }) + + test("controllerPathForIdentifier with fileExtension", async () => { + expect(ControllerDefinition.controllerPathForIdentifier("some", "mjs")).toEqual("some_controller.mjs") + expect(ControllerDefinition.controllerPathForIdentifier("namespaced--some", "ts")).toEqual("namespaced/some_controller.ts") + }) + + test("type", async () => { + expect((await controllerDefinitionFor(project, "some_controller.js", null)).type).toEqual("javascript") + expect((await controllerDefinitionFor(project, "some_underscored.mjs", null)).type).toEqual("javascript") + expect((await controllerDefinitionFor(project, "some_underscored.cjs", null)).type).toEqual("javascript") + expect((await controllerDefinitionFor(project, "some_underscored.jsx", null)).type).toEqual("javascript") + expect((await controllerDefinitionFor(project, "some_underscored.ts", null)).type).toEqual("typescript") + expect((await controllerDefinitionFor(project, "some_underscored.mts", null)).type).toEqual("typescript") + expect((await controllerDefinitionFor(project, "some_underscored.tsx", null)).type).toEqual("typescript") + }) +}) diff --git a/test/controller_definition/exported_controller_definitions.test.ts b/test/controller_definition/exported_controller_definitions.test.ts new file mode 100644 index 0000000..d8f2753 --- /dev/null +++ b/test/controller_definition/exported_controller_definitions.test.ts @@ -0,0 +1,50 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" + +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject("empty") + +describe("exported controller definitions", () => { + beforeEach(() => { + project = setupProject("empty") + }) + + test("only exports exported controllers", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Parent extends Controller {} + export default class extends Parent {} + ` + + + const sourceFile = new SourceFile(project, "export_controller.js", code) + project.projectFiles.push(sourceFile) + + await project.initialize() + + expect(sourceFile.controllerDefinitions).toHaveLength(2) + + const parent = sourceFile.controllerDefinitions[0] + const child = sourceFile.controllerDefinitions[1] + + expect(parent).toBeDefined() + expect(child).toBeDefined() + + expect(project.registeredControllers).toHaveLength(0) + expect(project.controllerDefinitions).toHaveLength(1) + expect(project.allControllerDefinitions).toHaveLength(2) + expect(project.allProjectControllerDefinitions).toHaveLength(2) + + expect(project.controllerDefinitions).not.toContain(parent) + expect(project.controllerDefinitions).toContain(child) + + expect(project.allControllerDefinitions).toContain(parent) + expect(project.allControllerDefinitions).toContain(child) + + expect(project.allProjectControllerDefinitions).toContain(parent) + expect(project.allProjectControllerDefinitions).toContain(child) + }) +}) diff --git a/test/controller_definition/identifiers.test.ts b/test/controller_definition/identifiers.test.ts new file mode 100644 index 0000000..e50b42c --- /dev/null +++ b/test/controller_definition/identifiers.test.ts @@ -0,0 +1,115 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { ControllerDefinition } from "../../src" +import { setupProject, classDeclarationFor } from "../helpers/setup" + +let project = setupProject() + +describe("ControllerDefinition", () => { + beforeEach(() => { + project = project = setupProject() + }) + + describe("guessedIdentifier", () => { + test("top-level", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some_controller.js")) + + expect(controller.guessedIdentifier).toEqual("some") + }) + + test("top-level underscored", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some_underscored_controller.js")) + + expect(controller.guessedIdentifier).toEqual("some-underscored") + }) + + test("top-level dasherized", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some-underscored_controller.js")) + + expect(controller.guessedIdentifier).toEqual("some-underscored") + }) + + test("namespaced", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/namespaced/some_controller.js")) + + expect(controller.guessedIdentifier).toEqual("namespaced--some") + }) + + test("deeply nested", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/some_controller.js")) + + expect(controller.guessedIdentifier).toEqual("a--bunch--of--levels--some") + }) + + test("deeply nested underscored", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/some_underscored_controller.js")) + + expect(controller.guessedIdentifier).toEqual("a--bunch--of--levels--some-underscored") + }) + + test("deeply nested dasherized", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/some-underscored_controller.js")) + + expect(controller.guessedIdentifier).toEqual("a--bunch--of--levels--some-underscored") + }) + + test("deeply nested all dasherized", async () => { + const controller = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/some-underscored-controller.js")) + + expect(controller.guessedIdentifier).toEqual("a--bunch--of--levels--some-underscored") + }) + + // TODO: update implementation once this gets released + // https://github.com/hotwired/stimulus-webpack-helpers/pull/3 + test("nested with only controller", async () => { + const controller1 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/controller.js")) + const controller2 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/controller.ts")) + + expect(controller1.guessedIdentifier).toEqual("a--bunch--of--levels") + expect(controller2.guessedIdentifier).toEqual("a--bunch--of--levels") + }) + + test("without controller suffix", async () => { + const controller1 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/something.js")) + const controller2 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/something.ts")) + + expect(controller1.guessedIdentifier).toEqual("something") + expect(controller2.guessedIdentifier).toEqual("something") + }) + + test("nested without controller suffix", async () => { + const controller1 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/something.js")) + const controller2 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch/of/levels/something.ts")) + + expect(controller1.guessedIdentifier).toEqual("a--bunch--of--levels--something") + expect(controller2.guessedIdentifier).toEqual("a--bunch--of--levels--something") + }) + + test("controller with dashes and underscores", async () => { + const controller1 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some-thing_controller.js")) + const controller2 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some-thing_controller.ts")) + const controller3 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some_thing-controller.js")) + const controller4 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some_thing-controller.ts")) + + expect(controller1.guessedIdentifier).toEqual("some-thing") + expect(controller2.guessedIdentifier).toEqual("some-thing") + expect(controller3.guessedIdentifier).toEqual("some-thing") + expect(controller4.guessedIdentifier).toEqual("some-thing") + }) + + test("controller with dasherized name", async () => { + const controller1 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some-thing-controller.js")) + const controller2 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/some-thing-controller.ts")) + + expect(controller1.guessedIdentifier).toEqual("some-thing") + expect(controller2.guessedIdentifier).toEqual("some-thing") + }) + + test("nested controller with dasherized name", async () => { + const controller1 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch-of/levels/some-thing-controller.js")) + const controller2 = new ControllerDefinition(project, await classDeclarationFor(project, "app/javascript/controllers/a/bunch-of/levels/some-thing-controller.ts")) + + expect(controller1.guessedIdentifier).toEqual("a--bunch-of--levels--some-thing") + expect(controller2.guessedIdentifier).toEqual("a--bunch-of--levels--some-thing") + }) + }) +}) diff --git a/test/controller_definition/inheritance.test.ts b/test/controller_definition/inheritance.test.ts new file mode 100644 index 0000000..d70c384 --- /dev/null +++ b/test/controller_definition/inheritance.test.ts @@ -0,0 +1,164 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" + +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject("app") + +const parentCode = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static targets = ["parentTarget1", "parentTarget2"] + static classes = ["parentClass1", "parentClass2"] + static values = { + parentValue1: Boolean, + parentValue2: { + type: String, + default: "parent2" + } + } + + parentAction1() {} + parentAction2() {} + } +` + +const childCode = dedent` + import ParentController from "./parent_controller" + + export default class extends ParentController { + static targets = ["childTarget1", "childTarget2"] + static classes = ["childClass1", "childClass2"] + static values = { + childValue1: Array, + childValue2: { + type: Object, + default: { value: "child2" } + } + } + + childAction1() {} + childAction2() {} + } +` + +describe("inheritance", () => { + beforeEach(() => { + project = setupProject("app") + }) + + test("inherits actions", async () => { + const parentFile = new SourceFile(project, "parent_controller.js", parentCode) + const childFile = new SourceFile(project, "child_controller.js", childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.initialize() + + const parent = parentFile.controllerDefinitions[0] + const child = childFile.controllerDefinitions[0] + + expect(parent).toBeDefined() + expect(child).toBeDefined() + + expect(parent.localActionNames).toEqual(["parentAction1", "parentAction2"]) + expect(parent.actionNames).toEqual(["parentAction1", "parentAction2"]) + + expect(child.localActionNames).toEqual(["childAction1", "childAction2"]) + expect(child.actionNames).toEqual(["childAction1", "childAction2", "parentAction1", "parentAction2"]) + }) + + test("inherits targets", async () => { + const parentFile = new SourceFile(project, "parent_controller.js", parentCode) + const childFile = new SourceFile(project, "child_controller.js", childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.initialize() + + const parent = parentFile.controllerDefinitions[0] + const child = childFile.controllerDefinitions[0] + + expect(parent).toBeDefined() + expect(child).toBeDefined() + + expect(parent.localTargetNames).toEqual(["parentTarget1", "parentTarget2"]) + expect(parent.targetNames).toEqual(["parentTarget1", "parentTarget2"]) + + expect(child.localTargetNames).toEqual(["childTarget1", "childTarget2"]) + expect(child.targetNames).toEqual(["childTarget1", "childTarget2", "parentTarget1", "parentTarget2"]) + }) + + test("inherits values", async () => { + const parentFile = new SourceFile(project, "parent_controller.js", parentCode) + const childFile = new SourceFile(project, "child_controller.js", childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.initialize() + + const parent = parentFile.controllerDefinitions[0] + const child = childFile.controllerDefinitions[0] + + expect(parent).toBeDefined() + expect(child).toBeDefined() + + expect(parent.localValueNames).toEqual(["parentValue1", "parentValue2"]) + expect(parent.valueNames).toEqual(["parentValue1", "parentValue2"]) + expect(child.localValueNames).toEqual(["childValue1", "childValue2"]) + expect(child.valueNames).toEqual(["childValue1", "childValue2", "parentValue1", "parentValue2"]) + + expect(Object.values(parent.localValues).map(v => [v.type, v.default])).toEqual([ + ["Boolean", false], + ["String", "parent2"], + ]) + + expect(Object.values(parent.values).map(v => [v.type, v.default])).toEqual([ + ["Boolean", false], + ["String", "parent2"], + ]) + + expect(Object.values(child.localValues).map(v => [v.type, v.default])).toEqual([ + ["Array", []], + ["Object", {value: "child2"}] + ]) + + expect(Object.values(child.values).map(v => [v.type, v.default])).toEqual([ + ["Array", []], + ["Object", {value: "child2"}], + ["Boolean", false], + ["String", "parent2"], + ]) + }) + + test("inherits classes", async () => { + const parentFile = new SourceFile(project, "parent_controller.js", parentCode) + const childFile = new SourceFile(project, "child_controller.js", childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.initialize() + + const parent = parentFile.controllerDefinitions[0] + const child = childFile.controllerDefinitions[0] + + expect(parent).toBeDefined() + expect(child).toBeDefined() + + expect(parent.localClassNames).toEqual(["parentClass1", "parentClass2"]) + expect(parent.classNames).toEqual(["parentClass1", "parentClass2"]) + + expect(child.localClassNames).toEqual(["childClass1", "childClass2"]) + expect(child.classNames).toEqual(["childClass1", "childClass2", "parentClass1", "parentClass2"]) + }) + + test.skip("inherits outlets", async () => { + expect(true).toBeTruthy() + }) +}) diff --git a/test/export_declaration/nextResolvedClassDeclaration.test.ts b/test/export_declaration/nextResolvedClassDeclaration.test.ts new file mode 100644 index 0000000..e1e8a68 --- /dev/null +++ b/test/export_declaration/nextResolvedClassDeclaration.test.ts @@ -0,0 +1,232 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile, ClassDeclaration } from "../../src" + +let project = new Project(process.cwd()) + +describe("ExportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("nextResolvedClassDeclaration", () => { + test("resolve named import class defined in other file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + export { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const exportDeclaration = childFile.exportDeclarations[0] + const parent = parentFile.findClass("ParentController") + + expect(parent).toBeDefined() + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(exportDeclaration.nextResolvedClassDeclaration.className).toEqual("ParentController") + expect(exportDeclaration.nextResolvedClassDeclaration).toEqual(parent) + }) + + test("resolve default import class defined in other file", async () => { + const parentCode = dedent` + export default class ParentController {} + ` + const childCode = dedent` + export { default } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const exportDeclaration = childFile.exportDeclarations[0] + const parent = parentFile.findClass("ParentController") + + expect(parent).toBeDefined() + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(exportDeclaration.nextResolvedClassDeclaration.className).toEqual("ParentController") + expect(exportDeclaration.nextResolvedClassDeclaration).toEqual(parent) + }) + + test("resolve re-export named", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + + const childCode = dedent` + export { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const exportDeclaration = childFile.exportDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(exportDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(exportDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export default as default", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default } from "./grandparent_controller" + ` + + const childCode = dedent` + export { default } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const exportDeclaration = childFile.exportDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(exportDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(exportDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export from default to named", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default as RenamedController } from "./grandparent_controller" + ` + + const childCode = dedent` + export { RenamedController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const exportDeclaration = childFile.exportDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(exportDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(exportDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export from named to default", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController as default } from "./grandparent_controller" + ` + + const childCode = dedent` + export { default } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const exportDeclaration = childFile.exportDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(exportDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(exportDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + }) + }) +}) diff --git a/test/export_declaration/nextResolvedPath.test.ts b/test/export_declaration/nextResolvedPath.test.ts new file mode 100644 index 0000000..614d28c --- /dev/null +++ b/test/export_declaration/nextResolvedPath.test.ts @@ -0,0 +1,68 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ExportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("nextResolvedPath", () => { + test("resolve relative path to other file", async () => { + const childCode = dedent` + export { ParentController } from "./parent_controller" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.exportDeclarations.length).toEqual(1) + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedPath).toBeDefined() + expect(project.relativePath(exportDeclaration.nextResolvedPath)).toEqual("src/parent_controller.js") + }) + + test("resolve relative path to other file up a directory", async () => { + const childCode = dedent` + export { ParentController } from "../parent_controller" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.exportDeclarations.length).toEqual(1) + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedPath).toBeDefined() + expect(project.relativePath(exportDeclaration.nextResolvedPath)).toEqual("src/parent_controller.js") + }) + + test("resolve path to node module entry point", async () => { + const childCode = dedent` + export { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.exportDeclarations.length).toEqual(1) + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedPath).toBeDefined() + expect(project.relativePath(exportDeclaration.nextResolvedPath)).toEqual("node_modules/tailwindcss-stimulus-components/src/index.js") + }) + }) +}) diff --git a/test/export_declaration/nextResolvedSourceFile.test.ts b/test/export_declaration/nextResolvedSourceFile.test.ts new file mode 100644 index 0000000..2874abf --- /dev/null +++ b/test/export_declaration/nextResolvedSourceFile.test.ts @@ -0,0 +1,59 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ExportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("nextResolvedSourceFile", () => { + test("resolve relative import to file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + export { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedSourceFile).toBeDefined() + expect(exportDeclaration.nextResolvedSourceFile).toBeInstanceOf(SourceFile) + expect(exportDeclaration.nextResolvedSourceFile).toEqual(parentFile) + expect(project.relativePath(exportDeclaration.nextResolvedSourceFile.path)).toEqual("src/parent_controller.js") + }) + + test("resolve SourceFile to node module entry point", async () => { + const childCode = dedent` + export { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + const exportDeclaration = childFile.exportDeclarations[0] + const nodeModule = exportDeclaration.resolvedNodeModule + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.nextResolvedSourceFile).toBeDefined() + expect(exportDeclaration.nextResolvedSourceFile).toBeInstanceOf(SourceFile) + expect(exportDeclaration.nextResolvedSourceFile).toEqual(nodeModule.entrypointSourceFile) + expect(project.relativePath(exportDeclaration.nextResolvedSourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/index.js") + }) + }) +}) diff --git a/test/export_declaration/resolvedClassDeclaration.test.ts b/test/export_declaration/resolvedClassDeclaration.test.ts new file mode 100644 index 0000000..a1a510f --- /dev/null +++ b/test/export_declaration/resolvedClassDeclaration.test.ts @@ -0,0 +1,308 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile, ClassDeclaration } from "../../src" + +let project = new Project(process.cwd()) + +describe("ExportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("resolvedClassDeclaration", () => { + test("resolve named import class defined in other file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + export { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const childExport = childFile.exportDeclarations[0] + const parentExport = parentFile.exportDeclarations[0] + + const parent = parentFile.findClass("ParentController") + expect(parent).toBeDefined() + + expect(childExport).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(childExport.resolvedClassDeclaration.className).toEqual("ParentController") + expect(childExport.resolvedClassDeclaration).toEqual(parent) + + expect(parentExport).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(parentExport.resolvedClassDeclaration.className).toEqual("ParentController") + expect(parentExport.resolvedClassDeclaration).toEqual(parent) + }) + + test("resolve default import class defined in other file", async () => { + const parentCode = dedent` + export default class ParentController {} + ` + const childCode = dedent` + export { default } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const childExport = childFile.exportDeclarations[0] + const parentExport = parentFile.exportDeclarations[0] + + const parent = parentFile.findClass("ParentController") + expect(parent).toBeDefined() + + expect(childExport).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(childExport.resolvedClassDeclaration.className).toEqual("ParentController") + expect(childExport.resolvedClassDeclaration).toEqual(parent) + + expect(parentExport).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(parentExport.resolvedClassDeclaration.className).toEqual("ParentController") + expect(parentExport.resolvedClassDeclaration).toEqual(parent) + }) + + test("resolve re-export named", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + + const childCode = dedent` + export { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const childExport = childFile.exportDeclarations[0] + const parentExport = parentFile.exportDeclarations[0] + const grandparentExport = grandparentFile.exportDeclarations[0] + + const grandparent = grandparentFile.findClass("GrandparentController") + expect(grandparent).toBeDefined() + + expect(childExport).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(childExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(childExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(parentExport).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(parentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(parentExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(grandparentExport).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(grandparentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(grandparentExport.resolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export default as default", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default } from "./grandparent_controller" + ` + + const childCode = dedent` + export { default } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const childExport = childFile.exportDeclarations[0] + const parentExport = parentFile.exportDeclarations[0] + const grandparentExport = grandparentFile.exportDeclarations[0] + + const grandparent = grandparentFile.findClass("GrandparentController") + expect(grandparent).toBeDefined() + + expect(childExport).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(childExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(childExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(parentExport).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(parentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(parentExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(grandparentExport).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(grandparentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(grandparentExport.resolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export from default to named", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default as RenamedController } from "./grandparent_controller" + ` + + const childCode = dedent` + export { RenamedController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const childExport = childFile.exportDeclarations[0] + const parentExport = parentFile.exportDeclarations[0] + const grandparentExport = grandparentFile.exportDeclarations[0] + + const grandparent = grandparentFile.findClass("GrandparentController") + expect(grandparent).toBeDefined() + + expect(childExport).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(childExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(childExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(parentExport).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(parentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(parentExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(grandparentExport).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(grandparentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(grandparentExport.resolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export from named to default", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController as default } from "./grandparent_controller" + ` + + const childCode = dedent` + export { default } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.exportDeclarations.length).toEqual(1) + + const childExport = childFile.exportDeclarations[0] + const parentExport = parentFile.exportDeclarations[0] + const grandparentExport = grandparentFile.exportDeclarations[0] + + const grandparent = grandparentFile.findClass("GrandparentController") + expect(grandparent).toBeDefined() + + expect(childExport).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeDefined() + expect(childExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(childExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(childExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(parentExport).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeDefined() + expect(parentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(parentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(parentExport.resolvedClassDeclaration).toEqual(grandparent) + + expect(grandparentExport).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeDefined() + expect(grandparentExport.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(grandparentExport.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(grandparentExport.resolvedClassDeclaration).toEqual(grandparent) + }) + }) +}) diff --git a/test/export_declaration/resolvedPath.test.ts b/test/export_declaration/resolvedPath.test.ts new file mode 100644 index 0000000..98bf4ee --- /dev/null +++ b/test/export_declaration/resolvedPath.test.ts @@ -0,0 +1,113 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ExportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("resolvedPath", () => { + test("resolve relative path to other file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + + const childCode = dedent` + export { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.exportDeclarations.length).toEqual(1) + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(exportDeclaration.resolvedPath)).toEqual("src/parent_controller.js") + }) + + test("resolve relative path to other file up a directory", async () => { + const parentCode = dedent` + export class ParentController {} + ` + + const childCode = dedent` + export { ParentController } from "../parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.exportDeclarations.length).toEqual(1) + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(exportDeclaration.resolvedPath)).toEqual("src/parent_controller.js") + }) + + test("resolve relative path through multiple files", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + + const childCode = dedent` + export { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "src/grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.exportDeclarations.length).toEqual(1) + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(exportDeclaration.resolvedPath)).toEqual("src/grandparent_controller.js") + }) + + test("resolve path to node module entry point", async () => { + const childCode = dedent` + export { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.exportDeclarations.length).toEqual(1) + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(exportDeclaration.resolvedPath)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + }) + }) +}) diff --git a/test/export_declaration/resolvedSourceFile.test.ts b/test/export_declaration/resolvedSourceFile.test.ts new file mode 100644 index 0000000..2084567 --- /dev/null +++ b/test/export_declaration/resolvedSourceFile.test.ts @@ -0,0 +1,78 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ExportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("resolvedSourceFile", () => { + test("resolve relative import to file", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + const childCode = dedent` + export { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "src/grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.resolvedSourceFile).toBeDefined() + expect(exportDeclaration.resolvedSourceFile).toBeInstanceOf(SourceFile) + expect(exportDeclaration.resolvedSourceFile).toEqual(grandparentFile) + expect(project.relativePath(exportDeclaration.resolvedSourceFile.path)).toEqual("src/grandparent_controller.js") + }) + + test("resolve SourceFile to node module", async () => { + const childCode = dedent` + export { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.resolvedSourceFile).toBeDefined() + expect(exportDeclaration.resolvedSourceFile).toBeInstanceOf(SourceFile) + expect(project.relativePath(exportDeclaration.resolvedSourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + }) + + test("doesn't resolve SourceFile to node module if export doesn't exist", async () => { + const childCode = dedent` + export { SomethingElse } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + const exportDeclaration = childFile.exportDeclarations[0] + + expect(exportDeclaration).toBeDefined() + expect(exportDeclaration.resolvedSourceFile).toBeUndefined() + }) + }) +}) diff --git a/test/fixtures/app/index.html b/test/fixtures/app/index.html new file mode 100644 index 0000000..feaa792 --- /dev/null +++ b/test/fixtures/app/index.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/test/fixtures/app/src/controllers/application.js b/test/fixtures/app/src/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/test/fixtures/app/src/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/test/fixtures/app/src/controllers/custom_modal_controller.js b/test/fixtures/app/src/controllers/custom_modal_controller.js new file mode 100644 index 0000000..03cbeb6 --- /dev/null +++ b/test/fixtures/app/src/controllers/custom_modal_controller.js @@ -0,0 +1,5 @@ +import { Modal } from "tailwindcss-stimulus-components" + +export default class extends Modal { + +} diff --git a/test/fixtures/app/src/controllers/hello_controller.js b/test/fixtures/app/src/controllers/hello_controller.js new file mode 100644 index 0000000..b5f2c85 --- /dev/null +++ b/test/fixtures/app/src/controllers/hello_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "@hotwired/stimulus" + +class ParentController extends Controller {} + +export default class extends ParentController { + static targets = ["output", "output"]; + static classes = ["active", "active"]; + + static otherValues = { + name: String, + } + + static values = { + ...this.otherValues, + name: String, + } +} diff --git a/test/fixtures/app/src/controllers/index.js b/test/fixtures/app/src/controllers/index.js new file mode 100644 index 0000000..06fd0f8 --- /dev/null +++ b/test/fixtures/app/src/controllers/index.js @@ -0,0 +1,7 @@ +import { application } from "./application" + +import HelloController from "./hello_controller" +application.register("hello", HelloController) + +import CustomModal from "./custom_modal_controller" +application.register("custom-modal", CustomModal) diff --git a/test/fixtures/app/yarn.lock b/test/fixtures/app/yarn.lock new file mode 100644 index 0000000..0749573 --- /dev/null +++ b/test/fixtures/app/yarn.lock @@ -0,0 +1,130 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/runtime@^7.21.0": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7" + integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw== + dependencies: + regenerator-runtime "^0.14.0" + +"@hotwired/stimulus@^3.0.0", "@hotwired/stimulus@^3.0.1", "@hotwired/stimulus@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" + integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== + +"@stimulus-library/controllers@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@stimulus-library/controllers/-/controllers-1.0.6.tgz#8122303e6dfa1139abda15e5aa41b81ce303bedf" + integrity sha512-cdA4NmiHI2oLImHcphtJnVmhxQ2Krz2Nh85UlXez0US9OS50SiVkxe+nAajt0eJ5o6HHfrr5SUxiUilQqlK90g== + dependencies: + "@stimulus-library/mixins" "^1.0.5" + "@stimulus-library/utilities" "^1.0.5" + date-fns "^2.29.3" + +"@stimulus-library/mixins@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@stimulus-library/mixins/-/mixins-1.0.5.tgz#4b8834e7279978caf4ea277735d40c350b1e56f2" + integrity sha512-saPVDcYVMTFAebaRJ4IAoNdEnfJpMIQxMgQf/CNugEDbxQb6+r7ZEIW3AmXedkKda3roN8TKrL7nUacXWxaz5Q== + dependencies: + "@hotwired/stimulus" "^3.0.0" + "@stimulus-library/utilities" "^1.0.5" + +"@stimulus-library/utilities@^1.0.2", "@stimulus-library/utilities@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@stimulus-library/utilities/-/utilities-1.0.5.tgz#8f790e1f13e64553c0bce617f6f3b56b7be77422" + integrity sha512-cTJk4OQwMy+VaXhSRszfZuIgt2GY49fn3IzktS15IV2qdVsaxF66mIdbuYRJgdIpMsQzQ/1gW5iS2P10iDpFpg== + dependencies: + "@hotwired/stimulus" "^3.0.0" + "@stimulus-library/utilities" "^1.0.2" + mitt "^3.0.0" + +"@vytant/stimulus-decorators@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@vytant/stimulus-decorators/-/stimulus-decorators-1.1.0.tgz#3d8a202c73fe952f6f674191487aae5483841fd4" + integrity sha512-6vQiXRSozjLOw3lfeZbTplQL5d0o09U2saGa6tKsUP9eGXAfjxRTxFjdiL+6UclOhdAg4k0tvW1n+1DXdFWQZQ== + dependencies: + "@hotwired/stimulus" "^3.0.0" + reflect-metadata "^0.1.13" + +date-fns@^2.29.3: + version "2.30.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" + integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== + dependencies: + "@babel/runtime" "^7.21.0" + +hotkeys-js@>=3, hotkeys-js@^3.8.7: + version "3.13.7" + resolved "https://registry.yarnpkg.com/hotkeys-js/-/hotkeys-js-3.13.7.tgz#0188d8e2fca16a3f1d66541b48de0bb9df613726" + integrity sha512-ygFIdTqqwG4fFP7kkiYlvayZppeIQX2aPpirsngkv1xM1lP0piDY5QEh68nQnIKvz64hfocxhBaD/uK3sSK1yQ== + +mitt@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + +reflect-metadata@^0.1.13: + version "0.1.14" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.14.tgz#24cf721fe60677146bb77eeb0e1f9dece3d65859" + integrity sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A== + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +stimulus-checkbox@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/stimulus-checkbox/-/stimulus-checkbox-2.0.0.tgz#ee4908ca6263556f0015eb36495ddf70611bca56" + integrity sha512-tuflIcPariCD6Ju/qoRXLbZR+f5FiZKVHYCA3Qj6i3PVqhYG0pgEYCPBKT3bmbSIZsAfK9Xc1pi+zryqlfjl0g== + dependencies: + "@hotwired/stimulus" "^3.2.2" + +stimulus-clipboard@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/stimulus-clipboard/-/stimulus-clipboard-4.0.1.tgz#acc9212b479fedc633ecdec8f191a28c1d826a6b" + integrity sha512-dem+ihC3Q8+gbyGINdd+dK+9d5vUTnOwoH+n3KcDJvbxrFcq9lV8mWjyhEaDquGxYy3MmqSdz9FHQbG88TBqGg== + +stimulus-datepicker@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/stimulus-datepicker/-/stimulus-datepicker-1.0.6.tgz#64eb8895a8aae902fd60b672f3c957a7f9b42158" + integrity sha512-MP2WcmibFTqb76iRLEP/TC32FKmU9Ca6xeptQiov6v8PivgZ1YMGmr7fb34zrdTHiwVHgzjxTm/6oP5kis1iuQ== + +stimulus-dropdown@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/stimulus-dropdown/-/stimulus-dropdown-2.1.0.tgz#22f15cd1dc247e08f04c3f95d7ab9d8102602a07" + integrity sha512-p4Bs56/ilB2E0lfFaNajKIHZK1PMUUDnhDl74f97bn087fxIfRB7WQekVtTTWJdRlf3EIgSsDX7K1TsaLiIcLg== + dependencies: + stimulus-use "^0.51.1" + +stimulus-hotkeys@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/stimulus-hotkeys/-/stimulus-hotkeys-2.3.0.tgz#95e5c25cc47ca4eaa82a65831d57dae8cc97640a" + integrity sha512-oDQu7yrrEAsu3ohFAI1E7hNMIn/4IGLtGlyRoAds0Q3JO509Ua0MhoQkBYl0ZdVOMOKTZchjjIDhT2pNUaZHWQ== + dependencies: + "@hotwired/stimulus" "^3.0.1" + hotkeys-js "^3.8.7" + +stimulus-inline-input-validations@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/stimulus-inline-input-validations/-/stimulus-inline-input-validations-1.2.0.tgz#5d8893e3f330044214e761cca2a73dcb715bb68a" + integrity sha512-3TVEmM9YBsVJdW2boux7C50+mnfikH0cMJ38ZinKmbVfXnGR1Sdyaweqe3FPOU8o2ktmWSRzYzk1n63y3hls8w== + +stimulus-use@^0.51.1: + version "0.51.3" + resolved "https://registry.yarnpkg.com/stimulus-use/-/stimulus-use-0.51.3.tgz#d7ac671aff8d0db253296dec89d38aa6f4b27e2a" + integrity sha512-V4YETxMFL4/bpmcqlwFtaOaJg9sLF+XlWsvXrsoWVA5jffsqe7uAvV6gGPPQta7Hgx01vovA0yNsWUe2eU9Jmw== + dependencies: + hotkeys-js ">=3" + +stimulus-use@^0.52.2: + version "0.52.2" + resolved "https://registry.yarnpkg.com/stimulus-use/-/stimulus-use-0.52.2.tgz#fc992fababe03f8d8bc2d9470c8cdb40bd075917" + integrity sha512-413+tIw9n6Jnb0OFiQE1i3aP01i0hhGgAnPp1P6cNuBbhhqG2IOp8t1O/4s5Tw2lTvSYrFeLNdaY8sYlDaULeg== + +tailwindcss-stimulus-components@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/tailwindcss-stimulus-components/-/tailwindcss-stimulus-components-4.0.4.tgz#1df5f2a488aa89365561bb33357095cd59ed831a" + integrity sha512-xNlMs1WufKiTMQtVklwHfrR/iuPVaFA0Mk5uefRnHztmr7w4g6BzKAWHyfte60pjhcQbmlbshHMOZiq/dkXnhw== diff --git a/test/fixtures/empty/index.html b/test/fixtures/empty/index.html new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/empty/package.json b/test/fixtures/empty/package.json new file mode 100644 index 0000000..0154e17 --- /dev/null +++ b/test/fixtures/empty/package.json @@ -0,0 +1,7 @@ +{ + "name": "empty", + "private": true, + "dependencies": { + "@hotwired/stimulus": "^3.2.2" + } +} diff --git a/test/fixtures/empty/src/controllers/.keep b/test/fixtures/empty/src/controllers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/empty/yarn.lock b/test/fixtures/empty/yarn.lock new file mode 100644 index 0000000..7efa14b --- /dev/null +++ b/test/fixtures/empty/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@hotwired/stimulus@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" + integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== diff --git a/test/fixtures/esbuild-rails/app/javascript/controllers/hello_controller.js b/test/fixtures/esbuild-rails/app/javascript/controllers/hello_controller.js index e3fea3f..3f8d3c6 100644 --- a/test/fixtures/esbuild-rails/app/javascript/controllers/hello_controller.js +++ b/test/fixtures/esbuild-rails/app/javascript/controllers/hello_controller.js @@ -1,5 +1,7 @@ import { Controller } from "@hotwired/stimulus" +export class NonDefaultExportController extends Controller {} + export default class extends Controller { connect() { diff --git a/test/fixtures/esbuild/app/javascript/controllers/hello_controller.js b/test/fixtures/esbuild/app/javascript/controllers/hello_controller.js index e3fea3f..3f8d3c6 100644 --- a/test/fixtures/esbuild/app/javascript/controllers/hello_controller.js +++ b/test/fixtures/esbuild/app/javascript/controllers/hello_controller.js @@ -1,5 +1,7 @@ import { Controller } from "@hotwired/stimulus" +export class NonDefaultExportController extends Controller {} + export default class extends Controller { connect() { diff --git a/test/fixtures/importmap-laravel-eager/resources/js/app.js b/test/fixtures/importmap-laravel-eager/resources/js/app.js new file mode 100644 index 0000000..a7bc227 --- /dev/null +++ b/test/fixtures/importmap-laravel-eager/resources/js/app.js @@ -0,0 +1 @@ +import "libs" diff --git a/test/fixtures/importmap-laravel-eager/resources/js/controllers/hello_controller.js b/test/fixtures/importmap-laravel-eager/resources/js/controllers/hello_controller.js new file mode 100644 index 0000000..293a497 --- /dev/null +++ b/test/fixtures/importmap-laravel-eager/resources/js/controllers/hello_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" + +export class NonDefaultExportController extends Controller {} + +export default class extends Controller { + static targets = ["output"] + + static values = { + message: { type: String, default: "Hello World" }, + } + + connect() { + this.#updateElement(this.messageValue) + } + + update({ params: { message } }) { + this.#updateElement(message) + } + + #updateElement(message) { + this.outputTarget.textContent = message + } +} diff --git a/test/fixtures/importmap-laravel-eager/resources/js/controllers/index.js b/test/fixtures/importmap-laravel-eager/resources/js/controllers/index.js new file mode 100644 index 0000000..ee7d9d1 --- /dev/null +++ b/test/fixtures/importmap-laravel-eager/resources/js/controllers/index.js @@ -0,0 +1,5 @@ +import { Stimulus } from "libs/stimulus" + +// Eager load all controllers defined in the import map under controllers/**/*_controller +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", Stimulus) diff --git a/test/fixtures/importmap-laravel-eager/resources/js/libs/index.js b/test/fixtures/importmap-laravel-eager/resources/js/libs/index.js new file mode 100644 index 0000000..72ef077 --- /dev/null +++ b/test/fixtures/importmap-laravel-eager/resources/js/libs/index.js @@ -0,0 +1 @@ +import "controllers" diff --git a/test/fixtures/importmap-laravel-eager/resources/js/libs/stimulus.js b/test/fixtures/importmap-laravel-eager/resources/js/libs/stimulus.js new file mode 100644 index 0000000..c6320db --- /dev/null +++ b/test/fixtures/importmap-laravel-eager/resources/js/libs/stimulus.js @@ -0,0 +1,10 @@ +import { Application } from "@hotwired/stimulus" + +const Stimulus = Application.start() + +// Configure Stimulus development experience +Stimulus.debug = false + +window.Stimulus = Stimulus + +export { Stimulus } diff --git a/test/fixtures/importmap-laravel-eager/resources/views/welcome.blade.php b/test/fixtures/importmap-laravel-eager/resources/views/welcome.blade.php new file mode 100644 index 0000000..9e6299a --- /dev/null +++ b/test/fixtures/importmap-laravel-eager/resources/views/welcome.blade.php @@ -0,0 +1,17 @@ + + + + + + Importmap Laravel Lazy + + + +

Stimulus Example

+ +
+
+ +
+ + diff --git a/test/fixtures/importmap-laravel-eager/routes/importmap.php b/test/fixtures/importmap-laravel-eager/routes/importmap.php new file mode 100644 index 0000000..b1f88fd --- /dev/null +++ b/test/fixtures/importmap-laravel-eager/routes/importmap.php @@ -0,0 +1,8 @@ + + + + + + Importmap Laravel Lazy + + + +

Stimulus Example

+ +
+
+ +
+ + diff --git a/test/fixtures/importmap-laravel-lazy/routes/importmap.php b/test/fixtures/importmap-laravel-lazy/routes/importmap.php new file mode 100644 index 0000000..b1f88fd --- /dev/null +++ b/test/fixtures/importmap-laravel-lazy/routes/importmap.php @@ -0,0 +1,8 @@ +=3: + version "3.13.7" + resolved "https://registry.yarnpkg.com/hotkeys-js/-/hotkeys-js-3.13.7.tgz#0188d8e2fca16a3f1d66541b48de0bb9df613726" + integrity sha512-ygFIdTqqwG4fFP7kkiYlvayZppeIQX2aPpirsngkv1xM1lP0piDY5QEh68nQnIKvz64hfocxhBaD/uK3sSK1yQ== + +stimulus-dropdown@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/stimulus-dropdown/-/stimulus-dropdown-2.1.0.tgz#22f15cd1dc247e08f04c3f95d7ab9d8102602a07" + integrity sha512-p4Bs56/ilB2E0lfFaNajKIHZK1PMUUDnhDl74f97bn087fxIfRB7WQekVtTTWJdRlf3EIgSsDX7K1TsaLiIcLg== + dependencies: + stimulus-use "^0.51.1" + +stimulus-use@^0.51.1: + version "0.51.3" + resolved "https://registry.yarnpkg.com/stimulus-use/-/stimulus-use-0.51.3.tgz#d7ac671aff8d0db253296dec89d38aa6f4b27e2a" + integrity sha512-V4YETxMFL4/bpmcqlwFtaOaJg9sLF+XlWsvXrsoWVA5jffsqe7uAvV6gGPPQta7Hgx01vovA0yNsWUe2eU9Jmw== + dependencies: + hotkeys-js ">=3" diff --git a/test/fixtures/packages/tailwindcss-stimulus-components/app/javascript/controllers/hello_controller.js b/test/fixtures/packages/tailwindcss-stimulus-components/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..ce6e409 --- /dev/null +++ b/test/fixtures/packages/tailwindcss-stimulus-components/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Modal } from "tailwindcss-stimulus-components" + +export default class extends Modal { + connect() { + + } +} diff --git a/test/fixtures/packages/tailwindcss-stimulus-components/package.json b/test/fixtures/packages/tailwindcss-stimulus-components/package.json new file mode 100644 index 0000000..425049d --- /dev/null +++ b/test/fixtures/packages/tailwindcss-stimulus-components/package.json @@ -0,0 +1,7 @@ +{ + "name": "tailwindcss-stimulus-components", + "private": true, + "dependencies": { + "tailwindcss-stimulus-components": "^4.0.4" + } +} diff --git a/test/fixtures/packages/tailwindcss-stimulus-components/yarn.lock b/test/fixtures/packages/tailwindcss-stimulus-components/yarn.lock new file mode 100644 index 0000000..3833c2e --- /dev/null +++ b/test/fixtures/packages/tailwindcss-stimulus-components/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +tailwindcss-stimulus-components@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/tailwindcss-stimulus-components/-/tailwindcss-stimulus-components-4.0.4.tgz#1df5f2a488aa89365561bb33357095cd59ed831a" + integrity sha512-xNlMs1WufKiTMQtVklwHfrR/iuPVaFA0Mk5uefRnHztmr7w4g6BzKAWHyfte60pjhcQbmlbshHMOZiq/dkXnhw== diff --git a/test/fixtures/shakapacker/app/javascript/controllers/hello_controller.js b/test/fixtures/shakapacker/app/javascript/controllers/hello_controller.js index e3fea3f..3f8d3c6 100644 --- a/test/fixtures/shakapacker/app/javascript/controllers/hello_controller.js +++ b/test/fixtures/shakapacker/app/javascript/controllers/hello_controller.js @@ -1,5 +1,7 @@ import { Controller } from "@hotwired/stimulus" +export class NonDefaultExportController extends Controller {} + export default class extends Controller { connect() { diff --git a/test/fixtures/shakapacker/package.json b/test/fixtures/shakapacker/package.json index 07e1f1d..bbd6723 100644 --- a/test/fixtures/shakapacker/package.json +++ b/test/fixtures/shakapacker/package.json @@ -8,6 +8,8 @@ "@babel/runtime": "7", "@types/babel__core": "7", "@types/webpack": "5", + "@hotwired/stimulus": "^3.2.2", + "@hotwired/stimulus-webpack-helpers": "^1.0.1", "babel-loader": "8", "compression-webpack-plugin": "9", "shakapacker": "7.2.2", diff --git a/test/fixtures/shakapacker/yarn.lock b/test/fixtures/shakapacker/yarn.lock index 2a2fb52..04df1d1 100644 --- a/test/fixtures/shakapacker/yarn.lock +++ b/test/fixtures/shakapacker/yarn.lock @@ -959,6 +959,16 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@hotwired/stimulus-webpack-helpers@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus-webpack-helpers/-/stimulus-webpack-helpers-1.0.1.tgz#4cd74487adeca576c9865ac2b9fe5cb20cef16dd" + integrity sha512-wa/zupVG0eWxRYJjC1IiPBdt3Lruv0RqGN+/DTMmUWUyMAEB27KXmVY6a8YpUVTM7QwVuaLNGW4EqDgrS2upXQ== + +"@hotwired/stimulus@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" + integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== + "@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": version "0.3.3" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" diff --git a/test/fixtures/vite-laravel/package.json b/test/fixtures/vite-laravel/package.json new file mode 100644 index 0000000..95e2354 --- /dev/null +++ b/test/fixtures/vite-laravel/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.1.0", + "axios": "^1.6.4", + "laravel-vite-plugin": "^1.0.0", + "vite": "^5.0.0" + } +} diff --git a/test/fixtures/vite-laravel/resources/js/app.js b/test/fixtures/vite-laravel/resources/js/app.js new file mode 100644 index 0000000..c35f3cf --- /dev/null +++ b/test/fixtures/vite-laravel/resources/js/app.js @@ -0,0 +1 @@ +import "./libs" diff --git a/test/fixtures/vite-laravel/resources/js/controllers/hello_controller.js b/test/fixtures/vite-laravel/resources/js/controllers/hello_controller.js new file mode 100644 index 0000000..293a497 --- /dev/null +++ b/test/fixtures/vite-laravel/resources/js/controllers/hello_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" + +export class NonDefaultExportController extends Controller {} + +export default class extends Controller { + static targets = ["output"] + + static values = { + message: { type: String, default: "Hello World" }, + } + + connect() { + this.#updateElement(this.messageValue) + } + + update({ params: { message } }) { + this.#updateElement(message) + } + + #updateElement(message) { + this.outputTarget.textContent = message + } +} diff --git a/test/fixtures/vite-laravel/resources/js/controllers/index.js b/test/fixtures/vite-laravel/resources/js/controllers/index.js new file mode 100644 index 0000000..a0f9b57 --- /dev/null +++ b/test/fixtures/vite-laravel/resources/js/controllers/index.js @@ -0,0 +1,8 @@ +// This file is auto-generated by `php artisan stimulus:install` +// Run that command whenever you add a new controller or create them with +// `php artisan stimulus:make controllerName` + +import { Stimulus } from "../libs/stimulus" + +import HelloController from "./hello_controller" +Stimulus.register("hello", HelloController) diff --git a/test/fixtures/vite-laravel/resources/js/libs/index.js b/test/fixtures/vite-laravel/resources/js/libs/index.js new file mode 100644 index 0000000..bf29d3d --- /dev/null +++ b/test/fixtures/vite-laravel/resources/js/libs/index.js @@ -0,0 +1 @@ +import "../controllers" diff --git a/test/fixtures/vite-laravel/resources/js/libs/stimulus.js b/test/fixtures/vite-laravel/resources/js/libs/stimulus.js new file mode 100644 index 0000000..d3f7037 --- /dev/null +++ b/test/fixtures/vite-laravel/resources/js/libs/stimulus.js @@ -0,0 +1,8 @@ +import { Application } from "@hotwired/stimulus" + +const Stimulus = Application.start() + +// Configure Stimulus development experience +Stimulus.debug = false + +export { Stimulus } diff --git a/test/fixtures/vite-laravel/resources/views/welcome.blade.php b/test/fixtures/vite-laravel/resources/views/welcome.blade.php new file mode 100644 index 0000000..9f1f5cf --- /dev/null +++ b/test/fixtures/vite-laravel/resources/views/welcome.blade.php @@ -0,0 +1,17 @@ + + + + + + Vite Laravel + + + +

Stimulus Example

+ +
+
+ +
+ + diff --git a/test/fixtures/vite-laravel/vite.config.js b/test/fixtures/vite-laravel/vite.config.js new file mode 100644 index 0000000..00c95a9 --- /dev/null +++ b/test/fixtures/vite-laravel/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import laravel from "laravel-vite-plugin"; + +export default defineConfig({ + plugins: [ + laravel({ + input: ["resources/css/app.css", "resources/js/app.js"], + refresh: true, + }), + ], +}); diff --git a/test/fixtures/vite-laravel/yarn.lock b/test/fixtures/vite-laravel/yarn.lock new file mode 100644 index 0000000..0b501d1 --- /dev/null +++ b/test/fixtures/vite-laravel/yarn.lock @@ -0,0 +1,362 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" + integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA== + +"@esbuild/android-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4" + integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA== + +"@esbuild/android-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" + integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w== + +"@esbuild/android-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d" + integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew== + +"@esbuild/darwin-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e" + integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g== + +"@esbuild/darwin-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd" + integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A== + +"@esbuild/freebsd-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487" + integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA== + +"@esbuild/freebsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c" + integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg== + +"@esbuild/linux-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b" + integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA== + +"@esbuild/linux-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef" + integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w== + +"@esbuild/linux-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601" + integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA== + +"@esbuild/linux-loong64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" + integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA== + +"@esbuild/linux-mips64el@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec" + integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w== + +"@esbuild/linux-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8" + integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg== + +"@esbuild/linux-riscv64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf" + integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg== + +"@esbuild/linux-s390x@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8" + integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg== + +"@esbuild/linux-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78" + integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg== + +"@esbuild/netbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b" + integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA== + +"@esbuild/openbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0" + integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw== + +"@esbuild/sunos-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30" + integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA== + +"@esbuild/win32-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae" + integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A== + +"@esbuild/win32-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67" + integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ== + +"@esbuild/win32-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae" + integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA== + +"@hotwired/stimulus@^3.1.0": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.2.tgz#071aab59c600fed95b97939e605ff261a4251608" + integrity sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A== + +"@rollup/rollup-android-arm-eabi@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz#38c3abd1955a3c21d492af6b1a1dca4bb1d894d6" + integrity sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w== + +"@rollup/rollup-android-arm64@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz#3822e929f415627609e53b11cec9a4be806de0e2" + integrity sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ== + +"@rollup/rollup-darwin-arm64@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz#6c082de71f481f57df6cfa3701ab2a7afde96f69" + integrity sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ== + +"@rollup/rollup-darwin-x64@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz#c34ca0d31f3c46a22c9afa0e944403eea0edcfd8" + integrity sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg== + +"@rollup/rollup-linux-arm-gnueabihf@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz#48e899c1e438629c072889b824a98787a7c2362d" + integrity sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA== + +"@rollup/rollup-linux-arm64-gnu@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz#788c2698a119dc229062d40da6ada8a090a73a68" + integrity sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA== + +"@rollup/rollup-linux-arm64-musl@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz#3882a4e3a564af9e55804beeb67076857b035ab7" + integrity sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ== + +"@rollup/rollup-linux-riscv64-gnu@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz#0c6ad792e1195c12bfae634425a3d2aa0fe93ab7" + integrity sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw== + +"@rollup/rollup-linux-x64-gnu@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz#9d62485ea0f18d8674033b57aa14fb758f6ec6e3" + integrity sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA== + +"@rollup/rollup-linux-x64-musl@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz#50e8167e28b33c977c1f813def2b2074d1435e05" + integrity sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw== + +"@rollup/rollup-win32-arm64-msvc@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz#68d233272a2004429124494121a42c4aebdc5b8e" + integrity sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw== + +"@rollup/rollup-win32-ia32-msvc@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz#366ca62221d1689e3b55a03f4ae12ae9ba595d40" + integrity sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA== + +"@rollup/rollup-win32-x64-msvc@4.12.0": + version "4.12.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz#9ffdf9ed133a7464f4ae187eb9e1294413fab235" + integrity sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg== + +"@types/estree@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.6.4: + version "1.6.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" + integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== + dependencies: + follow-redirects "^1.15.4" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +esbuild@^0.19.3: + version "0.19.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" + integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.19.12" + "@esbuild/android-arm" "0.19.12" + "@esbuild/android-arm64" "0.19.12" + "@esbuild/android-x64" "0.19.12" + "@esbuild/darwin-arm64" "0.19.12" + "@esbuild/darwin-x64" "0.19.12" + "@esbuild/freebsd-arm64" "0.19.12" + "@esbuild/freebsd-x64" "0.19.12" + "@esbuild/linux-arm" "0.19.12" + "@esbuild/linux-arm64" "0.19.12" + "@esbuild/linux-ia32" "0.19.12" + "@esbuild/linux-loong64" "0.19.12" + "@esbuild/linux-mips64el" "0.19.12" + "@esbuild/linux-ppc64" "0.19.12" + "@esbuild/linux-riscv64" "0.19.12" + "@esbuild/linux-s390x" "0.19.12" + "@esbuild/linux-x64" "0.19.12" + "@esbuild/netbsd-x64" "0.19.12" + "@esbuild/openbsd-x64" "0.19.12" + "@esbuild/sunos-x64" "0.19.12" + "@esbuild/win32-arm64" "0.19.12" + "@esbuild/win32-ia32" "0.19.12" + "@esbuild/win32-x64" "0.19.12" + +follow-redirects@^1.15.4: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +laravel-vite-plugin@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/laravel-vite-plugin/-/laravel-vite-plugin-1.0.1.tgz#b92d0c939ccd60879746b23282100131f753cec7" + integrity sha512-laLEZUnSskIDZtLb2FNRdcjsRUhh1VOVvapbVGVTeaBPJTCF/b6gbPiX2dZdcH1RKoOE0an7L+2gnInk6K33Zw== + dependencies: + picocolors "^1.0.0" + vite-plugin-full-reload "^1.1.0" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +postcss@^8.4.35: + version "8.4.35" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7" + integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +rollup@^4.2.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.12.0.tgz#0b6d1e5f3d46bbcf244deec41a7421dc54cc45b5" + integrity sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.12.0" + "@rollup/rollup-android-arm64" "4.12.0" + "@rollup/rollup-darwin-arm64" "4.12.0" + "@rollup/rollup-darwin-x64" "4.12.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.12.0" + "@rollup/rollup-linux-arm64-gnu" "4.12.0" + "@rollup/rollup-linux-arm64-musl" "4.12.0" + "@rollup/rollup-linux-riscv64-gnu" "4.12.0" + "@rollup/rollup-linux-x64-gnu" "4.12.0" + "@rollup/rollup-linux-x64-musl" "4.12.0" + "@rollup/rollup-win32-arm64-msvc" "4.12.0" + "@rollup/rollup-win32-ia32-msvc" "4.12.0" + "@rollup/rollup-win32-x64-msvc" "4.12.0" + fsevents "~2.3.2" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +vite-plugin-full-reload@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/vite-plugin-full-reload/-/vite-plugin-full-reload-1.1.0.tgz#ca6fa32631024a459ea9e5613dd4c0ff0f3b7995" + integrity sha512-3cObNDzX6DdfhD9E7kf6w2mNunFpD7drxyNgHLw+XwIYAgb+Xt16SEXo0Up4VH+TMf3n+DSVJZtW2POBGcBYAA== + dependencies: + picocolors "^1.0.0" + picomatch "^2.3.1" + +vite@^5.0.0: + version "5.1.3" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.3.tgz#dd072653a80225702265550a4700561740dfde55" + integrity sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew== + dependencies: + esbuild "^0.19.3" + postcss "^8.4.35" + rollup "^4.2.0" + optionalDependencies: + fsevents "~2.3.3" diff --git a/test/fixtures/vite-rails/app/frontend/controllers/hello_controller.js b/test/fixtures/vite-rails/app/frontend/controllers/hello_controller.js index e3fea3f..3f8d3c6 100644 --- a/test/fixtures/vite-rails/app/frontend/controllers/hello_controller.js +++ b/test/fixtures/vite-rails/app/frontend/controllers/hello_controller.js @@ -1,5 +1,7 @@ import { Controller } from "@hotwired/stimulus" +export class NonDefaultExportController extends Controller {} + export default class extends Controller { connect() { diff --git a/test/fixtures/webpacker/app/javascript/controllers/hello_controller.js b/test/fixtures/webpacker/app/javascript/controllers/hello_controller.js index e3fea3f..3f8d3c6 100644 --- a/test/fixtures/webpacker/app/javascript/controllers/hello_controller.js +++ b/test/fixtures/webpacker/app/javascript/controllers/hello_controller.js @@ -1,5 +1,7 @@ import { Controller } from "@hotwired/stimulus" +export class NonDefaultExportController extends Controller {} + export default class extends Controller { connect() { diff --git a/test/helpers/mock.ts b/test/helpers/mock.ts new file mode 100644 index 0000000..9c7c108 --- /dev/null +++ b/test/helpers/mock.ts @@ -0,0 +1,7 @@ +import { vi } from "vitest" + +import * as fs from "../../src/util/fs" + +export function mockFile(content: string) { + return vi.spyOn(fs, "readFile").mockReturnValue(new Promise(resolve => resolve(content))) +} diff --git a/test/helpers/parse.ts b/test/helpers/parse.ts new file mode 100644 index 0000000..a49cc24 --- /dev/null +++ b/test/helpers/parse.ts @@ -0,0 +1,18 @@ +import { setupProject } from "./setup" + +import { SourceFile } from "../../src/source_file" +import type { ControllerDefinition } from "../../src/controller_definition" + +export function parseController(code: string, filename: string, controllerName?: string): ControllerDefinition { + const sourceFile = new SourceFile(setupProject(), filename, code) + sourceFile.initialize() + sourceFile.analyze() + + sourceFile.classDeclarations.forEach(klass => klass.analyze()) + + if (controllerName) { + return sourceFile.findClass(controllerName)?.controllerDefinition + } else { + return sourceFile.classDeclarations[0]?.controllerDefinition + } +} diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts index 3cc2ccf..cb07b1a 100644 --- a/test/helpers/setup.ts +++ b/test/helpers/setup.ts @@ -1,8 +1,20 @@ +import path from "path" +import dedent from "dedent" import { beforeEach } from "vitest" -import { Parser, Project } from "../../src" +import { Parser, Project, SourceFile, RegisteredController } from "../../src" -export const setupParser = () => { - const project = new Project(process.cwd()) +export const setupProject = (fixture?: string): Project => { + let projectPath = process.cwd() + + if (fixture) { + projectPath = path.join(process.cwd(), "test", "fixtures", fixture) + } + + return new Project(projectPath) +} + +export const setupParser = (): Parser => { + const project = setupProject() let parser = new Parser(project) beforeEach(() => { @@ -11,3 +23,40 @@ export const setupParser = () => { return parser } + +const basicController = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller {} +` + +export const sourceFileFor = async (project: Project, path: string, code: string = basicController) => { + const sourceFile = new SourceFile(project, path, code) + project.projectFiles.push(sourceFile) + + await sourceFile.initialize() + sourceFile.analyze() + + return sourceFile +} + +export const classDeclarationFor = async (project: Project, path: string) => { + + return (await sourceFileFor(project, path, basicController)).classDeclarations[0] +} + +export const controllerDefinitionFor = async (project: Project, path: string, identifier?: string) => { + const classDeclaration = await classDeclarationFor(project, path) + + if (identifier) { + classDeclaration.sourceFile.project.controllersFile?.registeredControllers.push( + new RegisteredController( + identifier, + classDeclaration.controllerDefinition, + "load" + ) + ) + } + + return classDeclaration.controllerDefinition +} diff --git a/test/identifiers.test.ts b/test/identifiers.test.ts deleted file mode 100644 index e7f671f..0000000 --- a/test/identifiers.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { expect, test } from "vitest" -import { Project, ControllerDefinition } from "../src" - -const project = new Project(process.cwd()) - -test("top-level", () => { - const controller = new ControllerDefinition(project, "some_controller.js") - - expect(controller.identifier).toEqual("some") -}) - -test("top-level underscored", () => { - const controller = new ControllerDefinition(project, "some_underscored_controller.js") - - expect(controller.identifier).toEqual("some-underscored") -}) - -test("top-level dasherized", () => { - const controller = new ControllerDefinition(project, "some-underscored_controller.js") - - expect(controller.identifier).toEqual("some-underscored") -}) - -test("namespaced", () => { - const controller = new ControllerDefinition(project, "namespaced/some_controller.js") - - expect(controller.identifier).toEqual("namespaced--some") -}) - -test("deeply nested", () => { - const controller = new ControllerDefinition(project, "a/bunch/of/levels/some_controller.js") - - expect(controller.identifier).toEqual("a--bunch--of--levels--some") -}) - -test("deeply nested underscored", () => { - const controller = new ControllerDefinition(project, "a/bunch/of/levels/some_underscored_controller.js") - - expect(controller.identifier).toEqual("a--bunch--of--levels--some-underscored") -}) - -test("deeply nested dasherized", () => { - const controller = new ControllerDefinition(project, "a/bunch/of/levels/some-underscored_controller.js") - - expect(controller.identifier).toEqual("a--bunch--of--levels--some-underscored") -}) - -test("deeply nested all dasherized", () => { - const controller = new ControllerDefinition(project, "a/bunch/of/levels/some-underscored-controller.js") - - expect(controller.identifier).toEqual("a--bunch--of--levels--some-underscored") -}) - -// TODO: update implementation once this gets released -// https://github.com/hotwired/stimulus-webpack-helpers/pull/3 -test("nested with only controller", () => { - const controller1 = new ControllerDefinition(project, "a/bunch/of/levels/controller.js") - const controller2 = new ControllerDefinition(project, "a/bunch/of/levels/controller.ts") - - expect(controller1.identifier).toEqual("a--bunch--of--levels") - expect(controller2.identifier).toEqual("a--bunch--of--levels") -}) - -test("without controller suffix", () => { - const controller1 = new ControllerDefinition(project, "something.js") - const controller2 = new ControllerDefinition(project, "something.ts") - - expect(controller1.identifier).toEqual("something") - expect(controller2.identifier).toEqual("something") -}) - -test("nested without controller suffix", () => { - const controller1 = new ControllerDefinition(project, "a/bunch/of/levels/something.js") - const controller2 = new ControllerDefinition(project, "a/bunch/of/levels/something.ts") - - expect(controller1.identifier).toEqual("a--bunch--of--levels--something") - expect(controller2.identifier).toEqual("a--bunch--of--levels--something") -}) - -test("controller with dashes and underscores", () => { - const controller1 = new ControllerDefinition(project, "some-thing_controller.js") - const controller2 = new ControllerDefinition(project, "some-thing_controller.ts") - const controller3 = new ControllerDefinition(project, "some_thing-controller.js") - const controller4 = new ControllerDefinition(project, "some_thing-controller.ts") - - expect(controller1.identifier).toEqual("some-thing") - expect(controller2.identifier).toEqual("some-thing") - expect(controller3.identifier).toEqual("some-thing") - expect(controller4.identifier).toEqual("some-thing") -}) - -test("controller with dasherized name", () => { - const controller1 = new ControllerDefinition(project, "some-thing-controller.js") - const controller2 = new ControllerDefinition(project, "some-thing-controller.ts") - - expect(controller1.identifier).toEqual("some-thing") - expect(controller2.identifier).toEqual("some-thing") -}) - -test("nested controller with dasherized name", () => { - const controller1 = new ControllerDefinition(project, "a/bunch-of/levels/some-thing-controller.js") - const controller2 = new ControllerDefinition(project, "a/bunch-of/levels/some-thing-controller.ts") - - expect(controller1.identifier).toEqual("a--bunch-of--levels--some-thing") - expect(controller2.identifier).toEqual("a--bunch-of--levels--some-thing") -}) diff --git a/test/import_declaration/nextResolvedClassDeclaration.test.ts b/test/import_declaration/nextResolvedClassDeclaration.test.ts new file mode 100644 index 0000000..18e2e82 --- /dev/null +++ b/test/import_declaration/nextResolvedClassDeclaration.test.ts @@ -0,0 +1,325 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile, ClassDeclaration } from "../../src" + +let project = new Project(process.cwd()) + +describe("ImportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("nextResolvedClassDeclaration", () => { + test("resolve named import class defined in other file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + import { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const parent = parentFile.findClass("ParentController") + + expect(parent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.nextResolvedClassDeclaration.className).toEqual("ParentController") + expect(importDeclaration.nextResolvedClassDeclaration).toEqual(parent) + }) + + test("resolve default import class defined in other file", async () => { + const parentCode = dedent` + export default class ParentController {} + ` + const childCode = dedent` + import ParentController from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const parent = parentFile.findClass("ParentController") + + expect(parent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.nextResolvedClassDeclaration.className).toEqual("ParentController") + expect(importDeclaration.nextResolvedClassDeclaration).toEqual(parent) + }) + + test("resolve named classes through files", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + import { GrandparentController } from "./grandparent_controller" + + export class ParentController extends GrandparentController {} + ` + + const childCode = dedent` + import { ParentController } from "./parent_controller" + + class ChildController extends ParentController {} + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const child = childFile.findClass("ChildController") + const parent = parentFile.findClass("ParentController") + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(child).toBeDefined() + expect(parent).toBeDefined() + expect(grandparent).toBeDefined() + + expect(child.nextResolvedClassDeclaration).toBeDefined() + expect(child.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(child.nextResolvedClassDeclaration).toEqual(parent) + expect(child.nextResolvedClassDeclaration.className).toEqual("ParentController") + expect(child.nextResolvedClassDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + expect(child.nextResolvedClassDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + + expect(parent.nextResolvedClassDeclaration).toBeDefined() + expect(parent.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(parent.nextResolvedClassDeclaration).toEqual(grandparent) + + expect(parent.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(parent.nextResolvedClassDeclaration.nextResolvedClassDeclaration).toBeUndefined() + + expect(grandparent.nextResolvedClassDeclaration).toBeUndefined() + + expect(project.relativePath(child.nextResolvedClassDeclaration.sourceFile.path)).toEqual("parent_controller.js") + expect(project.relativePath(child.nextResolvedClassDeclaration.nextResolvedClassDeclaration.sourceFile.path)).toEqual("grandparent_controller.js") + expect(project.relativePath(parent.nextResolvedClassDeclaration.sourceFile.path)).toEqual("grandparent_controller.js") + }) + + test("resolve re-export named", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + + const childCode = dedent` + import { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + + // The next nextResolvedSourceFile is the parent_controller.js SourceFile + expect(project.relativePath(importDeclaration.nextResolvedSourceFile.path)).toEqual("parent_controller.js") + + // But because the parent_controller.js SourceFile doesn't declare the class, nextResolvedClassDeclaration + // will resolve to the GrandparentController class in the grandparent_controller.js + expect(importDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + + expect(project.relativePath(importDeclaration.nextResolvedClassDeclaration.sourceFile.path)).toEqual("grandparent_controller.js") + }) + + test("resolve re-export default as default", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default } from "./grandparent_controller" + ` + + const childCode = dedent` + import ParentController from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + + // The next nextResolvedSourceFile is the parent_controller.js SourceFile + expect(project.relativePath(importDeclaration.nextResolvedSourceFile.path)).toEqual("parent_controller.js") + + // But because the parent_controller.js SourceFile doesn't declare the class, nextResolvedClassDeclaration + // will resolve to the GrandparentController class in the grandparent_controller.js + expect(importDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + + expect(project.relativePath(importDeclaration.nextResolvedClassDeclaration.sourceFile.path)).toEqual("grandparent_controller.js") + }) + + test("resolve re-export from default to named", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default as RenamedController } from "./grandparent_controller" + ` + + const childCode = dedent` + import { RenamedController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + + // The next nextResolvedSourceFile is the parent_controller.js SourceFile + expect(project.relativePath(importDeclaration.nextResolvedSourceFile.path)).toEqual("parent_controller.js") + + // But because the parent_controller.js SourceFile doesn't declare the class, nextResolvedClassDeclaration + // will resolve to the GrandparentController class in the grandparent_controller.js + expect(importDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + + expect(project.relativePath(importDeclaration.nextResolvedClassDeclaration.sourceFile.path)).toEqual("grandparent_controller.js") + }) + + test("resolve re-export from named to default", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController as default } from "./grandparent_controller" + ` + + const childCode = dedent` + import RenamedController from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + + // The next nextResolvedSourceFile is the parent_controller.js SourceFile + expect(project.relativePath(importDeclaration.nextResolvedSourceFile.path)).toEqual("parent_controller.js") + + // But because the parent_controller.js SourceFile doesn't declare the class, nextResolvedClassDeclaration + // will resolve to the GrandparentController class in the grandparent_controller.js + expect(importDeclaration.nextResolvedClassDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.nextResolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.nextResolvedClassDeclaration).toEqual(grandparent) + + expect(project.relativePath(importDeclaration.nextResolvedClassDeclaration.sourceFile.path)).toEqual("grandparent_controller.js") + }) + }) +}) diff --git a/test/import_declaration/nextResolvedPath.test.ts b/test/import_declaration/nextResolvedPath.test.ts new file mode 100644 index 0000000..75c5ade --- /dev/null +++ b/test/import_declaration/nextResolvedPath.test.ts @@ -0,0 +1,68 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ImportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("nextResolvedPath", () => { + test("resolve relative path to other file", async () => { + const childCode = dedent` + import { ParentController } from "./parent_controller" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedPath).toBeDefined() + expect(project.relativePath(importDeclaration.nextResolvedPath)).toEqual("src/parent_controller.js") + }) + + test("resolve relative path to other file up a directory", async () => { + const childCode = dedent` + import { ParentController } from "../parent_controller" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedPath).toBeDefined() + expect(project.relativePath(importDeclaration.nextResolvedPath)).toEqual("src/parent_controller.js") + }) + + test("resolve path to node module entry point", async () => { + const childCode = dedent` + import { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedPath).toBeDefined() + expect(project.relativePath(importDeclaration.nextResolvedPath)).toEqual("node_modules/tailwindcss-stimulus-components/src/index.js") + }) + }) +}) diff --git a/test/import_declaration/nextResolvedSourceFile.test.ts b/test/import_declaration/nextResolvedSourceFile.test.ts new file mode 100644 index 0000000..aef84b6 --- /dev/null +++ b/test/import_declaration/nextResolvedSourceFile.test.ts @@ -0,0 +1,59 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ImportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("nextResolvedSourceFile", () => { + test("resolve relative import to file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + import { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedSourceFile).toBeDefined() + expect(importDeclaration.nextResolvedSourceFile).toBeInstanceOf(SourceFile) + expect(importDeclaration.nextResolvedSourceFile).toEqual(parentFile) + expect(project.relativePath(importDeclaration.nextResolvedSourceFile.path)).toEqual("src/parent_controller.js") + }) + + test("resolve SourceFile to node module entry point", async () => { + const childCode = dedent` + import { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + const importDeclaration = childFile.importDeclarations[0] + const nodeModule = importDeclaration.resolvedNodeModule + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.nextResolvedSourceFile).toBeDefined() + expect(importDeclaration.nextResolvedSourceFile).toBeInstanceOf(SourceFile) + expect(importDeclaration.nextResolvedSourceFile).toEqual(nodeModule.entrypointSourceFile) + expect(project.relativePath(importDeclaration.nextResolvedSourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/index.js") + }) + }) +}) diff --git a/test/import_declaration/resolvedClassDeclaration.test.ts b/test/import_declaration/resolvedClassDeclaration.test.ts new file mode 100644 index 0000000..71eee00 --- /dev/null +++ b/test/import_declaration/resolvedClassDeclaration.test.ts @@ -0,0 +1,232 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile, ClassDeclaration } from "../../src" + +let project = new Project(process.cwd()) + +describe("ImportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("resolvedClassDeclaration", () => { + test("resolve named import class defined in other file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + import { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const parent = parentFile.findClass("ParentController") + + expect(parent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.resolvedClassDeclaration.className).toEqual("ParentController") + expect(importDeclaration.resolvedClassDeclaration).toEqual(parent) + }) + + test("resolve default import class defined in other file", async () => { + const parentCode = dedent` + export default class ParentController {} + ` + const childCode = dedent` + import ParentController from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(parentFile.classDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const parent = parentFile.findClass("ParentController") + + expect(parent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.resolvedClassDeclaration.className).toEqual("ParentController") + expect(importDeclaration.resolvedClassDeclaration).toEqual(parent) + }) + + test("resolve re-export named", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + + const childCode = dedent` + import { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.resolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export default as default", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default } from "./grandparent_controller" + ` + + const childCode = dedent` + import ParentController from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.resolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export from default to named", async () => { + const grandparentCode = dedent` + export default class GrandparentController {} + ` + + const parentCode = dedent` + export { default as RenamedController } from "./grandparent_controller" + ` + + const childCode = dedent` + import { RenamedController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.resolvedClassDeclaration).toEqual(grandparent) + }) + + test("resolve re-export from named to default", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController as default } from "./grandparent_controller" + ` + + const childCode = dedent` + import RenamedController from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "child_controller.js"), childCode) + + project.projectFiles.push(childFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(grandparentFile) + + await project.analyze() + + expect(grandparentFile.classDeclarations.length).toEqual(1) + expect(grandparentFile.exportDeclarations.length).toEqual(1) + expect(parentFile.exportDeclarations.length).toEqual(1) + expect(childFile.importDeclarations.length).toEqual(1) + + const importDeclaration = childFile.importDeclarations[0] + const grandparent = grandparentFile.findClass("GrandparentController") + + expect(grandparent).toBeDefined() + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeDefined() + expect(importDeclaration.resolvedClassDeclaration).toBeInstanceOf(ClassDeclaration) + expect(importDeclaration.resolvedClassDeclaration.className).toEqual("GrandparentController") + expect(importDeclaration.resolvedClassDeclaration).toEqual(grandparent) + }) + }) +}) diff --git a/test/import_declaration/resolvedPath.test.ts b/test/import_declaration/resolvedPath.test.ts new file mode 100644 index 0000000..38301bd --- /dev/null +++ b/test/import_declaration/resolvedPath.test.ts @@ -0,0 +1,145 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ImportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("resolvedPath", () => { + test("doesn't resolve relative path if file doesn't exist", async () => { + const childCode = dedent` + import { ParentController } from "./parent_controller" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedPath).toBeUndefined() + }) + + test("resolve relative path to other file", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + import { ParentController } from "./parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(importDeclaration.resolvedPath)).toEqual("src/parent_controller.js") + }) + + test("doens't resolve relative path to other file up a directory if file doesn't exist", async () => { + const childCode = dedent` + import { ParentController } from "../parent_controller" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedPath).toBeUndefined() + }) + + test("resolve relative path to other file up a directory", async () => { + const parentCode = dedent` + export class ParentController {} + ` + const childCode = dedent` + import { ParentController } from "../parent_controller" + ` + + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(importDeclaration.resolvedPath)).toEqual("src/parent_controller.js") + }) + + test("resolve relative path through multiple files", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + + const childCode = dedent` + import { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "src/grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(importDeclaration.resolvedPath)).toEqual("src/grandparent_controller.js") + }) + + test("resolve path to node module entry point", async () => { + const childCode = dedent` + import { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/controllers/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + expect(childFile.importDeclarations.length).toEqual(1) + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedPath).toBeDefined() + expect(project.relativePath(importDeclaration.resolvedPath)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + }) + }) +}) diff --git a/test/import_declaration/resolvedSourceFile.test.ts b/test/import_declaration/resolvedSourceFile.test.ts new file mode 100644 index 0000000..cbc3c00 --- /dev/null +++ b/test/import_declaration/resolvedSourceFile.test.ts @@ -0,0 +1,62 @@ +import dedent from "dedent" +import path from "path" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile } from "../../src" + +let project = new Project(process.cwd()) + +describe("ImportDeclaration", () => { + beforeEach(() => { + project = new Project(`${process.cwd()}/test/fixtures/app`) + }) + + describe("resolvedSourceFile", () => { + test("resolve relative import to file", async () => { + const grandparentCode = dedent` + export class GrandparentController {} + ` + const parentCode = dedent` + export { GrandparentController } from "./grandparent_controller" + ` + const childCode = dedent` + import { GrandparentController } from "./parent_controller" + ` + + const grandparentFile = new SourceFile(project, path.join(project.projectPath, "src/grandparent_controller.js"), grandparentCode) + const parentFile = new SourceFile(project, path.join(project.projectPath, "src/parent_controller.js"), parentCode) + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + + project.projectFiles.push(grandparentFile) + project.projectFiles.push(parentFile) + project.projectFiles.push(childFile) + + await project.analyze() + + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedSourceFile).toBeDefined() + expect(importDeclaration.resolvedSourceFile).toBeInstanceOf(SourceFile) + expect(importDeclaration.resolvedSourceFile).toEqual(grandparentFile) + expect(project.relativePath(importDeclaration.resolvedSourceFile.path)).toEqual("src/grandparent_controller.js") + }) + + test("resolve SourceFile to node module entry point", async () => { + const childCode = dedent` + import { Modal } from "tailwindcss-stimulus-components" + ` + + const childFile = new SourceFile(project, path.join(project.projectPath, "src/child_controller.js"), childCode) + project.projectFiles.push(childFile) + + await project.analyze() + + const importDeclaration = childFile.importDeclarations[0] + + expect(importDeclaration).toBeDefined() + expect(importDeclaration.resolvedSourceFile).toBeDefined() + expect(importDeclaration.resolvedSourceFile).toBeInstanceOf(SourceFile) + expect(project.relativePath(importDeclaration.resolvedSourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + }) + }) +}) diff --git a/test/node_module.test.ts b/test/node_module.test.ts new file mode 100644 index 0000000..6709226 --- /dev/null +++ b/test/node_module.test.ts @@ -0,0 +1,54 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { NodeModule } from "../src" +import { setupProject } from "./helpers/setup" + +let project = setupProject("packages/tailwindcss-stimulus-components") + +describe("NodeModule", () => { + beforeEach(() => { + project = setupProject("packages/tailwindcss-stimulus-components") + }) + + test("entrypointSourceFile", async () => { + const nodeModule = await NodeModule.forProject(project, "tailwindcss-stimulus-components") + + expect(project.relativePath(nodeModule.entrypointSourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/index.js") + }) + + test("classDeclarations", async () => { + const nodeModule = await NodeModule.forProject(project, "tailwindcss-stimulus-components") + + await nodeModule.initialize() + await nodeModule.analyze() + + expect(nodeModule.sourceFiles).toHaveLength(11) + expect(nodeModule.classDeclarations).toHaveLength(9) + }) + + test("controllerDefinitions", async () => { + const nodeModule = await NodeModule.forProject(project, "tailwindcss-stimulus-components") + + await nodeModule.initialize() + await nodeModule.analyze() + + expect(nodeModule.sourceFiles).toHaveLength(11) + expect(nodeModule.controllerDefinitions).toHaveLength(8) + }) + + test("allows to be refreshed", async () => { + const nodeModule = await NodeModule.forProject(project, "tailwindcss-stimulus-components") + + await nodeModule.initialize() + await nodeModule.analyze() + + expect(nodeModule.sourceFiles).toHaveLength(11) + expect(nodeModule.classDeclarations).toHaveLength(9) + expect(nodeModule.controllerDefinitions).toHaveLength(8) + + await nodeModule.refresh() + + expect(nodeModule.sourceFiles).toHaveLength(11) + expect(nodeModule.classDeclarations).toHaveLength(9) + expect(nodeModule.controllerDefinitions).toHaveLength(8) + }) +}) diff --git a/test/packages/app.test.ts b/test/packages/app.test.ts new file mode 100644 index 0000000..abb0a87 --- /dev/null +++ b/test/packages/app.test.ts @@ -0,0 +1,297 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +let project = setupProject("app") + +describe("packages", () => { + beforeEach(() => { + project = setupProject("app") + }) + + describe("app", () => { + test("detects controllers", async () => { + expect(project.controllerDefinitions.length).toEqual(0) + + await project.initialize() + + expect(Array.from(project.referencedNodeModules).sort()).toEqual([ + "@hotwired/stimulus", + "tailwindcss-stimulus-components" + ]) + + expect(project.detectedNodeModules.map(module => module.name).sort()).toEqual(["tailwindcss-stimulus-components"]) + expect(Array.from(project.controllerRoots)).toEqual(["src/controllers"]) + expect(project.guessedControllerRoots).toEqual([ + "src/controllers", + "node_modules/tailwindcss-stimulus-components/src", + ]) + + const exportedIdentifiers = [ + "alert", + "autosave", + "color-preview", + "custom-modal", + "dropdown", + "hello", + "modal", + "parent", + "popover", + "slideover", + "tabs", + "toggle", + ] + + expect(project.registeredControllers.map(controller => controller.identifier).sort()).toEqual(["custom-modal", "hello"]) + expect(project.controllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(["custom-modal", "hello"]) + expect(project.allControllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(exportedIdentifiers) + + await project.refresh() + + expect(project.allControllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(exportedIdentifiers) + + const controller = project.allControllerDefinitions.find(controller => controller.guessedIdentifier === "modal") + expect(controller.targetNames).toEqual(["container", "background"]) + expect(controller.valueNames).toEqual(["open", "restoreScroll"]) + expect(controller.valueDefinitionsMap.open.type).toEqual("Boolean") + expect(controller.valueDefinitionsMap.restoreScroll.type).toEqual("Boolean") + + const sourceFileCount = project.projectFiles.length + const allSourceFileCount = project.allSourceFiles.length + + expect(sourceFileCount).toEqual(4) + expect(allSourceFileCount).toEqual(15) + + // re-analyzing shouldn't add source files or controllers twice + await project.analyze() + + expect(project.allControllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(exportedIdentifiers) + + await project.analyzeAllDetectedModules() + + expect(project.projectFiles.length).toEqual(sourceFileCount) + expect(project.allSourceFiles.length).toEqual(allSourceFileCount) + expect(project.controllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(["custom-modal", "hello"]) + expect(project.allControllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(exportedIdentifiers) + expect(project.allSourceFiles.flatMap(sourceFile => sourceFile.controllerDefinitions).map(controller => controller.guessedIdentifier).sort()).toEqual(exportedIdentifiers) + }) + + test("detect all controllers", async () => { + expect(project.controllerDefinitions.length).toEqual(0) + + await project.initialize() + await project.detectAvailablePackages() + + expect(Array.from(project.referencedNodeModules).sort()).toEqual([ + "@hotwired/stimulus", + "tailwindcss-stimulus-components", + ]) + + expect(project.detectedNodeModules.map(module => module.name).sort()).toEqual([ + "@stimulus-library/controllers", + "@stimulus-library/mixins", + "@stimulus-library/utilities", + "@vytant/stimulus-decorators", + "stimulus-checkbox", + "stimulus-clipboard", + "stimulus-datepicker", + "stimulus-dropdown", + "stimulus-hotkeys", + "stimulus-inline-input-validations", + "stimulus-use", + "tailwindcss-stimulus-components", + ]) + + expect(Array.from(project.controllerRoots)).toEqual(["src/controllers"]) + + expect(project.guessedControllerRoots).toEqual([ + "src/controllers", + "node_modules/tailwindcss-stimulus-components/src", + ]) + + expect(project.controllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(["custom-modal", "hello"]) + expect(project.allControllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual([ + "alert", + "autosave", + "color-preview", + "custom-modal", + "dropdown", + "hello", + "modal", + "parent", + "popover", + "slideover", + "tabs", + "toggle", + ]) + + await project.analyzeAllDetectedModules() + + const allIdentifiers = [ + "AlertController", + "AnchorSpyController", + "ApplicationController", + "AsyncBlockController", + "AutoSubmitFormController", + "AutosizeController", + "BackLinkController", + "BaseController", + "CharCountController", + "CheckboxDisableInputsController", + "CheckboxEnableInputsController", + "CheckboxSelectAllController", + "CheckboxXORController", + "ClickOutsideComposableController", + "ClickOutsideController", + "ClipboardController", + "ClockController", + "ConfirmController", + "ConfirmNavigationController", + "CountdownController", + "Datepicker", + "DebounceController", + "DebugController", + "DetectDirtyController", + "DetectDirtyFormController", + "DisableWithController", + "DismissableController", + "DurationController", + "ElementSaveController", + "EmptyDomController", + "EnableInputsController", + "EphemeralController", + "EqualizeController", + "FallbackImageController", + "FocusStealController", + "FormDirtyConfirmNavigationController", + "FormRcController", + "FormSaveController", + "FullscreenController", + "HoverComposableController", + "HoverController", + "IdleComposableController", + "IdleController", + "InstallClassMethodComposableController", + "IntersectionComposableController", + "IntersectionController", + "IntersectionController", + "IntervalController", + "LazyBlockController", + "LazyLoadComposableController", + "LazyLoadController", + "LightboxImageController", + "LimitedSelectionCheckboxesController", + "LoadBlockController", + "MediaPlayerController", + "MutationComposableController", + "MutationController", + "NavigateFormErrorsController", + "NestedFormController", + "ParentController", + "PasswordConfirmController", + "PasswordPeekController", + "PersistedDismissableController", + "PersistedRemoveController", + "PollBlockController", + "PrefetchController", + "PresenceController", + "PrintButtonController", + "PrintController", + "RefreshPageController", + "RemoteFormController", + "RemoveController", + "ResizeComposableController", + "ResizeController", + "ResponsiveIframeBodyController", + "ResponsiveIframeWrapperController", + "ScrollContainerController", + "ScrollIntoFocusController", + "ScrollToBottomController", + "ScrollToController", + "ScrollToTopController", + "SelfDestructController", + "SignalActionController", + "SignalBaseController", + "SignalDisableController", + "SignalDomChildrenController", + "SignalEnableController", + "SignalInputController", + "SignalVisibilityController", + "StickyController", + "SyncInputsController", + "TableSortController", + "TableTruncateController", + "TabsController", + "TargetMutationComposableController", + "TargetMutationController", + "TeleportController", + "TemporaryStateController", + "ThrottleController", + "TimeDistanceController", + "TimeoutController", + "ToggleClassController", + "TransitionComposableController", + "TransitionController", + "TreeViewController", + "TrixComposableController", + "TrixModifierController", + "TurboFrameHistoryController", + "TurboFrameRCController", + "TurboFrameRefreshController", + "TweenNumberController", + "UserFocusController", + "ValueWarnController", + "VisibilityComposableController", + "VisibilityController", + "WindowFocusComposableController", + "WindowFocusController", + "WindowResizeComposableController", + "WindowResizeController", + "WordCountController", + "alert", + "autosave", + "color-preview", + "custom-modal", + "dropdown", + "hello", + "i", + "input-validator", + "modal", + "popover", + "slideover", + "stimulus-checkbox", + "stimulus-hotkeys", + "t", + "tabs", + "toggle", + ] + + expect(project.allControllerDefinitions.map(controller => controller.classDeclaration.className || controller.guessedIdentifier).sort()).toEqual(allIdentifiers) + expect(project.allSourceFiles.flatMap(sourceFile => sourceFile.controllerDefinitions).map(controller => controller.classDeclaration.className || controller.guessedIdentifier).sort()).toEqual(allIdentifiers) + + const controller = project.allControllerDefinitions.find(controller => controller.guessedIdentifier === "modal") + expect(controller.targetNames).toEqual(["container", "background"]) + expect(controller.valueNames).toEqual(["open", "restoreScroll"]) + expect(controller.valueDefinitionsMap.open.type).toEqual("Boolean") + expect(controller.valueDefinitionsMap.restoreScroll.type).toEqual("Boolean") + + expect(project.projectFiles.length).toEqual(4) + expect(project.allSourceFiles.length).toEqual(168) + const allSourceFiles = project.allSourceFiles.map(s => s.path).sort() + + // re-analyzing shouldn't add source files or controllers twice + await project.analyze() + + expect(project.allControllerDefinitions.map(controller => controller.classDeclaration.className || controller.guessedIdentifier).sort()).toEqual(allIdentifiers) + + await project.analyzeAllDetectedModules() + + expect(project.projectFiles.length).toEqual(4) + expect(project.allSourceFiles.length).toEqual(168) + expect(project.allSourceFiles.map(s => s.path).sort()).toEqual(allSourceFiles) + + expect(project.controllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(["custom-modal", "hello"]) + expect(project.allControllerDefinitions.map(controller => controller.classDeclaration.className || controller.guessedIdentifier).sort()).toEqual(allIdentifiers) + expect(project.allSourceFiles.flatMap(sourceFile => sourceFile.controllerDefinitions).map(controller => controller.classDeclaration.className || controller.guessedIdentifier).sort()).toEqual(allIdentifiers) + }) + }) +}) diff --git a/test/packages/tailwindcss-stimulus-components.test.ts b/test/packages/tailwindcss-stimulus-components.test.ts new file mode 100644 index 0000000..4802de0 --- /dev/null +++ b/test/packages/tailwindcss-stimulus-components.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from "vitest" +import { Project, StimulusControllerClassDeclaration } from "../../src" + +const project = new Project(`${process.cwd()}/test/fixtures/packages/tailwindcss-stimulus-components`) + +describe("packages", () => { + describe("tailwindcss-stimulus-components", () => { + + // this test is flaky, sometimes we slideover controller is actually present + test("detects controllers", async () => { + expect(project.controllerDefinitions.length).toEqual(0) + + await project.initialize() + + expect(project.detectedNodeModules.map(module => module.name)).toEqual(["tailwindcss-stimulus-components"]) + expect(Array.from(project.controllerRoots)).toEqual([]) + expect(project.guessedControllerRoots).toEqual([ + "app/javascript/controllers", + "node_modules/tailwindcss-stimulus-components/src", + ]) + expect(project.controllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual(["hello"]) + + expect(project.allControllerDefinitions.map(controller => controller.guessedIdentifier).sort()).toEqual([ + "alert", + "autosave", + "color-preview", + "dropdown", + "hello", + "modal", + "popover", + "slideover", + "tabs", + "toggle", + ]) + + const modalController = project.allControllerDefinitions.find(controller => controller.guessedIdentifier === "modal") + + expect(modalController.targetNames).toEqual(["container", "background"]) + expect(modalController.valueNames).toEqual(["open", "restoreScroll"]) + expect(modalController.valueDefinitionsMap.open.type).toEqual("Boolean") + expect(modalController.valueDefinitionsMap.restoreScroll.type).toEqual("Boolean") + expect(modalController.classDeclaration.superClass).toBeDefined() + expect(modalController.classDeclaration.superClass.className).toEqual("Controller") + expect(modalController.classDeclaration.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + expect(modalController.classDeclaration.superClass.isStimulusClassDeclaration).toEqual(true) + expect(modalController.classDeclaration.superClass.importDeclaration.source).toEqual("@hotwired/stimulus") + expect(modalController.classDeclaration.superClass.importDeclaration.localName).toEqual("Controller") + expect(modalController.classDeclaration.superClass.importDeclaration.originalName).toEqual("Controller") + expect(modalController.classDeclaration.superClass.importDeclaration.isStimulusImport).toEqual(true) + + const slideoverSontroller = project.allControllerDefinitions.find(controller => controller.guessedIdentifier === "slideover") + + expect(slideoverSontroller.targetNames).toEqual(["menu", "overlay", "close", "menu", "button", "menuItem"]) + expect(slideoverSontroller.valueNames).toEqual(["open"]) + expect(slideoverSontroller.classDeclaration.superClass.className).toEqual(undefined) + expect(slideoverSontroller.classDeclaration.superClass.isStimulusDescendant).toEqual(true) + expect(slideoverSontroller.classDeclaration.superClass.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + }) +}) diff --git a/test/parser/classes.test.ts b/test/parser/classes.test.ts index b340479..6ea50f8 100644 --- a/test/parser/classes.test.ts +++ b/test/parser/classes.test.ts @@ -1,11 +1,10 @@ -import { describe, expect, test } from "vitest" -import { setupParser } from "../helpers/setup" - -const parser = setupParser() +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { parseController } from "../helpers/parse" describe("parse classes", () => { test("static", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -13,14 +12,14 @@ describe("parse classes", () => { } ` - const controller = parser.parseController(code, "class_controller.js") + const controller = parseController(code, "class_controller.js") expect(controller.isTyped).toBeFalsy() - expect(controller.classes).toEqual(["one", "two", "three"]) + expect(controller.classNames).toEqual(["one", "two", "three"]) }) test("duplicate static classes", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -28,19 +27,69 @@ describe("parse classes", () => { } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.js") expect(controller.isTyped).toBeFalsy() - expect(controller.classes).toEqual(["one", "one", "three"]) + expect(controller.classNames).toEqual(["one", "one", "three"]) expect(controller.hasErrors).toBeTruthy() expect(controller.errors).toHaveLength(1) - expect(controller.errors[0].message).toEqual("Duplicate definition of class:one") - expect(controller.errors[0].loc.start.line).toEqual(5) - expect(controller.errors[0].loc.end.line).toEqual(5) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Class "one"`) + expect(controller.errors[0].loc.start.line).toEqual(4) + expect(controller.errors[0].loc.start.column).toEqual(19) + expect(controller.errors[0].loc.end.line).toEqual(4) + expect(controller.errors[0].loc.end.column).toEqual(42) + }) + + test("duplicate static classes from parent", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Parent extends Controller { + static classes = ["one"] + } + + export default class Child extends Parent { + static classes = ["one", "three"] + } + ` + + const controller = parseController(code, "target_controller.js", "Child") + + expect(controller.isTyped).toBeFalsy() + expect(controller.classNames).toEqual(["one", "three", "one"]) + expect(controller.hasErrors).toBeTruthy() + expect(controller.errors).toHaveLength(1) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Class "one". A parent controller already defines this Class.`) + expect(controller.errors[0].loc.start.line).toEqual(8) + expect(controller.errors[0].loc.start.column).toEqual(19) + expect(controller.errors[0].loc.end.line).toEqual(8) + expect(controller.errors[0].loc.end.column).toEqual(35) + }) + + test("assigns classes outside of class via member expression", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class One extends Controller {} + class Two extends Controller {} + + One.classes = ["one", "two"] + ` + + const one = parseController(code, "classes_controller.js", "One") + const two = parseController(code, "classes_controller.js", "Two") + + expect(one.isTyped).toBeFalsy() + expect(one.classNames).toEqual(["one", "two"]) + expect(one.hasErrors).toBeFalsy() + + expect(two.isTyped).toBeFalsy() + expect(two.classNames).toEqual([]) + expect(two.hasErrors).toBeFalsy() }) test("single @Class decorator", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Class, TypedController } from "@vytant/stimulus-decorators"; @@ -50,14 +99,14 @@ describe("parse classes", () => { } ` - const controller = parser.parseController(code, "class_controller.js") + const controller = parseController(code, "class_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.classes).toEqual(["random"]) + expect(controller.classNames).toEqual(["random"]) }) test("single @Classes decorator", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Classes, TypedController } from "@vytant/stimulus-decorators"; @@ -67,14 +116,14 @@ describe("parse classes", () => { } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.classes).toEqual(["random"]) + expect(controller.classNames).toEqual(["random"]) }) test("parse multiple class definitions", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Class, TypedController } from "@vytant/stimulus-decorators"; @@ -85,14 +134,14 @@ describe("parse classes", () => { } ` - const controller = parser.parseController(code, "decorator_controller.js") + const controller = parseController(code, "decorator_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.classes).toEqual(["one", "two"]) + expect(controller.classNames).toEqual(["one", "two"]) }) test("parse mix decorator and static definitions", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Class, Classes, TypedController } from "@vytant/stimulus-decorators"; @@ -106,9 +155,9 @@ describe("parse classes", () => { } ` - const controller = parser.parseController(code, "decorator_controller.js") + const controller = parseController(code, "decorator_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.classes).toEqual(["output", "name", "item", "one", "two"]) + expect(controller.classNames).toEqual(["output", "name", "item", "one", "two"]) }) }) diff --git a/test/parser/decorators.test.ts b/test/parser/decorators.test.ts index 2e11da7..1daa493 100644 --- a/test/parser/decorators.test.ts +++ b/test/parser/decorators.test.ts @@ -1,11 +1,10 @@ -import { expect, test, describe } from "vitest" -import { setupParser } from "../helpers/setup" - -const parser = setupParser() +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { parseController } from "../helpers/parse" describe("decorator", () => { test("parse single target", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -15,14 +14,14 @@ describe("decorator", () => { } ` - const controller = parser.parseController(code, 'target_controller.js') + const controller = parseController(code, 'target_controller.js') expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(['output']) + expect(controller.targetNames).toEqual(['output']) }) test("parse multiple target definitions", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -33,14 +32,14 @@ describe("decorator", () => { } ` - const controller = parser.parseController(code, 'decorator_controller.js') + const controller = parseController(code, 'decorator_controller.js') expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(['output', 'name']) + expect(controller.targetNames).toEqual(['output', 'name']) }) test("parse mix decorator and static definitions", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -53,9 +52,49 @@ describe("decorator", () => { } ` - const controller = parser.parseController(code, 'decorator_controller.js') + const controller = parseController(code, 'decorator_controller.js') expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(['output', 'name', 'one', 'two']) + expect(controller.targetNames).toEqual(['output', 'name', 'one', 'two']) + }) + + test("adds error when decorator is used but controller is not decorated with @TypedController", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + import { Target } from "@vytant/stimulus-decorators"; + + export default class extends Controller { + @Target private readonly outputTarget!: HTMLDivElement; + } + ` + + const controller = parseController(code, 'target_controller.js') + expect(controller.isTyped).toBeFalsy() + expect(controller.errors.length).toEqual(1) + expect(controller.errors[0].message).toEqual("Controller needs to be decorated with @TypedController in order to use decorators.") + expect(controller.errors[0].loc.start.line).toEqual(4) + expect(controller.errors[0].loc.start.column).toEqual(15) + expect(controller.errors[0].loc.end.line).toEqual(6) + expect(controller.errors[0].loc.end.column).toEqual(1) + expect(controller.targetNames).toEqual(['output']) + }) + + test("adds error when controller is decorated with @TypedController but no decorated in controller is used", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + import { TypedController } from "@vytant/stimulus-decorators"; + + @TypedController + export default class extends Controller {} + ` + + const controller = parseController(code, 'target_controller.js') + expect(controller.isTyped).toBeTruthy() + expect(controller.errors.length).toEqual(1) + expect(controller.errors[0].message).toEqual("Controller was decorated with @TypedController but Controller didn't use any decorators.") + expect(controller.errors[0].loc.start.line).toEqual(5) + expect(controller.errors[0].loc.start.column).toEqual(15) + expect(controller.errors[0].loc.end.line).toEqual(5) + expect(controller.errors[0].loc.end.column).toEqual(42) }) }) diff --git a/test/parser/javascript.test.ts b/test/parser/javascript.test.ts index a11ddbd..91be5b9 100644 --- a/test/parser/javascript.test.ts +++ b/test/parser/javascript.test.ts @@ -1,37 +1,67 @@ -import { expect, test, vi, describe } from "vitest" -import { setupParser } from "../helpers/setup" +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { parseController } from "../helpers/parse" -const parser = setupParser() +import { Project } from "../../src/project" +import { SourceFile } from "../../src/source_file" describe("with JS Syntax", () => { + test("doesn't parse non Stimulus class", () => { + const code = dedent` + export default class extends Controller { + static targets = ["one", "two", "three"] + } + ` + const controller = parseController(code, "target_controller.js") + + expect(controller).toBeUndefined() + }) + test("parse targets", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["one", "two", "three"] } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.js") - expect(controller.targets).toEqual(["one", "two", "three"]) + expect(controller.targetNames).toEqual(["one", "two", "three"]) }) test("parse classes", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { static classes = ["one", "two", "three"] } ` - const controller = parser.parseController(code, "class_controller.js") + const controller = parseController(code, "class_controller.js") - expect(controller.classes).toEqual(["one", "two", "three"]) + expect(controller.classNames).toEqual(["one", "two", "three"]) + }) + + // TODO: instead, we could also mark the SpreadElement node with + // a warning that says that we couldn't parse it + test.todo("parse classes with spread", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static spread = ["one", "two"] + static classes = [...this.spread, "three"] + } + ` + const controller = parseController(code, "class_controller.js") + + expect(controller.classNames).toEqual(["three"]) + expect(controller.classNames).toEqual(["one", "two", "three"]) }) test("parse values", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -44,9 +74,9 @@ describe("with JS Syntax", () => { } } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.js") - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "" }, object: { type: "Object", default: {} }, boolean: { type: "Boolean", default: false }, @@ -56,7 +86,7 @@ describe("with JS Syntax", () => { }) test("parse values with with default values", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -69,9 +99,38 @@ describe("with JS Syntax", () => { } } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.js") + + expect(controller.valueDefinitionsMap).toEqual({ + string: { type: "String", default: "string" }, + object: { type: "Object", default: { object: "Object" } }, + boolean: { type: "Boolean", default: true }, + array: { type: "Array", default: ["Array"] }, + number: { type: "Number", default: 1 }, + }) + }) + + test.todo("parse values with spread", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static spread = { + string: { type: String, default: "string" }, + object: { type: Object, default: { object: "Object" } } + } + + static values = { + ...this.spread, + boolean: { type: Boolean, default: true }, + array: { type: Array, default: ["Array"] }, + number: { type: Number, default: 1 } + } + } + ` + const controller = parseController(code, "value_controller.js") - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "string" }, object: { type: "Object", default: { object: "Object" } }, boolean: { type: "Boolean", default: true }, @@ -80,29 +139,29 @@ describe("with JS Syntax", () => { }) }) - test("should handle syntax errors", () => { - const code = ` + test("should handle syntax errors", async () => { + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { ` - const spy = vi.spyOn(console, 'error') - const controller = parser.parseController(code, "error_controller.js") + const project = new Project(process.cwd()) + const sourceFile = new SourceFile(project, "error_controller.js", code) + project.projectFiles.push(sourceFile) - expect(controller.identifier).toEqual("error") - expect(controller.hasErrors).toBeTruthy() - expect(controller.errors).toHaveLength(1) - expect(controller.errors[0].message).toEqual("Error parsing controller") - expect(controller.errors[0].cause.message).toEqual("'}' expected.") - // expect(controller.errors[0].loc.start.line).toEqual(9) - // expect(controller.errors[0].loc.end.line).toEqual(9) + await project.analyze() - expect(spy).toBeCalledWith("Error while parsing controller in 'error_controller.js': '}' expected.") + expect(sourceFile.hasErrors).toBeTruthy() + expect(sourceFile.errors).toHaveLength(1) + expect(sourceFile.errors[0].message).toEqual("Error parsing controller: '}' expected.") + expect(sourceFile.errors[0].cause.message).toEqual("'}' expected.") + // expect(sourceFile.errors[0].loc.start.line).toEqual(9) + // expect(sourceFile.errors[0].loc.end.line).toEqual(9) }) test("parse arrow function", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -118,44 +177,65 @@ describe("with JS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.js") + const controller = parseController(code, "controller.js") - expect(controller.methods).toEqual(["connect", "load"]) + expect(controller.actionNames).toEqual(["connect", "load"]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) test("parse methods", () => { - const code = ` + const code = dedent` + import { Controller } from "@hotwired/stimulus" + export default class extends Controller { load() {} unload() {} } ` - const controller = parser.parseController(code, "controller.js") + const controller = parseController(code, "controller.js") - expect(controller.methods).toEqual(["load", "unload"]) + expect(controller.actionNames).toEqual(["load", "unload"]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) test("parse private methods", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { #load() {} } ` - const controller = parser.parseController(code, "controller.js") + const controller = parseController(code, "controller.js") - expect(controller.methods).toEqual(["#load"]) + expect(controller.actionNames).toEqual(["#load"]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) + test("parse nested object/array default value types", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static values = { + object: { type: Object, default: { object: { some: { more: { levels: {} } } } } }, + array: { type: Array, default: [["Array", "with", ["nested", ["values"]]]] }, + } + } + ` + const controller = parseController(code, "value_controller.js") + + expect(controller.valueDefinitionsMap).toEqual({ + object: { type: "Object", default: { object: { some: { more: { levels: {} } } } } }, + array: { type: "Array", default: [["Array", "with", ["nested", ["values"]]]] }, + }) + }) + test("parse controller with public class fields", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -166,15 +246,15 @@ describe("with JS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.js") + const controller = parseController(code, "controller.js") - expect(controller.methods).toEqual([]) + expect(controller.actionNames).toEqual([]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) test("parse controller with private getter", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -184,15 +264,15 @@ describe("with JS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.js") + const controller = parseController(code, "controller.js") expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) - expect(controller.methods).toEqual([]) + expect(controller.actionNames).toEqual([]) }) test("parse controller with private setter", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -202,15 +282,15 @@ describe("with JS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.js") + const controller = parseController(code, "controller.js") expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) - expect(controller.methods).toEqual([]) + expect(controller.actionNames).toEqual([]) }) test("parse controller with variable declaration in method body", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -220,10 +300,10 @@ describe("with JS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.js") + const controller = parseController(code, "controller.js") expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) - expect(controller.methods).toEqual(["method"]) + expect(controller.actionNames).toEqual(["method"]) }) }) diff --git a/test/parser/minified.test.ts b/test/parser/minified.test.ts index 359db69..00034b6 100644 --- a/test/parser/minified.test.ts +++ b/test/parser/minified.test.ts @@ -1,11 +1,10 @@ -import { expect, test, describe } from "vitest" -import { setupParser } from "../helpers/setup" - -const parser = setupParser() +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { parseController } from "../helpers/parse" describe("compiled JavaScript", () => { test("transpiled", () => { - const code = ` + const code = dedent` import { Controller as o } from "@hotwired/stimulus"; class r extends o { @@ -37,26 +36,56 @@ describe("compiled JavaScript", () => { }; ` - const controller = parser.parseController(code, "minified_controller.js") + const controller = parseController(code, "minified_controller.js") expect(controller.hasErrors).toEqual(false) - expect(controller.methods).toEqual(["initialize", "connect", "disconnect"]) - expect(controller.targets).toEqual(["item"]) - expect(controller.classes).toEqual(["active", "inactive"]) - // expect(Object.keys(controller.values)).toEqual(["class", "threshold", "rootMargin"]) + expect(controller.actionNames).toEqual(["initialize", "connect", "disconnect"]) + expect(controller.targetNames).toEqual(["item"]) + expect(controller.classNames).toEqual(["active", "inactive"]) + expect(controller.valueNames).toEqual(["class", "threshold", "rootMargin"]) + }) + + test("transpiled with duplicate targets", () => { + const code = dedent` + import { Controller as o } from "@hotwired/stimulus"; + + class r extends o { + static targets = ["item"] + } + + r.targets = ["item"]; + + export { + r as default + }; + ` + + const controller = parseController(code, "minified_controller.js") + + expect(controller.hasErrors).toEqual(true) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Target "item"`) + expect(controller.errors[0].loc.start.line).toEqual(4) + expect(controller.errors[0].loc.start.column).toEqual(19) + expect(controller.errors[0].loc.end.line).toEqual(4) + expect(controller.errors[0].loc.end.column).toEqual(27) + + expect(controller.actionNames).toEqual([]) + expect(controller.targetNames).toEqual(["item", "item"]) + expect(controller.classNames).toEqual([]) + expect(Object.keys(controller.valueDefinitions)).toEqual([]) }) - test("transpiled/minified", () => { - const code = ` + test.skip("transpiled/minified", () => { + const code = dedent` (function(e,t){typeof exports=="object"&&typeof module<"u"?module.exports=t(require("@hotwired/stimulus")):typeof define=="function"&&define.amd?define(["@hotwired/stimulus"],t):(e=typeof globalThis<"u"?globalThis:e||self,e.StimulusScrollReveal=t(e.Stimulus))})(this,function(e){"use strict";class t extends e.Controller{initialize(){this.intersectionObserverCallback=this.intersectionObserverCallback.bind(this)}connect(){this.class=this.classValue||this.defaultOptions.class||"in",this.threshold=this.thresholdValue||this.defaultOptions.threshold||.1,this.rootMargin=this.rootMarginValue||this.defaultOptions.rootMargin||"0px",this.observer=new IntersectionObserver(this.intersectionObserverCallback,this.intersectionObserverOptions),this.itemTargets.forEach(s=>this.observer.observe(s))}disconnect(){this.itemTargets.forEach(s=>this.observer.unobserve(s))}intersectionObserverCallback(s,o){s.forEach(r=>{if(r.intersectionRatio>this.threshold){const i=r.target;i.classList.add(...this.class.split(" ")),i.dataset.delay&&(i.style.transitionDelay=i.dataset.delay),o.unobserve(i)}})}get intersectionObserverOptions(){return{threshold:this.threshold,rootMargin:this.rootMargin}}get defaultOptions(){return{}}}return t.targets=["item"],t.values={class:String,threshold:Number,rootMargin:String},t}); ` - const controller = parser.parseController(code, "minified_controller.js") + const controller = parseController(code, "minified_controller.js") expect(controller.hasErrors).toEqual(false) - expect(controller.methods).toEqual(["initialize", "connect", "disconnect","intersectionObserverCallback"]) - expect(controller.classes).toEqual([]) - // expect(controller.targets).toEqual(["item"]) - // expect(Object.keys(controller.values)).toEqual(["class", "threshold", "rootMargin"]) + expect(controller.actionNames).toEqual(["initialize", "connect", "disconnect", "intersectionObserverCallback"]) + expect(controller.classNames).toEqual([]) + expect(controller.targetNames).toEqual(["item"]) + expect(Object.keys(controller.valueDefinitions)).toEqual(["class", "threshold", "rootMargin"]) }) }) diff --git a/test/parser/parent.inheritance.test.ts b/test/parser/parent.inheritance.test.ts deleted file mode 100644 index a8b1e25..0000000 --- a/test/parser/parent.inheritance.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect, test, describe } from "vitest" -// import { setupParser } from "../helpers/setup" - -// const parser = setupParser() - -describe("inheritance", () => { - test.skip("inherits methods", () => { - expect(true).toBeTruthy() - }) - - test.skip("inherits targets", () => { - expect(true).toBeTruthy() - }) - - test.skip("inherits values", () => { - expect(true).toBeTruthy() - }) - - test.skip("inherits classes", () => { - expect(true).toBeTruthy() - }) - - test.skip("inherits outlets", () => { - expect(true).toBeTruthy() - }) -}) diff --git a/test/parser/parent.test.ts b/test/parser/parent.test.ts index dff8278..59c9e5a 100644 --- a/test/parser/parent.test.ts +++ b/test/parser/parent.test.ts @@ -1,119 +1,191 @@ -import { expect, test, describe } from "vitest" -import { setupParser } from "../helpers/setup" +import path from "path" +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" -const parser = setupParser() +import { ClassDeclaration, StimulusControllerClassDeclaration } from "../../src/class_declaration" +import { SourceFile } from "../../src/source_file" + +let project = setupProject("packages/stimulus-dropdown") describe("@hotwired/stimulus Controller", () => { - test("parse parent", () => { - const code = ` + test("parse parent", async () => { + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller {} ` - const controller = parser.parseController(code, "parent_controller.js") - - expect(controller.parent.constant).toEqual("Controller") - expect(controller.parent.type).toEqual("default") - expect(controller.parent.package).toEqual("@hotwired/stimulus") - expect(controller.parent.definition).toEqual(undefined) - expect(controller.parent.identifier).toEqual(undefined) - expect(controller.parent.controllerFile).toEqual(undefined) + + const sourceFile = new SourceFile(project, "parent_controller.js", code) + + project.projectFiles.push(sourceFile) + + await project.analyze() + + const classDeclaration = sourceFile.classDeclarations[0] + + expect(sourceFile.classDeclarations.length).toEqual(1) + expect(classDeclaration.className).toEqual(undefined) + expect(classDeclaration.superClass.className).toEqual("Controller") + expect(classDeclaration.superClass.importDeclaration.localName).toEqual("Controller") + expect(classDeclaration.superClass.importDeclaration.originalName).toEqual("Controller") + expect(classDeclaration.superClass.importDeclaration.source).toEqual("@hotwired/stimulus") + expect(classDeclaration.superClass.importDeclaration.isStimulusImport).toEqual(true) + expect(classDeclaration.superClass.superClass).toEqual(undefined) }) - test("parse parent with import alias", () => { - const code = ` + test("parse parent with import alias", async () => { + const code = dedent` import { Controller as StimulusController } from "@hotwired/stimulus" export default class extends StimulusController {} ` - const controller = parser.parseController(code, "parent_controller.js") - - expect(controller.parent.constant).toEqual("StimulusController") - expect(controller.parent.type).toEqual("default") - expect(controller.parent.package).toEqual("@hotwired/stimulus") - expect(controller.parent.definition).toEqual(undefined) - expect(controller.parent.parent).toEqual(undefined) - expect(controller.parent.identifier).toEqual(undefined) - expect(controller.parent.controllerFile).toEqual(undefined) + + const sourceFile = new SourceFile(project, "parent_controller.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const classDeclaration = sourceFile.classDeclarations[0] + + expect(sourceFile.classDeclarations.length).toEqual(1) + expect(classDeclaration.isStimulusDescendant).toEqual(true) + expect(classDeclaration.className).toEqual(undefined) + expect(classDeclaration.superClass.className).toEqual("StimulusController") + expect(classDeclaration.superClass.importDeclaration.localName).toEqual("StimulusController") + expect(classDeclaration.superClass.importDeclaration.originalName).toEqual("Controller") + expect(classDeclaration.superClass.importDeclaration.source).toEqual("@hotwired/stimulus") + expect(classDeclaration.superClass.importDeclaration.isStimulusImport).toEqual(true) + expect(classDeclaration.superClass.superClass).toEqual(undefined) }) }) describe("with controller in same file", () => { - test("parse parent", () => { - const code = ` + test("parse parent", async () => { + const code = dedent` import { Controller } from "@hotwired/stimulus" class AbstractController extends Controller {} export default class extends AbstractController {} ` - const controller = parser.parseController(code, "parent_controller.js") - - expect(controller.parent.constant).toEqual("AbstractController") - expect(controller.parent.type).toEqual("unknown") - expect(controller.parent.package).toEqual(undefined) - expect(controller.parent.definition).toEqual(undefined) - expect(controller.parent.parent).toEqual(undefined) - expect(controller.parent.identifier).toEqual(undefined) - expect(controller.parent.controllerFile).toEqual(undefined) - // expect(controller.parent.controllerFile).toEqual("app/javascript/controllers/parent_controller.js") + + const sourceFile = new SourceFile(project, "parent_controller.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const abstractController = sourceFile.classDeclarations[0] + const exportController = sourceFile.classDeclarations[1] + + expect(sourceFile.classDeclarations.length).toEqual(2) + + expect(abstractController.isStimulusDescendant).toEqual(true) + expect(abstractController.className).toEqual("AbstractController") + expect(abstractController.superClass.className).toEqual("Controller") + expect(abstractController.superClass.importDeclaration.localName).toEqual("Controller") + expect(abstractController.superClass.importDeclaration.originalName).toEqual("Controller") + expect(abstractController.superClass.importDeclaration.source).toEqual("@hotwired/stimulus") + expect(abstractController.superClass.importDeclaration.isStimulusImport).toEqual(true) + expect(abstractController.superClass.superClass).toEqual(undefined) + + expect(exportController.isStimulusDescendant).toEqual(true) + expect(exportController.className).toEqual(undefined) + expect(exportController.superClass.className).toEqual("AbstractController") + expect(exportController.superClass.importDeclaration).toEqual(undefined) + expect(exportController.superClass).toEqual(abstractController) }) }) describe("with controller from other file", () => { - test("parse parent", () => { - const code = ` + test("parse parent", async () => { + const applicationCode = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller {} + ` + + const helloCode = dedent` import ApplicationController from "./application_controller" export default class extends ApplicationController {} ` - const controller = parser.parseController(code, "parent_controller.js") - - expect(controller.parent.constant).toEqual("ApplicationController") - expect(controller.parent.type).toEqual("import") - expect(controller.parent.package).toEqual("./application_controller") - expect(controller.parent.definition).toEqual(undefined) - expect(controller.parent.parent).toEqual(undefined) - expect(controller.parent.identifier).toEqual(undefined) - expect(controller.parent.controllerFile).toEqual(undefined) - // expect(controller.parent.controllerFile).toEqual("app/javascript/controllers/application_controller.js") + + const applicationFile = new SourceFile(project, path.join(project.projectPath, "application_controller.js"), applicationCode) + const helloFile = new SourceFile(project, path.join(project.projectPath, "parent_controller.js"), helloCode) + + project.projectFiles.push(applicationFile) + project.projectFiles.push(helloFile) + + await project.analyze() + + const applicationController = applicationFile.classDeclarations[0] + const helloController = helloFile.classDeclarations[0] + + expect(helloFile.classDeclarations.length).toEqual(1) + + expect(helloController.isStimulusDescendant).toEqual(true) + expect(helloController.superClass.isStimulusDescendant).toEqual(true) + expect(applicationController.isStimulusDescendant).toEqual(true) + + expect(helloController.className).toEqual(undefined) + expect(applicationController.className).toEqual(undefined) + + expect(helloController.superClass).toEqual(applicationController) + expect(project.relativePath(helloController.superClass.sourceFile.path)).toEqual("application_controller.js") + + expect(helloController.superClass).toBeInstanceOf(ClassDeclaration) + expect(helloController.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + expect(helloController.superClass.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) }) }) describe("with controller from stimulus package", () => { - test("parse parent with default import", () => { - const code = ` - import SomeController from "some-package" + test("parse parent with default import", async () => { + const code = dedent` + import SomeController from "stimulus-dropdown" export default class extends SomeController {} ` - const controller = parser.parseController(code, "parent_controller.js") - - expect(controller.parent.constant).toEqual("SomeController") - expect(controller.parent.type).toEqual("import") - expect(controller.parent.package).toEqual("some-package") - expect(controller.parent.definition).toEqual(undefined) - expect(controller.parent.parent).toEqual(undefined) - expect(controller.parent.identifier).toEqual(undefined) - expect(controller.parent.controllerFile).toEqual(undefined) - // expect(controller.parent.controllerFile).toEqual("some-package/dist/some_controller.js") + + const sourceFile = new SourceFile(project, "parent_controller.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const classDeclaration = sourceFile.classDeclarations[0] + + expect(sourceFile.classDeclarations.length).toEqual(1) + expect(classDeclaration.isStimulusDescendant).toEqual(true) + expect(classDeclaration.className).toEqual(undefined) + expect(classDeclaration.superClass.className).toEqual("i") + expect(classDeclaration.superClass.superClass.className).toEqual("e") + expect(classDeclaration.superClass.superClass.isStimulusClassDeclaration).toEqual(true) + expect(project.relativePath(classDeclaration.superClass.sourceFile.path)).toEqual("node_modules/stimulus-dropdown/dist/stimulus-dropdown.mjs") }) - test("parse parent with regular import", () => { - const code = ` - import { SomeController } from "some-package" + test("parse parent with regular import", async () => { + const project = setupProject("packages/tailwindcss-stimulus-components") - export default class extends SomeController {} + const code = dedent` + import { Modal } from "tailwindcss-stimulus-components" + + export default class extends Modal {} ` - const controller = parser.parseController(code, "parent_controller.js") - - expect(controller.parent.constant).toEqual("SomeController") - expect(controller.parent.type).toEqual("import") - expect(controller.parent.package).toEqual("some-package") - expect(controller.parent.definition).toEqual(undefined) - expect(controller.parent.parent).toEqual(undefined) - expect(controller.parent.identifier).toEqual(undefined) - expect(controller.parent.controllerFile).toEqual(undefined) - // expect(controller.parent.controllerFile).toEqual("some-package/dist/some_controller.js") + + const sourceFile = new SourceFile(project, "parent_controller.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const classDeclaration = sourceFile.classDeclarations[0] + expect(classDeclaration).toBeDefined() + + expect(sourceFile.errors.length).toEqual(0) + expect(sourceFile.classDeclarations.length).toEqual(1) + expect(classDeclaration.isStimulusDescendant).toEqual(true) + expect(classDeclaration.className).toEqual(undefined) + expect(classDeclaration.superClass.className).toEqual(undefined) + expect(classDeclaration.superClass.superClass.isStimulusClassDeclaration).toEqual(true) }) }) diff --git a/test/parser/targets.test.ts b/test/parser/targets.test.ts index 388c17e..6411473 100644 --- a/test/parser/targets.test.ts +++ b/test/parser/targets.test.ts @@ -1,25 +1,24 @@ -import { describe, expect, test } from "vitest" -import { setupParser } from "../helpers/setup" - -const parser = setupParser() +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { parseController } from "../helpers/parse" describe("parse targets", () => { test("static targets", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["one", "two", "three"] } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.js") expect(controller.isTyped).toBeFalsy() - expect(controller.targets).toEqual(["one", "two", "three"]) + expect(controller.targetNames).toEqual(["one", "two", "three"]) }) test("duplicate static targets", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -27,19 +26,139 @@ describe("parse targets", () => { } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.js") + + expect(controller.isTyped).toBeFalsy() + expect(controller.targetNames).toEqual(["one", "one", "three"]) + expect(controller.hasErrors).toBeTruthy() + expect(controller.errors).toHaveLength(1) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Target "one"`) + expect(controller.errors[0].loc.start.line).toEqual(4) + expect(controller.errors[0].loc.start.column).toEqual(19) + expect(controller.errors[0].loc.end.line).toEqual(4) + expect(controller.errors[0].loc.end.column).toEqual(42) + }) + + test("duplicate static targets from parent", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Parent extends Controller { + static targets = ["one"] + } + + export default class Child extends Parent { + static targets = ["one", "three"] + } + ` + + const controller = parseController(code, "target_controller.js", "Child") expect(controller.isTyped).toBeFalsy() - expect(controller.targets).toEqual(["one", "one", "three"]) + expect(controller.targetNames).toEqual(["one", "three", "one"]) expect(controller.hasErrors).toBeTruthy() expect(controller.errors).toHaveLength(1) - expect(controller.errors[0].message).toEqual("Duplicate definition of target:one") - expect(controller.errors[0].loc.start.line).toEqual(5) - expect(controller.errors[0].loc.end.line).toEqual(5) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Target "one". A parent controller already defines this Target.`) + expect(controller.errors[0].loc.start.line).toEqual(8) + expect(controller.errors[0].loc.start.column).toEqual(19) + expect(controller.errors[0].loc.end.line).toEqual(8) + expect(controller.errors[0].loc.end.column).toEqual(35) + }) + + test("assigns targets outside of class via member expression", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class One extends Controller {} + class Two extends Controller {} + + One.targets = ["one", "two"] + ` + + const one = parseController(code, "target_controller.js", "One") + const two = parseController(code, "target_controller.js", "Two") + + expect(one.isTyped).toBeFalsy() + expect(one.targetNames).toEqual(["one", "two"]) + expect(one.hasErrors).toBeFalsy() + + expect(two.isTyped).toBeFalsy() + expect(two.targetNames).toEqual([]) + expect(two.hasErrors).toBeFalsy() + }) + + test("other literals are treated a strings in static targets array", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static targets = ["one", 1, 3.14, /something/, true, false, null, undefined] + } + ` + + const controller = parseController(code, "target_controller.js") + + expect(controller.isTyped).toBeFalsy() + expect(controller.targetNames).toEqual(["one", "1", "3.14", "/something/", "true", "false"]) + expect(controller.hasErrors).toBeFalsy() + }) + + test.todo("variable reference in static targets array", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + const variable = "two" + + export default class extends Controller { + static targets = ["one", variable] + } + ` + + const controller = parseController(code, "target_controller.js") + + expect(controller.isTyped).toBeFalsy() + expect(controller.targetNames).toEqual(["one", "two"]) + expect(controller.hasErrors).toBeFalsy() + }) + + test.todo("trace variable reference in static targets array", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + const variable = "two" + const another = variable + + export default class extends Controller { + static targets = ["one", another] + } + ` + + const controller = parseController(code, "target_controller.js") + + expect(controller.isTyped).toBeFalsy() + expect(controller.targetNames).toEqual(["one", "two"]) + expect(controller.hasErrors).toBeFalsy() + }) + + test.todo("trace static property literal reference in static targets array", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static property = "another" + static targets = ["one", this.property] + } + ` + + const controller = parseController(code, "target_controller.js") + + expect(controller.isTyped).toBeFalsy() + expect(controller.targetNames).toEqual(["one", "two"]) + expect(controller.hasErrors).toBeFalsy() }) test("single @Target decorator", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -49,14 +168,14 @@ describe("parse targets", () => { } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(["output"]) + expect(controller.targetNames).toEqual(["output"]) }) test("duplicate @Target decorator", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -67,19 +186,21 @@ describe("parse targets", () => { } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.js") expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(["output", "output"]) + expect(controller.targetNames).toEqual(["output", "output"]) expect(controller.hasErrors).toBeTruthy() expect(controller.errors).toHaveLength(1) - expect(controller.errors[0].message).toEqual("Duplicate definition of target:output") - expect(controller.errors[0].loc.start.line).toEqual(8) - expect(controller.errors[0].loc.end.line).toEqual(8) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Target "output"`) + expect(controller.errors[0].loc.start.line).toEqual(7) + expect(controller.errors[0].loc.start.column).toEqual(2) + expect(controller.errors[0].loc.end.line).toEqual(7) + expect(controller.errors[0].loc.end.column).toEqual(57) }) test("single @Targets decorator", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Targets, TypedController } from "@vytant/stimulus-decorators"; @@ -89,14 +210,14 @@ describe("parse targets", () => { } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(["output"]) + expect(controller.targetNames).toEqual(["output"]) }) test("parse multiple target definitions", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -107,14 +228,14 @@ describe("parse targets", () => { } ` - const controller = parser.parseController(code, "decorator_controller.js") + const controller = parseController(code, "decorator_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(["output", "name"]) + expect(controller.targetNames).toEqual(["output", "name"]) }) test("parse mix decorator and static definitions", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -128,14 +249,14 @@ describe("parse targets", () => { } ` - const controller = parser.parseController(code, "decorator_controller.js") + const controller = parseController(code, "decorator_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(["output", "name", "item", "one", "two"]) + expect(controller.targetNames).toEqual(["output", "name", "item", "one", "two"]) }) test("duplicate target in mix", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" import { Target, TypedController } from "@vytant/stimulus-decorators"; @@ -147,14 +268,16 @@ describe("parse targets", () => { } ` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.targets).toEqual(["output", "output"]) + expect(controller.targetNames).toEqual(["output", "output"]) expect(controller.hasErrors).toBeTruthy() expect(controller.errors).toHaveLength(1) - expect(controller.errors[0].message).toEqual("Duplicate definition of target:output") - expect(controller.errors[0].loc.start.line).toEqual(9) - expect(controller.errors[0].loc.end.line).toEqual(9) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Target "output"`) + expect(controller.errors[0].loc.start.line).toEqual(6) + expect(controller.errors[0].loc.start.column).toEqual(19) + expect(controller.errors[0].loc.end.line).toEqual(6) + expect(controller.errors[0].loc.end.column).toEqual(29) }) }) diff --git a/test/parser/typescript.test.ts b/test/parser/typescript.test.ts index a29500b..ca85cd8 100644 --- a/test/parser/typescript.test.ts +++ b/test/parser/typescript.test.ts @@ -1,11 +1,13 @@ -import { expect, test, vi, describe } from "vitest" -import { setupParser } from "../helpers/setup" +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { parseController } from "../helpers/parse" -const parser = setupParser() +import { Project } from "../../src/project" +import { SourceFile } from "../../src/source_file" describe("with TS Syntax", () => { test("parse typescript code", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -16,13 +18,13 @@ describe("with TS Syntax", () => { } }` - const controller = parser.parseController(code, "target_controller.js") + const controller = parseController(code, "target_controller.js") - expect(controller.targets).toEqual(["one", "two", "three"]) + expect(controller.targetNames).toEqual(["one", "two", "three"]) }) test("parse targets", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -33,26 +35,26 @@ describe("with TS Syntax", () => { declare readonly threeTarget: HTMLElement } ` - const controller = parser.parseController(code, "target_controller.ts") + const controller = parseController(code, "target_controller.ts") - expect(controller.targets).toEqual(["one", "two", "three"]) + expect(controller.targetNames).toEqual(["one", "two", "three"]) }) test("parse classes", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { static classes = ["one", "two", "three"] } ` - const controller = parser.parseController(code, "class_controller.ts") + const controller = parseController(code, "class_controller.ts") - expect(controller.classes).toEqual(["one", "two", "three"]) + expect(controller.classNames).toEqual(["one", "two", "three"]) }) test("parse values", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -71,9 +73,9 @@ describe("with TS Syntax", () => { declare numberValue: number } ` - const controller = parser.parseController(code, "value_controller.ts") + const controller = parseController(code, "value_controller.ts") - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "" }, object: { type: "Object", default: {} }, boolean: { type: "Boolean", default: false }, @@ -83,7 +85,7 @@ describe("with TS Syntax", () => { }) test("parse values with with default values", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -102,9 +104,9 @@ describe("with TS Syntax", () => { declare numberValue: number } ` - const controller = parser.parseController(code, "value_controller.ts") + const controller = parseController(code, "value_controller.ts") - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "string" }, object: { type: "Object", default: { object: "Object" } }, boolean: { type: "Boolean", default: true }, @@ -113,29 +115,30 @@ describe("with TS Syntax", () => { }) }) - test("should handle syntax errors", () => { - const code = ` + test("should handle syntax errors", async () => { + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { ` - const spy = vi.spyOn(console, 'error') - const controller = parser.parseController(code, "error_controller.ts") + const project = new Project(process.cwd()) + const sourceFile = new SourceFile(project, "error_controller.js", code) + project.projectFiles.push(sourceFile) - expect(controller.identifier).toEqual("error") - expect(controller.hasErrors).toBeTruthy() - expect(controller.errors).toHaveLength(1) - expect(controller.errors[0].message).toEqual("Error parsing controller") - expect(controller.errors[0].cause.message).toEqual("'}' expected.") - // expect(controller.errors[0].loc.start.line).toEqual(9) - // expect(controller.errors[0].loc.end.line).toEqual(9) + await project.analyze() - expect(spy).toBeCalledWith("Error while parsing controller in 'error_controller.ts': '}' expected.") + // expect(sourceFile.identifier).toEqual("error") + expect(sourceFile.hasErrors).toBeTruthy() + expect(sourceFile.errors).toHaveLength(1) + expect(sourceFile.errors[0].message).toEqual("Error parsing controller: '}' expected.") + expect(sourceFile.errors[0].cause.message).toEqual("'}' expected.") + // expect(sourceFile.errors[0].loc.start.line).toEqual(9) + // expect(sourceFile.errors[0].loc.end.line).toEqual(9) }) test("parse arrow function", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -147,15 +150,17 @@ describe("with TS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.ts") + const controller = parseController(code, "controller.ts") - expect(controller.methods).toEqual(["connect", "load"]) + expect(controller.actionNames).toEqual(["connect", "load"]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) test("parse methods", () => { - const code = ` + const code = dedent` + import { Controller } from "@hotwired/stimulus" + export default class extends Controller { load(): void {} @@ -164,15 +169,15 @@ describe("with TS Syntax", () => { isSomething(): Boolean {} } ` - const controller = parser.parseController(code, "controller.ts") + const controller = parseController(code, "controller.ts") - expect(controller.methods).toEqual(["load", "unload", "isSomething"]) + expect(controller.actionNames).toEqual(["load", "unload", "isSomething"]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) test("parse private methods", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -180,16 +185,34 @@ describe("with TS Syntax", () => { private unload() {} } ` + const controller = parseController(code, "controller.ts") - const controller = parser.parseController(code, "controller.ts") - - expect(controller.methods).toEqual(["#load", "#unload"]) + expect(controller.actionNames).toEqual(["#load", "#unload"]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) + test("parse nested object/array default value types", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static values = { + object: { type: Object, default: { object: { some: { more: { levels: {} } } } } }, + array: { type: Array, default: [["Array", "with", ["nested", ["values"]]]] }, + } + } + ` + const controller = parseController(code, "value_controller.js") + + expect(controller.valueDefinitionsMap).toEqual({ + object: { type: "Object", default: { object: { some: { more: { levels: {} } } } } }, + array: { type: "Array", default: [["Array", "with", ["nested", ["values"]]]] }, + }) + }) + test("parse controller with public class fields", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -200,14 +223,14 @@ describe("with TS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.ts") + const controller = parseController(code, "controller.ts") expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) test("parse controller with private getter", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -217,15 +240,15 @@ describe("with TS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.ts") + const controller = parseController(code, "controller.ts") - expect(controller.methods).toEqual([]) + expect(controller.actionNames).toEqual([]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) test("parse controller with private setter", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -235,9 +258,9 @@ describe("with TS Syntax", () => { } ` - const controller = parser.parseController(code, "controller.ts") + const controller = parseController(code, "controller.ts") - expect(controller.methods).toEqual([]) + expect(controller.actionNames).toEqual([]) expect(controller.hasErrors).toBeFalsy() expect(controller.errors).toHaveLength(0) }) diff --git a/test/parser/values.test.ts b/test/parser/values.test.ts index 507a606..1b4cee1 100644 --- a/test/parser/values.test.ts +++ b/test/parser/values.test.ts @@ -1,11 +1,10 @@ -import { describe, expect, test } from "vitest" -import { setupParser } from "../helpers/setup" - -const parser = setupParser() +import dedent from "dedent" +import { describe, test, expect } from "vitest" +import { parseController } from "../helpers/parse" describe("parse values", () => { test("static", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -19,10 +18,10 @@ describe("parse values", () => { } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.js") expect(controller.isTyped).toBeFalsy() - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "" }, object: { type: "Object", default: {} }, boolean: { type: "Boolean", default: false }, @@ -32,7 +31,7 @@ describe("parse values", () => { }) test("static with default values", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -46,10 +45,10 @@ describe("parse values", () => { } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.js") expect(controller.isTyped).toBeFalsy() - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "string" }, object: { type: "Object", default: { object: "Object" } }, boolean: { type: "Boolean", default: true }, @@ -58,8 +57,146 @@ describe("parse values", () => { }) }) + test("duplicate static values", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller { + static values = { + one: String, + one: { type: "String", default: ""}, + three: { type: "String", default: ""}, + } + } + ` + + const controller = parseController(code, "target_controller.js") + + expect(controller.isTyped).toBeFalsy() + expect(controller.valueNames).toEqual(["one", "one", "three"]) + expect(controller.hasErrors).toBeTruthy() + expect(controller.errors).toHaveLength(1) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Value "one"`) + expect(controller.errors[0].loc.start.line).toEqual(4) + expect(controller.errors[0].loc.start.column).toEqual(18) + expect(controller.errors[0].loc.end.line).toEqual(8) + expect(controller.errors[0].loc.end.column).toEqual(3) + }) + + test("duplicate static values from parent", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Parent extends Controller { + static values = { + one: String, + } + } + + export default class Child extends Parent { + static values = { + one: { type: "String", default: ""}, + three: { type: "String", default: ""}, + } + } + ` + + const controller = parseController(code, "target_controller.js", "Child") + + expect(controller.isTyped).toBeFalsy() + expect(controller.valueNames).toEqual(["one", "three", "one"]) + expect(controller.hasErrors).toBeTruthy() + expect(controller.errors).toHaveLength(1) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Value "one". A parent controller already defines this Value.`) + expect(controller.errors[0].loc.start.line).toEqual(10) + expect(controller.errors[0].loc.start.column).toEqual(18) + expect(controller.errors[0].loc.end.line).toEqual(13) + expect(controller.errors[0].loc.end.column).toEqual(3) + }) + + test("assigns values outside of class via member expression", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class One extends Controller {} + class Two extends Controller {} + + One.values = { + one: String, + two: Boolean + } + ` + + const one = parseController(code, "values_controller.js", "One") + const two = parseController(code, "values_controller.js", "Two") + + expect(one.isTyped).toBeFalsy() + expect(one.valueNames).toEqual(["one", "two"]) + expect(one.hasErrors).toBeFalsy() + + expect(two.isTyped).toBeFalsy() + expect(two.valueNames).toEqual([]) + expect(two.hasErrors).toBeFalsy() + }) + + test("duplicate decorator mixed with static values", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + import { Value, TypedController } from "@vytant/stimulus-decorators"; + + @TypedController + export default class extends Controller { + @Value(String) oneValue!: string; + + static values = { + one: { type: "String", default: ""} + } + } + ` + + const controller = parseController(code, "target_controller.ts") + + expect(controller.isTyped).toBeTruthy() + expect(controller.valueNames).toEqual(["one", "one"]) + expect(controller.hasErrors).toBeTruthy() + expect(controller.errors).toHaveLength(1) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Value "one"`) + expect(controller.errors[0].loc.start.line).toEqual(8) + expect(controller.errors[0].loc.start.column).toEqual(18) + expect(controller.errors[0].loc.end.line).toEqual(10) + expect(controller.errors[0].loc.end.column).toEqual(3) + }) + + test("duplicate static values mixed with decorator", () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + import { Value, TypedController } from "@vytant/stimulus-decorators"; + + @TypedController + export default class extends Controller { + static values = { + one: { type: "String", default: ""} + } + + @Value(String) oneValue!: string; + } + ` + + const controller = parseController(code, "target_controller.ts") + + expect(controller.isTyped).toBeTruthy() + expect(controller.valueNames).toEqual(["one", "one"]) + expect(controller.hasErrors).toBeTruthy() + expect(controller.errors).toHaveLength(1) + expect(controller.errors[0].message).toEqual(`Duplicate definition of Stimulus Value "one"`) + expect(controller.errors[0].loc.start.line).toEqual(6) + expect(controller.errors[0].loc.start.column).toEqual(18) + expect(controller.errors[0].loc.end.line).toEqual(8) + expect(controller.errors[0].loc.end.column).toEqual(3) + }) + test("decorated", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus"; import { Value, TypedController } from "@vytant/stimulus-decorators"; @@ -73,10 +210,10 @@ describe("parse values", () => { } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "" }, object: { type: "Object", default: {} }, boolean: { type: "Boolean", default: false }, @@ -86,7 +223,7 @@ describe("parse values", () => { }) test("decorated with default values", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus"; import { Value, TypedController } from "@vytant/stimulus-decorators"; @@ -100,10 +237,10 @@ describe("parse values", () => { } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ string: { type: "String", default: "string" }, object: { type: "Object", default: {} }, boolean: { type: "Boolean", default: false }, @@ -113,7 +250,7 @@ describe("parse values", () => { }) test("parse static value with nested object/array default value", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { @@ -124,10 +261,10 @@ describe("parse values", () => { } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.js") expect(controller.isTyped).toBeFalsy() - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ object: { type: "Object", default: { object: { some: { more: { levels: {} } } } }, @@ -140,7 +277,7 @@ describe("parse values", () => { }) test("parse decorated @Value with nested object/array with default value", () => { - const code = ` + const code = dedent` import { Controller } from "@hotwired/stimulus"; import { Value, TypedController } from "@vytant/stimulus-decorators"; @@ -151,10 +288,10 @@ describe("parse values", () => { } ` - const controller = parser.parseController(code, "value_controller.js") + const controller = parseController(code, "value_controller.ts") expect(controller.isTyped).toBeTruthy() - expect(controller.values).toEqual({ + expect(controller.valueDefinitionsMap).toEqual({ object: { type: "Object", default: { object: { some: { more: { levels: {} } } } }, diff --git a/test/project.test.ts b/test/project.test.ts deleted file mode 100644 index 1613494..0000000 --- a/test/project.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { expect, test, beforeEach } from "vitest" -import { Project } from "../src" - -let project: Project - -beforeEach(() => { - project = new Project(process.cwd()) -}) - -test("relativePath", () => { - expect(project.relativePath(`${process.cwd()}/path/to/some/file.js`)).toEqual( - "path/to/some/file.js" - ) -}) - -test("relativeControllerPath", () => { - expect( - project.relativeControllerPath( - `${process.cwd()}/app/javascript/controllers/some_controller.js` - ) - ).toEqual("some_controller.js") - expect( - project.relativeControllerPath( - `${process.cwd()}/app/javascript/controllers/nested/some_controller.js` - ) - ).toEqual("nested/some_controller.js") - expect( - project.relativeControllerPath( - `${process.cwd()}/app/javascript/controllers/nested/deeply/some_controller.js` - ) - ).toEqual("nested/deeply/some_controller.js") -}) - -test("controllerRoot and controllerRoots", async () => { - const project = new Project("test/fixtures/controller-paths") - - expect(project.controllerRootFallback).toEqual("app/javascript/controllers") - expect(project.controllerRoot).toEqual("app/javascript/controllers") - expect(project.controllerRoots).toEqual(["app/javascript/controllers"]) - - await project.analyze() - - expect(project.controllerRoot).toEqual("app/javascript/controllers") - - expect(project.controllerRoots).toEqual([ - "app/javascript/controllers", - "app/packs/controllers", - "resources/js/controllers", - ]) -}) - -test("identifier in different controllerRoots", async () => { - const project = new Project("test/fixtures/controller-paths") - - await project.analyze() - - const identifiers = project.controllerDefinitions.map(controller => controller.identifier).sort() - - expect(identifiers).toEqual([ - "laravel", - "nested--laravel", - "nested--rails", - "nested--twice--laravel", - "nested--twice--rails", - "nested--twice--webpack", - "nested--webpack", - "rails", - "typescript", - "webpack", - ]) -}) - -test("static calculateControllerRoots", () => { - expect( - Project.calculateControllerRoots([ - "app/javascript/controllers/some_controller.js", - "app/javascript/controllers/nested/some_controller.js", - "app/javascript/controllers/nested/deeply/some_controller.js", - ]) - ).toEqual([ - "app/javascript/controllers" - ]) - - expect( - Project.calculateControllerRoots( - [ - "app/packs/controllers/some_controller.js", - "app/packs/controllers/nested/some_controller.js", - "app/packs/controllers/nested/deeply/some_controller.js", - "app/javascript/controllers/some_controller.js", - "app/javascript/controllers/nested/some_controller.js", - "app/javascript/controllers/nested/deeply/some_controller.js", - "resources/js/controllers/some_controller.js", - "resources/js/controllers/nested/some_controller.js", - "resources/js/controllers/nested/deeply/some_controller.js", - ] - ) - ).toEqual([ - "app/javascript/controllers", - "app/packs/controllers", - "resources/js/controllers" - ]) -}) - -// TODO: enable again when we merge node modules support -test.skip("possibleControllerPathsForIdentifier", async () => { - expect(project.possibleControllerPathsForIdentifier("rails")).toEqual([ - "app/javascript/controllers/rails_controller.js", - "app/javascript/controllers/rails_controller.mjs", - "app/javascript/controllers/rails_controller.cjs", - "app/javascript/controllers/rails_controller.jsx", - "app/javascript/controllers/rails_controller.ts", - "app/javascript/controllers/rails_controller.mts", - "app/javascript/controllers/rails_controller.tsx", - ]) - - await project.analyze() - - expect(project.possibleControllerPathsForIdentifier("rails")).toEqual([ - "test/fixtures/controller-paths/app/javascript/controllers/rails_controller.js", - "test/fixtures/controller-paths/app/javascript/controllers/rails_controller.mjs", - "test/fixtures/controller-paths/app/javascript/controllers/rails_controller.cjs", - "test/fixtures/controller-paths/app/javascript/controllers/rails_controller.jsx", - "test/fixtures/controller-paths/app/javascript/controllers/rails_controller.ts", - "test/fixtures/controller-paths/app/javascript/controllers/rails_controller.mts", - "test/fixtures/controller-paths/app/javascript/controllers/rails_controller.tsx", - "test/fixtures/controller-paths/app/packs/controllers/rails_controller.js", - "test/fixtures/controller-paths/app/packs/controllers/rails_controller.mjs", - "test/fixtures/controller-paths/app/packs/controllers/rails_controller.cjs", - "test/fixtures/controller-paths/app/packs/controllers/rails_controller.jsx", - "test/fixtures/controller-paths/app/packs/controllers/rails_controller.ts", - "test/fixtures/controller-paths/app/packs/controllers/rails_controller.mts", - "test/fixtures/controller-paths/app/packs/controllers/rails_controller.tsx", - "test/fixtures/controller-paths/resources/js/controllers/rails_controller.js", - "test/fixtures/controller-paths/resources/js/controllers/rails_controller.mjs", - "test/fixtures/controller-paths/resources/js/controllers/rails_controller.cjs", - "test/fixtures/controller-paths/resources/js/controllers/rails_controller.jsx", - "test/fixtures/controller-paths/resources/js/controllers/rails_controller.ts", - "test/fixtures/controller-paths/resources/js/controllers/rails_controller.mts", - "test/fixtures/controller-paths/resources/js/controllers/rails_controller.tsx", - ]) -}) - -test("findControllerPathForIdentifier", async () => { - expect(await project.findControllerPathForIdentifier("rails")).toBeNull() - expect(await project.findControllerPathForIdentifier("nested--twice--rails")).toBeNull() - expect(await project.findControllerPathForIdentifier("typescript")).toBeNull() - expect(await project.findControllerPathForIdentifier("webpack")).toBeNull() - expect(await project.findControllerPathForIdentifier("nested--webpack")).toBeNull() - expect(await project.findControllerPathForIdentifier("doesnt-exist")).toBeNull() - - await project.analyze() - - expect(await project.findControllerPathForIdentifier("rails")).toEqual("test/fixtures/controller-paths/app/javascript/controllers/rails_controller.js") - expect(await project.findControllerPathForIdentifier("nested--twice--rails")).toEqual("test/fixtures/controller-paths/app/javascript/controllers/nested/twice/rails_controller.js") - expect(await project.findControllerPathForIdentifier("typescript")).toEqual("test/fixtures/controller-paths/app/javascript/controllers/typescript_controller.ts") - expect(await project.findControllerPathForIdentifier("webpack")).toEqual("test/fixtures/controller-paths/app/packs/controllers/webpack_controller.js") - expect(await project.findControllerPathForIdentifier("nested--webpack")).toEqual("test/fixtures/controller-paths/app/packs/controllers/nested/webpack_controller.js") - expect(await project.findControllerPathForIdentifier("doesnt-exist")).toBeNull() -}) diff --git a/test/project/lifecycle.test.ts b/test/project/lifecycle.test.ts new file mode 100644 index 0000000..4e0c664 --- /dev/null +++ b/test/project/lifecycle.test.ts @@ -0,0 +1,63 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +let project = setupProject("app") + +describe("Project", () => { + beforeEach(() => { + project = setupProject("app") + }) + + test("has no files by default", () => { + expect(project.projectFiles.length).toEqual(0) + expect(project.controllerDefinitions.length).toEqual(0) + }) + + test("picks up files on initially", async () => { + expect(project.projectFiles.length).toEqual(0) + expect(project.controllerDefinitions.length).toEqual(0) + + await project.initialize() + + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + }) + + test("doesn't re-add files when calling initialize() more than once", async () => { + expect(project.projectFiles.length).toEqual(0) + expect(project.controllerDefinitions.length).toEqual(0) + + await project.initialize() + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + + await project.initialize() + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + + await project.initialize() + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + }) + + test("doesn't re-add files when calling initialize() once and refresh() more than once", async () => { + expect(project.projectFiles.length).toEqual(0) + expect(project.controllerDefinitions.length).toEqual(0) + + await project.initialize() + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + + await project.refresh() + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + + await project.refresh() + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + + await project.refresh() + expect(project.projectFiles).toHaveLength(4) + expect(project.controllerDefinitions).toHaveLength(2) + }) +}) diff --git a/test/project/project.test.ts b/test/project/project.test.ts new file mode 100644 index 0000000..266814a --- /dev/null +++ b/test/project/project.test.ts @@ -0,0 +1,151 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +let project = setupProject("controller-paths") + +describe("Project", () => { + beforeEach(() => { + project = setupProject("controller-paths") + }) + + test("relativePath", () => { + expect(project.relativePath(`${project.projectPath}/path/to/some/file.js`)).toEqual( + "path/to/some/file.js" + ) + }) + + test("relativeControllerPath", () => { + expect( + project.relativeControllerPath( + `${project.projectPath}/app/javascript/controllers/some_controller.js` + ) + ).toEqual("some_controller.js") + expect( + project.relativeControllerPath( + `${project.projectPath}/app/javascript/controllers/nested/some_controller.js` + ) + ).toEqual("nested/some_controller.js") + expect( + project.relativeControllerPath( + `${project.projectPath}/app/javascript/controllers/nested/deeply/some_controller.js` + ) + ).toEqual("nested/deeply/some_controller.js") + }) + + test("controllerRoot and controllerRoots", async () => { + expect(project.controllerRootFallback).toEqual("app/javascript/controllers") + expect(project.controllerRoot).toEqual("app/javascript/controllers") + expect(project.guessedControllerRoots).toEqual([ + "app/javascript/controllers", + ]) + expect(Array.from(project.controllerRoots)).toEqual([]) + + await project.initialize() + + expect(project.controllerRoot).toEqual("app/javascript/controllers") + expect(Array.from(project.controllerRoots)).toEqual([]) + expect(project.guessedControllerRoots).toEqual([ + "app/javascript/controllers", + "app/packs/controllers", + "resources/js/controllers", + ]) + }) + + test("identifier in different controllerRoots", async () => { + expect(project.controllerDefinitions.map(controller => controller.guessedIdentifier)).toEqual([]) + + await project.initialize() + + project._controllerRoots.add(...["app/packs/controllers", "resources/js/controllers"]) + + const identifiers = project.controllerDefinitions.map(controller => controller.guessedIdentifier).sort() + + expect(identifiers).toEqual([ + "laravel", + "nested--laravel", + "nested--rails", + "nested--twice--laravel", + "nested--twice--rails", + "nested--twice--webpack", + "nested--webpack", + "rails", + "typescript", + "webpack", + ]) + }) + + test("possibleControllerPathsForIdentifier", async () => { + // This is only using the controllerRootFallback because we haven't found/analyzed anything else yet + expect(project.possibleControllerPathsForIdentifier("rails")).toEqual([ + "app/javascript/controllers/rails_controller.cjs", + "app/javascript/controllers/rails_controller.js", + "app/javascript/controllers/rails_controller.jsx", + "app/javascript/controllers/rails_controller.mjs", + "app/javascript/controllers/rails_controller.mts", + "app/javascript/controllers/rails_controller.ts", + "app/javascript/controllers/rails_controller.tsx", + ]) + + await project.initialize() + + expect(project.possibleControllerPathsForIdentifier("rails")).toEqual([ + "app/javascript/controllers/rails_controller.cjs", + "app/javascript/controllers/rails_controller.js", + "app/javascript/controllers/rails_controller.jsx", + "app/javascript/controllers/rails_controller.mjs", + "app/javascript/controllers/rails_controller.mts", + "app/javascript/controllers/rails_controller.ts", + "app/javascript/controllers/rails_controller.tsx", + "app/packs/controllers/rails_controller.cjs", + "app/packs/controllers/rails_controller.js", + "app/packs/controllers/rails_controller.jsx", + "app/packs/controllers/rails_controller.mjs", + "app/packs/controllers/rails_controller.mts", + "app/packs/controllers/rails_controller.ts", + "app/packs/controllers/rails_controller.tsx", + "resources/js/controllers/rails_controller.cjs", + "resources/js/controllers/rails_controller.js", + "resources/js/controllers/rails_controller.jsx", + "resources/js/controllers/rails_controller.mjs", + "resources/js/controllers/rails_controller.mts", + "resources/js/controllers/rails_controller.ts", + "resources/js/controllers/rails_controller.tsx", + ]) + }) + + test("findControllerPathForIdentifier for controllers that are not in project", async () => { + project = setupProject("app") + + expect(await project.findControllerPathForIdentifier("rails")).toBeNull() + expect(await project.findControllerPathForIdentifier("nested--twice--rails")).toBeNull() + expect(await project.findControllerPathForIdentifier("typescript")).toBeNull() + expect(await project.findControllerPathForIdentifier("webpack")).toBeNull() + expect(await project.findControllerPathForIdentifier("nested--webpack")).toBeNull() + expect(await project.findControllerPathForIdentifier("doesnt-exist")).toBeNull() + }) + + test("findControllerPathForIdentifier", async () => { + project = setupProject("controller-paths") + + // it can find these before the initialize() call + // because they are in the controller root fallback folder + expect(await project.findControllerPathForIdentifier("rails")).toEqual("app/javascript/controllers/rails_controller.js") + expect(await project.findControllerPathForIdentifier("nested--twice--rails")).toEqual("app/javascript/controllers/nested/twice/rails_controller.js") + expect(await project.findControllerPathForIdentifier("typescript")).toEqual("app/javascript/controllers/typescript_controller.ts") + + // but it cannot find these because they are in a non-standard location + expect(await project.findControllerPathForIdentifier("webpack")).toBeNull() + expect(await project.findControllerPathForIdentifier("nested--webpack")).toBeNull() + expect(await project.findControllerPathForIdentifier("doesnt-exist")).toBeNull() + + await project.initialize() + + // but after initializing the project it knows about all the controller roots and can find them + expect(await project.findControllerPathForIdentifier("rails")).toEqual("app/javascript/controllers/rails_controller.js") + expect(await project.findControllerPathForIdentifier("nested--twice--rails")).toEqual("app/javascript/controllers/nested/twice/rails_controller.js") + expect(await project.findControllerPathForIdentifier("typescript")).toEqual("app/javascript/controllers/typescript_controller.ts") + expect(await project.findControllerPathForIdentifier("webpack")).toEqual("app/packs/controllers/webpack_controller.js") + expect(await project.findControllerPathForIdentifier("nested--webpack")).toEqual("app/packs/controllers/nested/webpack_controller.js") + expect(await project.findControllerPathForIdentifier("doesnt-exist")).toBeNull() + }) +}) diff --git a/test/project/referencedNodeModules.test.ts b/test/project/referencedNodeModules.test.ts new file mode 100644 index 0000000..78ed372 --- /dev/null +++ b/test/project/referencedNodeModules.test.ts @@ -0,0 +1,51 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { Project } from "../../src/project" +import { SourceFile } from "../../src/source_file" +import { setupProject } from "../helpers/setup" + +let project: Project + +describe("Project", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("referencedNodeModules", () => { + test("empty by default", () => { + expect(Array.from(project.referencedNodeModules)).toEqual([]) + }) + + test("detects default import", async () => { + const sourceFile = new SourceFile(project, "abc.js", `import Something from "somewhere"`) + project.projectFiles.push(sourceFile) + + expect(Array.from(project.referencedNodeModules)).toEqual([]) + + await project.analyze() + + expect(Array.from(project.referencedNodeModules)).toEqual(["somewhere"]) + }) + + test("detects named import", async () => { + const sourceFile = new SourceFile(project, "abc.js", `import { Something } from "somewhere"`) + project.projectFiles.push(sourceFile) + + expect(Array.from(project.referencedNodeModules)).toEqual([]) + + await project.analyze() + + expect(Array.from(project.referencedNodeModules)).toEqual(["somewhere"]) + }) + + test("doesn't detect relative import", async () => { + const sourceFile = new SourceFile(project, "abc.js", `import { Something } from "./somewhere"`) + project.projectFiles.push(sourceFile) + + expect(Array.from(project.referencedNodeModules)).toEqual([]) + + await project.analyze() + + expect(Array.from(project.referencedNodeModules)).toEqual([]) + }) + }) +}) diff --git a/test/source_file/class_declarations.test.ts b/test/source_file/class_declarations.test.ts new file mode 100644 index 0000000..6fc3840 --- /dev/null +++ b/test/source_file/class_declarations.test.ts @@ -0,0 +1,253 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { Project, SourceFile, StimulusControllerClassDeclaration } from "../../src" + +let project = new Project(process.cwd()) + +describe("SourceFile", () => { + beforeEach(() => { + project = new Project(process.cwd()) + }) + + describe("classDeclarations", () => { + test("named class", async () => { + const code = dedent` + class Something {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.isStimulusDescendant).toBeFalsy() + expect(something.superClass).toBeUndefined() + }) + + test("multiple classes", async () => { + const code = dedent` + class Something {} + class Better {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + const better = sourceFile.findClass("Better") + + expect(something).toBeDefined() + expect(better).toBeDefined() + + expect(something.isStimulusDescendant).toBeFalsy() + expect(better.isStimulusDescendant).toBeFalsy() + + expect(something.superClass).toBeUndefined() + expect(better.superClass).toBeUndefined() + }) + + test("anonymous class", async () => { + const code = dedent` + export default class {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const anonymous = sourceFile.findClass(undefined) + expect(anonymous.superClass).toBeUndefined() + }) + + test("anonymous class with extends", async () => { + const code = dedent` + class Something {} + export default class extends Something {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const anonymous = sourceFile.findClass(undefined) + const something = sourceFile.findClass("Something") + + expect(anonymous).toBeDefined() + expect(something).toBeDefined() + + expect(anonymous.isStimulusDescendant).toBeFalsy() + expect(something.isStimulusDescendant).toBeFalsy() + + expect(anonymous.superClass).toBeDefined() + expect(anonymous.superClass).toEqual(something) + + expect(something.superClass).toBeUndefined() + }) + + test("named class with superclass", async () => { + const code = dedent` + class Better {} + class Something extends Better {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + const better = sourceFile.findClass("Better") + + expect(something).toBeDefined() + expect(better).toBeDefined() + + expect(something.isStimulusDescendant).toBeFalsy() + expect(better.isStimulusDescendant).toBeFalsy() + + expect(something.superClass).toBeDefined() + expect(something.superClass).toEqual(better) + + expect(better.superClass).toBeUndefined() + }) + + test("named class with superclass from import", async () => { + const code = dedent` + import { Controller } from "better" + class Something extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.isStimulusDescendant).toBeFalsy() + expect(something).not.toBeInstanceOf(StimulusControllerClassDeclaration) + + expect(something.superClass).toBeUndefined() + expect(something.superClass).not.toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("named class with superclass from Stimulus Controller import", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + class Something extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.isStimulusDescendant).toBeTruthy() + expect(something.superClass).toBeDefined() + expect(something.superClass.isStimulusDescendant).toBeTruthy() + expect(something.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("anonymous class assigned to variable from Stimulus Controller import", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + const Something = class extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.isStimulusDescendant).toBeTruthy() + expect(something.superClass).toBeDefined() + expect(something.superClass.isStimulusDescendant).toBeTruthy() + expect(something.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("named class with superclass from import via second class", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + class Even extends Controller {} + class Better extends Even {} + class Something extends Better {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + const better = sourceFile.findClass("Better") + const even = sourceFile.findClass("Even") + + expect(something).toBeDefined() + expect(better).toBeDefined() + expect(even).toBeDefined() + + expect(something.isStimulusDescendant).toBeTruthy() + expect(better.isStimulusDescendant).toBeTruthy() + expect(even.isStimulusDescendant).toBeTruthy() + + expect(something.superClass).toBeDefined() + expect(something.superClass).toEqual(better) + + expect(better.superClass).toBeDefined() + expect(better.superClass).toEqual(even) + + expect(even.superClass).toBeDefined() + expect(even.superClass.isStimulusDescendant).toBeTruthy() + expect(even.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + + test("named class with superclass from import rename via second class", async () => { + const code = dedent` + import { Controller as StimulusController } from "@hotwired/stimulus" + class Even extends StimulusController {} + class Better extends Even {} + class Something extends Better {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findClass("Something") + const better = sourceFile.findClass("Better") + const even = sourceFile.findClass("Even") + + expect(something).toBeDefined() + expect(better).toBeDefined() + expect(even).toBeDefined() + + expect(something.isStimulusDescendant).toBeTruthy() + expect(better.isStimulusDescendant).toBeTruthy() + expect(even.isStimulusDescendant).toBeTruthy() + + expect(something.superClass).toBeDefined() + expect(something.superClass).toEqual(better) + + expect(better.superClass).toBeDefined() + expect(better.superClass).toEqual(even) + + expect(even.superClass).toBeDefined() + expect(even.superClass.isStimulusDescendant).toBeTruthy() + expect(even.superClass).toBeInstanceOf(StimulusControllerClassDeclaration) + }) + }) +}) diff --git a/test/source_file/class_exports.test.ts b/test/source_file/class_exports.test.ts new file mode 100644 index 0000000..93b6e8c --- /dev/null +++ b/test/source_file/class_exports.test.ts @@ -0,0 +1,366 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("SourceFile", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("class exports", () => { + test("export named class", async () => { + const code = dedent` + class Something {} + export { Something } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeFalsy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeUndefined() + }) + + test("import and export named class", async () => { + const code = dedent` + import { SuperClass } from "./super_class" + + class Something extends SuperClass {} + + export { Something } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeFalsy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeFalsy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeUndefined() + expect(something.superClassName).toEqual("SuperClass") + }) + + test("import and export named Controller", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Something extends Controller {} + + export { Something } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and export named Controller with alias", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Something extends Controller {} + + export { Something as SomethingController } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("SomethingController") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and export named class in-line", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export class Something extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and export default Controller", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Something extends Controller {} + + export default Something + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toBeUndefined() + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and export default Controller in single statement", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class Something extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toBeUndefined() + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and export default anonymous Controller class in single statement", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toBeUndefined() + expect(sourceFile.exportDeclarations[0].localName).toBeUndefined() + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + + const something = sourceFile.findClass(undefined) + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and default export anonymous class assinged to const", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + const Something = class extends Controller {} + + export default Something + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toBeUndefined() + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and name export anonymous class assigned to const", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + const Something = class extends Controller {} + + export { Something } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and name export anonymous class assigned to const inline", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + export const Something = class extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const something = sourceFile.findClass("Something") + + expect(something).toBeDefined() + expect(something.superClass).toBeDefined() + expect(something.superClass.className).toEqual("Controller") + }) + + test("import and name export anonymous class assigned to const via class declaration", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + + class Something extends Controller {} + const AnotherThing = class extends Something {} + + export { AnotherThing } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toBeTruthy() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].isStimulusExport).toBeTruthy() + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("AnotherThing") + expect(sourceFile.exportDeclarations[0].localName).toEqual("AnotherThing") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + + const anotherThing = sourceFile.findClass("AnotherThing") + const something = sourceFile.findClass("Something") + + expect(anotherThing).toBeDefined() + expect(something).toBeDefined() + + expect(anotherThing.superClass).toBeDefined() + expect(something.superClass).toBeDefined() + + expect(anotherThing.superClass.className).toEqual("Something") + expect(something.superClass.className).toEqual("Controller") + }) + }) +}) diff --git a/test/source_file/export_declarations.test.ts b/test/source_file/export_declarations.test.ts new file mode 100644 index 0000000..0e3ec91 --- /dev/null +++ b/test/source_file/export_declarations.test.ts @@ -0,0 +1,609 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("SourceFile", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("exportDeclarations", () => { + test("export default", async () => { + const code = dedent` + export default Something + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export named variable", async () => { + const code = dedent` + const something = "something" + export { something } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export named class", async () => { + const code = dedent` + class Something {} + export { Something } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export object", async () => { + const code = dedent` + export {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(0) + }) + + test("export function", async () => { + const code = dedent` + export function something() {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export default named function ", async () => { + const code = dedent` + function something() {} + + export default something + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export named arrow function ", async () => { + const code = dedent` + export const something = async () => {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export default named arrow function", async () => { + const code = dedent` + const something = async () => {} + + export default something + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export const", async () => { + const code = dedent` + export const something = 0 + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export let", async () => { + const code = dedent` + export let something = 0 + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export var", async () => { + const code = dedent` + export var something = 0 + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export default const", async () => { + const code = dedent` + const something = 0 + + export default something + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default literal", async () => { + const code = dedent` + export default 0 + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default anonymous function ", async () => { + const code = dedent` + export default function() {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default anonymous arrow function ", async () => { + const code = dedent` + export default async () => {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default anonymous array expression", async () => { + const code = dedent` + export default [] + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default anonymous object expression", async () => { + const code = dedent` + export default {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export named with rename", async () => { + const code = dedent` + export { something as somethingElse } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("somethingElse") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export named mulitple", async () => { + const code = dedent` + export { something, somethingElse } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(2) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + expect(sourceFile.exportDeclarations[1].exportedName).toEqual("somethingElse") + expect(sourceFile.exportDeclarations[1].localName).toEqual("somethingElse") + expect(sourceFile.exportDeclarations[1].type).toEqual("named") + }) + + test("export namespace", async () => { + const code = dedent` + export * from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("namespace") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + + test("export namespace with rename", async () => { + const code = dedent` + export * as something from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("namespace") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + + test("export default from namespace", async () => { + const code = dedent` + export { default } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + + test("export default with rename from namespace", async () => { + const code = dedent` + export { default as something } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + + test("export named as default", async () => { + const code = dedent` + function something() {} + + export { something as default } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export named with rename from", async () => { + const code = dedent` + export { something as somethingElse } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("somethingElse") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + + test("export class", async () => { + const code = dedent` + export class Something {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export default class", async () => { + const code = dedent` + class Something {} + + export default Something + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default class inline", async () => { + const code = dedent` + export default class Something {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("Something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default named function inline", async () => { + const code = dedent` + export default function something() {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default named arrow function inline", async () => { + const code = dedent` + export default something = async () => {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default anonymous class", async () => { + const code = dedent` + export default class {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export default anonymous class with extends", async () => { + const code = dedent` + export default class extends Controller {} + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("default") + }) + + test("export type", async () => { + const code = dedent` + export type { something } + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + }) + + test("export type from namespace", async () => { + const code = dedent` + export type { something } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual("something") + expect(sourceFile.exportDeclarations[0].type).toEqual("named") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + + test("export type * namespace", async () => { + const code = dedent` + export type * from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("namespace") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + + test("export type * with rename from namespace", async () => { + const code = dedent` + export type * as something from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.exportDeclarations.length).toEqual(1) + expect(sourceFile.exportDeclarations[0].exportedName).toEqual("something") + expect(sourceFile.exportDeclarations[0].localName).toEqual(undefined) + expect(sourceFile.exportDeclarations[0].type).toEqual("namespace") + expect(sourceFile.exportDeclarations[0].source).toEqual("something") + }) + }) +}) diff --git a/test/source_file/import_declarations.test.ts b/test/source_file/import_declarations.test.ts new file mode 100644 index 0000000..949e927 --- /dev/null +++ b/test/source_file/import_declarations.test.ts @@ -0,0 +1,225 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("SourceFile", async () => { + beforeEach(() => { + project = setupProject() + }) + + describe("importDeclarations", async () => { + test("file import", async () => { + const code = dedent` + import "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations).toEqual([]) + }) + + test("default import", async () => { + const code = dedent` + import Something from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.findImport("Something").isRenamedImport).toEqual(false) + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toEqual(false) + expect(sourceFile.importDeclarations[0].localName).toEqual("Something") + expect(sourceFile.importDeclarations[0].originalName).toEqual(undefined) + expect(sourceFile.importDeclarations[0].source).toEqual("something") + expect(sourceFile.importDeclarations[0].type).toEqual("default") + }) + + test("named import", async () => { + const code = dedent` + import { something } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isRenamedImport).toEqual(false) + expect(sourceFile.importDeclarations[0].isStimulusImport).toEqual(false) + expect(sourceFile.importDeclarations[0].localName).toEqual("something") + expect(sourceFile.importDeclarations[0].originalName).toEqual("something") + expect(sourceFile.importDeclarations[0].source).toEqual("something") + expect(sourceFile.importDeclarations[0].type).toEqual("named") + }) + + test("named import with rename", async () => { + const code = dedent` + import { something as somethingElse } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isRenamedImport).toEqual(true) + expect(sourceFile.importDeclarations[0].isStimulusImport).toEqual(false) + expect(sourceFile.importDeclarations[0].localName).toEqual("somethingElse") + expect(sourceFile.importDeclarations[0].originalName).toEqual("something") + expect(sourceFile.importDeclarations[0].source).toEqual("something") + expect(sourceFile.importDeclarations[0].type).toEqual("named") + }) + + test("namespace import", async () => { + const code = dedent` + import * as something from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.findImport("something").isRenamedImport).toEqual(false) + + expect(sourceFile.importDeclarations.length).toEqual(1) + expect(sourceFile.importDeclarations[0].isStimulusImport).toEqual(false) + expect(sourceFile.importDeclarations[0].localName).toEqual("something") + expect(sourceFile.importDeclarations[0].originalName).toEqual(undefined) + expect(sourceFile.importDeclarations[0].source).toEqual("something") + expect(sourceFile.importDeclarations[0].type).toEqual("namespace") + }) + + test("mixed import", async () => { + const code = dedent` + import onething, { anotherthing, thirdthing as something } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + expect(sourceFile.importDeclarations.length).toEqual(3) + + const anotherthing = sourceFile.findImport("anotherthing") + const something = sourceFile.findImport("something") + const onething = sourceFile.findImport("onething") + + expect(onething.isRenamedImport).toEqual(false) + expect(onething.isStimulusImport).toEqual(false) + expect(onething.localName).toEqual("onething") + expect(onething.originalName).toEqual(undefined) + expect(onething.source).toEqual("something") + expect(onething.type).toEqual("default") + + expect(anotherthing.isRenamedImport).toEqual(false) + expect(anotherthing.isStimulusImport).toEqual(false) + expect(anotherthing.localName).toEqual("anotherthing") + expect(anotherthing.originalName).toEqual("anotherthing") + expect(anotherthing.source).toEqual("something") + expect(anotherthing.type).toEqual("named") + + expect(something.isRenamedImport).toEqual(true) + expect(something.isStimulusImport).toEqual(false) + expect(something.localName).toEqual("something") + expect(something.originalName).toEqual("thirdthing") + expect(something.source).toEqual("something") + expect(something.type).toEqual("named") + }) + + test("import default as", async () => { + const code = dedent` + import { default as something } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findImport("something") + + // this is technically a "renamed" import, but it doesn't make a difference + // this is equivalent to `import something from "something"` + expect(something.isRenamedImport).toEqual(false) + expect(something.isStimulusImport).toEqual(false) + expect(something.localName).toEqual("something") + expect(something.originalName).toEqual(undefined) + expect(something.source).toEqual("something") + expect(something.type).toEqual("default") + }) + + test("type import", async () => { + const code = dedent` + import type { something } from "something" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const something = sourceFile.findImport("something") + + expect(something.isRenamedImport).toEqual(false) + expect(something.isStimulusImport).toEqual(false) + expect(something.localName).toEqual("something") + expect(something.originalName).toEqual("something") + expect(something.source).toEqual("something") + expect(something.type).toEqual("named") + }) + + test("stimulus controller import", async () => { + const code = dedent` + import { Controller } from "@hotwired/stimulus" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const controller = sourceFile.findImport("Controller") + + expect(controller.isRenamedImport).toEqual(false) + expect(controller.isStimulusImport).toEqual(true) + expect(controller.localName).toEqual("Controller") + expect(controller.originalName).toEqual("Controller") + expect(controller.source).toEqual("@hotwired/stimulus") + expect(controller.type).toEqual("named") + }) + + test("stimulus controller import with alias", async () => { + const code = dedent` + import { Controller as StimulusController } from "@hotwired/stimulus" + ` + + const sourceFile = new SourceFile(project, "abc.js", code) + project.projectFiles.push(sourceFile) + + await project.analyze() + + const controller = sourceFile.findImport("StimulusController") + + expect(controller.isRenamedImport).toEqual(true) + expect(controller.isStimulusImport).toEqual(true) + expect(controller.localName).toEqual("StimulusController") + expect(controller.originalName).toEqual("Controller") + expect(controller.source).toEqual("@hotwired/stimulus") + expect(controller.type).toEqual("named") + }) + }) +}) diff --git a/test/source_file/refresh.test.ts b/test/source_file/refresh.test.ts new file mode 100644 index 0000000..fe4ed27 --- /dev/null +++ b/test/source_file/refresh.test.ts @@ -0,0 +1,44 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { mockFile } from "../helpers/mock" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("SourceFile", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("refresh", () => { + test("refreshes content", async () => { + const sourceFile = new SourceFile(project, "file.js", "initial") + + expect(sourceFile.content).toEqual("initial") + + mockFile("updated") + + await sourceFile.refresh() + expect(sourceFile.content).toEqual("updated") + }) + + test("refreshes class declarations", async () => { + const sourceFile = new SourceFile(project, "file.js", "") + + expect(sourceFile.classDeclarations.length).toEqual(0) + + mockFile(`class Class {}`) + + await sourceFile.refresh() + expect(sourceFile.classDeclarations.length).toEqual(1) + + mockFile(` + class Class {} + class Another {} + `) + + await sourceFile.refresh() + expect(sourceFile.classDeclarations.length).toEqual(2) + }) + }) +}) diff --git a/test/source_file/resolve_files.test.ts b/test/source_file/resolve_files.test.ts new file mode 100644 index 0000000..06013a5 --- /dev/null +++ b/test/source_file/resolve_files.test.ts @@ -0,0 +1,366 @@ +import dedent from "dedent" +import path from "path" + +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +const emptyController = ` + import { Controller } from "@hotwired/stimulus" + + export default class extends Controller {} +` + +let project = setupProject("app") + +describe("SourceFile", () => { + beforeEach(() => { + project = setupProject("app") + }) + + describe("resolve files", () => { + test("relative path same directory with .js files", async () => { + const applicationControllerCode = emptyController + + const helloControllerCode = dedent` + import ApplicationController from "./application_controller" + + export default class extends ApplicationController {} + ` + + const applicationControllerFile = new SourceFile(project, "app/javascript/application_controller.js", applicationControllerCode) + const helloControllerFile = new SourceFile(project, "app/javascript/hello_controller.js", helloControllerCode) + + project.projectFiles.push(applicationControllerFile) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.importDeclarations.length).toEqual(1) + expect(helloControllerFile.importDeclarations[0].resolvedPath).toEqual("app/javascript/application_controller.js") + }) + + test("relative path same directory with .ts files", async () => { + const applicationControllerCode = emptyController + + const helloControllerCode = dedent` + import ApplicationController from "./nested/application_controller" + + export default class extends ApplicationController {} + ` + + const applicationControllerFile = new SourceFile(project, "app/javascript/nested/application_controller.ts", applicationControllerCode) + const helloControllerFile = new SourceFile(project, "app/javascript/hello_controller.ts", helloControllerCode) + + project.projectFiles.push(applicationControllerFile) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.importDeclarations.length).toEqual(1) + expect(helloControllerFile.importDeclarations[0].resolvedPath).toEqual("app/javascript/nested/application_controller.ts") + }) + + test("relative path same directory with .js file extension", async () => { + const applicationControllerCode = emptyController + + const helloControllerCode = dedent` + import ApplicationController from "./application_controller.js" + + export default class extends ApplicationController {} + ` + + const applicationControllerFile = new SourceFile(project, "app/javascript/application_controller.js", applicationControllerCode) + const helloControllerFile = new SourceFile(project, "app/javascript/hello_controller.js", helloControllerCode) + + project.projectFiles.push(applicationControllerFile) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.importDeclarations.length).toEqual(1) + expect(helloControllerFile.importDeclarations[0].resolvedPath).toEqual("app/javascript/application_controller.js") + }) + + test("relative path same directory with .ts file extension", async () => { + const applicationControllerCode = emptyController + + const helloControllerCode = dedent` + import ApplicationController from "./application_controller.ts" + + export default class extends ApplicationController {} + ` + + const applicationControllerFile = new SourceFile(project, "app/javascript/application_controller.ts", applicationControllerCode) + const helloControllerFile = new SourceFile(project, "app/javascript/hello_controller.ts", helloControllerCode) + + project.projectFiles.push(applicationControllerFile) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.importDeclarations.length).toEqual(1) + expect(helloControllerFile.importDeclarations[0].resolvedPath).toEqual("app/javascript/application_controller.ts") + }) + + test("relative path directory above", async () => { + const applicationControllerCode = emptyController + + const helloControllerCode = dedent` + import ApplicationController from "../application_controller" + + export default class extends ApplicationController {} + ` + + const applicationControllerFile = new SourceFile(project, "app/javascript/application_controller.js", applicationControllerCode) + const helloControllerFile = new SourceFile(project, "app/javascript/nested/hello_controller.js", helloControllerCode) + + project.projectFiles.push(applicationControllerFile) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.importDeclarations.length).toEqual(1) + expect(helloControllerFile.importDeclarations[0].resolvedPath).toEqual("app/javascript/application_controller.js") + }) + + test("relative path directory below", async () => { + const applicationControllerCode = emptyController + + const helloControllerCode = dedent` + import ApplicationController from "./nested/application_controller" + + export default class extends ApplicationController {} + ` + + const applicationControllerFile = new SourceFile(project, "app/javascript/nested/application_controller.js", applicationControllerCode) + const helloControllerFile = new SourceFile(project, "app/javascript/hello_controller.js", helloControllerCode) + + project.projectFiles.push(applicationControllerFile) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.importDeclarations.length).toEqual(1) + expect(helloControllerFile.importDeclarations[0].resolvedPath).toEqual("app/javascript/nested/application_controller.js") + }) + + test("doesn't resolve node module path for unknown package", async () => { + const helloControllerCode = dedent` + import { Modal } from "some-unknown-package" + + export default class extends ApplicationController {} + ` + + const helloControllerFile = new SourceFile(project, "app/javascript/hello_controller.js", helloControllerCode) + + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.importDeclarations.length).toEqual(1) + expect(helloControllerFile.importDeclarations[0].resolvedPath).toBeUndefined() + }) + + test("doesn't resolve controller definition when ancestor is not stimulus controller but stimulus ancestor gets imported", async () => { + const helloControllerCode = dedent` + import { Modal } from "tailwindcss-stimulus-components" + + class ApplicationController extends Controller {} + export default class extends ApplicationController {} + ` + + const helloControllerFile = new SourceFile(project, "app/javascript/hello_controller.js", helloControllerCode) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(helloControllerFile.resolvedControllerDefinitions).toEqual([]) + }) + + test("resolve node module package path with node module in detectedNodeModules", async () => { + const helloControllerCode = dedent` + import { Modal } from "tailwindcss-stimulus-components" + + class ApplicationController extends Modal {} + class IntermediateController extends ApplicationController {} + + export default class extends IntermediateController {} + ` + + const helloControllerFile = new SourceFile(project, path.join(project.projectPath, "app/javascript/hello_controller.js"), helloControllerCode) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(project.projectFiles.map(file => [project.relativePath(file.path), file.content])).toEqual([["app/javascript/hello_controller.js", helloControllerCode]]) + expect(project.detectedNodeModules.map(m => m.name)).toContain("tailwindcss-stimulus-components") + expect(Array.from(project.referencedNodeModules)).toEqual([ + "tailwindcss-stimulus-components", + "@hotwired/stimulus", + ]) + + expect(helloControllerFile.exportDeclarations).toHaveLength(1) + + const declaration = helloControllerFile.exportDeclarations[0] + + expect(declaration).toBeDefined() + + expect(declaration.exportedClassDeclaration).toBeDefined() + expect(project.relativePath(declaration.exportedClassDeclaration.sourceFile.path)).toEqual("app/javascript/hello_controller.js") + + expect(project.relativePath(declaration.resolvedPath)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + expect(project.relativePath(declaration.resolvedSourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + expect(project.relativePath(declaration.resolvedExportDeclaration.sourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + expect(project.relativePath(declaration.resolvedClassDeclaration.sourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + expect(project.relativePath(declaration.resolvedControllerDefinition.classDeclaration.sourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/modal.js") + + expect(declaration.resolvedClassDeclaration.superClass.className).toEqual("Controller") + expect(declaration.resolvedClassDeclaration.superClass.importDeclaration.source).toEqual("@hotwired/stimulus") + expect(declaration.resolvedClassDeclaration.superClass.importDeclaration.isStimulusImport).toEqual(true) + + expect(declaration.resolvedControllerDefinition.classNames).toEqual([]) + expect(declaration.resolvedControllerDefinition.targetNames).toEqual(["container", "background"]) + expect(declaration.resolvedControllerDefinition.valueNames).toEqual(["open", "restoreScroll"]) + expect(declaration.resolvedControllerDefinition.actionNames).toEqual([ + "disconnect", + "open", + "close", + "closeBackground", + "openValueChanged", + "lockScroll", + "unlockScroll", + "saveScrollPosition", + "restoreScrollPosition" + ]) + }) + + test("resolves file through ancestors", async () => { + const helloControllerCode = dedent` + import { Modal } from "tailwindcss-stimulus-components" + + class ApplicationController extends Modal { + third() {} + } + + class IntermediateController extends ApplicationController { + second() {} + } + + export default class extends IntermediateController { + first() {} + } + ` + + const helloControllerFile = new SourceFile(project, path.join(project.projectPath, "app/javascript/hello_controller.js"), helloControllerCode) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(project.projectFiles.map(file => [project.relativePath(file.path), file.content])).toEqual([["app/javascript/hello_controller.js", helloControllerCode]]) + expect(project.detectedNodeModules.map(m => m.name)).toContain("tailwindcss-stimulus-components") + expect(Array.from(project.referencedNodeModules)).toEqual([ + "tailwindcss-stimulus-components", + "@hotwired/stimulus" + ]) + + expect(helloControllerFile.exportDeclarations).toHaveLength(1) + + const declaration = helloControllerFile.exportDeclarations[0] + const klass = declaration.exportedClassDeclaration + + expect(klass).toBeDefined() + expect(klass.ancestors).toHaveLength(4) + + expect(klass.ancestors.map(klass => project.relativePath(klass.sourceFile.path))).toEqual([ + "app/javascript/hello_controller.js", + "app/javascript/hello_controller.js", + "app/javascript/hello_controller.js", + "node_modules/tailwindcss-stimulus-components/src/modal.js", + ]) + + expect(klass.ancestors.map(klass => klass.className)).toEqual([ + undefined, + "IntermediateController", + "ApplicationController", + undefined, + ]) + + expect(klass.ancestors.map(klass => klass.controllerDefinition?.localActionNames)).toEqual([ + ["first"], + ["second"], + ["third"], + [ + "disconnect", + "open", + "close", + "closeBackground", + "openValueChanged", + "lockScroll", + "unlockScroll", + "saveScrollPosition", + "restoreScrollPosition" + ], + ]) + + }) + + test("resolve node module package path with node module in detectedNodeModules via second file", async () => { + const applicationControllerCode = dedent` + import { Autosave } from "tailwindcss-stimulus-components" + + export default class extends Autosave {} + ` + + const helloControllerCode = dedent` + import ApplicationController from "./application_controller" + + class IntermediateController extends ApplicationController {} + + export default class Hello extends IntermediateController {} + ` + + const applicationControllerFile = new SourceFile(project, path.join(project.projectPath, "app/javascript/application_controller.js"), applicationControllerCode) + const helloControllerFile = new SourceFile(project, path.join(project.projectPath, "app/javascript/hello_controller.js"), helloControllerCode) + + project.projectFiles.push(applicationControllerFile) + project.projectFiles.push(helloControllerFile) + + await project.analyze() + + expect(project.projectFiles.map(file => [project.relativePath(file.path), file.content])).toEqual([ + ["app/javascript/application_controller.js", applicationControllerCode], + ["app/javascript/hello_controller.js", helloControllerCode], + ]) + + expect(Array.from(project.referencedNodeModules)).toEqual([ + "tailwindcss-stimulus-components", + "@hotwired/stimulus", + ]) + + expect(helloControllerFile.exportDeclarations).toHaveLength(1) + + const declaration = helloControllerFile.exportDeclarations[0] + + expect(declaration).toBeDefined() + + expect(declaration.exportedClassDeclaration).toBeDefined() + expect(project.relativePath(declaration.exportedClassDeclaration.sourceFile.path)).toEqual("app/javascript/hello_controller.js") + + expect(project.relativePath(declaration.resolvedPath)).toEqual("node_modules/tailwindcss-stimulus-components/src/autosave.js") + expect(project.relativePath(declaration.resolvedSourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/autosave.js") + expect(project.relativePath(declaration.resolvedExportDeclaration.sourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/autosave.js") + expect(project.relativePath(declaration.resolvedClassDeclaration.sourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/autosave.js") + expect(project.relativePath(declaration.resolvedControllerDefinition.classDeclaration.sourceFile.path)).toEqual("node_modules/tailwindcss-stimulus-components/src/autosave.js") + + expect(declaration.resolvedClassDeclaration.superClass.className).toEqual("Controller") + expect(declaration.resolvedClassDeclaration.superClass.importDeclaration.source).toEqual("@hotwired/stimulus") + expect(declaration.resolvedClassDeclaration.superClass.importDeclaration.isStimulusImport).toEqual(true) + + expect(declaration.resolvedControllerDefinition.actionNames).toEqual(["connect", "save", "success", "error", "setStatus"]) + expect(declaration.resolvedControllerDefinition.classNames).toEqual([]) + expect(declaration.resolvedControllerDefinition.targetNames).toEqual(["form", "status"]) + expect(declaration.resolvedControllerDefinition.valueNames).toEqual(["submitDuration", "statusDuration", "submittingText", "successText", "errorText"]) + }) + }) +}) diff --git a/test/source_file/source_file.test.ts b/test/source_file/source_file.test.ts new file mode 100644 index 0000000..aa1335c --- /dev/null +++ b/test/source_file/source_file.test.ts @@ -0,0 +1,60 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject() + +describe("SourceFile", () => { + beforeEach(() => { + project = setupProject() + }) + + test("parses with content", () => { + const sourceFile = new SourceFile(project, "abc.js", "") + + expect(sourceFile.hasContent).toEqual(true) + expect(sourceFile.errors.length).toEqual(0) + expect(sourceFile.controllerDefinitions).toEqual([]) + expect(sourceFile.ast).toBeUndefined() + + sourceFile.initialize() + + expect(sourceFile.hasContent).toEqual(true) + expect(sourceFile.errors.length).toEqual(0) + expect(sourceFile.controllerDefinitions).toEqual([]) + expect(sourceFile.ast).toEqual({ + body: [], + comments: [], + loc: { + end: { + column: 0, + line: 1, + }, + start: { + column: 0, + line: 1, + }, + }, + range: [0, 0], + sourceType: "script", + tokens: [], + type: "Program", + }) + }) + + test("doesn't parse with no content", () => { + const sourceFile = new SourceFile(project, "abc.js", undefined) + + expect(sourceFile.ast).toBeUndefined() + expect(sourceFile.hasContent).toEqual(false) + expect(sourceFile.errors).toHaveLength(0) + + sourceFile.parse() + + expect(sourceFile.ast).toBeUndefined() + expect(sourceFile.hasContent).toEqual(false) + expect(sourceFile.errors).toHaveLength(1) + expect(sourceFile.errors[0].message).toEqual("File content hasn't been read yet") + expect(sourceFile.controllerDefinitions).toEqual([]) + }) +}) diff --git a/test/source_file/typescript.test.ts b/test/source_file/typescript.test.ts new file mode 100644 index 0000000..8f87725 --- /dev/null +++ b/test/source_file/typescript.test.ts @@ -0,0 +1,199 @@ +import dedent from "dedent" +import { describe, beforeEach, test, expect } from "vitest" +import { SourceFile } from "../../src" +import { setupProject } from "../helpers/setup" + +let project = setupProject("app") + +describe("SourceFile", () => { + beforeEach(() => { + project = setupProject("app") + }) + + describe("TypeScript syntax", () => { + test("ignores TSTypeAnnotation", async () => { + const code = dedent` + const greeting: string = "Hello World!" + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSTypeAnnotation as const", async () => { + const code = dedent` + const abc = [] as const + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSAsExpression", async () => { + const code = dedent` + const abc: string = 1 as string + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSModuleDeclaration kind=global", async () => { + const code = dedent` + declare global {} + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSModuleDeclaration kind=namespace", async () => { + const code = dedent` + namespace MyNamespace {} + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSTypeAliasDeclaration", async () => { + const code = dedent` + type MyType = { + abc?: string + } + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSInterfaceDeclaration", async () => { + const code = dedent` + interface MyInterface { + abc?: string + } + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSSatisfiesExpression", async () => { + const code = dedent` + const abc: string = 1 satisfies string + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSTypeAssertion", async () => { + const code = dedent` + let something: any = 123; + let number = something; + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + + test("ignores TSAbstractMethodDefinition", async () => { + const code = dedent` + abstract class Person { + abstract find(string): Person; + } + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(2) + }) + + test("ignores TSTypeParameterDeclaration", async () => { + const code = dedent` + function generic(items: T[]): T[] { + return new Array().concat(items) + } + + class Test {} + ` + + const controllerFile = new SourceFile(project, "hello_controller.ts", code) + project.projectFiles.push(controllerFile) + + await project.analyze() + + expect(controllerFile.errors.length).toEqual(0) + expect(controllerFile.classDeclarations.length).toEqual(1) + }) + }) +}) diff --git a/test/system/bun.test.ts b/test/system/bun.test.ts new file mode 100644 index 0000000..eaf6ffb --- /dev/null +++ b/test/system/bun.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("bun") + +describe("System", () => { + test("bun", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "register"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/system/esbuild-rails.test.ts b/test/system/esbuild-rails.test.ts new file mode 100644 index 0000000..c68229b --- /dev/null +++ b/test/system/esbuild-rails.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("esbuild-rails") + +describe("System", () => { + test("esbuild-rails", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "esbuild-rails"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/system/esbuild.test.ts b/test/system/esbuild.test.ts new file mode 100644 index 0000000..7006aa9 --- /dev/null +++ b/test/system/esbuild.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("esbuild") + +describe("System", () => { + test("esbuild", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "register"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/system/importmap-laravel-eager.test.ts b/test/system/importmap-laravel-eager.test.ts new file mode 100644 index 0000000..c0c7a1b --- /dev/null +++ b/test/system/importmap-laravel-eager.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("importmap-laravel-eager") + +describe("System", () => { + test("importmap-laravel-eager", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("resources/js/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("resources/js/libs/stimulus.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "stimulus-loading-eager"]]) + expect(Array.from(project.controllerRoots)).toEqual(["resources/js/controllers"]) + }) +}) diff --git a/test/system/importmap-laravel-lazy.test.ts b/test/system/importmap-laravel-lazy.test.ts new file mode 100644 index 0000000..ec2bd91 --- /dev/null +++ b/test/system/importmap-laravel-lazy.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("importmap-laravel-lazy") + +describe("System", () => { + test("importmap-laravel-lazy", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("resources/js/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("resources/js/libs/stimulus.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "stimulus-loading-lazy"]]) + expect(Array.from(project.controllerRoots)).toEqual(["resources/js/controllers"]) + }) +}) diff --git a/test/system/importmap-rails-eager.test.ts b/test/system/importmap-rails-eager.test.ts new file mode 100644 index 0000000..edec465 --- /dev/null +++ b/test/system/importmap-rails-eager.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("importmap-rails-eager") + +describe("System", () => { + test("importmap-rails-eager", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "stimulus-loading-eager"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/system/importmap-rails-lazy.test.ts b/test/system/importmap-rails-lazy.test.ts new file mode 100644 index 0000000..6a7ff4d --- /dev/null +++ b/test/system/importmap-rails-lazy.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("importmap-rails-lazy") + +describe("System", () => { + test("importmap-rails-lazy", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "stimulus-loading-lazy"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/system/rollup.test.ts b/test/system/rollup.test.ts new file mode 100644 index 0000000..20ab817 --- /dev/null +++ b/test/system/rollup.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("rollup") + +describe("System", () => { + test("rollup", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "register"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/system/shakapacker.test.ts b/test/system/shakapacker.test.ts new file mode 100644 index 0000000..9504d23 --- /dev/null +++ b/test/system/shakapacker.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("shakapacker") + +describe("System", () => { + test("shakapacker", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "stimulus-webpack-helpers"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/system/vite-laravel.test.ts b/test/system/vite-laravel.test.ts new file mode 100644 index 0000000..d1676b8 --- /dev/null +++ b/test/system/vite-laravel.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("vite-laravel") + +describe("System", () => { + test("vite-laravel", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("resources/js/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("resources/js/libs/stimulus.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "register"]]) + expect(Array.from(project.controllerRoots)).toEqual(["resources/js/controllers"]) + }) +}) diff --git a/test/system/vite-rails.test.ts b/test/system/vite-rails.test.ts new file mode 100644 index 0000000..8739330 --- /dev/null +++ b/test/system/vite-rails.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("vite-rails") + +describe("System", () => { + test("vite-rails", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/frontend/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/frontend/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "stimulus-vite-helpers"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/frontend/controllers"]) + }) +}) diff --git a/test/system/webpacker.test.ts b/test/system/webpacker.test.ts new file mode 100644 index 0000000..ef96ee3 --- /dev/null +++ b/test/system/webpacker.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from "vitest" +import { setupProject } from "../helpers/setup" + +const project = setupProject("webpacker") + +describe("System", () => { + test("webpacker", async () => { + expect(project.controllersFile).toBeUndefined() + expect(project.applicationFile).toBeUndefined() + expect(project.registeredControllers.length).toEqual(0) + + await project.initialize() + + expect(project.controllersFile).toBeDefined() + expect(project.relativePath(project.controllersFile.path)).toEqual("app/javascript/controllers/index.js") + + expect(project.applicationFile).toBeDefined() + expect(project.relativePath(project.applicationFile.path)).toEqual("app/javascript/controllers/application.js") + + expect(project.registeredControllers.length).toEqual(1) + expect(project.registeredControllers.map(controller => [controller.identifier, controller.loadMode])).toEqual([["hello", "stimulus-webpack-helpers"]]) + expect(Array.from(project.controllerRoots)).toEqual(["app/javascript/controllers"]) + }) +}) diff --git a/test/util/nestedFolderSort.test.ts b/test/util/fs/nestedFolderSort.test.ts similarity index 94% rename from test/util/nestedFolderSort.test.ts rename to test/util/fs/nestedFolderSort.test.ts index 216d63d..35091bf 100644 --- a/test/util/nestedFolderSort.test.ts +++ b/test/util/fs/nestedFolderSort.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "vitest" -import { nestedFolderSort } from "../../src/util" +import { nestedFolderSort } from "../../../src/util/fs" -describe("util", () => { +describe("util.fs", () => { describe("nestedFolderSort", () => { test("empty", () => { expect([].sort(nestedFolderSort)).toEqual([]) diff --git a/test/util/npm/findNodeModulesPath.test.ts b/test/util/npm/findNodeModulesPath.test.ts new file mode 100644 index 0000000..ce6d2e3 --- /dev/null +++ b/test/util/npm/findNodeModulesPath.test.ts @@ -0,0 +1,36 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { findNodeModulesPath } from "../../../src/util/npm" +import { setupProject } from "../../helpers/setup" + +let project = setupProject() + +describe("util.npm", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("findNodeModulesPath", () => { + test("for root directory", async() => { + expect(await findNodeModulesPath(project.projectPath)).toEqual(`${project.projectPath}/node_modules`) + }) + + test("for any directory", async () => { + expect(await findNodeModulesPath(`${project.projectPath}/test/packages`)).toEqual(`${project.projectPath}/node_modules`) + }) + + test("for top-level file", async () => { + expect(await findNodeModulesPath(`${project.projectPath}/package.json`)).toEqual(`${project.projectPath}/node_modules`) + }) + + test("for any file", async () => { + expect(await findNodeModulesPath(`${project.projectPath}/test/packages/util.test.ts`)).toEqual(`${project.projectPath}/node_modules`) + }) + + test("for directory outside project", async () => { + const splits = project.projectPath.split("/") + const path = splits.slice(0, splits.length - 1).join("/") + + expect(await findNodeModulesPath(path)).toEqual(null) + }) + }) +}) diff --git a/test/util/npm/hasDependency.test.ts b/test/util/npm/hasDependency.test.ts new file mode 100644 index 0000000..fcea001 --- /dev/null +++ b/test/util/npm/hasDependency.test.ts @@ -0,0 +1,21 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { hasDepedency } from "../../../src/util/npm" +import { setupProject } from "../../helpers/setup" + +let project = setupProject() + +describe("util.npm", () => { + beforeEach(() => { + project = setupProject() + }) + + describe("hasDepedency", () => { + test("has dependency", async () => { + expect(await hasDepedency(project.projectPath, "acorn")).toEqual(true) + }) + + test("doesn't have dependency", async() => { + expect(await hasDepedency(project.projectPath, "some-package")).toEqual(false) + }) + }) +}) diff --git a/test/util/npm/nodeModuleForPackageName.test.ts b/test/util/npm/nodeModuleForPackageName.test.ts new file mode 100644 index 0000000..9d6cb10 --- /dev/null +++ b/test/util/npm/nodeModuleForPackageName.test.ts @@ -0,0 +1,42 @@ +import { describe, beforeEach, test, expect } from "vitest" +import { nodeModuleForPackageName } from "../../../src/util/npm" +import { setupProject } from "../../helpers/setup" + +let project = setupProject("app") + +describe("util.npm", () => { + beforeEach(() => { + project = setupProject("app") + }) + + describe("nodeModuleForPackageName", () => { + test("find and analyzes node module", async () => { + const nodeModule = await nodeModuleForPackageName(project, "tailwindcss-stimulus-components") + + expect(nodeModule.name).toEqual("tailwindcss-stimulus-components") + expect(nodeModule.type).toEqual("source") + expect(nodeModule.project).toEqual(project) + + expect(project.relativePath(nodeModule.path)).toEqual("node_modules/tailwindcss-stimulus-components") + expect(project.relativePath(nodeModule.entrypoint)).toEqual("node_modules/tailwindcss-stimulus-components/src/index.js") + + expect(nodeModule.controllerRoots.map(path => project.relativePath(path))).toEqual([ + "node_modules/tailwindcss-stimulus-components/src" + ]) + + expect(nodeModule.files.map(file => project.relativePath(file))).toEqual([ + "node_modules/tailwindcss-stimulus-components/src/transition.js", + "node_modules/tailwindcss-stimulus-components/src/toggle.js", + "node_modules/tailwindcss-stimulus-components/src/tabs.js", + "node_modules/tailwindcss-stimulus-components/src/slideover.js", + "node_modules/tailwindcss-stimulus-components/src/popover.js", + "node_modules/tailwindcss-stimulus-components/src/modal.js", + "node_modules/tailwindcss-stimulus-components/src/index.js", + "node_modules/tailwindcss-stimulus-components/src/dropdown.js", + "node_modules/tailwindcss-stimulus-components/src/color_preview.js", + "node_modules/tailwindcss-stimulus-components/src/autosave.js", + "node_modules/tailwindcss-stimulus-components/src/alert.js", + ]) + }) + }) +}) diff --git a/test/util/project/calculateControllerRoots.test.ts b/test/util/project/calculateControllerRoots.test.ts new file mode 100644 index 0000000..71b2dd1 --- /dev/null +++ b/test/util/project/calculateControllerRoots.test.ts @@ -0,0 +1,175 @@ +import { describe, test, expect } from "vitest" +import { calculateControllerRoots } from "../../../src/util/project" + +describe("util.project", () => { + describe("calculateControllerRoots", () => { + test("same root", () => { + expect( + calculateControllerRoots([ + "app/javascript/controllers/some_controller.js", + "app/javascript/controllers/nested/some_controller.js", + "app/javascript/controllers/nested/deeply/some_controller.js", + ]) + ).toEqual([ + "app/javascript/controllers" + ]) + }) + + test("different roots", () => { + expect( + calculateControllerRoots( + [ + "app/packs/controllers/some_controller.js", + "app/packs/controllers/nested/some_controller.js", + "app/packs/controllers/nested/deeply/some_controller.js", + "app/javascript/controllers/some_controller.js", + "app/javascript/controllers/nested/some_controller.js", + "app/javascript/controllers/nested/deeply/some_controller.js", + "resources/js/controllers/some_controller.js", + "resources/js/controllers/nested/some_controller.js", + "resources/js/controllers/nested/deeply/some_controller.js", + ] + ) + ).toEqual([ + "app/javascript/controllers", + "app/packs/controllers", + "resources/js/controllers" + ]) + }) + + describe("no common root", () => { + test("nested first", () => { + expect( + calculateControllerRoots( + [ + "test/fixtures/controller-paths/app/javascript/controllers/typescript_controller.ts", + "test/fixtures/controller-paths/app/packs/controllers/webpack_controller.js", + "test/fixtures/controller-paths/app/packs/controllers/nested/twice/webpack_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/laravel_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/nested/twice/laravel_controller.js", + "test/fixtures/controller-paths/app/javascript/controllers/nested/twice/rails_controller.js", + "node_modules/tailwindcss-stimulus-components/src/tabs.js", + "node_modules/tailwindcss-stimulus-components/src/toggle.js", + "node_modules/tailwindcss-stimulus-components/src/nested/slideover.js", + ] + ) + ).toEqual([ + "node_modules/tailwindcss-stimulus-components/src", + "test/fixtures/controller-paths/app/javascript/controllers", + "test/fixtures/controller-paths/app/packs/controllers", + "test/fixtures/controller-paths/resources/js/controllers", + ]) + }) + + test("nested last", () => { + expect( + calculateControllerRoots( + [ + "test/fixtures/controller-paths/app/packs/controllers/nested/twice/webpack_controller.js", + "test/fixtures/controller-paths/app/packs/controllers/nested/webpack_controller.js", + "test/fixtures/controller-paths/app/packs/controllers/webpack_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/nested/twice/rails_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/nested/twice/laravel_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/laravel_controller.js", + "node_modules/tailwindcss-stimulus-components/src/nested/slideover.js", + "node_modules/tailwindcss-stimulus-components/src/toggle.js", + ] + ) + ).toEqual([ + "node_modules/tailwindcss-stimulus-components/src", + "test/fixtures/controller-paths/app/packs/controllers", + "test/fixtures/controller-paths/resources/js/controllers", + ]) + }) + + test("nested mixed", () => { + expect( + calculateControllerRoots( + [ + "test/fixtures/controller-paths/app/packs/controllers/nested/webpack_controller.js", + "test/fixtures/controller-paths/app/packs/controllers/nested/twice/webpack_controller.js", + "test/fixtures/controller-paths/app/packs/controllers/webpack_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/nested/rails_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/nested/twice/laravel_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/laravel_controller.js", + "node_modules/tailwindcss-stimulus-components/src/nested/slideover.js", + "node_modules/tailwindcss-stimulus-components/src/toggle.js", + "node_modules/tailwindcss-stimulus-components/src/nested/twice/modal.js", + ] + ) + ).toEqual([ + "node_modules/tailwindcss-stimulus-components/src", + "test/fixtures/controller-paths/app/packs/controllers", + "test/fixtures/controller-paths/resources/js/controllers", + ]) + }) + + test("with only one file", () => { + expect( + calculateControllerRoots( + [ + "test/fixtures/controller-paths/app/packs/controllers/webpack_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/laravel_controller.js", + "node_modules/tailwindcss-stimulus-components/src/modal.js", + ] + ) + ).toEqual([ + "node_modules/tailwindcss-stimulus-components/src", + "test/fixtures/controller-paths/app/packs/controllers", + "test/fixtures/controller-paths/resources/js/controllers", + ]) + }) + + test("with only one file in nested folder", () => { + expect( + calculateControllerRoots( + [ + "test/fixtures/controller-paths/app/packs/controllers/nested/webpack_controller.js", + "test/fixtures/controller-paths/resources/js/controllers/nested/laravel_controller.js", + "node_modules/tailwindcss-stimulus-components/src/nested/modal.js", + ] + ) + ).toEqual([ + "node_modules/tailwindcss-stimulus-components/src/nested", + "test/fixtures/controller-paths/app/packs/controllers", + "test/fixtures/controller-paths/resources/js/controllers", + ]) + }) + + test("with no controllers folder and only one file in nested folder", () => { + expect( + calculateControllerRoots( + [ + "test/fixtures/controller-paths/app/packs/nested/webpack_controller.js", + "test/fixtures/controller-paths/resources/js/nested/laravel_controller.js", + "node_modules/tailwindcss-stimulus-components/src/nested/modal.js", + ] + ) + ).toEqual([ + "node_modules/tailwindcss-stimulus-components/src/nested", + "test/fixtures/controller-paths/app/packs/nested", + "test/fixtures/controller-paths/resources/js/nested", + ]) + }) + + test("with with no controllers folder and multiple files", () => { + expect( + calculateControllerRoots( + [ + "node_modules/tailwindcss-stimulus-components/src/modal.js", + "node_modules/tailwindcss-stimulus-components/src/nested/modal.js", + "test/fixtures/controller-paths/app/packs/nested/webpack_controller.js", + "test/fixtures/controller-paths/app/packs/webpack_controller.js", + "test/fixtures/controller-paths/resources/js/laravel_controller.js", + "test/fixtures/controller-paths/resources/js/nested/laravel_controller.js", + ] + ) + ).toEqual([ + "node_modules/tailwindcss-stimulus-components/src", + "test/fixtures/controller-paths/app/packs", + "test/fixtures/controller-paths/resources/js", + ]) + }) + }) + }) +}) diff --git a/test/value_definition.test.ts b/test/value_definition.test.ts index ff96877..2f1bfec 100644 --- a/test/value_definition.test.ts +++ b/test/value_definition.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest" +import { describe, test, expect } from "vitest" import { ValueDefinition } from "../src/controller_property_definition" describe("ValueDefinition", () => { diff --git a/yarn.lock b/yarn.lock index 4a9a291..65e2f97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -299,6 +299,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.0.1.tgz#dcfabce192db5b8bf77ea3c82cfaabe6e6a3c901" integrity sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg== +"@typescript-eslint/types@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.0.2.tgz#b6edd108648028194eb213887d8d43ab5750351c" + integrity sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA== + "@typescript-eslint/typescript-estree@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz#1d52ac03da541693fa5bcdc13ad655def5046faf" @@ -321,6 +326,14 @@ "@typescript-eslint/types" "7.0.1" eslint-visitor-keys "^3.4.1" +"@typescript-eslint/visitor-keys@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz#2899b716053ad7094962beb895d11396fc12afc7" + integrity sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ== + dependencies: + "@typescript-eslint/types" "7.0.2" + eslint-visitor-keys "^3.4.1" + "@vitest/expect@1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.3.0.tgz#09b374357b51be44f4fba9336d59024756f902dc" @@ -1211,7 +1224,7 @@ vite@^5.0.0: optionalDependencies: fsevents "~2.3.3" -vitest@^1.0.4: +vitest@^1.2.2: version "1.3.0" resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.3.0.tgz#3b04e2a8270b2db0929829cb8f03df7bffd1b5a2" integrity sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==