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==