From 727746887ba1dcc6ab1b31f63c98305929035981 Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 08:59:27 +0200 Subject: [PATCH 01/11] chore: Replaced ember-template-recast with @glimmer/syntax --- packages/ast/template/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ast/template/package.json b/packages/ast/template/package.json index 85543990..69d047ff 100644 --- a/packages/ast/template/package.json +++ b/packages/ast/template/package.json @@ -46,7 +46,7 @@ "test": "sh build.sh --test && mt dist-for-testing --quiet" }, "dependencies": { - "ember-template-recast": "^6.1.5" + "@glimmer/syntax": "^0.95.0" }, "devDependencies": { "@codemod-utils/tests": "workspace:*", From 52bdba448bf452d33b821f2b23ad0b45d2083b82 Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 08:59:31 +0200 Subject: [PATCH 02/11] chore: Added lockfile --- pnpm-lock.yaml | 313 ++++++------------------------------------------- 1 file changed, 33 insertions(+), 280 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f690088..fa3d2911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,9 +166,9 @@ importers: packages/ast/template: dependencies: - ember-template-recast: - specifier: ^6.1.5 - version: 6.1.5 + '@glimmer/syntax': + specifier: ^0.95.0 + version: 0.95.0 devDependencies: '@codemod-utils/tests': specifier: workspace:* @@ -762,29 +762,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@glimmer/env@0.1.7': - resolution: {integrity: sha512-JKF/a9I9jw6fGoz8kA7LEQslrwJ5jms5CXhu/aqkBWk+PmZ6pTl8mlb/eJ/5ujBGTiQzBhy5AIWF712iA+4/mw==} + '@glimmer/interfaces@0.94.6': + resolution: {integrity: sha512-sp/1WePvB/8O+jrcUHwjboNPTKrdGicuHKA9T/lh0vkYK2qM5Xz4i25lQMQ38tEMiw7KixrjHiTUiaXRld+IwA==} - '@glimmer/global-context@0.84.3': - resolution: {integrity: sha512-8Oy9Wg5IZxMEeAnVmzD2NkObf89BeHoFSzJgJROE/deutd3rxg83mvlOez4zBBGYwnTb+VGU2LYRpet92egJjA==} + '@glimmer/syntax@0.95.0': + resolution: {integrity: sha512-W/PHdODnpONsXjbbdY9nedgIHpglMfOzncf/moLVrKIcCfeQhw2vG07Rs/YW8KeJCgJRCLkQsi+Ix7XvrurGAg==} - '@glimmer/interfaces@0.84.3': - resolution: {integrity: sha512-dk32ykoNojt0mvEaIW6Vli5MGTbQo58uy3Epj7ahCgTHmWOKuw/0G83f2UmFprRwFx689YTXG38I/vbpltEjzg==} + '@glimmer/util@0.94.8': + resolution: {integrity: sha512-HfCKeZ74clF9BsPDBOqK/yRNa/ke6niXFPM6zRn9OVYw+ZAidLs7V8He/xljUHlLRL322kaZZY8XxRW7ALEwyg==} - '@glimmer/reference@0.84.3': - resolution: {integrity: sha512-lV+p/aWPVC8vUjmlvYVU7WQJsLh319SdXuAWoX/SE3pq340BJlAJiEcAc6q52y9JNhT57gMwtjMX96W5Xcx/qw==} + '@glimmer/wire-format@0.94.8': + resolution: {integrity: sha512-A+Cp5m6vZMAEu0Kg/YwU2dJZXyYxVJs2zI57d3CP6NctmX7FsT8WjViiRUmt5abVmMmRH5b8BUovqY6GSMAdrw==} - '@glimmer/syntax@0.84.3': - resolution: {integrity: sha512-ioVbTic6ZisLxqTgRBL2PCjYZTFIwobifCustrozRU2xGDiYvVIL0vt25h2c1ioDsX59UgVlDkIK4YTAQQSd2A==} - - '@glimmer/util@0.84.3': - resolution: {integrity: sha512-qFkh6s16ZSRuu2rfz3T4Wp0fylFj3HBsONGXQcrAdZjdUaIS6v3pNj6mecJ71qRgcym9Hbaq/7/fefIwECUiKw==} - - '@glimmer/validator@0.84.3': - resolution: {integrity: sha512-RTBV4TokUB0vI31UC7ikpV7lOYpWUlyqaKV//pRC4pexYMlmqnVhkFrdiimB/R1XyNdUOQUmnIAcdic39NkbhQ==} - - '@handlebars/parser@2.0.0': - resolution: {integrity: sha512-EP9uEDZv/L5Qh9IWuMUGJRfwhXJ4h1dqKTT4/3+tY0eu7sPis7xh23j61SYUnNF4vqCQvvUXpDo9Bh/+q1zASA==} + '@handlebars/parser@2.2.2': + resolution: {integrity: sha512-n/SZW+12rwikx/f8YcSv9JCi5p9vn1Bnts9ZtVvfErG4h0gbjHI1H1ZMhVUnaOC7yzFc6PtsCKIK8XeTnL90Gw==} + engines: {node: ^18 || ^20 || ^22 || >=24} '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1331,6 +1323,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1589,12 +1582,6 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} - async-promise-queue@1.0.5: - resolution: {integrity: sha512-xi0aQ1rrjPWYmqbwr18rrSKbSaXIeIwSd1J4KAgVfkq8utNbdZoht7GfvfY6swFUAMJ9obkc4WPJmtGwl+B8dw==} - - async@2.6.4: - resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1602,9 +1589,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.19: resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} engines: {node: '>=6.0.0'} @@ -1617,9 +1601,6 @@ packages: birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1636,9 +1617,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - cacheable@2.3.4: resolution: {integrity: sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==} @@ -1665,14 +1643,6 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1681,10 +1651,6 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1695,17 +1661,9 @@ packages: colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} - colors@1.4.0: - resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} - engines: {node: '>=0.1.90'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - comment-parser@1.4.6: resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} engines: {node: '>= 12.0.0'} @@ -1756,14 +1714,6 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1776,9 +1726,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1809,11 +1756,6 @@ packages: electron-to-chromium@1.5.336: resolution: {integrity: sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==} - ember-template-recast@6.1.5: - resolution: {integrity: sha512-VnRN8FzEHQnw/5rCv6Wnq8MVYXbGQbFY+rEufvWV+FO/IsxMahGEud4MYWtTA2q8iG+qJFrDQefNvQ//7MI7Qw==} - engines: {node: 12.* || 14.* || >= 16.*} - hasBin: true - emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -2191,9 +2133,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2213,9 +2152,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} @@ -2237,10 +2173,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2261,10 +2193,6 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -2428,10 +2356,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -2485,10 +2409,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -2507,9 +2427,6 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2545,10 +2462,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -2559,10 +2472,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -2712,10 +2621,6 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -2748,10 +2653,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2767,9 +2668,6 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2797,9 +2695,6 @@ packages: shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2860,9 +2755,6 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2950,10 +2842,6 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} - engines: {node: '>=14.14'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2995,6 +2883,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -3137,9 +3029,6 @@ packages: resolution: {integrity: sha512-41TvKmDGVpm2iuH7o+DAOt06yyu/cSHpX3uzAwetzASvlNtVddgIjXIb2DfB/Wa20B1Jo86+1Dv1CraSU7hWdw==} engines: {node: 10.* || >= 12.*} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3159,9 +3048,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerpool@6.5.1: - resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3582,43 +3468,28 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@glimmer/env@0.1.7': {} - - '@glimmer/global-context@0.84.3': - dependencies: - '@glimmer/env': 0.1.7 - - '@glimmer/interfaces@0.84.3': + '@glimmer/interfaces@0.94.6': dependencies: '@simple-dom/interface': 1.4.0 + type-fest: 4.41.0 - '@glimmer/reference@0.84.3': + '@glimmer/syntax@0.95.0': dependencies: - '@glimmer/env': 0.1.7 - '@glimmer/global-context': 0.84.3 - '@glimmer/interfaces': 0.84.3 - '@glimmer/util': 0.84.3 - '@glimmer/validator': 0.84.3 - - '@glimmer/syntax@0.84.3': - dependencies: - '@glimmer/interfaces': 0.84.3 - '@glimmer/util': 0.84.3 - '@handlebars/parser': 2.0.0 + '@glimmer/interfaces': 0.94.6 + '@glimmer/util': 0.94.8 + '@glimmer/wire-format': 0.94.8 + '@handlebars/parser': 2.2.2 simple-html-tokenizer: 0.5.11 - '@glimmer/util@0.84.3': + '@glimmer/util@0.94.8': dependencies: - '@glimmer/env': 0.1.7 - '@glimmer/interfaces': 0.84.3 - '@simple-dom/interface': 1.4.0 + '@glimmer/interfaces': 0.94.6 - '@glimmer/validator@0.84.3': + '@glimmer/wire-format@0.94.8': dependencies: - '@glimmer/env': 0.1.7 - '@glimmer/global-context': 0.84.3 + '@glimmer/interfaces': 0.94.6 - '@handlebars/parser@2.0.0': {} + '@handlebars/parser@2.2.2': {} '@humanfs/core@0.19.1': {} @@ -4333,23 +4204,10 @@ snapshots: astral-regex@2.0.0: {} - async-promise-queue@1.0.5: - dependencies: - async: 2.6.4 - debug: 2.6.9 - transitivePeerDependencies: - - supports-color - - async@2.6.4: - dependencies: - lodash: 4.18.1 - balanced-match@1.0.2: {} balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.10.19: {} better-path-resolve@1.0.0: @@ -4358,12 +4216,6 @@ snapshots: birpc@2.9.0: {} - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4385,11 +4237,6 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - cacheable@2.3.4: dependencies: '@cacheable/memory': 2.0.8 @@ -4415,12 +4262,6 @@ snapshots: chardet@2.1.1: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -4433,8 +4274,6 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.0 - clone@1.0.4: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4443,12 +4282,8 @@ snapshots: colord@2.9.3: {} - colors@1.4.0: {} - comma-separated-tokens@2.0.3: {} - commander@8.3.0: {} - comment-parser@1.4.6: {} concat-map@0.0.1: {} @@ -4494,20 +4329,12 @@ snapshots: dataloader@1.4.0: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 deep-is@0.1.4: {} - defaults@1.0.4: - dependencies: - clone: 1.0.4 - dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -4528,22 +4355,6 @@ snapshots: electron-to-chromium@1.5.336: {} - ember-template-recast@6.1.5: - dependencies: - '@glimmer/reference': 0.84.3 - '@glimmer/syntax': 0.84.3 - '@glimmer/validator': 0.84.3 - async-promise-queue: 1.0.5 - colors: 1.4.0 - commander: 8.3.0 - globby: 11.1.0 - ora: 5.4.1 - slash: 3.0.0 - tmp: 0.2.3 - workerpool: 6.5.1 - transitivePeerDependencies: - - supports-color - emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -4948,8 +4759,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -4963,8 +4772,6 @@ snapshots: imurmurhash@0.1.4: {} - inherits@2.0.4: {} - ini@1.3.8: {} is-arrayish@0.2.1: {} @@ -4981,8 +4788,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-interactive@1.0.0: {} - is-number@7.0.0: {} is-path-inside@4.0.0: {} @@ -4995,8 +4800,6 @@ snapshots: dependencies: better-path-resolve: 1.0.0 - is-unicode-supported@0.1.0: {} - is-windows@1.0.2: {} isexe@2.0.0: {} @@ -5120,11 +4923,6 @@ snapshots: lodash@4.18.1: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - lru-cache@11.2.6: {} lru-cache@5.1.1: @@ -5184,8 +4982,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mimic-fn@2.1.0: {} - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -5200,8 +4996,6 @@ snapshots: mri@1.2.0: {} - ms@2.0.0: {} - ms@2.1.3: {} nanoid@3.3.12: {} @@ -5220,10 +5014,6 @@ snapshots: normalize-path@3.0.0: {} - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -5241,18 +5031,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - outdent@0.5.0: {} oxc-minify@0.128.0: @@ -5396,12 +5174,6 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -5430,11 +5202,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - reusify@1.1.0: {} rolldown@1.0.0-rc.17: @@ -5466,8 +5233,6 @@ snapshots: dependencies: tslib: 2.8.1 - safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} semver@6.3.1: {} @@ -5493,8 +5258,6 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} simple-html-tokenizer@0.5.11: {} @@ -5553,10 +5316,6 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5671,8 +5430,6 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tmp@0.2.3: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5705,6 +5462,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -5883,10 +5642,6 @@ snapshots: matcher-collection: 2.0.1 minimatch: 3.1.5 - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -5904,8 +5659,6 @@ snapshots: word-wrap@1.2.5: {} - workerpool@6.5.1: {} - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 From 3be400041d7b89af2e3932ce65034d676094b1ef Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 08:59:35 +0200 Subject: [PATCH 03/11] chore: Copied source code from ember-template-recast --- .../template/src/-private/glimmer-syntax.ts | 137 ++ .../src/-private/glimmer-syntax/builders.ts | 63 + .../src/-private/glimmer-syntax/parser.ts | 1470 +++++++++++++++++ .../src/-private/glimmer-syntax/utils.ts | 112 ++ packages/ast/template/src/index.ts | 2 +- 5 files changed, 1783 insertions(+), 1 deletion(-) create mode 100644 packages/ast/template/src/-private/glimmer-syntax.ts create mode 100644 packages/ast/template/src/-private/glimmer-syntax/builders.ts create mode 100644 packages/ast/template/src/-private/glimmer-syntax/parser.ts create mode 100644 packages/ast/template/src/-private/glimmer-syntax/utils.ts diff --git a/packages/ast/template/src/-private/glimmer-syntax.ts b/packages/ast/template/src/-private/glimmer-syntax.ts new file mode 100644 index 00000000..b30ed764 --- /dev/null +++ b/packages/ast/template/src/-private/glimmer-syntax.ts @@ -0,0 +1,137 @@ +import { + type AST, + type NodeVisitor, + print as upstreamPrint, + traverse, + Walker, +} from '@glimmer/syntax'; + +import { builders } from './glimmer-syntax/builders.js'; +import { type NodeInfo, Parser } from './glimmer-syntax/parser.js'; + +const NODE_INFO = new WeakMap(); + +export function parse(template: string): AST.Template { + return new Parser(template, NODE_INFO).ast; +} + +export function print(ast: AST.Node): string { + return upstreamPrint(ast, { + entityEncoding: 'raw', + // @ts-expect-error: Incorrect type + override: (ast) => { + const info = NODE_INFO.get(ast); + + if (info) { + return info.parse_result.print(ast); + } + }, + }); +} + +type TransformOptions = { + /** + * The path (relative to the current working directory) to the file being transformed. + * + * This is useful when a given transform need to have differing behavior based on the + * location of the file (e.g. a component template should be modified differently than + * a route template). + */ + filePath?: string; + + /** + * The plugin to use for transformation. + */ + plugin: TransformPluginBuilder; + + /** + * The template to transform (either as a string or a pre-parsed AST.Template). + */ + template: string | AST.Template; +}; + +type TransformPluginBuilder = { + (env: TransformPluginEnv): NodeVisitor; +}; + +type TransformPluginEnv = { + contents: string; + filePath: string | undefined; + parseOptions: { + srcName: string | undefined; + }; + syntax: { + Walker: typeof Walker; + builders: typeof builders; + parse: typeof parse; + print: typeof print; + traverse: typeof traverse; + }; +}; + +type TransformResult = { + ast: AST.Template; + code: string; +}; + +export function transform( + template: string | AST.Template, + plugin: TransformPluginBuilder, +): TransformResult; +export function transform(options: TransformOptions): TransformResult; +export function transform( + templateOrOptions: string | AST.Template | TransformOptions, + plugin?: TransformPluginBuilder, +): TransformResult { + let ast: AST.Template; + let contents: string; + let filePath: undefined | string; + let template: string | AST.Template; + + if (plugin === undefined) { + const options = templateOrOptions as TransformOptions; + // TransformOptions invocation style + filePath = options.filePath; + plugin = options.plugin; + template = options.template; + } else { + filePath = undefined; + template = templateOrOptions as AST.Template; + } + + if (typeof template === 'string') { + ast = parse(template); + contents = template; + } else { + // assume we were passed an ast + ast = template; + contents = print(ast); + } + + const env: TransformPluginEnv = { + contents, + filePath, + parseOptions: { + srcName: filePath, + }, + syntax: { + Walker, + builders, + parse, + print, + traverse, + }, + }; + + const visitor = plugin(env); + + traverse(ast, visitor); + + return { + ast, + code: print(ast), + }; +} + +export type { AST, NodeVisitor }; +export { builders }; diff --git a/packages/ast/template/src/-private/glimmer-syntax/builders.ts b/packages/ast/template/src/-private/glimmer-syntax/builders.ts new file mode 100644 index 00000000..93511cb2 --- /dev/null +++ b/packages/ast/template/src/-private/glimmer-syntax/builders.ts @@ -0,0 +1,63 @@ +import { type AST, builders as _builders } from '@glimmer/syntax'; + +export type QuoteType = '"' | "'"; + +export interface AnnotatedAttrNode extends AST.AttrNode { + /** + * Supports cases like `` or `
` + */ + isValueless?: boolean; + + /** + * TextNode values can use single, double, or no quotes + * `type=input` vs `type='input'` vs `type="input"` + * ConcatStatement values can use single or double quotes + * `class='thing {{get this classNames}}'` vs `class="thing {{get this classNames}}"` + * MustacheStatements never use quotes + */ + quoteType?: QuoteType | null; +} + +export interface AnnotatedStringLiteral extends AST.StringLiteral { + quoteType?: QuoteType; +} + +/** + * The glimmer printer doesn't have any formatting suppport. It always uses + * double quotes, and won't print attrs without a value. To choose quote types + * or omit the value, we have to do it ourselves. + */ +export function useCustomPrinter(node: AST.BaseNode): boolean { + switch (node.type) { + case 'AttrNode': { + const n = node as AnnotatedAttrNode; + return Boolean(n.isValueless) || n.quoteType !== undefined; + } + + case 'StringLiteral': { + return Boolean((node as AnnotatedStringLiteral).quoteType); + } + + default: { + return false; + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ReplaceReturnType any, NewReturn> = ( + ...a: Parameters +) => NewReturn; + +/** + * Update glimmer's builders to return our annotated types, so that tests and + * users can specify formatting properties on constructed nodes. + */ +type _Builders = typeof _builders; + +export interface Builders extends Omit<_Builders, 'attr' | 'string'> { + attr: ReplaceReturnType; + string: ReplaceReturnType; +} + +export const builders = _builders; diff --git a/packages/ast/template/src/-private/glimmer-syntax/parser.ts b/packages/ast/template/src/-private/glimmer-syntax/parser.ts new file mode 100644 index 00000000..3268b9f6 --- /dev/null +++ b/packages/ast/template/src/-private/glimmer-syntax/parser.ts @@ -0,0 +1,1470 @@ +import { + type AST, + builders, + preprocess, + print as _print, + traverse, +} from '@glimmer/syntax'; + +import { + type AnnotatedAttrNode, + type AnnotatedStringLiteral, + type QuoteType, + useCustomPrinter, +} from './builders.js'; +import { getLines, sortByLoc, sourceForLoc } from './utils.js'; + +const leadingWhitespace = /(^\s+)/; +const attrNodeParts = /(^[^=]+)(\s+)?(=)?(\s+)?(['"])?(\S+)?/; +const hashPairParts = /(^[^=]+)(\s+)?=(\s+)?(\S+)/; +const invalidUnquotedAttrValue = /[^-.a-zA-Z0-9]/; + +const voidTagNames = new Set([ + 'area', + 'base', + 'br', + 'col', + 'command', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]); + +/* + This is needed to address issues in the glimmer-vm AST _before_ any of the nodes and node + values are cached. The specific issues being worked around are: + + * https://github.com/glimmerjs/glimmer-vm/pull/953 + * https://github.com/glimmerjs/glimmer-vm/pull/954 +*/ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any +function fixASTIssues(sourceLines: any, ast: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + traverse(ast, { + AttrNode(attr: AST.AttrNode) { + const node = attr as AnnotatedAttrNode; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const source = sourceForLoc(sourceLines, node.loc); + const attrNodePartsResults = source.match(attrNodeParts); + if (attrNodePartsResults === null) { + throw new Error(`Could not match attr node parts for ${source}`); + } + const [, , , equals, , quote] = attrNodePartsResults; + const isValueless = !equals; + + // TODO: manually working around https://github.com/glimmerjs/glimmer-vm/pull/953 + if ( + isValueless && + node.value.type === 'TextNode' && + node.value.chars === '' + ) { + // \n is not valid within an attribute name (it would indicate two attributes) + // always assume the attribute ends on the starting line + const { + start: { line, column }, + } = node.loc; + node.loc = builders.loc(line, column, line, column + node.name.length); + } + + node.isValueless = isValueless; + node.quoteType = (quote as QuoteType) || null; + }, + + StringLiteral(lit) { + const quotes = /^['"]/; + const node = lit as AnnotatedStringLiteral; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const source = sourceForLoc(sourceLines, node.loc); + if (!source.match(quotes)) { + throw new Error('Invalid string literal found'); + } + node.quoteType = source[0] as QuoteType; + }, + + TextNode(node, path) { + if (path.parentNode === null) { + throw new Error( + 'ember-template-recast: Error while sanitizing input AST: found TextNode with no parentNode', + ); + } + + switch (path.parentNode.type) { + case 'AttrNode': { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const source = sourceForLoc(sourceLines, node.loc); + if ( + node.chars.length > 0 && + ((source.startsWith(`'`) && source.endsWith(`'`)) || + (source.startsWith(`"`) && source.endsWith(`"`))) + ) { + const { start, end } = node.loc; + node.loc = builders.loc( + start.line, + start.column + 1, + end.line, + end.column - 1, + ); + } + break; + } + case 'ConcatStatement': { + const parent = path.parentNode; + const isFirstPart = parent.parts.indexOf(node) === 0; + + const { start, end } = node.loc; + if ( + isFirstPart && + node.loc.start.column > path.parentNode.loc.start.column + 1 + ) { + // TODO: manually working around https://github.com/glimmerjs/glimmer-vm/pull/954 + node.loc = builders.loc( + start.line, + start.column - 1, + end.line, + end.column, + ); + } else if (isFirstPart && node.chars.charAt(0) === '\n') { + node.loc = builders.loc( + start.line, + start.column + 1, + end.line, + end.column, + ); + } + } + } + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return ast; +} + +export interface NodeInfo { + hadHash?: boolean; + hadParams?: boolean; + hashSource?: string; + node: AST.Node; + original: AST.Node; + paramsSource?: string; + parse_result: Parser; + postHashWhitespace?: string; + postParamsWhitespace?: string; + postPathWhitespace?: string; + source: string; +} + +export class Parser { + private _originalAst: AST.Template; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private ancestor = new Map(); + public ast: AST.Template; + private dirtyFields = new Map>(); + private nodeInfo: WeakMap; + private source: string[]; + + constructor( + template: string, + nodeInfo: WeakMap = new WeakMap(), + ) { + let ast = preprocess(template, { + mode: 'codemod', + }); + + const source = getLines(template); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + ast = fixASTIssues(source, ast); + this.source = source; + this._originalAst = ast; + + this.nodeInfo = nodeInfo; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.ast = this.wrapNode(null, ast); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + private _rebuildParamsHash( + ast: + | AST.MustacheStatement + | AST.SubExpression + | AST.ElementModifierStatement + | AST.BlockStatement, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeInfo: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dirtyFields: any, + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { original } = nodeInfo; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (dirtyFields.has('hash')) { + if (ast.hash.pairs.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.hashSource = ''; + + if (ast.params.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = ''; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postParamsWhitespace = ''; + } + } else { + let joinWith; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (original.hash.pairs.length > 1) { + joinWith = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.hash.pairs[0].loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.hash.pairs[1].loc.start, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadParams) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postPathWhitespace; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadHash) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postParamsWhitespace; + } else { + joinWith = ' '; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (joinWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinWith = ' '; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.hashSource = ast.hash.pairs + .map((pair: AST.HashPair) => { + return this.print(pair); + }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + .join(joinWith); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!nodeInfo.hadHash) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + nodeInfo.postParamsWhitespace = joinWith; + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + dirtyFields.delete('hash'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (dirtyFields.has('params')) { + let joinWith; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (original.params.length > 1) { + joinWith = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.params[0].loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.params[1].loc.start, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadParams) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postPathWhitespace; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadHash) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postParamsWhitespace; + } else { + joinWith = ' '; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (joinWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinWith = ' '; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.paramsSource = ast.params + .map((param) => this.print(param)) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + .join(joinWith); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (nodeInfo.hadParams && ast.params.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = ''; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (!nodeInfo.hadParams && ast.params.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = joinWith; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + dirtyFields.delete('params'); + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private _updateNodeInfoForParamsHash(_ast: any, nodeInfo: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { original } = nodeInfo; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hadParams = (nodeInfo.hadParams = original.params.length > 0); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hadHash = (nodeInfo.hadHash = original.hash.pairs.length > 0); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = hadParams + ? this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.path.loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.params[0].loc.start, + }) + : ''; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.paramsSource = hadParams + ? this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.params[0].loc.start, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.params[original.params.length - 1].loc.end, + }) + : ''; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postParamsWhitespace = hadHash + ? this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + start: hadParams + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.params[original.params.length - 1].loc.end + : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.path.loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.hash.loc.start, + }) + : ''; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.hashSource = hadHash ? this.sourceForLoc(original.hash.loc) : ''; + + const postHashSource = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + start: hadHash + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.hash.loc.end + : hadParams + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.params[original.params.length - 1].loc.end + : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.path.loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.loc.end, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postHashWhitespace = ''; + const postHashWhitespaceMatch = postHashSource.match(leadingWhitespace); + if (postHashWhitespaceMatch) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postHashWhitespace = postHashWhitespaceMatch[0]; + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private markAsDirty(node: any, property: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + let dirtyFields = this.dirtyFields.get(node); + if (dirtyFields === undefined) { + dirtyFields = new Set(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.dirtyFields.set(node, dirtyFields); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + dirtyFields.add(property); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const ancestor = this.ancestor.get(node); + if (ancestor !== null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.markAsDirty(ancestor.node, ancestor.key); + } + } + + print(_ast: AST.Node = this._originalAst): string { + if (!_ast) { + return ''; + } + + const nodeInfo = this.nodeInfo.get(_ast); + + if (nodeInfo === undefined) { + return this.printUserSuppliedNode(_ast); + } + + // this ensures that we are operating on the actual node and not a + // proxy (we can get Proxies here when transforms splice body/children) + const ast = nodeInfo.node; + + // make a copy of the dirtyFields, so we can easily track + // unhandled dirtied fields + const dirtyFields = new Set(this.dirtyFields.get(ast)); + if (dirtyFields.size === 0 && nodeInfo !== undefined) { + return nodeInfo.source; + } + + // TODO: splice the original source **excluding** "children" + // based on dirtyFields + const output = []; + + const { original } = nodeInfo; + + switch (ast.type) { + // @ts-expect-error: Incorrect type + case 'Program': + case 'Block': + case 'Template': + { + let bodySource = nodeInfo.source; + + if (dirtyFields.has('body')) { + bodySource = ast.body.map((node) => this.print(node)).join(''); + + dirtyFields.delete('body'); + } + + output.push(bodySource); + } + break; + case 'ElementNode': + { + const element = original as AST.ElementNode; + const { selfClosing, children } = element; + const hadChildren = children.length > 0; + const hadBlockParams = element.blockParams.length > 0; + + let openSource = `<${element.tag}`; + + const originalOpenParts = [ + ...element.attributes, + ...element.modifiers, + ...element.comments, + ].sort(sortByLoc); + + let postTagWhitespace; + if (originalOpenParts.length > 0) { + postTagWhitespace = this.sourceForLoc({ + start: { + line: element.loc.start.line, + column: + element.loc.start.column + 1 /* < */ + element.tag.length, + }, + // @ts-expect-error: Incorrect type + end: originalOpenParts[0].loc.start, + }); + } else if (selfClosing) { + postTagWhitespace = nodeInfo.source.substring( + openSource.length, + nodeInfo.source.length - 2, + ); + } else { + postTagWhitespace = ''; + } + + let openPartsSource = originalOpenParts.reduce( + (acc, part, index, parts) => { + const partSource = this.sourceForLoc(part.loc); + + if (index === parts.length - 1) { + return acc + partSource; + } + + let joinPartWith = this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: parts[index].loc.end, + // @ts-expect-error: Incorrect type + end: parts[index + 1].loc.start, + }); + + if (joinPartWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinPartWith = ' '; + } + + return acc + partSource + joinPartWith; + }, + '', + ); + + let postPartsWhitespace = ''; + if (originalOpenParts.length > 0) { + const postPartsSource = this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: originalOpenParts[originalOpenParts.length - 1].loc.end, + end: hadChildren + ? // @ts-expect-error: Incorrect type + element.children[0].loc.start + : element.loc.end, + }); + + const matchedWhitespace = postPartsSource.match(leadingWhitespace); + if (matchedWhitespace) { + postPartsWhitespace = matchedWhitespace[0]; + } + } else if (hadBlockParams) { + const postPartsSource = this.sourceForLoc({ + start: { + line: element.loc.start.line, + column: element.loc.start.column + 1 + element.tag.length, + }, + end: hadChildren + ? // @ts-expect-error: Incorrect type + element.children[0].loc.start + : element.loc.end, + }); + + const matchedWhitespace = postPartsSource.match(leadingWhitespace); + if (matchedWhitespace) { + postPartsWhitespace = matchedWhitespace[0]; + } + } + + let blockParamsSource = ''; + let postBlockParamsWhitespace = ''; + if (element.blockParams.length > 0) { + const blockParamStartIndex = nodeInfo.source.indexOf('as |'); + const blockParamsEndIndex = nodeInfo.source.indexOf( + '|', + blockParamStartIndex + 4, + ); + blockParamsSource = nodeInfo.source.substring( + blockParamStartIndex, + blockParamsEndIndex + 1, + ); + + // Match closing index after start of block params to avoid closing tag if /> or > encountered in string + const closeOpenIndex = + nodeInfo.source + .substring(blockParamStartIndex) + .indexOf(selfClosing ? '/>' : '>') + blockParamStartIndex; + postBlockParamsWhitespace = nodeInfo.source.substring( + blockParamsEndIndex + 1, + closeOpenIndex, + ); + } + + let closeOpen = selfClosing ? `/>` : `>`; + + let childrenSource = hadChildren + ? this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: element.children[0].loc.start, + // @ts-expect-error: Incorrect type + end: element.children[children.length - 1].loc.end, + }) + : ''; + + let closeSource = selfClosing + ? '' + : voidTagNames.has(element.tag) + ? '' + : ``; + + if (dirtyFields.has('tag')) { + openSource = `<${ast.tag}`; + + closeSource = selfClosing + ? '' + : voidTagNames.has(ast.tag) + ? '' + : ``; + + dirtyFields.delete('tag'); + } + + if (dirtyFields.has('children')) { + childrenSource = ast.children + .map((child) => this.print(child)) + .join(''); + + if (selfClosing) { + closeOpen = `>`; + closeSource = ``; + ast.selfClosing = false; + + if (originalOpenParts.length === 0 && postTagWhitespace === ' ') { + postTagWhitespace = ''; + } + + if (originalOpenParts.length > 0 && postPartsWhitespace === ' ') { + postPartsWhitespace = ''; + } + } + + dirtyFields.delete('children'); + } + + if ( + dirtyFields.has('attributes') || + dirtyFields.has('comments') || + dirtyFields.has('modifiers') + ) { + const openParts = [ + ...ast.attributes, + ...ast.modifiers, + ...ast.comments, + ].sort(sortByLoc); + + openPartsSource = openParts.reduce((acc, part, index, parts) => { + const partSource = this.print(part); + + if (index === parts.length - 1) { + return acc + partSource; + } + + let joinPartWith = this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: parts[index].loc.end, + // @ts-expect-error: Incorrect type + end: parts[index + 1].loc.start, + }); + + if (joinPartWith === '' || joinPartWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinPartWith = ' '; + } + + return acc + partSource + joinPartWith; + }, ''); + + if (originalOpenParts.length === 0) { + postTagWhitespace = ' '; + } + + if (openParts.length === 0 && originalOpenParts.length > 0) { + postTagWhitespace = ''; + } + + if (openParts.length > 0 && ast.selfClosing) { + postPartsWhitespace = postPartsWhitespace || ' '; + } + + dirtyFields.delete('attributes'); + dirtyFields.delete('comments'); + dirtyFields.delete('modifiers'); + } + + if (dirtyFields.has('blockParams')) { + if (ast.blockParams.length === 0) { + blockParamsSource = ''; + postPartsWhitespace = ''; + } else { + blockParamsSource = `as |${ast.blockParams.join(' ')}|`; + + // ensure we have at least a space + postPartsWhitespace = postPartsWhitespace || ' '; + } + + dirtyFields.delete('blockParams'); + } + + output.push( + openSource, + postTagWhitespace, + openPartsSource, + postPartsWhitespace, + blockParamsSource, + postBlockParamsWhitespace, + closeOpen, + childrenSource, + closeSource, + ); + } + break; + case 'MustacheStatement': + case 'ElementModifierStatement': + case 'SubExpression': + { + this._updateNodeInfoForParamsHash(ast, nodeInfo); + + let openSource = this.sourceForLoc({ + start: original.loc.start, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: (original as any).path.loc.end, + }); + + let endSource = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + start: nodeInfo.hadHash + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (original as any).hash.loc.end + : nodeInfo.hadParams + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (original as any).params[(original as any).params.length - 1] + .loc.end + : // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (original as any).path.loc.end, + end: original.loc.end, + }).trimLeft(); + + if (dirtyFields.has('path')) { + openSource = + this.sourceForLoc({ + start: original.loc.start, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: (original as any).path.loc.start, + }) + this.print(ast.path); + + dirtyFields.delete('path'); + } + + if (dirtyFields.has('type')) { + // we only support going from SubExpression -> MustacheStatement + if ( + original.type !== 'SubExpression' || + ast.type !== 'MustacheStatement' + ) { + throw new Error( + `ember-template-recast only supports updating the 'type' for SubExpression to MustacheStatement (you attempted to change ${original.type} to ${ast.type})`, + ); + } + + // TODO: this is a logic error, assumes ast.path is a PathExpression but it could be a number of other things + openSource = `{{${(ast.path as AST.PathExpression).original}`; + endSource = '}}'; + + dirtyFields.delete('type'); + } + + this._rebuildParamsHash(ast, nodeInfo, dirtyFields); + + output.push( + openSource, + nodeInfo.postPathWhitespace, + nodeInfo.paramsSource, + nodeInfo.postParamsWhitespace, + nodeInfo.hashSource, + nodeInfo.postHashWhitespace, + endSource, + ); + } + break; + case 'ConcatStatement': + { + let partsSource = this.sourceForLoc({ + start: { + line: original.loc.start.line, + column: original.loc.start.column + 1, + }, + + end: { + line: original.loc.end.line, + column: original.loc.end.column - 1, + }, + }); + + if (dirtyFields.has('parts')) { + partsSource = ast.parts.map((part) => this.print(part)).join(''); + + dirtyFields.delete('parts'); + } + + output.push(partsSource); + } + break; + case 'BlockStatement': + { + const block = original as AST.BlockStatement; + + this._updateNodeInfoForParamsHash(ast, nodeInfo); + + const hadProgram = block.program.body.length > 0; + const hadProgramBlockParams = block.program.blockParams.length > 0; + + let openSource = this.sourceForLoc({ + start: block.loc.start, + end: block.path.loc.end, + }); + + let blockParamsSource = ''; + let postBlockParamsWhitespace = ''; + if (hadProgramBlockParams) { + const blockParamsSourceScratch = this.sourceForLoc({ + start: nodeInfo.hadHash + ? block.hash.loc.end + : nodeInfo.hadParams + ? // @ts-expect-error: Incorrect type + block.params[block.params.length - 1].loc.end + : block.path.loc.end, + end: original.loc.end, + }); + + const indexOfAsPipe = blockParamsSourceScratch.indexOf('as |'); + const indexOfEndPipe = blockParamsSourceScratch.indexOf( + '|', + indexOfAsPipe + 4, + ); + + blockParamsSource = blockParamsSourceScratch.substring( + indexOfAsPipe, + indexOfEndPipe + 1, + ); + + const postBlockParamsWhitespaceMatch = blockParamsSourceScratch + .substring(indexOfEndPipe + 1) + .match(leadingWhitespace); + if (postBlockParamsWhitespaceMatch) { + postBlockParamsWhitespace = postBlockParamsWhitespaceMatch[0]; + } + } + + let openEndSource; + { + const openEndSourceScratch = this.sourceForLoc({ + start: nodeInfo.hadHash + ? block.hash.loc.end + : nodeInfo.hadParams + ? // @ts-expect-error: Incorrect type + block.params[block.params.length - 1].loc.end + : block.path.loc.end, + end: block.loc.end, + }); + + let startingOffset = 0; + if (hadProgramBlockParams) { + const indexOfAsPipe = openEndSourceScratch.indexOf('as |'); + const indexOfEndPipe = openEndSourceScratch.indexOf( + '|', + indexOfAsPipe + 4, + ); + + startingOffset = indexOfEndPipe + 1; + } + + const indexOfFirstCurly = openEndSourceScratch.indexOf('}'); + const indexOfSecondCurly = openEndSourceScratch.indexOf( + '}', + indexOfFirstCurly + 1, + ); + + openEndSource = openEndSourceScratch + .substring(startingOffset, indexOfSecondCurly + 1) + .trimLeft(); + } + + let programSource = hadProgram + ? this.sourceForLoc(block.program.loc) + : ''; + + let inversePreamble = ''; + if (block.inverse) { + if (hadProgram) { + inversePreamble = this.sourceForLoc({ + start: block.program.loc.end, + end: block.inverse.loc.start, + }); + } else { + const openEndSourceScratch = this.sourceForLoc({ + start: nodeInfo.hadHash + ? block.hash.loc.end + : nodeInfo.hadParams + ? // @ts-expect-error: Incorrect type + block.params[block.params.length - 1].loc.end + : block.path.loc.end, + end: block.loc.end, + }); + + const indexOfFirstCurly = openEndSourceScratch.indexOf('}'); + const indexOfSecondCurly = openEndSourceScratch.indexOf( + '}', + indexOfFirstCurly + 1, + ); + const indexOfThirdCurly = openEndSourceScratch.indexOf( + '}', + indexOfSecondCurly + 1, + ); + const indexOfFourthCurly = openEndSourceScratch.indexOf( + '}', + indexOfThirdCurly + 1, + ); + + inversePreamble = openEndSourceScratch.substring( + indexOfSecondCurly + 1, + indexOfFourthCurly + 1, + ); + } + } + + // GH #149 + // In the event we're dealing with a chain of if/else-if/else, the inverse + // should encompass the entirety of the chain. Sadly, the loc param of + // original.inverse in this case only captures the block of the first inverse + // not the entire chain. We instead look at the loc param of the nested body + // node, which does report the entire chain. + // In this case, because it also includes the preamble, we must also trim + // that from our final inverse source. + let inverseSource; + if (block.inverse && block.inverse.chained) { + // @ts-expect-error: Incorrect type + inverseSource = this.sourceForLoc(block.inverse.body[0].loc) || ''; + inverseSource = inverseSource.slice(inversePreamble.length); + } else { + inverseSource = block.inverse + ? this.sourceForLoc(block.inverse.loc) + : ''; + } + + let endSource = ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + if (!(ast as any).wasChained) { + const firstOpenCurlyFromEndIndex = nodeInfo.source.lastIndexOf('{'); + const secondOpenCurlyFromEndIndex = nodeInfo.source.lastIndexOf( + '{', + firstOpenCurlyFromEndIndex - 1, + ); + + endSource = nodeInfo.source.substring(secondOpenCurlyFromEndIndex); + } + + this._rebuildParamsHash(ast, nodeInfo, dirtyFields); + + if (dirtyFields.has('path')) { + openSource = + this.sourceForLoc({ + start: original.loc.start, + end: block.path.loc.start, + }) + _print(ast.path); + + // TODO: this is a logic error + const pathIndex = endSource.indexOf( + (block.path as AST.PathExpression).original, + ); + endSource = + endSource.slice(0, pathIndex) + + (ast.path as AST.PathExpression).original + + endSource.slice( + pathIndex + (block.path as AST.PathExpression).original.length, + ); + + dirtyFields.delete('path'); + } + + if (dirtyFields.has('program')) { + const programDirtyFields = new Set( + this.dirtyFields.get(ast.program), + ); + + if (programDirtyFields.has('blockParams')) { + if (ast.program.blockParams.length === 0) { + nodeInfo.postHashWhitespace = ''; + blockParamsSource = ''; + } else { + nodeInfo.postHashWhitespace = + nodeInfo.postHashWhitespace || ' '; + blockParamsSource = `as |${ast.program.blockParams.join(' ')}|`; + } + programDirtyFields.delete('blockParams'); + } + + if (programDirtyFields.has('body')) { + programSource = ast.program.body + .map((child) => this.print(child)) + .join(''); + + programDirtyFields.delete('body'); + } + + if (programDirtyFields.size > 0) { + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Unhandled mutations for ${ast.program.type}: ${Array.from(programDirtyFields)}`, + ); + } + + dirtyFields.delete('program'); + } + + if (dirtyFields.has('inverse')) { + if (!ast.inverse) { + inverseSource = ''; + inversePreamble = ''; + } else { + if (ast.inverse.chained) { + inversePreamble = ''; + const inverseBody = ast.inverse.body[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (inverseBody as any).wasChained = true; + inverseSource = this.print(inverseBody); + } else { + inverseSource = ast.inverse.body + .map((child) => this.print(child)) + .join(''); + } + + if (!block.inverse) { + // TODO: detect {{else}} vs {{else if foo}} + inversePreamble = '{{else}}'; + } + } + + dirtyFields.delete('inverse'); + } + + output.push( + openSource, + nodeInfo.postPathWhitespace, + nodeInfo.paramsSource, + nodeInfo.postParamsWhitespace, + nodeInfo.hashSource, + nodeInfo.postHashWhitespace, + blockParamsSource, + postBlockParamsWhitespace, + openEndSource, + programSource, + inversePreamble, + inverseSource, + endSource, + ); + } + break; + case 'HashPair': + { + const hashPair = original as AST.HashPair; + const { source } = nodeInfo; + const hashPairPartsResult = source.match(hashPairParts); + if (hashPairPartsResult === null) { + throw new Error('Could not match hash pair parts'); + } + // eslint-disable-next-line prefer-const + let [, keySource, postKeyWhitespace, postEqualsWhitespace] = + hashPairPartsResult; + let valueSource = this.sourceForLoc(hashPair.value.loc); + + if (dirtyFields.has('key')) { + keySource = ast.key; + + dirtyFields.delete('key'); + } + + if (dirtyFields.has('value')) { + valueSource = this.print(ast.value); + + dirtyFields.delete('value'); + } + + output.push( + keySource, + postKeyWhitespace, + '=', + postEqualsWhitespace, + valueSource, + ); + } + break; + case 'AttrNode': + { + const attrNode = original as AST.AttrNode; + const { source } = nodeInfo; + const attrNodePartsResults = source.match(attrNodeParts); + if (attrNodePartsResults === null) { + throw new Error(`Could not match attr node parts for ${source}`); + } + + let [ + , + nameSource, + // eslint-disable-next-line prefer-const + postNameWhitespace, + equals, + // eslint-disable-next-line prefer-const + postEqualsWhitespace, + quote, + ] = attrNodePartsResults; + let valueSource = this.sourceForLoc(attrNode.value.loc); + // Source of ConcatStatements includes their quotes, + // but source of an AttrNode's TextNode value does not. + // Normalize on not including them, then always printing them ourselves: + if (attrNode.value.type === 'ConcatStatement') { + valueSource = valueSource.slice(1, -1); + } + + const node = ast as AnnotatedAttrNode; + + if (dirtyFields.has('name')) { + nameSource = node.name; + dirtyFields.delete('name'); + } + + if (dirtyFields.has('quoteType')) { + // Ensure the quote type they've specified is valid for the value + if (node.value.type === 'MustacheStatement' && node.quoteType) { + throw new Error( + 'Mustache statements should not be quoted as attribute values', + ); + } else if ( + node.value.type === 'ConcatStatement' && + !node.quoteType + ) { + throw new Error( + 'ConcatStatements must be quoted as attribute values', + ); + } else if ( + node.value.type == 'TextNode' && + !node.quoteType && + node.value.chars.match(invalidUnquotedAttrValue) + ) { + throw new Error( + `\`${node.value.chars}\` is invalid as an unquoted attribute value. Alphanumeric, hyphens, and periods only`, + ); + } + quote = node.quoteType || ''; // null => empty string + } else if (dirtyFields.has('value')) { + // They updated the value without choosing a quote type. We'll use the previous quote + // type or default to double quote if necessary + if (node.value.type === 'MustacheStatement') { + quote = ''; + } else if ( + node.value.type === 'TextNode' && + node.quoteType === null && + !node.value.chars.match(invalidUnquotedAttrValue) + ) { + // If old value was unquoted, and new value is also ok as unquoted, preserve that. + quote = ''; + } else { + quote = quote || '"'; + } + } + dirtyFields.delete('quoteType'); + + if (dirtyFields.has('isValueless')) { + if (node.isValueless) { + equals = ''; + quote = ''; + valueSource = ''; + dirtyFields.delete('isValueless'); + dirtyFields.delete('value'); + } else { + equals = '='; + if (node.value.type !== 'MustacheStatement' && !quote) { + quote = '"'; + } + dirtyFields.delete('isValueless'); + } + } + + if (dirtyFields.has('value')) { + equals = '='; + // If they created a ConcatStatement node, we need to print it ourselves here. + // Otherwise, since it has no nodeInfo, it will print using the glimmer printer + // which hardcodes double quotes. + if (node.value.type === 'ConcatStatement') { + valueSource = node.value.parts + .map((part) => this.print(part)) + .join(''); + } else { + valueSource = this.print(node.value); + } + } + dirtyFields.delete('value'); + + output.push( + nameSource, + postNameWhitespace, + equals, + postEqualsWhitespace, + quote, + valueSource, + quote, + ); + } + break; + case 'PathExpression': + { + let { source } = nodeInfo; + + if (dirtyFields.has('original')) { + source = ast.original; + dirtyFields.delete('original'); + } + + output.push(source); + } + break; + case 'MustacheCommentStatement': + case 'CommentStatement': + { + const commentStatement = original as AST.CommentStatement; + const indexOfValue = nodeInfo.source.indexOf(commentStatement.value); + const openSource = nodeInfo.source.substring(0, indexOfValue); + let valueSource = commentStatement.value; + const endSource = nodeInfo.source.substring( + indexOfValue + valueSource.length, + ); + + if (dirtyFields.has('value')) { + valueSource = ast.value; + + dirtyFields.delete('value'); + } + + output.push(openSource, valueSource, endSource); + } + break; + case 'TextNode': + { + let { source } = nodeInfo; + + if (dirtyFields.has('chars')) { + source = ast.chars; + dirtyFields.delete('chars'); + } + + output.push(source); + } + break; + case 'StringLiteral': + { + const node = ast as AnnotatedStringLiteral; + output.push(node.quoteType, node.value, node.quoteType); + } + break; + case 'BooleanLiteral': + case 'NumberLiteral': + case 'NullLiteral': + { + let { source } = nodeInfo; + + if (dirtyFields.has('value')) { + source = ast.value?.toString() || ''; + dirtyFields.delete('value'); + } + + output.push(source); + } + break; + default: + throw new Error( + `ember-template-recast does not have the ability to update ${original.type}. Please open an issue so we can add support.`, + ); + } + + for (const field of dirtyFields.values()) { + if (field in Object.keys(original)) { + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `ember-template-recast could not handle the mutations of \`${Array.from( + dirtyFields, + )}\` on ${original.type}`, + ); + } + } + + return output.join(''); + } + + // User-created nodes will have no nodeInfo, but we support + // formatting properties that the glimmer printer does not. + // If the user-created node specifies no custom formatting, + // just use the glimmer printer. + // These overrides could go away if glimmer had a concrete + // syntax tree type and printer. + printUserSuppliedNode(_ast: AST.Node): string { + switch (_ast.type) { + case 'StringLiteral': + { + const quote = (_ast as AnnotatedStringLiteral).quoteType || '"'; + return quote + _ast.value + quote; + } + // @ts-expect-error: Incorrect type + break; + case 'AttrNode': + { + const node = _ast as AnnotatedAttrNode; + if (node.isValueless) { + if (node.value.type !== 'TextNode' || node.value.chars !== '') { + throw new Error( + 'The value property of a valueless attr must be an empty TextNode', + ); + } + return node.name; + } + if ( + node.isValueless === undefined && + node.value.type === 'TextNode' && + node.value.chars === '' + ) { + return node.name; + } + switch (node.value.type) { + case 'MustacheStatement': + return node.name + '=' + this.print(node.value); + // @ts-expect-error: Incorrect type + break; + case 'ConcatStatement': + { + const value = node.value.parts + .map((part) => this.print(part)) + .join(''); + const quote = node.quoteType || '"'; + return node.name + '=' + quote + value + quote; + } + // @ts-expect-error: Incorrect type + break; + case 'TextNode': + { + if ( + node.quoteType === null && + node.value.chars.match(invalidUnquotedAttrValue) + ) { + throw new Error( + `You specified a quoteless attribute \`${node.value.chars}\`, which is invalid without quotes`, + ); + } + let quote: string; + if (node.quoteType === null) { + quote = ''; + } else { + quote = node.quoteType || '"'; + } + return node.name + '=' + quote + node.value.chars + quote; + } + // @ts-expect-error: Incorrect type + break; + } + } + // @ts-expect-error: Incorrect type + break; + default: + return _print(_ast, { + entityEncoding: 'raw', + // @ts-expect-error: Incorrect type + override: (ast) => { + if (this.nodeInfo.has(ast) || useCustomPrinter(ast)) { + return this.print(ast); + } + }, + }); + } + } + + /* + Used to associate the original source with a given node (while wrapping AST nodes + in a proxy). + */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private sourceForLoc(loc: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return sourceForLoc(this.source, loc); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private wrapNode(ancestor: any, node: any) { + this.ancestor.set(node, ancestor); + + const nodeInfo = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + node, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + original: JSON.parse(JSON.stringify(node)), + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + source: this.sourceForLoc(node.loc), + parse_result: this, + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.nodeInfo.set(node, nodeInfo); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hasLocInfo = !!node.loc; + const propertyProxyMap = new Map(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const proxy = new Proxy(node, { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + get: (target, property) => { + if (propertyProxyMap.has(property)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return propertyProxyMap.get(property); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Reflect.get(target, property); + }, + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + set: (target, property, value) => { + if (propertyProxyMap.has(property)) { + propertyProxyMap.set(property, value); + } + + Reflect.set(target, property, value); + + if (hasLocInfo) { + this.markAsDirty(node, property); + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.markAsDirty(ancestor.node, ancestor.key); + } + + return true; + }, + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + deleteProperty: (target, property) => { + if (propertyProxyMap.has(property)) { + propertyProxyMap.delete(property); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const result = Reflect.deleteProperty(target, property); + + if (hasLocInfo) { + this.markAsDirty(node, property); + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.markAsDirty(ancestor.node, ancestor.key); + } + + return result; + }, + }); + + // this is needed in order to handle splicing of Template.body (which + // happens when during replacement) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.nodeInfo.set(proxy, nodeInfo); + + for (const key in node) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const value = node[key]; + + if (key !== 'loc' && typeof value === 'object' && value !== null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const propertyProxy = this.wrapNode({ node, key }, value); + + propertyProxyMap.set(key, propertyProxy); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return proxy; + } +} diff --git a/packages/ast/template/src/-private/glimmer-syntax/utils.ts b/packages/ast/template/src/-private/glimmer-syntax/utils.ts new file mode 100644 index 00000000..973ce069 --- /dev/null +++ b/packages/ast/template/src/-private/glimmer-syntax/utils.ts @@ -0,0 +1,112 @@ +import type { AST } from '@glimmer/syntax'; + +const reLines = /(.*?(?:\r\n?|\n|$))/gm; + +export function compact(array: unknown[]): unknown[] { + const newArray: unknown[] = []; + + array.forEach((a) => { + if (typeof a !== 'undefined' && a !== null && a !== '') { + newArray.push(a); + } + }); + + return newArray; +} + +export function compactJoin(array: unknown[], delimeter = ''): string { + return compact(array).join(delimeter); +} + +export function getLines(source: string): string[] { + const result = source.match(reLines); + + if (!result) { + throw new Error('could not parse source'); + } + + return result.slice(0, -1); +} + +export function isSyntheticWithNoLocation(node: AST.Node): boolean { + if (node && node.loc) { + const { start, end } = node.loc; + + return ( + node.loc.module === '(synthetic)' && + start.column === end.column && + start.line === end.line + ); + } + + return false; +} + +export function sortByLoc(a: AST.Node, b: AST.Node): -1 | 0 | 1 { + // be conservative about the location where a new node is inserted + if (isSyntheticWithNoLocation(a) || isSyntheticWithNoLocation(b)) { + return 0; + } + + if (a.loc.start.line < b.loc.start.line) { + return -1; + } + + if ( + a.loc.start.line === b.loc.start.line && + a.loc.start.column < b.loc.start.column + ) { + return -1; + } + + if ( + a.loc.start.line === b.loc.start.line && + a.loc.start.column === b.loc.start.column + ) { + return 0; + } + + return 1; +} + +export function sourceForLoc( + source: string | string[], + loc?: AST.SourceLocation, +): string { + if (!loc) { + return ''; + } + + const sourceLines = Array.isArray(source) ? source : getLines(source); + + const firstLine = loc.start.line - 1; + const lastLine = loc.end.line - 1; + const firstColumn = loc.start.column; + const lastColumn = loc.end.column; + + const string = []; + let currentLine = firstLine - 1; + let line; + + while (currentLine < lastLine) { + currentLine++; + // for templates that are completely empty the outer Template loc is line + // 0, column 0 for both start and end defaulting to empty string prevents + // more complicated logic below + line = sourceLines[currentLine] || ''; + + if (currentLine === firstLine) { + if (firstLine === lastLine) { + string.push(line.slice(firstColumn, lastColumn)); + } else { + string.push(line.slice(firstColumn)); + } + } else if (currentLine === lastLine) { + string.push(line.slice(0, lastColumn)); + } else { + string.push(line); + } + } + + return string.join(''); +} diff --git a/packages/ast/template/src/index.ts b/packages/ast/template/src/index.ts index 486d27fb..bf323756 100644 --- a/packages/ast/template/src/index.ts +++ b/packages/ast/template/src/index.ts @@ -4,7 +4,7 @@ import { type NodeVisitor, print, transform, -} from 'ember-template-recast'; +} from './-private/glimmer-syntax.js'; function traverse() { return function ( From 894da5b95009a9a22009c47435724eb59d44843c Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 08:59:41 +0200 Subject: [PATCH 04/11] chore: Copied tests from ember-template-recast --- .../-private/glimmer-syntax/attr-node.test.ts | 455 +++++++++++ .../glimmer-syntax/block-statement.test.ts | 431 +++++++++++ .../glimmer-syntax/boolean-literal.test.ts | 24 + .../glimmer-syntax/comment-statement.test.ts | 13 + .../glimmer-syntax/concat-statement.test.ts | 41 + .../element-modifier-statement.test.ts | 14 + .../glimmer-syntax/element-node.test.ts | 731 ++++++++++++++++++ .../-private/glimmer-syntax/hash-pair.test.ts | 24 + .../mustache-comment-statement.test.ts | 24 + .../glimmer-syntax/mustache-statement.test.ts | 231 ++++++ .../glimmer-syntax/null-literal.test.ts | 25 + .../glimmer-syntax/number-literal.test.ts | 14 + .../glimmer-syntax/string-literal.test.ts | 54 ++ .../glimmer-syntax/sub-expression.test.ts | 84 ++ .../-private/glimmer-syntax/text-node.test.ts | 36 + .../-private/glimmer-syntax/transform.test.ts | 59 ++ .../sorts-nodes-by-their-line-numbers.test.ts | 31 + .../sorts-synthetic-nodes-last.test.ts | 21 + ...e-matches-sorts-by-starting-column.test.ts | 31 + 19 files changed, 2343 insertions(+) create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/block-statement.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/boolean-literal.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/comment-statement.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/concat-statement.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/element-modifier-statement.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/hash-pair.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/mustache-comment-statement.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/number-literal.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/string-literal.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/sub-expression.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/text-node.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/transform.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts diff --git a/packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts new file mode 100644 index 00000000..3cd1ae9e --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts @@ -0,0 +1,455 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { + type AST, + builders, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | AttrNode > updating value', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.path.original = 'bar'; + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > updating attribute to be valueless', function () { + const template = ''; + let ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].isValueless = true; + + assert.strictEqual(print(ast), ''); + + ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].isValueless = true; + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.text('blah'); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > adding value to valueless attribute', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.mustache('true'); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > updating valueless attribute to a mustache statement does not add quotes', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].isValueless = false; + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.mustache('true'); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > modifying valueless attribute to have empty value', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].isValueless = false; + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > updating concat statement value', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.parts.push(builders.text(' other-static')); + + assert.strictEqual( + print(ast), + '', + ); +}); + +test('-private | glimmer-syntax | AttrNode > updating value from non-quotable to TextNode (GH#111)', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.text('hello!'); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > updating value from non-quotable to ConcatStatement (GH#111)', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.concat([ + builders.mustache('foo'), + builders.text(' static '), + builders.mustache('bar'), + ]); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > can determine if an AttrNode was valueless (required by ember-template-lint)', function () { + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].isValueless, + false, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].isValueless, + false, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].isValueless, + false, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].isValueless, + false, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].isValueless, + false, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].isValueless, + false, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].isValueless, + true, + ); +}); + +test('-private | glimmer-syntax | AttrNode > can determine type of quotes used from AST (required by ember-template-lint)', function () { + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].quoteType, + null, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].quoteType, + `"`, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].quoteType, + `'`, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].quoteType, + `"`, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].quoteType, + `'`, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].quoteType, + null, + ); + + assert.strictEqual( + // @ts-expect-error: Incorrect type + parse(``).body[0].attributes[0].quoteType, + null, + ); +}); + +test('-private | glimmer-syntax | AttrNode > renaming valueless attribute', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].name = 'data-foo'; + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | AttrNode > mutations retain custom whitespace formatting', function () { + const template = normalizeFile([``]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.path.original = 'bar'; + + assert.strictEqual( + print(ast), + normalizeFile([``]), + ); +}); + +test('-private | glimmer-syntax | AttrNode > mutations retain textarea whitespace formatting', function () { + const template = normalizeFile([``]); + const ast = parse(template); + + const element = ast.body[0] as AST.ElementNode; + const attrNode = element.attributes[0] as AST.AttrNode; + const attrValue = attrNode.value as AST.TextNode; + attrValue.chars = 'bar'; + + assert.strictEqual( + print(ast), + normalizeFile([``]), + ); +}); + +test('-private | glimmer-syntax | AttrNode > mutations in MustacheStatements retain whitespace in AttrNode', function () { + const template = normalizeFile([ + ``, + ` hello`, + `
`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.parts[1].params[1].value = 'bar'; + + assert.strictEqual(print(ast), template); +}); + +test('-private | glimmer-syntax | AttrNode > quotes are preserved when updated a TextNode value (double quote)', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.chars = 'hahah'; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > quotes are preserved when updated a TextNode value (single quote)', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.chars = 'hahah'; + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | AttrNode > can update a quoteless attribute value', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.chars = 'zomgyasss'; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > quoteless attribute values can be updated to a must-quote attribute value', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.chars = 'foo bar baz'; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > quotes are preserved when updating a ConcatStatement value', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.parts[0].chars = 'hahah '; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > quotes are preserved when updating an AttrNode name - issue #319', function () { + const template = '
'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].name = 'class'; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > quotes are preserved when updating an AttrNode value - issue #588', function () { + const template = '
'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.concat([builders.text('foobar')]); + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > TextNode quote types can be changed', function () { + let template = '
'; + let ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = "'"; + + assert.strictEqual(print(ast), "
"); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = '"'; + + assert.strictEqual(print(ast), '
'); + + template = '
'; + ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = null; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > ConcatStatement quote types can be changed', function () { + let template = '
'; + let ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = "'"; + + assert.strictEqual(print(ast), "
"); + + template = "
"; + ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = '"'; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > can create a single-quoted concat value', function () { + // We usually use the glimmer printer for any user-created nodes. + // But the glimmer printer hardcodes double-quotes for ConcatStatements. + // So, if the user specifies single quotes and creates a concat value, + // make sure it doesn't accidentally use multiple qutoes. + const template = '
'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = "'"; + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.concat([ + builders.mustache('foo'), + builders.text(' static '), + builders.mustache('bar'), + ]); + + assert.strictEqual(print(ast), "
"); +}); + +test('-private | glimmer-syntax | AttrNode > can specify quote style on a new attribute', function () { + const template = '
'; + const ast = parse(template); + + const c = builders.attr('class', builders.text('foo')); + // @ts-expect-error: Incorrect type + c.quoteType = null; + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push(c); + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > can specify valueless on a new attribute', function () { + const template = '
'; + const ast = parse(template); + + const c = builders.attr('...attributes', builders.text('')); + // @ts-expect-error: Incorrect type + c.isValueless = true; + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push(c); + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | AttrNode > invalid quote types are rejected', function () { + const template = '
'; + let ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = '"'; + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.mustache('foo'); + + assert.throws(() => { + print(ast); + }, 'should not be quoted'); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = "'"; + + assert.throws(() => { + print(ast); + }, 'should not be quoted'); + + ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = null; + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.concat([ + builders.mustache('foo'), + builders.text(' static'), + ]); + + assert.throws(() => { + print(ast); + }, 'must be quoted'); + + ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].quoteType = null; + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.text('foo bar'); + + assert.throws(() => { + print(ast); + }, '`foo bar` is invalid as an unquoted attribute'); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/block-statement.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/block-statement.test.ts new file mode 100644 index 00000000..c3f03ba4 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/block-statement.test.ts @@ -0,0 +1,431 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { + builders, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | BlockStatement > rename block component', function () { + const template = normalizeFile([ + `{{#foo-bar`, + ` baz="stuff"`, + `}}`, + `
`, + `
`, + `{{/foo-bar}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path = builders.path('baz-derp'); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{#baz-derp`, + ` baz="stuff"`, + `}}`, + `
`, + `
`, + `{{/baz-derp}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | BlockStatement > rename block component from longer to shorter name', function () { + const template = normalizeFile([ + `{{#this-is-a-long-name`, + ` hello="world"`, + `}}`, + `
`, + `
`, + `{{/this-is-a-long-name}}{{someInlineComponent hello="world"}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path = builders.path('baz-derp'); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{#baz-derp`, + ` hello="world"`, + `}}`, + `
`, + `
`, + `{{/baz-derp}}{{someInlineComponent hello="world"}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | BlockStatement > replacing a previously empty hash', function () { + const template = `{{#foo-bar}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash = builders.hash([ + builders.pair('hello', builders.string('world')), + ]); + + assert.strictEqual( + print(ast), + '{{#foo-bar hello="world"}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding multiple HashPair to previously empty hash', function () { + const template = '{{#foo-bar}}Hi there!{{/foo-bar}}{{baz}}'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('hello', builders.string('world'))); + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('foo', builders.string('bar'))); + + assert.strictEqual( + print(ast), + '{{#foo-bar hello="world" foo="bar"}}Hi there!{{/foo-bar}}{{baz}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > replacing empty hash w/ block params works', function () { + const template = `{{#foo-bar as |a b c|}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash = builders.hash([ + builders.pair('hello', builders.string('world')), + ]); + + assert.strictEqual( + print(ast), + '{{#foo-bar hello="world" as |a b c|}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding new HashPair to an empty hash w/ block params works', function () { + const template = `{{#foo-bar as |a b c|}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('hello', builders.string('world'))); + + assert.strictEqual( + print(ast), + '{{#foo-bar hello="world" as |a b c|}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > changing a HashPair key with a StringLiteral value (GH#112)', function () { + const template = `{{#foo-bar foo="some thing with a space"}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].key = 'bar'; + + assert.strictEqual( + print(ast), + '{{#foo-bar bar="some thing with a space"}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > changing a HashPair key with a SubExpression value (GH#112)', function () { + const template = `{{#foo-bar foo=(helper-here this.arg1 this.arg2)}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].key = 'bar'; + + assert.strictEqual( + print(ast), + '{{#foo-bar bar=(helper-here this.arg1 this.arg2)}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > changing a HashPair value from StringLiteral to SubExpression', function () { + const template = `{{#foo-bar foo="bar!"}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value = builders.sexpr('concat', [ + builders.string('hello'), + builders.string('world'), + ]); + + assert.strictEqual( + print(ast), + '{{#foo-bar foo=(concat "hello" "world")}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > changing a HashPair value from SubExpression to StringLiteral', function () { + const template = `{{#foo-bar foo=(concat "hello" "world")}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value = builders.string('hello world!'); + + assert.strictEqual( + print(ast), + '{{#foo-bar foo="hello world!"}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding param with no params or hash', function () { + const template = `{{#foo-bar}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params.push(builders.path('this.foo')); + + assert.strictEqual(print(ast), '{{#foo-bar this.foo}}Hi there!{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > adding param with empty program', function () { + const template = `{{#foo-bar}}{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params.push(builders.path('this.foo')); + + assert.strictEqual(print(ast), '{{#foo-bar this.foo}}{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > adding param with existing params', function () { + const template = `{{#foo-bar this.first}}Hi there!{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params.push(builders.path('this.foo')); + + assert.strictEqual( + print(ast), + '{{#foo-bar this.first this.foo}}Hi there!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding param with existing params infers indentation from existing params', function () { + const template = normalizeFile([ + `{{#foo-bar `, + ` `, + `this.first}}Hi there!{{/foo-bar}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params.push(builders.path('this.foo')); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{#foo-bar `, + ` `, + `this.first `, + ` `, + `this.foo}}Hi there!{{/foo-bar}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding child to end of program', function () { + const template = `{{#foo-bar}}Hello{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].program.body.push(builders.text(' world!')); + + assert.strictEqual(print(ast), '{{#foo-bar}}Hello world!{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > adding child to beginning of program', function () { + const template = `{{#foo-bar}}Hello{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].program.body.unshift(builders.text('ZOMG! ')); + + assert.strictEqual(print(ast), '{{#foo-bar}}ZOMG! Hello{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > adding child to end of inverse', function () { + const template = `{{#foo-bar}}{{else}}Hello{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse.body.push(builders.text(' world!')); + + assert.strictEqual( + print(ast), + '{{#foo-bar}}{{else}}Hello world!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding child to beginning of inverse', function () { + const template = `{{#foo-bar}}{{else}}Hello{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse.body.unshift(builders.text('ZOMG! ')); + + assert.strictEqual(print(ast), '{{#foo-bar}}{{else}}ZOMG! Hello{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > adding child to end of inverse preserves whitespace and whitespace control when program is also present', function () { + const template = normalizeFile([ + `{{#foo-bar}}Goodbye`, + ` {{~ else ~}} Hello{{/foo-bar}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse.body.push(builders.text(' world!')); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{#foo-bar}}Goodbye`, + ` {{~ else ~}} Hello world!{{/foo-bar}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding child to end of inverse preserves whitespace and whitespace control', function () { + const template = `{{#foo-bar}}{{~ else ~}}Hello{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse.body.push(builders.text(' world!')); + + assert.strictEqual( + print(ast), + '{{#foo-bar}}{{~ else ~}}Hello world!{{/foo-bar}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > add child in an {{else if foo}} chain', function () { + const template = `{{#if foo}}{{else if baz}}Hello{{/if}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse.body[0].program.body.push(builders.text(' world!')); + + assert.strictEqual( + print(ast), + '{{#if foo}}{{else if baz}}Hello world!{{/if}}', + ); +}); + +test('-private | glimmer-syntax | BlockStatement > adding an inverse', function () { + const template = `{{#foo-bar}}{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse = builders.blockItself([builders.text('ZOMG!')]); + + assert.strictEqual(print(ast), '{{#foo-bar}}{{else}}ZOMG!{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > removing an inverse', function () { + const template = `{{#foo-bar}}Goodbye{{else}}Hello{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse = null; + + assert.strictEqual(print(ast), '{{#foo-bar}}Goodbye{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > annotating an "else if" node', function () { + const template = '{{#if foo}}{{else if bar}}{{else}}{{/if}}'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].inverse.body[0]._isElseIfBlock = true; + + assert.strictEqual(print(ast), '{{#if foo}}{{else if bar}}{{else}}{{/if}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > add block param (when none existed)', function () { + const template = `{{#foo-bar}}{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].program.blockParams; + blockParams.push('foo'); + // @ts-expect-error: Incorrect type + ast.body[0].program.blockParams = blockParams; + + assert.strictEqual(print(ast), '{{#foo-bar as |foo|}}{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > remove only block param', function () { + const template = `{{#foo-bar as |a|}}{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].program.blockParams; + blockParams.pop(); + // @ts-expect-error: Incorrect type + ast.body[0].program.blockParams = blockParams; + + assert.strictEqual(print(ast), '{{#foo-bar}}{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > remove one block param of many', function () { + const template = `{{#foo-bar as |a b|}}{{/foo-bar}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].program.blockParams; + blockParams.pop(); + // @ts-expect-error: Incorrect type + ast.body[0].program.blockParams = blockParams; + + assert.strictEqual(print(ast), '{{#foo-bar as |a|}}{{/foo-bar}}'); +}); + +test('-private | glimmer-syntax | BlockStatement > remove one block param of many preserves custom whitespace', function () { + const template = normalizeFile([ + `{{#foo-bar`, + ` as |a b|`, + `}}`, + `{{/foo-bar}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].program.blockParams; + blockParams.pop(); + // @ts-expect-error: Incorrect type + ast.body[0].program.blockParams = blockParams; + + assert.strictEqual( + print(ast), + normalizeFile([`{{#foo-bar`, ` as |a|`, `}}`, `{{/foo-bar}}`]), + ); +}); + +test('-private | glimmer-syntax | BlockStatement > remove only block param preserves custom whitespace', function () { + const template = normalizeFile([ + `{{#foo-bar`, + ` some=thing`, + ` as |a|`, + `}}`, + `{{/foo-bar}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].program.blockParams; + blockParams.pop(); + // @ts-expect-error: Incorrect type + ast.body[0].program.blockParams = blockParams; + + assert.strictEqual( + print(ast), + normalizeFile([`{{#foo-bar`, ` some=thing`, `}}`, `{{/foo-bar}}`]), + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/boolean-literal.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/boolean-literal.test.ts new file mode 100644 index 00000000..354da673 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/boolean-literal.test.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { assert, test } from '@codemod-utils/tests'; + +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | BooleanLiteral > can be updated in MustacheStatement .path position', function () { + const template = `{{true}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path.value = false; + + assert.strictEqual(print(ast), `{{false}}`); +}); + +test('-private | glimmer-syntax | BooleanLiteral > can be updated in MustacheStatement .hash position', function () { + const template = `{{foo thing=true}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value.value = false; + + assert.strictEqual(print(ast), `{{foo thing=false}}`); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/comment-statement.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/comment-statement.test.ts new file mode 100644 index 00000000..c8d1787c --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/comment-statement.test.ts @@ -0,0 +1,13 @@ +import { assert, test } from '@codemod-utils/tests'; + +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | CommentStatement > can be updated', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].value = ' otherthing '; + + assert.strictEqual(print(ast), ``); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/concat-statement.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/concat-statement.test.ts new file mode 100644 index 00000000..686763ef --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/concat-statement.test.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { + builders, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | ConcatStatement > can add parts', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.parts.push(builders.text(' baz')); + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | ConcatStatement > preserves quote style', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.parts.push(builders.text(' baz')); + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | ConcatStatement > updating parts preserves custom whitespace', function () { + const template = normalizeFile([`
`]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.parts.push(builders.text(' baz')); + + assert.strictEqual( + print(ast), + normalizeFile([`
`]), + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/element-modifier-statement.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/element-modifier-statement.test.ts new file mode 100644 index 00000000..967d585f --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/element-modifier-statement.test.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { assert, test } from '@codemod-utils/tests'; + +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | ElementModifierStatement > can be updated', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].modifiers[0].path.original = 'other'; + + assert.strictEqual(print(ast), `
`); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts new file mode 100644 index 00000000..7a1dc350 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts @@ -0,0 +1,731 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { EOL } from 'node:os'; + +import { assert, normalizeFile, test } from '@codemod-utils/tests'; +import { traverse } from '@glimmer/syntax'; + +import { + type AST, + builders, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | ElementNode > creating void element', function () { + const template = ``; + const ast = parse(template); + + // in @glimmer/syntax v0.82.0, + // builders.element requires an empty object as a second arg + ast.body.push(builders.element('img', {})); + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > updating attributes on a non-self-closing void element', function () { + const template = ``; + const ast = parse(template); + + const element = ast.body[0] as AST.ElementNode; + const attribute = element.attributes[0] as AST.AttrNode; + const concat = attribute.value as AST.ConcatStatement; + (concat.parts[0] as AST.MustacheStatement).path = + builders.path('this.something'); + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > reusing another template part to build a new template', function () { + const template = `foo`; + const original = parse(template); + const text = original.body[0] as AST.TextNode; + const ast = builders.template([text]); + + assert.strictEqual(print(ast), `foo`); +}); + +test('-private | glimmer-syntax | ElementNode > wrapping a parsed node (which uses custom formatting) with a raw node', function () { + // Ensuring fix for GH#586 + // (infinite recursion when printing custom nodes containing parsed nodes) + // plays nicely with custom printing from GH#653 + // (specifying quoteType on custom nodes, adds a printing override) + const template = ``; + const original = parse(template); + + const raw_wrapping_ast = builders.template([ + builders.element('div', { + children: original.body, + }), + ]); + + assert.strictEqual( + print(raw_wrapping_ast), + `
`, + ); +}); + +test('-private | glimmer-syntax | ElementNode > changing an element to a void element does not print closing tag', function () { + const template = `
`; + const ast = parse(template); + + const element = ast.body[0] as AST.ElementNode; + element.tag = 'img'; + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > updating attributes on a self-closing void element', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.parts[0].path = + builders.path('this.something'); + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > changing an attribute value from mustache to text node (GH#111)', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.text('static thing 1'); + // @ts-expect-error: Incorrect type + ast.body[0].attributes[1].value = builders.text('static thing 2'); + + assert.strictEqual( + print(ast), + ``, + ); +}); + +test('-private | glimmer-syntax | ElementNode > changing an attribute value from text node to mustache (GH #139)', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.mustache('my-awesome-helper', [ + builders.string('hello'), + builders.string('world'), + ]); + + assert.strictEqual( + print(ast), + ``, + ); +}); + +test('-private | glimmer-syntax | ElementNode > changing an attribute value from text node to concat statement (GH #139)', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.concat([ + builders.text('Hello '), + builders.mustache('my-awesome-helper', [ + builders.string('hello'), + builders.string('world'), + ]), + builders.text(' world'), + ]); + + assert.strictEqual( + print(ast), + ``, + ); +}); + +test('-private | glimmer-syntax | ElementNode > changing an attribute value from mustache to mustache', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value = builders.mustache('my-awesome-helper', [ + builders.string('hello'), + builders.string('world'), + ]); + + assert.strictEqual( + print(ast), + ``, + ); +}); + +test('-private | glimmer-syntax | ElementNode > rename element tagname', function () { + const template = normalizeFile([ + `
`, + `
`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].tag = 'a'; + + assert.strictEqual( + print(ast), + normalizeFile([``, ` `]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > rename element tagname without children', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].tag = 'a'; + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > rename self-closing element tagname', function () { + const ast = parse(''); + + // @ts-expect-error: Incorrect type + ast.body[0].tag = 'Qux'; + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | ElementNode > rename self-closing element tagname with trailing whitespace', function () { + const ast = parse(''); + + // @ts-expect-error: Incorrect type + ast.body[0].tag = 'Qux'; + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | ElementNode > Rename tag and convert from self-closing with attributes to block element', function () { + const ast = parse(''); + + // @ts-expect-error: Incorrect type + ast.body[0].tag = 'Qux'; + // @ts-expect-error: Incorrect type + ast.body[0].children = [builders.text('bay')]; + + assert.strictEqual(print(ast), 'bay'); +}); + +test('-private | glimmer-syntax | ElementNode > convert from self-closing with attributes to block element', function () { + const ast = parse(''); + + // @ts-expect-error: Incorrect type + ast.body[0].children = [builders.text('bay')]; + + assert.strictEqual(print(ast), 'bay'); +}); + +test('-private | glimmer-syntax | ElementNode > convert from self-closing with specially spaced attributes to block element', function () { + const ast = parse(normalizeFile([``])); + + // @ts-expect-error: Incorrect type + ast.body[0].children = [builders.text('bay')]; + + assert.strictEqual( + print(ast), + normalizeFile([`bay`]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > Convert self-closing element with modifiers block element', function () { + const ast = parse(''); + + // @ts-expect-error: Incorrect type + ast.body[0].children = [builders.text('bay')]; + + assert.strictEqual( + print(ast), + 'bay', + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding attribute when none originally existed', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push( + builders.attr('data-test', builders.text('wheee')), + ); + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | ElementNode > adding attribute to ElementNode with block params', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push( + builders.attr('data-test', builders.text('wheee')), + ); + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > adding attribute to ElementNode with block params (extra whitespace)', function () { + const template = normalizeFile([``]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push( + builders.attr('data-test', builders.text('wheee')), + ); + + assert.strictEqual( + print(ast), + normalizeFile([``]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding boolean attribute to ElementNode', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push( + builders.attr('disabled', builders.mustache(builders.boolean(true))), + ); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | ElementNode > adding an attribute to existing list', function () { + const template = normalizeFile([ + `
`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push( + builders.attr('data-test', builders.text('wheee')), + ); + + assert.strictEqual( + print(ast), + normalizeFile([ + `
`, + ]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > creating an element with complex attributes', function () { + const template = ''; + const ast = parse(template); + + ast.body.push( + builders.element( + { name: 'FooBar', selfClosing: true }, + { + attrs: [ + builders.attr( + '@thing', + builders.mustache( + builders.path('hash'), + [], + builders.hash([builders.pair('something', builders.path('bar'))]), + ), + ), + ], + }, + ), + ); + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > modifying an attribute name (GH#112)', function () { + const template = normalizeFile([ + `
`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].name = 'data-test'; + + assert.strictEqual( + print(ast), + normalizeFile([ + `
`, + ]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > modifying attribute after valueless attribute', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[1].value.path = builders.path('this.hmmm'); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | ElementNode > modifying attribute after valueless attribute with special whitespace', function () { + const template = normalizeFile([ + ``, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[1].value.path = builders.path('this.hmmm'); + + assert.strictEqual( + print(ast), + normalizeFile([``]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding attribute after valueless attribute', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push(builders.attr('data-bar', builders.text('foo'))); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | ElementNode > adding valueless attribute when no open parts existed', function () { + const template = ''; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes.push(builders.attr('data-bar', builders.text(''))); + + assert.strictEqual(print(ast), ''); +}); + +test('-private | glimmer-syntax | ElementNode > adding modifier when no open parts originally existed', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].modifiers.push( + builders.elementModifier('on', [ + builders.string('click'), + builders.path('this.foo'), + ]), + ); + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | ElementNode > adding modifier with existing attributes', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].modifiers.push( + builders.elementModifier('on', [ + builders.string('click'), + builders.path('this.foo'), + ]), + ); + + assert.strictEqual( + print(ast), + `
`, + ); +}); + +// This is specifically testing the issue described in https://github.com/glimmerjs/glimmer-vm/pull/953 +test('-private | glimmer-syntax | ElementNode > adding modifier when ...attributes is present', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].modifiers.push( + builders.elementModifier('on', [ + builders.string('click'), + builders.path('this.foo'), + ]), + ); + + assert.strictEqual( + print(ast), + `
`, + ); +}); + +test('-private | glimmer-syntax | ElementNode > removing a modifier with other attributes', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].modifiers.shift(); + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | ElementNode > removing a modifier with no other attributes/comments/modifiers', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].modifiers.shift(); + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | ElementNode > adding comment when no open parts originally existed', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].comments.push( + builders.mustacheComment(' template-lint-disable '), + ); + + assert.strictEqual( + print(ast), + `
`, + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding comment with existing attributes', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].comments.push( + builders.mustacheComment(' template-lint-disable '), + ); + + assert.strictEqual( + print(ast), + `
`, + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding block param', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].blockParams; + blockParams.push('blah'); + // @ts-expect-error: Incorrect type + ast.body[0].blockParams = blockParams; + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > removing a block param', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].blockParams; + blockParams.pop(); + // @ts-expect-error: Incorrect type + ast.body[0].blockParams = blockParams; + + assert.strictEqual(print(ast), ``); +}); + +test('-private | glimmer-syntax | ElementNode > removing a block param preserves formatting of "open element closing"', function () { + const template = normalizeFile([ + ``, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const blockParams = ast.body[0].blockParams; + blockParams.pop(); + // @ts-expect-error: Incorrect type + ast.body[0].blockParams = blockParams; + + assert.strictEqual( + print(ast), + normalizeFile([``]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > interleaved attributes and modifiers are not modified when unchanged', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].comments.push( + builders.mustacheComment(' template-lint-disable '), + ); + + assert.strictEqual( + print(ast), + `
`, + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding children to element with children', function () { + const template = normalizeFile([`
    `, `
  • `, `
`]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].children.splice( + 2, + 0, + builders.text(`${EOL} `), + builders.element('li', { + attrs: [builders.attr('data-foo', builders.text('bar'))], + }), + ); + + assert.strictEqual( + print(ast), + normalizeFile([ + `
    `, + `
  • `, + `
  • `, + `
`, + ]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding children to an empty element', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].children.push(builders.text('some text')); + + assert.strictEqual(print(ast), '
some text
'); +}); + +test('-private | glimmer-syntax | ElementNode > adding children to a self closing element', function () { + const template = ``; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].children.push(builders.text('some text')); + + assert.strictEqual(print(ast), 'some text'); +}); + +test('-private | glimmer-syntax | ElementNode > moving a child to another ElementNode', function () { + const template = normalizeFile([ + `{{`, + ` special-formatting-here`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const child = ast.body[0].children.pop(); + ast.body.unshift(builders.text(EOL)); + ast.body.unshift(child); + + assert.strictEqual( + print(ast), + normalizeFile([`{{`, ` special-formatting-here`, `}}`, ``]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > adding a new attribute to an ElementNode while preserving the existing whitespaces', function () { + const template = normalizeFile([ + `
`, + `
`, + ]); + const ast = parse(template); + + const element = ast.body[0] as AST.ElementNode; + element.attributes.push(builders.attr('foo-foo', builders.text('wheee'))); + + assert.strictEqual( + print(ast), + normalizeFile([ + `
`, + `
`, + ]), + ); +}); + +test('-private | glimmer-syntax | ElementNode > issue can handle angle brackets in modifier argument values', function () { + const template = normalizeFile([ + `> Some Text Here"}}`, + ` @options={{this.items}}`, + ` as |item|`, + `>`, + ` {{item.name}}`, + ``, + ]); + const ast = parse(template); + + traverse(ast, { + ElementNode(node) { + node.tag = `${node.tag}`; + }, + }); + + assert.strictEqual(print(ast), template); +}); + +test('-private | glimmer-syntax | ElementNode > issue 706', function () { + const template = normalizeFile([ + ``, + ` {{#each this.data as |chunks|}}`, + ` {{#each chunks as |chunk|}}`, + ` {{#if (this.shouldShowImage chunk)}}`, + `

foo

`, + ` {{else}}`, + `

bar

`, + ` {{/if}}`, + ` {{/each}}`, + ` {{/each}}`, + `
`, + ]); + const ast = parse(template); + + const block1 = (ast.body[0] as AST.ElementNode) + .children[1] as AST.BlockStatement; + const block2 = block1.program.body[1] as AST.BlockStatement; + const block3 = block2.program.body[1] as AST.BlockStatement; + const element = block3.program.body[1] as AST.ElementNode; + const attribute = element.attributes[0] as AST.AttrNode; + (attribute.value as AST.TextNode).chars = 'foo'; + + assert.strictEqual( + print(ast), + normalizeFile([ + ``, + ` {{#each this.data as |chunks|}}`, + ` {{#each chunks as |chunk|}}`, + ` {{#if (this.shouldShowImage chunk)}}`, + `

foo

`, + ` {{else}}`, + `

bar

`, + ` {{/if}}`, + ` {{/each}}`, + ` {{/each}}`, + `
`, + ]), + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/hash-pair.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/hash-pair.test.ts new file mode 100644 index 00000000..23943daa --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/hash-pair.test.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { assert, test } from '@codemod-utils/tests'; + +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | HashPair > mutations', function () { + const template = '{{foo-bar bar=foo}}'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value.original = 'bar'; + + assert.strictEqual(print(ast), '{{foo-bar bar=bar}}'); +}); + +test('-private | glimmer-syntax | HashPair > mutations retain formatting', function () { + const template = '{{foo-bar bar= foo}}'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value.original = 'bar'; + + assert.strictEqual(print(ast), '{{foo-bar bar= bar}}'); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/mustache-comment-statement.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/mustache-comment-statement.test.ts new file mode 100644 index 00000000..9804634a --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/mustache-comment-statement.test.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { assert, test } from '@codemod-utils/tests'; + +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | MustacheCommentStatement > can be updated', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].comments[0].value = ' otherthing '; + + assert.strictEqual(print(ast), `
`); +}); + +test('-private | glimmer-syntax | MustacheCommentStatement > comments without `--` are preserved', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].comments[0].value = ' otherthing '; + + assert.strictEqual(print(ast), `
`); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts new file mode 100644 index 00000000..eb0a6ceb --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts @@ -0,0 +1,231 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { + type AST, + builders, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | MustacheStatement > path mutations retain custom whitespace formatting', function () { + const template = `{{ foo }}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path.original = 'bar'; + + assert.strictEqual(print(ast), '{{ bar }}'); +}); + +test('-private | glimmer-syntax | MustacheStatement > updating from this.foo to @foo via path.original mutation', function () { + const template = `{{this.foo}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path.original = '@foo'; + + assert.strictEqual(print(ast), '{{@foo}}'); +}); + +test('-private | glimmer-syntax | MustacheStatement > updating from this.foo to @foo via path replacement', function () { + const template = `{{this.foo}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path = builders.path('@foo'); + + assert.strictEqual(print(ast), '{{@foo}}'); +}); + +test('-private | glimmer-syntax | MustacheStatement > updating path via path replacement retains custom whitespace', function () { + const template = normalizeFile([`{{`, `@foo`, `}}`]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path = builders.path('this.foo'); + + assert.strictEqual(print(ast), normalizeFile([`{{`, `this.foo`, `}}`])); +}); + +test('-private | glimmer-syntax | MustacheStatement > rename non-block component', function () { + const template = normalizeFile([ + `{{foo-bar`, + ` baz="stuff"`, + ` other='single quote'`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].path = builders.path('baz-derp'); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{baz-derp`, + ` baz="stuff"`, + ` other='single quote'`, + `}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | MustacheStatement > can add param', function () { + const template = normalizeFile([ + `{{foo-bar`, + ` baz=(stuff`, + ` goes='here')`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params.push(builders.path('zomg')); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{foo-bar`, + ` zomg`, + ` baz=(stuff`, + ` goes='here')`, + `}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | MustacheStatement > can remove param', function () { + const template = normalizeFile([ + `{{foo-bar`, + ` hhaahahaha`, + ` baz=(stuff`, + ` goes='here')`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params.pop(); + + assert.strictEqual( + print(ast), + normalizeFile([`{{foo-bar`, ` baz=(stuff`, ` goes='here')`, `}}`]), + ); +}); + +test('-private | glimmer-syntax | MustacheStatement > replacing empty hash pair on MustacheStatement works', function () { + const template = '{{foo-bar}}'; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash = builders.hash([ + builders.pair('hello', builders.string('world')), + ]); + + assert.strictEqual(print(ast), `{{foo-bar hello="world"}}`); +}); + +test('-private | glimmer-syntax | MustacheStatement > infers indentation of hash when multiple HashPairs existed', function () { + const template = normalizeFile([ + `{{foo-bar`, + ` baz="stuff"`, + ` other='single quote'`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push( + builders.pair('some', builders.string('other-thing')), + ); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{foo-bar`, + ` baz="stuff"`, + ` other='single quote'`, + ` some="other-thing"`, + `}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | MustacheStatement > infers indentation of hash when no existing hash existed but params do', function () { + const template = normalizeFile([`{{foo-bar`, ` someParam`, `}}`]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push( + builders.pair('some', builders.string('other-thing')), + ); + + assert.strictEqual( + print(ast), + normalizeFile([`{{foo-bar`, ` someParam`, ` some="other-thing"`, `}}`]), + ); +}); + +test('-private | glimmer-syntax | MustacheStatement > infers indentation of new HashPairs when existing hash with single entry (but no params)', function () { + const template = normalizeFile([`{{foo-bar`, ` stuff=here`, `}}`]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push( + builders.pair('some', builders.string('other-thing')), + ); + + assert.strictEqual( + print(ast), + normalizeFile([`{{foo-bar`, ` stuff=here`, ` some="other-thing"`, `}}`]), + ); +}); + +test('-private | glimmer-syntax | MustacheStatement > can add literal hash pair values', function () { + const template = normalizeFile([`{{foo-bar`, ` first=thing`, `}}`]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('some', builders.null())); + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('other', builders.undefined())); + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('things', builders.boolean(true))); + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('go', builders.number(42))); + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs.push(builders.pair('here', builders.boolean(false))); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{foo-bar`, + ` first=thing`, + ` some=null`, + ` other=undefined`, + ` things=true`, + ` go=42`, + ` here=false`, + `}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | MustacheStatement > creating new MustacheStatement with single param has correct whitespace', function () { + const ast = parse(''); + + ast.body.push(builders.mustache('foo', [builders.string('hi')])); + + assert.strictEqual(print(ast), `{{foo "hi"}}`); +}); + +test('-private | glimmer-syntax | MustacheStatement > copying params and hash from a sub expression into a new MustacheStatement has correct whitespace', function () { + const ast = parse('{{some-helper (foo "hi")}}'); + + const mustache = ast.body[0] as AST.MustacheStatement; + const sexpr = mustache.params[0] as AST.SubExpression; + ast.body.push(builders.mustache(sexpr.path, sexpr.params, sexpr.hash)); + + assert.strictEqual(print(ast), `{{some-helper (foo "hi")}}{{foo "hi"}}`); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts new file mode 100644 index 00000000..57fa226c --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts @@ -0,0 +1,25 @@ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { + type AST, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | NullLiteral > it should print correctly', function () { + const template = normalizeFile([`{{contact-null`, ` null`, `}}`]); + const ast = parse(template); + + const mustache = ast.body[0] as AST.MustacheStatement; + const param = mustache.params[0] as AST.BaseNode; + + // Mark the param as dirty + const oldType = param.type; + param.type = 'ElementNode'; + param.type = oldType; + + assert.strictEqual( + print(ast), + normalizeFile([`{{contact-null`, ` null`, `}}`]), + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/number-literal.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/number-literal.test.ts new file mode 100644 index 00000000..f6bf5959 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/number-literal.test.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { assert, test } from '@codemod-utils/tests'; + +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | NumberLiteral > can be updated', function () { + const template = `{{foo 42}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params[0].value = 0; + + assert.strictEqual(print(ast), `{{foo 0}}`); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/string-literal.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/string-literal.test.ts new file mode 100644 index 00000000..6b457c58 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/string-literal.test.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { assert, test } from '@codemod-utils/tests'; + +import { + builders, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | StringLiteral > can be updated', function () { + const template = `{{foo "blah"}}`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].params[0].value = 'derp'; + + assert.strictEqual(print(ast), `{{foo "derp"}}`); +}); + +test('-private | glimmer-syntax | StringLiteral > can determine type of quotes used from AST (required by ember-template-lint)', function () { + // @ts-expect-error: Incorrect type + assert.strictEqual(parse(`{{foo "blah"}}`).body[0].params[0].quoteType, '"'); + + // @ts-expect-error: Incorrect type + assert.strictEqual(parse(`{{foo 'blah'}}`).body[0].params[0].quoteType, "'"); +}); + +test('-private | glimmer-syntax | StringLiteral > can update quote style', function () { + let ast = parse(`{{foo "blah"}}`); + + // @ts-expect-error: Incorrect type + ast.body[0].params[0].quoteType = "'"; + + assert.strictEqual(print(ast), `{{foo 'blah'}}`); + + ast = parse(`{{foo 'blah'}}`); + + // @ts-expect-error: Incorrect type + ast.body[0].params[0].quoteType = '"'; + + assert.strictEqual(print(ast), `{{foo "blah"}}`); +}); + +test('-private | glimmer-syntax | StringLiteral > can specify quote style on a new string literal', function () { + const ast = parse(`{{foo}}`); + + const s = builders.string('blah'); + // @ts-expect-error: Incorrect type + s.quoteType = "'"; + // @ts-expect-error: Incorrect type + ast.body[0].params.push(s); + + assert.strictEqual(print(ast), `{{foo 'blah'}}`); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/sub-expression.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/sub-expression.test.ts new file mode 100644 index 00000000..3a6a50aa --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/sub-expression.test.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { + builders, + parse, + print, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | SubExpression > rename path', function () { + const template = normalizeFile([ + `{{foo-bar`, + ` baz=(stuff`, + ` goes='here')`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value.path = builders.path('zomg'); + + assert.strictEqual( + print(ast), + normalizeFile([`{{foo-bar`, ` baz=(zomg`, ` goes='here')`, `}}`]), + ); +}); + +test('-private | glimmer-syntax | SubExpression > can add param', function () { + const template = normalizeFile([ + `{{foo-bar`, + ` baz=(stuff`, + ` goes='here')`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value.params.push(builders.path('zomg')); + + assert.strictEqual( + print(ast), + normalizeFile([ + `{{foo-bar`, + ` baz=(stuff`, + ` zomg`, + ` goes='here')`, + `}}`, + ]), + ); +}); + +test('-private | glimmer-syntax | SubExpression > can remove param', function () { + const template = normalizeFile([ + `{{foo-bar`, + ` baz=(stuff`, + ` hhaahahaha`, + ` goes='here')`, + `}}`, + ]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value.params.pop(); + + assert.strictEqual( + print(ast), + normalizeFile([`{{foo-bar`, ` baz=(stuff`, ` goes='here')`, `}}`]), + ); +}); + +test('-private | glimmer-syntax | SubExpression > replacing empty hash pair', function () { + const template = normalizeFile([`{{foo-bar`, ` baz=(stuff)`, `}}`]); + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].hash.pairs[0].value.hash = builders.hash([ + builders.pair('hello', builders.string('world')), + ]); + + assert.strictEqual( + print(ast), + normalizeFile([`{{foo-bar`, ` baz=(stuff hello="world")`, `}}`]), + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/text-node.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/text-node.test.ts new file mode 100644 index 00000000..7d6ec1a5 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/text-node.test.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { assert, test } from '@codemod-utils/tests'; + +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | TextNode > can be updated', function () { + const template = `Foo`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].chars = 'Bar'; + + assert.strictEqual(print(ast), 'Bar'); +}); + +test('-private | glimmer-syntax | TextNode > can be updated as value of AttrNode', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + ast.body[0].attributes[0].value.chars = 'hahah'; + + assert.strictEqual(print(ast), '
'); +}); + +test('-private | glimmer-syntax | TextNode > an AttrNode values quotes are removed when inserted in alternate positions (e.g. content)', function () { + const template = `
`; + const ast = parse(template); + + // @ts-expect-error: Incorrect type + const text = ast.body[0].attributes[0].value; + // @ts-expect-error: Incorrect type + ast.body[0].children.push(text); + + assert.strictEqual(print(ast), '
lol
'); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/transform.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/transform.test.ts new file mode 100644 index 00000000..2eabfe1b --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/transform.test.ts @@ -0,0 +1,59 @@ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { transform } from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | transform > can remove during traversal by returning `null`', function () { + const template = normalizeFile([ + `

here is some multiline string

`, + `{{ other-stuff }}`, + ]); + + // @ts-expect-error: Incorrect type + const { code } = transform(template, () => { + return { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + ElementNode() { + return null; + }, + }; + }); + + assert.strictEqual(code, normalizeFile([``, `{{ other-stuff }}`])); +}); + +test('-private | glimmer-syntax | transform > can replace with many items during traversal by returning an array', function () { + const template = normalizeFile([ + `

here is some multiline string

`, + `{{other-stuff}}`, + ]); + + const { code } = transform(template, (env) => { + const { builders: b } = env.syntax; + + return { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + ElementNode() { + return [b.text('hello '), b.text('world')]; + }, + }; + }); + + assert.strictEqual(code, normalizeFile([`hello world`, `{{other-stuff}}`])); +}); + +test('-private | glimmer-syntax | transform > MustacheStatements retain whitespace when multiline replacements occur', function () { + const template = normalizeFile([`

`, `{{ other-stuff }}`]); + + const { code } = transform(template, (env) => { + const { builders: b } = env.syntax; + + return { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + ElementNode() { + return [b.text('x'), b.text('y')]; + }, + }; + }); + + assert.strictEqual(code, normalizeFile([`xy`, `{{ other-stuff }}`])); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts new file mode 100644 index 00000000..0032d306 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts @@ -0,0 +1,31 @@ +import { assert, test } from '@codemod-utils/tests'; +import { builders } from '@glimmer/syntax'; + +import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; + +test('-private | glimmer-syntax | utils | sortByLoc > sorts nodes by their line numbers', function () { + const a = builders.pair( + 'a', + builders.path('foo'), + builders.loc(1, 1, 1, 5, 'foo.hbs'), + ); + + const b = builders.pair( + 'b', + builders.path('foo'), + builders.loc(3, 1, 1, 5, 'foo.hbs'), + ); + + const c = builders.pair( + 'c', + builders.path('foo'), + builders.loc(2, 1, 1, 5, 'foo.hbs'), + ); + + const nodes = [a, b, c].sort(sortByLoc); + + assert.deepStrictEqual( + nodes.map((node) => node.key), + ['a', 'c', 'b'], + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts new file mode 100644 index 00000000..630050c0 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts @@ -0,0 +1,21 @@ +import { assert, test } from '@codemod-utils/tests'; +import { builders } from '@glimmer/syntax'; + +import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; + +test('-private | glimmer-syntax | utils | sortByLoc > sorts synthetic nodes last', function () { + const a = builders.pair('a', builders.path('foo') /* no loc, "synthetic" */); + + const b = builders.pair( + 'b', + builders.path('foo'), + builders.loc(1, 1, 1, 5, 'foo.hbs'), + ); + + const nodes = [a, b].sort(sortByLoc); + + assert.deepStrictEqual( + nodes.map((node) => node.key), + ['a', 'b'], + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts new file mode 100644 index 00000000..5e9a9e78 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts @@ -0,0 +1,31 @@ +import { assert, test } from '@codemod-utils/tests'; +import { builders } from '@glimmer/syntax'; + +import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; + +test('-private | glimmer-syntax | utils | sortByLoc > when start line matches, sorts by starting column', function () { + const a = builders.pair( + 'a', + builders.path('foo'), + builders.loc(1, 1, 1, 5, 'foo.hbs'), + ); + + const b = builders.pair( + 'b', + builders.path('foo'), + builders.loc(2, 1, 1, 5, 'foo.hbs'), + ); + + const c = builders.pair( + 'c', + builders.path('foo'), + builders.loc(1, 6, 1, 9, 'foo.hbs'), + ); + + const nodes = [a, b, c].sort(sortByLoc); + + assert.deepStrictEqual( + nodes.map((node) => node.key), + ['a', 'c', 'b'], + ); +}); From 3345f5734fecb64aa480f3d2d09723ec3971d330 Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 09:00:24 +0200 Subject: [PATCH 05/11] refactor: Removed -private/glimmer-syntax/builders.ts --- .../template/src/-private/glimmer-syntax.ts | 2 +- .../src/-private/glimmer-syntax/builders.ts | 63 ------------------- .../src/-private/glimmer-syntax/parser.ts | 50 +++++++++++++-- 3 files changed, 45 insertions(+), 70 deletions(-) delete mode 100644 packages/ast/template/src/-private/glimmer-syntax/builders.ts diff --git a/packages/ast/template/src/-private/glimmer-syntax.ts b/packages/ast/template/src/-private/glimmer-syntax.ts index b30ed764..3082c1fa 100644 --- a/packages/ast/template/src/-private/glimmer-syntax.ts +++ b/packages/ast/template/src/-private/glimmer-syntax.ts @@ -1,12 +1,12 @@ import { type AST, + builders, type NodeVisitor, print as upstreamPrint, traverse, Walker, } from '@glimmer/syntax'; -import { builders } from './glimmer-syntax/builders.js'; import { type NodeInfo, Parser } from './glimmer-syntax/parser.js'; const NODE_INFO = new WeakMap(); diff --git a/packages/ast/template/src/-private/glimmer-syntax/builders.ts b/packages/ast/template/src/-private/glimmer-syntax/builders.ts deleted file mode 100644 index 93511cb2..00000000 --- a/packages/ast/template/src/-private/glimmer-syntax/builders.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { type AST, builders as _builders } from '@glimmer/syntax'; - -export type QuoteType = '"' | "'"; - -export interface AnnotatedAttrNode extends AST.AttrNode { - /** - * Supports cases like `` or `
` - */ - isValueless?: boolean; - - /** - * TextNode values can use single, double, or no quotes - * `type=input` vs `type='input'` vs `type="input"` - * ConcatStatement values can use single or double quotes - * `class='thing {{get this classNames}}'` vs `class="thing {{get this classNames}}"` - * MustacheStatements never use quotes - */ - quoteType?: QuoteType | null; -} - -export interface AnnotatedStringLiteral extends AST.StringLiteral { - quoteType?: QuoteType; -} - -/** - * The glimmer printer doesn't have any formatting suppport. It always uses - * double quotes, and won't print attrs without a value. To choose quote types - * or omit the value, we have to do it ourselves. - */ -export function useCustomPrinter(node: AST.BaseNode): boolean { - switch (node.type) { - case 'AttrNode': { - const n = node as AnnotatedAttrNode; - return Boolean(n.isValueless) || n.quoteType !== undefined; - } - - case 'StringLiteral': { - return Boolean((node as AnnotatedStringLiteral).quoteType); - } - - default: { - return false; - } - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ReplaceReturnType any, NewReturn> = ( - ...a: Parameters -) => NewReturn; - -/** - * Update glimmer's builders to return our annotated types, so that tests and - * users can specify formatting properties on constructed nodes. - */ -type _Builders = typeof _builders; - -export interface Builders extends Omit<_Builders, 'attr' | 'string'> { - attr: ReplaceReturnType; - string: ReplaceReturnType; -} - -export const builders = _builders; diff --git a/packages/ast/template/src/-private/glimmer-syntax/parser.ts b/packages/ast/template/src/-private/glimmer-syntax/parser.ts index 3268b9f6..bc4b0d4c 100644 --- a/packages/ast/template/src/-private/glimmer-syntax/parser.ts +++ b/packages/ast/template/src/-private/glimmer-syntax/parser.ts @@ -6,14 +6,52 @@ import { traverse, } from '@glimmer/syntax'; -import { - type AnnotatedAttrNode, - type AnnotatedStringLiteral, - type QuoteType, - useCustomPrinter, -} from './builders.js'; import { getLines, sortByLoc, sourceForLoc } from './utils.js'; +type QuoteType = '"' | "'"; + +interface AnnotatedAttrNode extends AST.AttrNode { + /** + * Supports cases like `` or `
` + */ + isValueless?: boolean; + + /** + * TextNode values can use single, double, or no quotes + * `type=input` vs `type='input'` vs `type="input"` + * ConcatStatement values can use single or double quotes + * `class='thing {{get this classNames}}'` vs `class="thing {{get this classNames}}"` + * MustacheStatements never use quotes + */ + quoteType?: QuoteType | null; +} + +interface AnnotatedStringLiteral extends AST.StringLiteral { + quoteType?: QuoteType; +} + +/** + * The glimmer printer doesn't have any formatting suppport. It always uses + * double quotes, and won't print attrs without a value. To choose quote types + * or omit the value, we have to do it ourselves. + */ +function useCustomPrinter(node: AST.BaseNode): boolean { + switch (node.type) { + case 'AttrNode': { + const n = node as AnnotatedAttrNode; + return Boolean(n.isValueless) || n.quoteType !== undefined; + } + + case 'StringLiteral': { + return Boolean((node as AnnotatedStringLiteral).quoteType); + } + + default: { + return false; + } + } +} + const leadingWhitespace = /(^\s+)/; const attrNodeParts = /(^[^=]+)(\s+)?(=)?(\s+)?(['"])?(\S+)?/; const hashPairParts = /(^[^=]+)(\s+)?=(\s+)?(\S+)/; From a31834f18d82e6dd7b5aab8f2256840377b9c142 Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 09:00:28 +0200 Subject: [PATCH 06/11] chore: Fixed type errors --- .../ast/template-tag/tests/helpers/update-templates.ts | 1 + packages/ast/template/src/index.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ast/template-tag/tests/helpers/update-templates.ts b/packages/ast/template-tag/tests/helpers/update-templates.ts index 0785abc1..5bc86b0a 100644 --- a/packages/ast/template-tag/tests/helpers/update-templates.ts +++ b/packages/ast/template-tag/tests/helpers/update-templates.ts @@ -4,6 +4,7 @@ export function removeClassAttribute(file: string): string { const traverse = AST.traverse(); const ast = traverse(file, { + // @ts-expect-error: Incorrect type AttrNode(node) { if (node.name !== 'class') { return; diff --git a/packages/ast/template/src/index.ts b/packages/ast/template/src/index.ts index bf323756..ffd0fdec 100644 --- a/packages/ast/template/src/index.ts +++ b/packages/ast/template/src/index.ts @@ -22,6 +22,12 @@ function traverse() { }; } +type AST = { + builders: typeof builders; + print: typeof print; + traverse: typeof traverse; +}; + /** * An object that provides `builders`, `print`, and `traverse`. * @@ -44,7 +50,7 @@ function traverse() { * } * ``` */ -export const AST = { +export const AST: AST = { builders, print, traverse, From 0ba227eb8eb0fb877efff57ba29a05d150578d3e Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 09:03:47 +0200 Subject: [PATCH 07/11] refactor: Simplified traverse --- .../template/src/-private/glimmer-syntax.ts | 107 +----------------- packages/ast/template/src/index.ts | 24 +--- .../glimmer-syntax/element-node.test.ts | 22 ---- .../-private/glimmer-syntax/transform.test.ts | 59 ---------- .../-private/glimmer-syntax/traverse.test.ts | 73 ++++++++++++ 5 files changed, 80 insertions(+), 205 deletions(-) delete mode 100644 packages/ast/template/tests/-private/glimmer-syntax/transform.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts diff --git a/packages/ast/template/src/-private/glimmer-syntax.ts b/packages/ast/template/src/-private/glimmer-syntax.ts index 3082c1fa..8a67a596 100644 --- a/packages/ast/template/src/-private/glimmer-syntax.ts +++ b/packages/ast/template/src/-private/glimmer-syntax.ts @@ -3,8 +3,7 @@ import { builders, type NodeVisitor, print as upstreamPrint, - traverse, - Walker, + traverse as upstreamTraverse, } from '@glimmer/syntax'; import { type NodeInfo, Parser } from './glimmer-syntax/parser.js'; @@ -29,107 +28,13 @@ export function print(ast: AST.Node): string { }); } -type TransformOptions = { - /** - * The path (relative to the current working directory) to the file being transformed. - * - * This is useful when a given transform need to have differing behavior based on the - * location of the file (e.g. a component template should be modified differently than - * a route template). - */ - filePath?: string; +export function traverse() { + return function (file: string, visitMethods: NodeVisitor = {}): AST.Template { + const { ast } = new Parser(file, NODE_INFO); - /** - * The plugin to use for transformation. - */ - plugin: TransformPluginBuilder; + upstreamTraverse(ast, visitMethods); - /** - * The template to transform (either as a string or a pre-parsed AST.Template). - */ - template: string | AST.Template; -}; - -type TransformPluginBuilder = { - (env: TransformPluginEnv): NodeVisitor; -}; - -type TransformPluginEnv = { - contents: string; - filePath: string | undefined; - parseOptions: { - srcName: string | undefined; - }; - syntax: { - Walker: typeof Walker; - builders: typeof builders; - parse: typeof parse; - print: typeof print; - traverse: typeof traverse; - }; -}; - -type TransformResult = { - ast: AST.Template; - code: string; -}; - -export function transform( - template: string | AST.Template, - plugin: TransformPluginBuilder, -): TransformResult; -export function transform(options: TransformOptions): TransformResult; -export function transform( - templateOrOptions: string | AST.Template | TransformOptions, - plugin?: TransformPluginBuilder, -): TransformResult { - let ast: AST.Template; - let contents: string; - let filePath: undefined | string; - let template: string | AST.Template; - - if (plugin === undefined) { - const options = templateOrOptions as TransformOptions; - // TransformOptions invocation style - filePath = options.filePath; - plugin = options.plugin; - template = options.template; - } else { - filePath = undefined; - template = templateOrOptions as AST.Template; - } - - if (typeof template === 'string') { - ast = parse(template); - contents = template; - } else { - // assume we were passed an ast - ast = template; - contents = print(ast); - } - - const env: TransformPluginEnv = { - contents, - filePath, - parseOptions: { - srcName: filePath, - }, - syntax: { - Walker, - builders, - parse, - print, - traverse, - }, - }; - - const visitor = plugin(env); - - traverse(ast, visitor); - - return { - ast, - code: print(ast), + return ast; }; } diff --git a/packages/ast/template/src/index.ts b/packages/ast/template/src/index.ts index ffd0fdec..d71c8159 100644 --- a/packages/ast/template/src/index.ts +++ b/packages/ast/template/src/index.ts @@ -1,26 +1,4 @@ -import { - type AST as _AST, - builders, - type NodeVisitor, - print, - transform, -} from './-private/glimmer-syntax.js'; - -function traverse() { - return function ( - file: string, - visitMethods: NodeVisitor = {}, - ): _AST.Template { - const { ast } = transform({ - plugin() { - return visitMethods; - }, - template: file, - }); - - return ast; - }; -} +import { builders, print, traverse } from './-private/glimmer-syntax.js'; type AST = { builders: typeof builders; diff --git a/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts index 7a1dc350..c12d308f 100644 --- a/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts +++ b/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts @@ -2,7 +2,6 @@ import { EOL } from 'node:os'; import { assert, normalizeFile, test } from '@codemod-utils/tests'; -import { traverse } from '@glimmer/syntax'; import { type AST, @@ -663,27 +662,6 @@ test('-private | glimmer-syntax | ElementNode > adding a new attribute to an Ele ); }); -test('-private | glimmer-syntax | ElementNode > issue can handle angle brackets in modifier argument values', function () { - const template = normalizeFile([ - `> Some Text Here"}}`, - ` @options={{this.items}}`, - ` as |item|`, - `>`, - ` {{item.name}}`, - ``, - ]); - const ast = parse(template); - - traverse(ast, { - ElementNode(node) { - node.tag = `${node.tag}`; - }, - }); - - assert.strictEqual(print(ast), template); -}); - test('-private | glimmer-syntax | ElementNode > issue 706', function () { const template = normalizeFile([ ` can remove during traversal by returning `null`', function () { - const template = normalizeFile([ - `

here is some multiline string

`, - `{{ other-stuff }}`, - ]); - - // @ts-expect-error: Incorrect type - const { code } = transform(template, () => { - return { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - ElementNode() { - return null; - }, - }; - }); - - assert.strictEqual(code, normalizeFile([``, `{{ other-stuff }}`])); -}); - -test('-private | glimmer-syntax | transform > can replace with many items during traversal by returning an array', function () { - const template = normalizeFile([ - `

here is some multiline string

`, - `{{other-stuff}}`, - ]); - - const { code } = transform(template, (env) => { - const { builders: b } = env.syntax; - - return { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - ElementNode() { - return [b.text('hello '), b.text('world')]; - }, - }; - }); - - assert.strictEqual(code, normalizeFile([`hello world`, `{{other-stuff}}`])); -}); - -test('-private | glimmer-syntax | transform > MustacheStatements retain whitespace when multiline replacements occur', function () { - const template = normalizeFile([`

`, `{{ other-stuff }}`]); - - const { code } = transform(template, (env) => { - const { builders: b } = env.syntax; - - return { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - ElementNode() { - return [b.text('x'), b.text('y')]; - }, - }; - }); - - assert.strictEqual(code, normalizeFile([`xy`, `{{ other-stuff }}`])); -}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts new file mode 100644 index 00000000..f8eb42ec --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts @@ -0,0 +1,73 @@ +import { assert, normalizeFile, test } from '@codemod-utils/tests'; + +import { + builders, + type NodeVisitor, + print, + traverse, +} from '../../../src/-private/glimmer-syntax.js'; + +test('-private | glimmer-syntax | traverse > can remove during traversal by returning `null`', function () { + const template = normalizeFile([ + `

here is some multiline string

`, + `{{ other-stuff }}`, + ]); + + const ast = traverse()(template, { + ElementNode() { + return null; + }, + } as NodeVisitor); + + assert.strictEqual(print(ast), normalizeFile([``, `{{ other-stuff }}`])); +}); + +test('-private | glimmer-syntax | traverse > can replace with many items during traversal by returning an array', function () { + const template = normalizeFile([ + `

here is some multiline string

`, + `{{other-stuff}}`, + ]); + + const ast = traverse()(template, { + ElementNode() { + return [builders.text('hello '), builders.text('world')]; + }, + }); + + assert.strictEqual( + print(ast), + normalizeFile([`hello world`, `{{other-stuff}}`]), + ); +}); + +test('-private | glimmer-syntax | traverse > issue can handle angle brackets in modifier argument values', function () { + const template = normalizeFile([ + `> Some Text Here"}}`, + ` @options={{this.items}}`, + ` as |item|`, + `>`, + ` {{item.name}}`, + ``, + ]); + + const ast = traverse()(template, { + ElementNode(node) { + node.tag = `${node.tag}`; + }, + }); + + assert.strictEqual(print(ast), template); +}); + +test('-private | glimmer-syntax | traverse > MustacheStatements retain whitespace when multiline replacements occur', function () { + const template = normalizeFile([`

`, `{{ other-stuff }}`]); + + const ast = traverse()(template, { + ElementNode() { + return [builders.text('x'), builders.text('y')]; + }, + }); + + assert.strictEqual(print(ast), normalizeFile([`xy`, `{{ other-stuff }}`])); +}); From 1cdee9d8bd002d7a7e6d2b3cf214cfc56530616e Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 09:03:51 +0200 Subject: [PATCH 08/11] refactor: Removed unused code --- .../template/src/-private/glimmer-syntax.ts | 1 - .../src/-private/glimmer-syntax/utils.ts | 22 ++----------------- .../-private/glimmer-syntax/attr-node.test.ts | 2 +- .../glimmer-syntax/element-node.test.ts | 2 +- .../glimmer-syntax/mustache-statement.test.ts | 2 +- .../glimmer-syntax/null-literal.test.ts | 7 ++---- .../-private/glimmer-syntax/traverse.test.ts | 2 +- 7 files changed, 8 insertions(+), 30 deletions(-) diff --git a/packages/ast/template/src/-private/glimmer-syntax.ts b/packages/ast/template/src/-private/glimmer-syntax.ts index 8a67a596..03dc7ede 100644 --- a/packages/ast/template/src/-private/glimmer-syntax.ts +++ b/packages/ast/template/src/-private/glimmer-syntax.ts @@ -38,5 +38,4 @@ export function traverse() { }; } -export type { AST, NodeVisitor }; export { builders }; diff --git a/packages/ast/template/src/-private/glimmer-syntax/utils.ts b/packages/ast/template/src/-private/glimmer-syntax/utils.ts index 973ce069..0b1d88e5 100644 --- a/packages/ast/template/src/-private/glimmer-syntax/utils.ts +++ b/packages/ast/template/src/-private/glimmer-syntax/utils.ts @@ -1,25 +1,7 @@ import type { AST } from '@glimmer/syntax'; -const reLines = /(.*?(?:\r\n?|\n|$))/gm; - -export function compact(array: unknown[]): unknown[] { - const newArray: unknown[] = []; - - array.forEach((a) => { - if (typeof a !== 'undefined' && a !== null && a !== '') { - newArray.push(a); - } - }); - - return newArray; -} - -export function compactJoin(array: unknown[], delimeter = ''): string { - return compact(array).join(delimeter); -} - export function getLines(source: string): string[] { - const result = source.match(reLines); + const result = source.match(/(.*?(?:\r\n?|\n|$))/gm); if (!result) { throw new Error('could not parse source'); @@ -28,7 +10,7 @@ export function getLines(source: string): string[] { return result.slice(0, -1); } -export function isSyntheticWithNoLocation(node: AST.Node): boolean { +function isSyntheticWithNoLocation(node: AST.Node): boolean { if (node && node.loc) { const { start, end } = node.loc; diff --git a/packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts index 3cd1ae9e..96347a8d 100644 --- a/packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts +++ b/packages/ast/template/tests/-private/glimmer-syntax/attr-node.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ import { assert, normalizeFile, test } from '@codemod-utils/tests'; +import type { AST } from '@glimmer/syntax'; import { - type AST, builders, parse, print, diff --git a/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts index c12d308f..b6f898a9 100644 --- a/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts +++ b/packages/ast/template/tests/-private/glimmer-syntax/element-node.test.ts @@ -2,9 +2,9 @@ import { EOL } from 'node:os'; import { assert, normalizeFile, test } from '@codemod-utils/tests'; +import type { AST } from '@glimmer/syntax'; import { - type AST, builders, parse, print, diff --git a/packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts index eb0a6ceb..5d9b0570 100644 --- a/packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts +++ b/packages/ast/template/tests/-private/glimmer-syntax/mustache-statement.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ import { assert, normalizeFile, test } from '@codemod-utils/tests'; +import type { AST } from '@glimmer/syntax'; import { - type AST, builders, parse, print, diff --git a/packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts index 57fa226c..19bffcae 100644 --- a/packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts +++ b/packages/ast/template/tests/-private/glimmer-syntax/null-literal.test.ts @@ -1,10 +1,7 @@ import { assert, normalizeFile, test } from '@codemod-utils/tests'; +import type { AST } from '@glimmer/syntax'; -import { - type AST, - parse, - print, -} from '../../../src/-private/glimmer-syntax.js'; +import { parse, print } from '../../../src/-private/glimmer-syntax.js'; test('-private | glimmer-syntax | NullLiteral > it should print correctly', function () { const template = normalizeFile([`{{contact-null`, ` null`, `}}`]); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts index f8eb42ec..27e57a12 100644 --- a/packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts +++ b/packages/ast/template/tests/-private/glimmer-syntax/traverse.test.ts @@ -1,8 +1,8 @@ import { assert, normalizeFile, test } from '@codemod-utils/tests'; +import type { NodeVisitor } from '@glimmer/syntax'; import { builders, - type NodeVisitor, print, traverse, } from '../../../src/-private/glimmer-syntax.js'; From d9abca95be52b2015de88ddd52415ea01e702609 Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 09:04:21 +0200 Subject: [PATCH 09/11] breaking: Consumed @glimmer/syntax directly --- .../template/src/-private/glimmer-syntax.ts | 23 +- .../src/-private/glimmer-syntax/parser.ts | 1508 ----------------- .../src/-private/glimmer-syntax/utils.ts | 94 - .../sorts-nodes-by-their-line-numbers.test.ts | 31 - .../sorts-synthetic-nodes-last.test.ts | 21 - ...e-matches-sorts-by-starting-column.test.ts | 31 - 6 files changed, 9 insertions(+), 1699 deletions(-) delete mode 100644 packages/ast/template/src/-private/glimmer-syntax/parser.ts delete mode 100644 packages/ast/template/src/-private/glimmer-syntax/utils.ts delete mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts delete mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts delete mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts diff --git a/packages/ast/template/src/-private/glimmer-syntax.ts b/packages/ast/template/src/-private/glimmer-syntax.ts index 03dc7ede..4087a0b6 100644 --- a/packages/ast/template/src/-private/glimmer-syntax.ts +++ b/packages/ast/template/src/-private/glimmer-syntax.ts @@ -2,35 +2,30 @@ import { type AST, builders, type NodeVisitor, + preprocess, print as upstreamPrint, traverse as upstreamTraverse, } from '@glimmer/syntax'; -import { type NodeInfo, Parser } from './glimmer-syntax/parser.js'; - -const NODE_INFO = new WeakMap(); +export function parse(file: string): AST.Template { + const ast = preprocess(file, { + mode: 'codemod', + }); -export function parse(template: string): AST.Template { - return new Parser(template, NODE_INFO).ast; + return ast; } export function print(ast: AST.Node): string { return upstreamPrint(ast, { entityEncoding: 'raw', - // @ts-expect-error: Incorrect type - override: (ast) => { - const info = NODE_INFO.get(ast); - - if (info) { - return info.parse_result.print(ast); - } - }, }); } export function traverse() { return function (file: string, visitMethods: NodeVisitor = {}): AST.Template { - const { ast } = new Parser(file, NODE_INFO); + const ast = preprocess(file, { + mode: 'codemod', + }); upstreamTraverse(ast, visitMethods); diff --git a/packages/ast/template/src/-private/glimmer-syntax/parser.ts b/packages/ast/template/src/-private/glimmer-syntax/parser.ts deleted file mode 100644 index bc4b0d4c..00000000 --- a/packages/ast/template/src/-private/glimmer-syntax/parser.ts +++ /dev/null @@ -1,1508 +0,0 @@ -import { - type AST, - builders, - preprocess, - print as _print, - traverse, -} from '@glimmer/syntax'; - -import { getLines, sortByLoc, sourceForLoc } from './utils.js'; - -type QuoteType = '"' | "'"; - -interface AnnotatedAttrNode extends AST.AttrNode { - /** - * Supports cases like `` or `
` - */ - isValueless?: boolean; - - /** - * TextNode values can use single, double, or no quotes - * `type=input` vs `type='input'` vs `type="input"` - * ConcatStatement values can use single or double quotes - * `class='thing {{get this classNames}}'` vs `class="thing {{get this classNames}}"` - * MustacheStatements never use quotes - */ - quoteType?: QuoteType | null; -} - -interface AnnotatedStringLiteral extends AST.StringLiteral { - quoteType?: QuoteType; -} - -/** - * The glimmer printer doesn't have any formatting suppport. It always uses - * double quotes, and won't print attrs without a value. To choose quote types - * or omit the value, we have to do it ourselves. - */ -function useCustomPrinter(node: AST.BaseNode): boolean { - switch (node.type) { - case 'AttrNode': { - const n = node as AnnotatedAttrNode; - return Boolean(n.isValueless) || n.quoteType !== undefined; - } - - case 'StringLiteral': { - return Boolean((node as AnnotatedStringLiteral).quoteType); - } - - default: { - return false; - } - } -} - -const leadingWhitespace = /(^\s+)/; -const attrNodeParts = /(^[^=]+)(\s+)?(=)?(\s+)?(['"])?(\S+)?/; -const hashPairParts = /(^[^=]+)(\s+)?=(\s+)?(\S+)/; -const invalidUnquotedAttrValue = /[^-.a-zA-Z0-9]/; - -const voidTagNames = new Set([ - 'area', - 'base', - 'br', - 'col', - 'command', - 'embed', - 'hr', - 'img', - 'input', - 'keygen', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -]); - -/* - This is needed to address issues in the glimmer-vm AST _before_ any of the nodes and node - values are cached. The specific issues being worked around are: - - * https://github.com/glimmerjs/glimmer-vm/pull/953 - * https://github.com/glimmerjs/glimmer-vm/pull/954 -*/ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any -function fixASTIssues(sourceLines: any, ast: any) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - traverse(ast, { - AttrNode(attr: AST.AttrNode) { - const node = attr as AnnotatedAttrNode; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const source = sourceForLoc(sourceLines, node.loc); - const attrNodePartsResults = source.match(attrNodeParts); - if (attrNodePartsResults === null) { - throw new Error(`Could not match attr node parts for ${source}`); - } - const [, , , equals, , quote] = attrNodePartsResults; - const isValueless = !equals; - - // TODO: manually working around https://github.com/glimmerjs/glimmer-vm/pull/953 - if ( - isValueless && - node.value.type === 'TextNode' && - node.value.chars === '' - ) { - // \n is not valid within an attribute name (it would indicate two attributes) - // always assume the attribute ends on the starting line - const { - start: { line, column }, - } = node.loc; - node.loc = builders.loc(line, column, line, column + node.name.length); - } - - node.isValueless = isValueless; - node.quoteType = (quote as QuoteType) || null; - }, - - StringLiteral(lit) { - const quotes = /^['"]/; - const node = lit as AnnotatedStringLiteral; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const source = sourceForLoc(sourceLines, node.loc); - if (!source.match(quotes)) { - throw new Error('Invalid string literal found'); - } - node.quoteType = source[0] as QuoteType; - }, - - TextNode(node, path) { - if (path.parentNode === null) { - throw new Error( - 'ember-template-recast: Error while sanitizing input AST: found TextNode with no parentNode', - ); - } - - switch (path.parentNode.type) { - case 'AttrNode': { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const source = sourceForLoc(sourceLines, node.loc); - if ( - node.chars.length > 0 && - ((source.startsWith(`'`) && source.endsWith(`'`)) || - (source.startsWith(`"`) && source.endsWith(`"`))) - ) { - const { start, end } = node.loc; - node.loc = builders.loc( - start.line, - start.column + 1, - end.line, - end.column - 1, - ); - } - break; - } - case 'ConcatStatement': { - const parent = path.parentNode; - const isFirstPart = parent.parts.indexOf(node) === 0; - - const { start, end } = node.loc; - if ( - isFirstPart && - node.loc.start.column > path.parentNode.loc.start.column + 1 - ) { - // TODO: manually working around https://github.com/glimmerjs/glimmer-vm/pull/954 - node.loc = builders.loc( - start.line, - start.column - 1, - end.line, - end.column, - ); - } else if (isFirstPart && node.chars.charAt(0) === '\n') { - node.loc = builders.loc( - start.line, - start.column + 1, - end.line, - end.column, - ); - } - } - } - }, - }); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return ast; -} - -export interface NodeInfo { - hadHash?: boolean; - hadParams?: boolean; - hashSource?: string; - node: AST.Node; - original: AST.Node; - paramsSource?: string; - parse_result: Parser; - postHashWhitespace?: string; - postParamsWhitespace?: string; - postPathWhitespace?: string; - source: string; -} - -export class Parser { - private _originalAst: AST.Template; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private ancestor = new Map(); - public ast: AST.Template; - private dirtyFields = new Map>(); - private nodeInfo: WeakMap; - private source: string[]; - - constructor( - template: string, - nodeInfo: WeakMap = new WeakMap(), - ) { - let ast = preprocess(template, { - mode: 'codemod', - }); - - const source = getLines(template); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - ast = fixASTIssues(source, ast); - this.source = source; - this._originalAst = ast; - - this.nodeInfo = nodeInfo; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - this.ast = this.wrapNode(null, ast); - } - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - private _rebuildParamsHash( - ast: - | AST.MustacheStatement - | AST.SubExpression - | AST.ElementModifierStatement - | AST.BlockStatement, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nodeInfo: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dirtyFields: any, - ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { original } = nodeInfo; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - if (dirtyFields.has('hash')) { - if (ast.hash.pairs.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.hashSource = ''; - - if (ast.params.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.postPathWhitespace = ''; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.postParamsWhitespace = ''; - } - } else { - let joinWith; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (original.hash.pairs.length > 1) { - joinWith = this.sourceForLoc({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - start: original.hash.pairs[0].loc.end, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: original.hash.pairs[1].loc.start, - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - } else if (nodeInfo.hadParams) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - joinWith = nodeInfo.postPathWhitespace; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - } else if (nodeInfo.hadHash) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - joinWith = nodeInfo.postParamsWhitespace; - } else { - joinWith = ' '; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - if (joinWith.trim() !== '') { - // if the autodetection above resulted in some non whitespace - // values, reset to `' '` - joinWith = ' '; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.hashSource = ast.hash.pairs - .map((pair: AST.HashPair) => { - return this.print(pair); - }) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - .join(joinWith); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (!nodeInfo.hadHash) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - nodeInfo.postParamsWhitespace = joinWith; - } - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - dirtyFields.delete('hash'); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - if (dirtyFields.has('params')) { - let joinWith; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (original.params.length > 1) { - joinWith = this.sourceForLoc({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - start: original.params[0].loc.end, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: original.params[1].loc.start, - }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - } else if (nodeInfo.hadParams) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - joinWith = nodeInfo.postPathWhitespace; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - } else if (nodeInfo.hadHash) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - joinWith = nodeInfo.postParamsWhitespace; - } else { - joinWith = ' '; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - if (joinWith.trim() !== '') { - // if the autodetection above resulted in some non whitespace - // values, reset to `' '` - joinWith = ' '; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.paramsSource = ast.params - .map((param) => this.print(param)) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - .join(joinWith); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (nodeInfo.hadParams && ast.params.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.postPathWhitespace = ''; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - } else if (!nodeInfo.hadParams && ast.params.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - nodeInfo.postPathWhitespace = joinWith; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - dirtyFields.delete('params'); - } - } - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any - private _updateNodeInfoForParamsHash(_ast: any, nodeInfo: any) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { original } = nodeInfo; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const hadParams = (nodeInfo.hadParams = original.params.length > 0); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const hadHash = (nodeInfo.hadHash = original.hash.pairs.length > 0); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.postPathWhitespace = hadParams - ? this.sourceForLoc({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - start: original.path.loc.end, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: original.params[0].loc.start, - }) - : ''; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.paramsSource = hadParams - ? this.sourceForLoc({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - start: original.params[0].loc.start, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: original.params[original.params.length - 1].loc.end, - }) - : ''; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.postParamsWhitespace = hadHash - ? this.sourceForLoc({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - start: hadParams - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - original.params[original.params.length - 1].loc.end - : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - original.path.loc.end, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: original.hash.loc.start, - }) - : ''; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.hashSource = hadHash ? this.sourceForLoc(original.hash.loc) : ''; - - const postHashSource = this.sourceForLoc({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - start: hadHash - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - original.hash.loc.end - : hadParams - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - original.params[original.params.length - 1].loc.end - : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - original.path.loc.end, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: original.loc.end, - }); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.postHashWhitespace = ''; - const postHashWhitespaceMatch = postHashSource.match(leadingWhitespace); - if (postHashWhitespaceMatch) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nodeInfo.postHashWhitespace = postHashWhitespaceMatch[0]; - } - } - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any - private markAsDirty(node: any, property: any) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - let dirtyFields = this.dirtyFields.get(node); - if (dirtyFields === undefined) { - dirtyFields = new Set(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.dirtyFields.set(node, dirtyFields); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - dirtyFields.add(property); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const ancestor = this.ancestor.get(node); - if (ancestor !== null) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.markAsDirty(ancestor.node, ancestor.key); - } - } - - print(_ast: AST.Node = this._originalAst): string { - if (!_ast) { - return ''; - } - - const nodeInfo = this.nodeInfo.get(_ast); - - if (nodeInfo === undefined) { - return this.printUserSuppliedNode(_ast); - } - - // this ensures that we are operating on the actual node and not a - // proxy (we can get Proxies here when transforms splice body/children) - const ast = nodeInfo.node; - - // make a copy of the dirtyFields, so we can easily track - // unhandled dirtied fields - const dirtyFields = new Set(this.dirtyFields.get(ast)); - if (dirtyFields.size === 0 && nodeInfo !== undefined) { - return nodeInfo.source; - } - - // TODO: splice the original source **excluding** "children" - // based on dirtyFields - const output = []; - - const { original } = nodeInfo; - - switch (ast.type) { - // @ts-expect-error: Incorrect type - case 'Program': - case 'Block': - case 'Template': - { - let bodySource = nodeInfo.source; - - if (dirtyFields.has('body')) { - bodySource = ast.body.map((node) => this.print(node)).join(''); - - dirtyFields.delete('body'); - } - - output.push(bodySource); - } - break; - case 'ElementNode': - { - const element = original as AST.ElementNode; - const { selfClosing, children } = element; - const hadChildren = children.length > 0; - const hadBlockParams = element.blockParams.length > 0; - - let openSource = `<${element.tag}`; - - const originalOpenParts = [ - ...element.attributes, - ...element.modifiers, - ...element.comments, - ].sort(sortByLoc); - - let postTagWhitespace; - if (originalOpenParts.length > 0) { - postTagWhitespace = this.sourceForLoc({ - start: { - line: element.loc.start.line, - column: - element.loc.start.column + 1 /* < */ + element.tag.length, - }, - // @ts-expect-error: Incorrect type - end: originalOpenParts[0].loc.start, - }); - } else if (selfClosing) { - postTagWhitespace = nodeInfo.source.substring( - openSource.length, - nodeInfo.source.length - 2, - ); - } else { - postTagWhitespace = ''; - } - - let openPartsSource = originalOpenParts.reduce( - (acc, part, index, parts) => { - const partSource = this.sourceForLoc(part.loc); - - if (index === parts.length - 1) { - return acc + partSource; - } - - let joinPartWith = this.sourceForLoc({ - // @ts-expect-error: Incorrect type - start: parts[index].loc.end, - // @ts-expect-error: Incorrect type - end: parts[index + 1].loc.start, - }); - - if (joinPartWith.trim() !== '') { - // if the autodetection above resulted in some non whitespace - // values, reset to `' '` - joinPartWith = ' '; - } - - return acc + partSource + joinPartWith; - }, - '', - ); - - let postPartsWhitespace = ''; - if (originalOpenParts.length > 0) { - const postPartsSource = this.sourceForLoc({ - // @ts-expect-error: Incorrect type - start: originalOpenParts[originalOpenParts.length - 1].loc.end, - end: hadChildren - ? // @ts-expect-error: Incorrect type - element.children[0].loc.start - : element.loc.end, - }); - - const matchedWhitespace = postPartsSource.match(leadingWhitespace); - if (matchedWhitespace) { - postPartsWhitespace = matchedWhitespace[0]; - } - } else if (hadBlockParams) { - const postPartsSource = this.sourceForLoc({ - start: { - line: element.loc.start.line, - column: element.loc.start.column + 1 + element.tag.length, - }, - end: hadChildren - ? // @ts-expect-error: Incorrect type - element.children[0].loc.start - : element.loc.end, - }); - - const matchedWhitespace = postPartsSource.match(leadingWhitespace); - if (matchedWhitespace) { - postPartsWhitespace = matchedWhitespace[0]; - } - } - - let blockParamsSource = ''; - let postBlockParamsWhitespace = ''; - if (element.blockParams.length > 0) { - const blockParamStartIndex = nodeInfo.source.indexOf('as |'); - const blockParamsEndIndex = nodeInfo.source.indexOf( - '|', - blockParamStartIndex + 4, - ); - blockParamsSource = nodeInfo.source.substring( - blockParamStartIndex, - blockParamsEndIndex + 1, - ); - - // Match closing index after start of block params to avoid closing tag if /> or > encountered in string - const closeOpenIndex = - nodeInfo.source - .substring(blockParamStartIndex) - .indexOf(selfClosing ? '/>' : '>') + blockParamStartIndex; - postBlockParamsWhitespace = nodeInfo.source.substring( - blockParamsEndIndex + 1, - closeOpenIndex, - ); - } - - let closeOpen = selfClosing ? `/>` : `>`; - - let childrenSource = hadChildren - ? this.sourceForLoc({ - // @ts-expect-error: Incorrect type - start: element.children[0].loc.start, - // @ts-expect-error: Incorrect type - end: element.children[children.length - 1].loc.end, - }) - : ''; - - let closeSource = selfClosing - ? '' - : voidTagNames.has(element.tag) - ? '' - : ``; - - if (dirtyFields.has('tag')) { - openSource = `<${ast.tag}`; - - closeSource = selfClosing - ? '' - : voidTagNames.has(ast.tag) - ? '' - : ``; - - dirtyFields.delete('tag'); - } - - if (dirtyFields.has('children')) { - childrenSource = ast.children - .map((child) => this.print(child)) - .join(''); - - if (selfClosing) { - closeOpen = `>`; - closeSource = ``; - ast.selfClosing = false; - - if (originalOpenParts.length === 0 && postTagWhitespace === ' ') { - postTagWhitespace = ''; - } - - if (originalOpenParts.length > 0 && postPartsWhitespace === ' ') { - postPartsWhitespace = ''; - } - } - - dirtyFields.delete('children'); - } - - if ( - dirtyFields.has('attributes') || - dirtyFields.has('comments') || - dirtyFields.has('modifiers') - ) { - const openParts = [ - ...ast.attributes, - ...ast.modifiers, - ...ast.comments, - ].sort(sortByLoc); - - openPartsSource = openParts.reduce((acc, part, index, parts) => { - const partSource = this.print(part); - - if (index === parts.length - 1) { - return acc + partSource; - } - - let joinPartWith = this.sourceForLoc({ - // @ts-expect-error: Incorrect type - start: parts[index].loc.end, - // @ts-expect-error: Incorrect type - end: parts[index + 1].loc.start, - }); - - if (joinPartWith === '' || joinPartWith.trim() !== '') { - // if the autodetection above resulted in some non whitespace - // values, reset to `' '` - joinPartWith = ' '; - } - - return acc + partSource + joinPartWith; - }, ''); - - if (originalOpenParts.length === 0) { - postTagWhitespace = ' '; - } - - if (openParts.length === 0 && originalOpenParts.length > 0) { - postTagWhitespace = ''; - } - - if (openParts.length > 0 && ast.selfClosing) { - postPartsWhitespace = postPartsWhitespace || ' '; - } - - dirtyFields.delete('attributes'); - dirtyFields.delete('comments'); - dirtyFields.delete('modifiers'); - } - - if (dirtyFields.has('blockParams')) { - if (ast.blockParams.length === 0) { - blockParamsSource = ''; - postPartsWhitespace = ''; - } else { - blockParamsSource = `as |${ast.blockParams.join(' ')}|`; - - // ensure we have at least a space - postPartsWhitespace = postPartsWhitespace || ' '; - } - - dirtyFields.delete('blockParams'); - } - - output.push( - openSource, - postTagWhitespace, - openPartsSource, - postPartsWhitespace, - blockParamsSource, - postBlockParamsWhitespace, - closeOpen, - childrenSource, - closeSource, - ); - } - break; - case 'MustacheStatement': - case 'ElementModifierStatement': - case 'SubExpression': - { - this._updateNodeInfoForParamsHash(ast, nodeInfo); - - let openSource = this.sourceForLoc({ - start: original.loc.start, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: (original as any).path.loc.end, - }); - - let endSource = this.sourceForLoc({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - start: nodeInfo.hadHash - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - (original as any).hash.loc.end - : nodeInfo.hadParams - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - (original as any).params[(original as any).params.length - 1] - .loc.end - : // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - (original as any).path.loc.end, - end: original.loc.end, - }).trimLeft(); - - if (dirtyFields.has('path')) { - openSource = - this.sourceForLoc({ - start: original.loc.start, - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - end: (original as any).path.loc.start, - }) + this.print(ast.path); - - dirtyFields.delete('path'); - } - - if (dirtyFields.has('type')) { - // we only support going from SubExpression -> MustacheStatement - if ( - original.type !== 'SubExpression' || - ast.type !== 'MustacheStatement' - ) { - throw new Error( - `ember-template-recast only supports updating the 'type' for SubExpression to MustacheStatement (you attempted to change ${original.type} to ${ast.type})`, - ); - } - - // TODO: this is a logic error, assumes ast.path is a PathExpression but it could be a number of other things - openSource = `{{${(ast.path as AST.PathExpression).original}`; - endSource = '}}'; - - dirtyFields.delete('type'); - } - - this._rebuildParamsHash(ast, nodeInfo, dirtyFields); - - output.push( - openSource, - nodeInfo.postPathWhitespace, - nodeInfo.paramsSource, - nodeInfo.postParamsWhitespace, - nodeInfo.hashSource, - nodeInfo.postHashWhitespace, - endSource, - ); - } - break; - case 'ConcatStatement': - { - let partsSource = this.sourceForLoc({ - start: { - line: original.loc.start.line, - column: original.loc.start.column + 1, - }, - - end: { - line: original.loc.end.line, - column: original.loc.end.column - 1, - }, - }); - - if (dirtyFields.has('parts')) { - partsSource = ast.parts.map((part) => this.print(part)).join(''); - - dirtyFields.delete('parts'); - } - - output.push(partsSource); - } - break; - case 'BlockStatement': - { - const block = original as AST.BlockStatement; - - this._updateNodeInfoForParamsHash(ast, nodeInfo); - - const hadProgram = block.program.body.length > 0; - const hadProgramBlockParams = block.program.blockParams.length > 0; - - let openSource = this.sourceForLoc({ - start: block.loc.start, - end: block.path.loc.end, - }); - - let blockParamsSource = ''; - let postBlockParamsWhitespace = ''; - if (hadProgramBlockParams) { - const blockParamsSourceScratch = this.sourceForLoc({ - start: nodeInfo.hadHash - ? block.hash.loc.end - : nodeInfo.hadParams - ? // @ts-expect-error: Incorrect type - block.params[block.params.length - 1].loc.end - : block.path.loc.end, - end: original.loc.end, - }); - - const indexOfAsPipe = blockParamsSourceScratch.indexOf('as |'); - const indexOfEndPipe = blockParamsSourceScratch.indexOf( - '|', - indexOfAsPipe + 4, - ); - - blockParamsSource = blockParamsSourceScratch.substring( - indexOfAsPipe, - indexOfEndPipe + 1, - ); - - const postBlockParamsWhitespaceMatch = blockParamsSourceScratch - .substring(indexOfEndPipe + 1) - .match(leadingWhitespace); - if (postBlockParamsWhitespaceMatch) { - postBlockParamsWhitespace = postBlockParamsWhitespaceMatch[0]; - } - } - - let openEndSource; - { - const openEndSourceScratch = this.sourceForLoc({ - start: nodeInfo.hadHash - ? block.hash.loc.end - : nodeInfo.hadParams - ? // @ts-expect-error: Incorrect type - block.params[block.params.length - 1].loc.end - : block.path.loc.end, - end: block.loc.end, - }); - - let startingOffset = 0; - if (hadProgramBlockParams) { - const indexOfAsPipe = openEndSourceScratch.indexOf('as |'); - const indexOfEndPipe = openEndSourceScratch.indexOf( - '|', - indexOfAsPipe + 4, - ); - - startingOffset = indexOfEndPipe + 1; - } - - const indexOfFirstCurly = openEndSourceScratch.indexOf('}'); - const indexOfSecondCurly = openEndSourceScratch.indexOf( - '}', - indexOfFirstCurly + 1, - ); - - openEndSource = openEndSourceScratch - .substring(startingOffset, indexOfSecondCurly + 1) - .trimLeft(); - } - - let programSource = hadProgram - ? this.sourceForLoc(block.program.loc) - : ''; - - let inversePreamble = ''; - if (block.inverse) { - if (hadProgram) { - inversePreamble = this.sourceForLoc({ - start: block.program.loc.end, - end: block.inverse.loc.start, - }); - } else { - const openEndSourceScratch = this.sourceForLoc({ - start: nodeInfo.hadHash - ? block.hash.loc.end - : nodeInfo.hadParams - ? // @ts-expect-error: Incorrect type - block.params[block.params.length - 1].loc.end - : block.path.loc.end, - end: block.loc.end, - }); - - const indexOfFirstCurly = openEndSourceScratch.indexOf('}'); - const indexOfSecondCurly = openEndSourceScratch.indexOf( - '}', - indexOfFirstCurly + 1, - ); - const indexOfThirdCurly = openEndSourceScratch.indexOf( - '}', - indexOfSecondCurly + 1, - ); - const indexOfFourthCurly = openEndSourceScratch.indexOf( - '}', - indexOfThirdCurly + 1, - ); - - inversePreamble = openEndSourceScratch.substring( - indexOfSecondCurly + 1, - indexOfFourthCurly + 1, - ); - } - } - - // GH #149 - // In the event we're dealing with a chain of if/else-if/else, the inverse - // should encompass the entirety of the chain. Sadly, the loc param of - // original.inverse in this case only captures the block of the first inverse - // not the entire chain. We instead look at the loc param of the nested body - // node, which does report the entire chain. - // In this case, because it also includes the preamble, we must also trim - // that from our final inverse source. - let inverseSource; - if (block.inverse && block.inverse.chained) { - // @ts-expect-error: Incorrect type - inverseSource = this.sourceForLoc(block.inverse.body[0].loc) || ''; - inverseSource = inverseSource.slice(inversePreamble.length); - } else { - inverseSource = block.inverse - ? this.sourceForLoc(block.inverse.loc) - : ''; - } - - let endSource = ''; - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - if (!(ast as any).wasChained) { - const firstOpenCurlyFromEndIndex = nodeInfo.source.lastIndexOf('{'); - const secondOpenCurlyFromEndIndex = nodeInfo.source.lastIndexOf( - '{', - firstOpenCurlyFromEndIndex - 1, - ); - - endSource = nodeInfo.source.substring(secondOpenCurlyFromEndIndex); - } - - this._rebuildParamsHash(ast, nodeInfo, dirtyFields); - - if (dirtyFields.has('path')) { - openSource = - this.sourceForLoc({ - start: original.loc.start, - end: block.path.loc.start, - }) + _print(ast.path); - - // TODO: this is a logic error - const pathIndex = endSource.indexOf( - (block.path as AST.PathExpression).original, - ); - endSource = - endSource.slice(0, pathIndex) + - (ast.path as AST.PathExpression).original + - endSource.slice( - pathIndex + (block.path as AST.PathExpression).original.length, - ); - - dirtyFields.delete('path'); - } - - if (dirtyFields.has('program')) { - const programDirtyFields = new Set( - this.dirtyFields.get(ast.program), - ); - - if (programDirtyFields.has('blockParams')) { - if (ast.program.blockParams.length === 0) { - nodeInfo.postHashWhitespace = ''; - blockParamsSource = ''; - } else { - nodeInfo.postHashWhitespace = - nodeInfo.postHashWhitespace || ' '; - blockParamsSource = `as |${ast.program.blockParams.join(' ')}|`; - } - programDirtyFields.delete('blockParams'); - } - - if (programDirtyFields.has('body')) { - programSource = ast.program.body - .map((child) => this.print(child)) - .join(''); - - programDirtyFields.delete('body'); - } - - if (programDirtyFields.size > 0) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Unhandled mutations for ${ast.program.type}: ${Array.from(programDirtyFields)}`, - ); - } - - dirtyFields.delete('program'); - } - - if (dirtyFields.has('inverse')) { - if (!ast.inverse) { - inverseSource = ''; - inversePreamble = ''; - } else { - if (ast.inverse.chained) { - inversePreamble = ''; - const inverseBody = ast.inverse.body[0]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - (inverseBody as any).wasChained = true; - inverseSource = this.print(inverseBody); - } else { - inverseSource = ast.inverse.body - .map((child) => this.print(child)) - .join(''); - } - - if (!block.inverse) { - // TODO: detect {{else}} vs {{else if foo}} - inversePreamble = '{{else}}'; - } - } - - dirtyFields.delete('inverse'); - } - - output.push( - openSource, - nodeInfo.postPathWhitespace, - nodeInfo.paramsSource, - nodeInfo.postParamsWhitespace, - nodeInfo.hashSource, - nodeInfo.postHashWhitespace, - blockParamsSource, - postBlockParamsWhitespace, - openEndSource, - programSource, - inversePreamble, - inverseSource, - endSource, - ); - } - break; - case 'HashPair': - { - const hashPair = original as AST.HashPair; - const { source } = nodeInfo; - const hashPairPartsResult = source.match(hashPairParts); - if (hashPairPartsResult === null) { - throw new Error('Could not match hash pair parts'); - } - // eslint-disable-next-line prefer-const - let [, keySource, postKeyWhitespace, postEqualsWhitespace] = - hashPairPartsResult; - let valueSource = this.sourceForLoc(hashPair.value.loc); - - if (dirtyFields.has('key')) { - keySource = ast.key; - - dirtyFields.delete('key'); - } - - if (dirtyFields.has('value')) { - valueSource = this.print(ast.value); - - dirtyFields.delete('value'); - } - - output.push( - keySource, - postKeyWhitespace, - '=', - postEqualsWhitespace, - valueSource, - ); - } - break; - case 'AttrNode': - { - const attrNode = original as AST.AttrNode; - const { source } = nodeInfo; - const attrNodePartsResults = source.match(attrNodeParts); - if (attrNodePartsResults === null) { - throw new Error(`Could not match attr node parts for ${source}`); - } - - let [ - , - nameSource, - // eslint-disable-next-line prefer-const - postNameWhitespace, - equals, - // eslint-disable-next-line prefer-const - postEqualsWhitespace, - quote, - ] = attrNodePartsResults; - let valueSource = this.sourceForLoc(attrNode.value.loc); - // Source of ConcatStatements includes their quotes, - // but source of an AttrNode's TextNode value does not. - // Normalize on not including them, then always printing them ourselves: - if (attrNode.value.type === 'ConcatStatement') { - valueSource = valueSource.slice(1, -1); - } - - const node = ast as AnnotatedAttrNode; - - if (dirtyFields.has('name')) { - nameSource = node.name; - dirtyFields.delete('name'); - } - - if (dirtyFields.has('quoteType')) { - // Ensure the quote type they've specified is valid for the value - if (node.value.type === 'MustacheStatement' && node.quoteType) { - throw new Error( - 'Mustache statements should not be quoted as attribute values', - ); - } else if ( - node.value.type === 'ConcatStatement' && - !node.quoteType - ) { - throw new Error( - 'ConcatStatements must be quoted as attribute values', - ); - } else if ( - node.value.type == 'TextNode' && - !node.quoteType && - node.value.chars.match(invalidUnquotedAttrValue) - ) { - throw new Error( - `\`${node.value.chars}\` is invalid as an unquoted attribute value. Alphanumeric, hyphens, and periods only`, - ); - } - quote = node.quoteType || ''; // null => empty string - } else if (dirtyFields.has('value')) { - // They updated the value without choosing a quote type. We'll use the previous quote - // type or default to double quote if necessary - if (node.value.type === 'MustacheStatement') { - quote = ''; - } else if ( - node.value.type === 'TextNode' && - node.quoteType === null && - !node.value.chars.match(invalidUnquotedAttrValue) - ) { - // If old value was unquoted, and new value is also ok as unquoted, preserve that. - quote = ''; - } else { - quote = quote || '"'; - } - } - dirtyFields.delete('quoteType'); - - if (dirtyFields.has('isValueless')) { - if (node.isValueless) { - equals = ''; - quote = ''; - valueSource = ''; - dirtyFields.delete('isValueless'); - dirtyFields.delete('value'); - } else { - equals = '='; - if (node.value.type !== 'MustacheStatement' && !quote) { - quote = '"'; - } - dirtyFields.delete('isValueless'); - } - } - - if (dirtyFields.has('value')) { - equals = '='; - // If they created a ConcatStatement node, we need to print it ourselves here. - // Otherwise, since it has no nodeInfo, it will print using the glimmer printer - // which hardcodes double quotes. - if (node.value.type === 'ConcatStatement') { - valueSource = node.value.parts - .map((part) => this.print(part)) - .join(''); - } else { - valueSource = this.print(node.value); - } - } - dirtyFields.delete('value'); - - output.push( - nameSource, - postNameWhitespace, - equals, - postEqualsWhitespace, - quote, - valueSource, - quote, - ); - } - break; - case 'PathExpression': - { - let { source } = nodeInfo; - - if (dirtyFields.has('original')) { - source = ast.original; - dirtyFields.delete('original'); - } - - output.push(source); - } - break; - case 'MustacheCommentStatement': - case 'CommentStatement': - { - const commentStatement = original as AST.CommentStatement; - const indexOfValue = nodeInfo.source.indexOf(commentStatement.value); - const openSource = nodeInfo.source.substring(0, indexOfValue); - let valueSource = commentStatement.value; - const endSource = nodeInfo.source.substring( - indexOfValue + valueSource.length, - ); - - if (dirtyFields.has('value')) { - valueSource = ast.value; - - dirtyFields.delete('value'); - } - - output.push(openSource, valueSource, endSource); - } - break; - case 'TextNode': - { - let { source } = nodeInfo; - - if (dirtyFields.has('chars')) { - source = ast.chars; - dirtyFields.delete('chars'); - } - - output.push(source); - } - break; - case 'StringLiteral': - { - const node = ast as AnnotatedStringLiteral; - output.push(node.quoteType, node.value, node.quoteType); - } - break; - case 'BooleanLiteral': - case 'NumberLiteral': - case 'NullLiteral': - { - let { source } = nodeInfo; - - if (dirtyFields.has('value')) { - source = ast.value?.toString() || ''; - dirtyFields.delete('value'); - } - - output.push(source); - } - break; - default: - throw new Error( - `ember-template-recast does not have the ability to update ${original.type}. Please open an issue so we can add support.`, - ); - } - - for (const field of dirtyFields.values()) { - if (field in Object.keys(original)) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `ember-template-recast could not handle the mutations of \`${Array.from( - dirtyFields, - )}\` on ${original.type}`, - ); - } - } - - return output.join(''); - } - - // User-created nodes will have no nodeInfo, but we support - // formatting properties that the glimmer printer does not. - // If the user-created node specifies no custom formatting, - // just use the glimmer printer. - // These overrides could go away if glimmer had a concrete - // syntax tree type and printer. - printUserSuppliedNode(_ast: AST.Node): string { - switch (_ast.type) { - case 'StringLiteral': - { - const quote = (_ast as AnnotatedStringLiteral).quoteType || '"'; - return quote + _ast.value + quote; - } - // @ts-expect-error: Incorrect type - break; - case 'AttrNode': - { - const node = _ast as AnnotatedAttrNode; - if (node.isValueless) { - if (node.value.type !== 'TextNode' || node.value.chars !== '') { - throw new Error( - 'The value property of a valueless attr must be an empty TextNode', - ); - } - return node.name; - } - if ( - node.isValueless === undefined && - node.value.type === 'TextNode' && - node.value.chars === '' - ) { - return node.name; - } - switch (node.value.type) { - case 'MustacheStatement': - return node.name + '=' + this.print(node.value); - // @ts-expect-error: Incorrect type - break; - case 'ConcatStatement': - { - const value = node.value.parts - .map((part) => this.print(part)) - .join(''); - const quote = node.quoteType || '"'; - return node.name + '=' + quote + value + quote; - } - // @ts-expect-error: Incorrect type - break; - case 'TextNode': - { - if ( - node.quoteType === null && - node.value.chars.match(invalidUnquotedAttrValue) - ) { - throw new Error( - `You specified a quoteless attribute \`${node.value.chars}\`, which is invalid without quotes`, - ); - } - let quote: string; - if (node.quoteType === null) { - quote = ''; - } else { - quote = node.quoteType || '"'; - } - return node.name + '=' + quote + node.value.chars + quote; - } - // @ts-expect-error: Incorrect type - break; - } - } - // @ts-expect-error: Incorrect type - break; - default: - return _print(_ast, { - entityEncoding: 'raw', - // @ts-expect-error: Incorrect type - override: (ast) => { - if (this.nodeInfo.has(ast) || useCustomPrinter(ast)) { - return this.print(ast); - } - }, - }); - } - } - - /* - Used to associate the original source with a given node (while wrapping AST nodes - in a proxy). - */ - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any - private sourceForLoc(loc: any) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return sourceForLoc(this.source, loc); - } - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any - private wrapNode(ancestor: any, node: any) { - this.ancestor.set(node, ancestor); - - const nodeInfo = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - node, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - original: JSON.parse(JSON.stringify(node)), - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - source: this.sourceForLoc(node.loc), - parse_result: this, - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.nodeInfo.set(node, nodeInfo); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const hasLocInfo = !!node.loc; - const propertyProxyMap = new Map(); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const proxy = new Proxy(node, { - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - get: (target, property) => { - if (propertyProxyMap.has(property)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return propertyProxyMap.get(property); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Reflect.get(target, property); - }, - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - set: (target, property, value) => { - if (propertyProxyMap.has(property)) { - propertyProxyMap.set(property, value); - } - - Reflect.set(target, property, value); - - if (hasLocInfo) { - this.markAsDirty(node, property); - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.markAsDirty(ancestor.node, ancestor.key); - } - - return true; - }, - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - deleteProperty: (target, property) => { - if (propertyProxyMap.has(property)) { - propertyProxyMap.delete(property); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const result = Reflect.deleteProperty(target, property); - - if (hasLocInfo) { - this.markAsDirty(node, property); - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.markAsDirty(ancestor.node, ancestor.key); - } - - return result; - }, - }); - - // this is needed in order to handle splicing of Template.body (which - // happens when during replacement) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.nodeInfo.set(proxy, nodeInfo); - - for (const key in node) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const value = node[key]; - - if (key !== 'loc' && typeof value === 'object' && value !== null) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const propertyProxy = this.wrapNode({ node, key }, value); - - propertyProxyMap.set(key, propertyProxy); - } - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return proxy; - } -} diff --git a/packages/ast/template/src/-private/glimmer-syntax/utils.ts b/packages/ast/template/src/-private/glimmer-syntax/utils.ts deleted file mode 100644 index 0b1d88e5..00000000 --- a/packages/ast/template/src/-private/glimmer-syntax/utils.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { AST } from '@glimmer/syntax'; - -export function getLines(source: string): string[] { - const result = source.match(/(.*?(?:\r\n?|\n|$))/gm); - - if (!result) { - throw new Error('could not parse source'); - } - - return result.slice(0, -1); -} - -function isSyntheticWithNoLocation(node: AST.Node): boolean { - if (node && node.loc) { - const { start, end } = node.loc; - - return ( - node.loc.module === '(synthetic)' && - start.column === end.column && - start.line === end.line - ); - } - - return false; -} - -export function sortByLoc(a: AST.Node, b: AST.Node): -1 | 0 | 1 { - // be conservative about the location where a new node is inserted - if (isSyntheticWithNoLocation(a) || isSyntheticWithNoLocation(b)) { - return 0; - } - - if (a.loc.start.line < b.loc.start.line) { - return -1; - } - - if ( - a.loc.start.line === b.loc.start.line && - a.loc.start.column < b.loc.start.column - ) { - return -1; - } - - if ( - a.loc.start.line === b.loc.start.line && - a.loc.start.column === b.loc.start.column - ) { - return 0; - } - - return 1; -} - -export function sourceForLoc( - source: string | string[], - loc?: AST.SourceLocation, -): string { - if (!loc) { - return ''; - } - - const sourceLines = Array.isArray(source) ? source : getLines(source); - - const firstLine = loc.start.line - 1; - const lastLine = loc.end.line - 1; - const firstColumn = loc.start.column; - const lastColumn = loc.end.column; - - const string = []; - let currentLine = firstLine - 1; - let line; - - while (currentLine < lastLine) { - currentLine++; - // for templates that are completely empty the outer Template loc is line - // 0, column 0 for both start and end defaulting to empty string prevents - // more complicated logic below - line = sourceLines[currentLine] || ''; - - if (currentLine === firstLine) { - if (firstLine === lastLine) { - string.push(line.slice(firstColumn, lastColumn)); - } else { - string.push(line.slice(firstColumn)); - } - } else if (currentLine === lastLine) { - string.push(line.slice(0, lastColumn)); - } else { - string.push(line); - } - } - - return string.join(''); -} diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts deleted file mode 100644 index 0032d306..00000000 --- a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { assert, test } from '@codemod-utils/tests'; -import { builders } from '@glimmer/syntax'; - -import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; - -test('-private | glimmer-syntax | utils | sortByLoc > sorts nodes by their line numbers', function () { - const a = builders.pair( - 'a', - builders.path('foo'), - builders.loc(1, 1, 1, 5, 'foo.hbs'), - ); - - const b = builders.pair( - 'b', - builders.path('foo'), - builders.loc(3, 1, 1, 5, 'foo.hbs'), - ); - - const c = builders.pair( - 'c', - builders.path('foo'), - builders.loc(2, 1, 1, 5, 'foo.hbs'), - ); - - const nodes = [a, b, c].sort(sortByLoc); - - assert.deepStrictEqual( - nodes.map((node) => node.key), - ['a', 'c', 'b'], - ); -}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts deleted file mode 100644 index 630050c0..00000000 --- a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { assert, test } from '@codemod-utils/tests'; -import { builders } from '@glimmer/syntax'; - -import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; - -test('-private | glimmer-syntax | utils | sortByLoc > sorts synthetic nodes last', function () { - const a = builders.pair('a', builders.path('foo') /* no loc, "synthetic" */); - - const b = builders.pair( - 'b', - builders.path('foo'), - builders.loc(1, 1, 1, 5, 'foo.hbs'), - ); - - const nodes = [a, b].sort(sortByLoc); - - assert.deepStrictEqual( - nodes.map((node) => node.key), - ['a', 'b'], - ); -}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts deleted file mode 100644 index 5e9a9e78..00000000 --- a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { assert, test } from '@codemod-utils/tests'; -import { builders } from '@glimmer/syntax'; - -import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; - -test('-private | glimmer-syntax | utils | sortByLoc > when start line matches, sorts by starting column', function () { - const a = builders.pair( - 'a', - builders.path('foo'), - builders.loc(1, 1, 1, 5, 'foo.hbs'), - ); - - const b = builders.pair( - 'b', - builders.path('foo'), - builders.loc(2, 1, 1, 5, 'foo.hbs'), - ); - - const c = builders.pair( - 'c', - builders.path('foo'), - builders.loc(1, 6, 1, 9, 'foo.hbs'), - ); - - const nodes = [a, b, c].sort(sortByLoc); - - assert.deepStrictEqual( - nodes.map((node) => node.key), - ['a', 'c', 'b'], - ); -}); From 0412d7fe261ae0f3dc5b5366a44dc64e1ffc7d2b Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 09:26:16 +0200 Subject: [PATCH 10/11] Revert "breaking: Consumed @glimmer/syntax directly" This reverts commit d9abca95be52b2015de88ddd52415ea01e702609. --- .../template/src/-private/glimmer-syntax.ts | 23 +- .../src/-private/glimmer-syntax/parser.ts | 1508 +++++++++++++++++ .../src/-private/glimmer-syntax/utils.ts | 94 + .../sorts-nodes-by-their-line-numbers.test.ts | 31 + .../sorts-synthetic-nodes-last.test.ts | 21 + ...e-matches-sorts-by-starting-column.test.ts | 31 + 6 files changed, 1699 insertions(+), 9 deletions(-) create mode 100644 packages/ast/template/src/-private/glimmer-syntax/parser.ts create mode 100644 packages/ast/template/src/-private/glimmer-syntax/utils.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts create mode 100644 packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts diff --git a/packages/ast/template/src/-private/glimmer-syntax.ts b/packages/ast/template/src/-private/glimmer-syntax.ts index 4087a0b6..03dc7ede 100644 --- a/packages/ast/template/src/-private/glimmer-syntax.ts +++ b/packages/ast/template/src/-private/glimmer-syntax.ts @@ -2,30 +2,35 @@ import { type AST, builders, type NodeVisitor, - preprocess, print as upstreamPrint, traverse as upstreamTraverse, } from '@glimmer/syntax'; -export function parse(file: string): AST.Template { - const ast = preprocess(file, { - mode: 'codemod', - }); +import { type NodeInfo, Parser } from './glimmer-syntax/parser.js'; + +const NODE_INFO = new WeakMap(); - return ast; +export function parse(template: string): AST.Template { + return new Parser(template, NODE_INFO).ast; } export function print(ast: AST.Node): string { return upstreamPrint(ast, { entityEncoding: 'raw', + // @ts-expect-error: Incorrect type + override: (ast) => { + const info = NODE_INFO.get(ast); + + if (info) { + return info.parse_result.print(ast); + } + }, }); } export function traverse() { return function (file: string, visitMethods: NodeVisitor = {}): AST.Template { - const ast = preprocess(file, { - mode: 'codemod', - }); + const { ast } = new Parser(file, NODE_INFO); upstreamTraverse(ast, visitMethods); diff --git a/packages/ast/template/src/-private/glimmer-syntax/parser.ts b/packages/ast/template/src/-private/glimmer-syntax/parser.ts new file mode 100644 index 00000000..bc4b0d4c --- /dev/null +++ b/packages/ast/template/src/-private/glimmer-syntax/parser.ts @@ -0,0 +1,1508 @@ +import { + type AST, + builders, + preprocess, + print as _print, + traverse, +} from '@glimmer/syntax'; + +import { getLines, sortByLoc, sourceForLoc } from './utils.js'; + +type QuoteType = '"' | "'"; + +interface AnnotatedAttrNode extends AST.AttrNode { + /** + * Supports cases like `` or `
` + */ + isValueless?: boolean; + + /** + * TextNode values can use single, double, or no quotes + * `type=input` vs `type='input'` vs `type="input"` + * ConcatStatement values can use single or double quotes + * `class='thing {{get this classNames}}'` vs `class="thing {{get this classNames}}"` + * MustacheStatements never use quotes + */ + quoteType?: QuoteType | null; +} + +interface AnnotatedStringLiteral extends AST.StringLiteral { + quoteType?: QuoteType; +} + +/** + * The glimmer printer doesn't have any formatting suppport. It always uses + * double quotes, and won't print attrs without a value. To choose quote types + * or omit the value, we have to do it ourselves. + */ +function useCustomPrinter(node: AST.BaseNode): boolean { + switch (node.type) { + case 'AttrNode': { + const n = node as AnnotatedAttrNode; + return Boolean(n.isValueless) || n.quoteType !== undefined; + } + + case 'StringLiteral': { + return Boolean((node as AnnotatedStringLiteral).quoteType); + } + + default: { + return false; + } + } +} + +const leadingWhitespace = /(^\s+)/; +const attrNodeParts = /(^[^=]+)(\s+)?(=)?(\s+)?(['"])?(\S+)?/; +const hashPairParts = /(^[^=]+)(\s+)?=(\s+)?(\S+)/; +const invalidUnquotedAttrValue = /[^-.a-zA-Z0-9]/; + +const voidTagNames = new Set([ + 'area', + 'base', + 'br', + 'col', + 'command', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]); + +/* + This is needed to address issues in the glimmer-vm AST _before_ any of the nodes and node + values are cached. The specific issues being worked around are: + + * https://github.com/glimmerjs/glimmer-vm/pull/953 + * https://github.com/glimmerjs/glimmer-vm/pull/954 +*/ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any +function fixASTIssues(sourceLines: any, ast: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + traverse(ast, { + AttrNode(attr: AST.AttrNode) { + const node = attr as AnnotatedAttrNode; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const source = sourceForLoc(sourceLines, node.loc); + const attrNodePartsResults = source.match(attrNodeParts); + if (attrNodePartsResults === null) { + throw new Error(`Could not match attr node parts for ${source}`); + } + const [, , , equals, , quote] = attrNodePartsResults; + const isValueless = !equals; + + // TODO: manually working around https://github.com/glimmerjs/glimmer-vm/pull/953 + if ( + isValueless && + node.value.type === 'TextNode' && + node.value.chars === '' + ) { + // \n is not valid within an attribute name (it would indicate two attributes) + // always assume the attribute ends on the starting line + const { + start: { line, column }, + } = node.loc; + node.loc = builders.loc(line, column, line, column + node.name.length); + } + + node.isValueless = isValueless; + node.quoteType = (quote as QuoteType) || null; + }, + + StringLiteral(lit) { + const quotes = /^['"]/; + const node = lit as AnnotatedStringLiteral; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const source = sourceForLoc(sourceLines, node.loc); + if (!source.match(quotes)) { + throw new Error('Invalid string literal found'); + } + node.quoteType = source[0] as QuoteType; + }, + + TextNode(node, path) { + if (path.parentNode === null) { + throw new Error( + 'ember-template-recast: Error while sanitizing input AST: found TextNode with no parentNode', + ); + } + + switch (path.parentNode.type) { + case 'AttrNode': { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const source = sourceForLoc(sourceLines, node.loc); + if ( + node.chars.length > 0 && + ((source.startsWith(`'`) && source.endsWith(`'`)) || + (source.startsWith(`"`) && source.endsWith(`"`))) + ) { + const { start, end } = node.loc; + node.loc = builders.loc( + start.line, + start.column + 1, + end.line, + end.column - 1, + ); + } + break; + } + case 'ConcatStatement': { + const parent = path.parentNode; + const isFirstPart = parent.parts.indexOf(node) === 0; + + const { start, end } = node.loc; + if ( + isFirstPart && + node.loc.start.column > path.parentNode.loc.start.column + 1 + ) { + // TODO: manually working around https://github.com/glimmerjs/glimmer-vm/pull/954 + node.loc = builders.loc( + start.line, + start.column - 1, + end.line, + end.column, + ); + } else if (isFirstPart && node.chars.charAt(0) === '\n') { + node.loc = builders.loc( + start.line, + start.column + 1, + end.line, + end.column, + ); + } + } + } + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return ast; +} + +export interface NodeInfo { + hadHash?: boolean; + hadParams?: boolean; + hashSource?: string; + node: AST.Node; + original: AST.Node; + paramsSource?: string; + parse_result: Parser; + postHashWhitespace?: string; + postParamsWhitespace?: string; + postPathWhitespace?: string; + source: string; +} + +export class Parser { + private _originalAst: AST.Template; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private ancestor = new Map(); + public ast: AST.Template; + private dirtyFields = new Map>(); + private nodeInfo: WeakMap; + private source: string[]; + + constructor( + template: string, + nodeInfo: WeakMap = new WeakMap(), + ) { + let ast = preprocess(template, { + mode: 'codemod', + }); + + const source = getLines(template); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + ast = fixASTIssues(source, ast); + this.source = source; + this._originalAst = ast; + + this.nodeInfo = nodeInfo; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.ast = this.wrapNode(null, ast); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + private _rebuildParamsHash( + ast: + | AST.MustacheStatement + | AST.SubExpression + | AST.ElementModifierStatement + | AST.BlockStatement, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nodeInfo: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dirtyFields: any, + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { original } = nodeInfo; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (dirtyFields.has('hash')) { + if (ast.hash.pairs.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.hashSource = ''; + + if (ast.params.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = ''; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postParamsWhitespace = ''; + } + } else { + let joinWith; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (original.hash.pairs.length > 1) { + joinWith = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.hash.pairs[0].loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.hash.pairs[1].loc.start, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadParams) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postPathWhitespace; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadHash) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postParamsWhitespace; + } else { + joinWith = ' '; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (joinWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinWith = ' '; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.hashSource = ast.hash.pairs + .map((pair: AST.HashPair) => { + return this.print(pair); + }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + .join(joinWith); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (!nodeInfo.hadHash) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + nodeInfo.postParamsWhitespace = joinWith; + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + dirtyFields.delete('hash'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (dirtyFields.has('params')) { + let joinWith; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (original.params.length > 1) { + joinWith = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.params[0].loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.params[1].loc.start, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadParams) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postPathWhitespace; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (nodeInfo.hadHash) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + joinWith = nodeInfo.postParamsWhitespace; + } else { + joinWith = ' '; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + if (joinWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinWith = ' '; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.paramsSource = ast.params + .map((param) => this.print(param)) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + .join(joinWith); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (nodeInfo.hadParams && ast.params.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = ''; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } else if (!nodeInfo.hadParams && ast.params.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = joinWith; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + dirtyFields.delete('params'); + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private _updateNodeInfoForParamsHash(_ast: any, nodeInfo: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { original } = nodeInfo; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hadParams = (nodeInfo.hadParams = original.params.length > 0); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hadHash = (nodeInfo.hadHash = original.hash.pairs.length > 0); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postPathWhitespace = hadParams + ? this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.path.loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.params[0].loc.start, + }) + : ''; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.paramsSource = hadParams + ? this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + start: original.params[0].loc.start, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.params[original.params.length - 1].loc.end, + }) + : ''; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postParamsWhitespace = hadHash + ? this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + start: hadParams + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.params[original.params.length - 1].loc.end + : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.path.loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.hash.loc.start, + }) + : ''; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.hashSource = hadHash ? this.sourceForLoc(original.hash.loc) : ''; + + const postHashSource = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + start: hadHash + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.hash.loc.end + : hadParams + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.params[original.params.length - 1].loc.end + : // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + original.path.loc.end, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: original.loc.end, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postHashWhitespace = ''; + const postHashWhitespaceMatch = postHashSource.match(leadingWhitespace); + if (postHashWhitespaceMatch) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nodeInfo.postHashWhitespace = postHashWhitespaceMatch[0]; + } + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private markAsDirty(node: any, property: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + let dirtyFields = this.dirtyFields.get(node); + if (dirtyFields === undefined) { + dirtyFields = new Set(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.dirtyFields.set(node, dirtyFields); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + dirtyFields.add(property); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const ancestor = this.ancestor.get(node); + if (ancestor !== null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.markAsDirty(ancestor.node, ancestor.key); + } + } + + print(_ast: AST.Node = this._originalAst): string { + if (!_ast) { + return ''; + } + + const nodeInfo = this.nodeInfo.get(_ast); + + if (nodeInfo === undefined) { + return this.printUserSuppliedNode(_ast); + } + + // this ensures that we are operating on the actual node and not a + // proxy (we can get Proxies here when transforms splice body/children) + const ast = nodeInfo.node; + + // make a copy of the dirtyFields, so we can easily track + // unhandled dirtied fields + const dirtyFields = new Set(this.dirtyFields.get(ast)); + if (dirtyFields.size === 0 && nodeInfo !== undefined) { + return nodeInfo.source; + } + + // TODO: splice the original source **excluding** "children" + // based on dirtyFields + const output = []; + + const { original } = nodeInfo; + + switch (ast.type) { + // @ts-expect-error: Incorrect type + case 'Program': + case 'Block': + case 'Template': + { + let bodySource = nodeInfo.source; + + if (dirtyFields.has('body')) { + bodySource = ast.body.map((node) => this.print(node)).join(''); + + dirtyFields.delete('body'); + } + + output.push(bodySource); + } + break; + case 'ElementNode': + { + const element = original as AST.ElementNode; + const { selfClosing, children } = element; + const hadChildren = children.length > 0; + const hadBlockParams = element.blockParams.length > 0; + + let openSource = `<${element.tag}`; + + const originalOpenParts = [ + ...element.attributes, + ...element.modifiers, + ...element.comments, + ].sort(sortByLoc); + + let postTagWhitespace; + if (originalOpenParts.length > 0) { + postTagWhitespace = this.sourceForLoc({ + start: { + line: element.loc.start.line, + column: + element.loc.start.column + 1 /* < */ + element.tag.length, + }, + // @ts-expect-error: Incorrect type + end: originalOpenParts[0].loc.start, + }); + } else if (selfClosing) { + postTagWhitespace = nodeInfo.source.substring( + openSource.length, + nodeInfo.source.length - 2, + ); + } else { + postTagWhitespace = ''; + } + + let openPartsSource = originalOpenParts.reduce( + (acc, part, index, parts) => { + const partSource = this.sourceForLoc(part.loc); + + if (index === parts.length - 1) { + return acc + partSource; + } + + let joinPartWith = this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: parts[index].loc.end, + // @ts-expect-error: Incorrect type + end: parts[index + 1].loc.start, + }); + + if (joinPartWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinPartWith = ' '; + } + + return acc + partSource + joinPartWith; + }, + '', + ); + + let postPartsWhitespace = ''; + if (originalOpenParts.length > 0) { + const postPartsSource = this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: originalOpenParts[originalOpenParts.length - 1].loc.end, + end: hadChildren + ? // @ts-expect-error: Incorrect type + element.children[0].loc.start + : element.loc.end, + }); + + const matchedWhitespace = postPartsSource.match(leadingWhitespace); + if (matchedWhitespace) { + postPartsWhitespace = matchedWhitespace[0]; + } + } else if (hadBlockParams) { + const postPartsSource = this.sourceForLoc({ + start: { + line: element.loc.start.line, + column: element.loc.start.column + 1 + element.tag.length, + }, + end: hadChildren + ? // @ts-expect-error: Incorrect type + element.children[0].loc.start + : element.loc.end, + }); + + const matchedWhitespace = postPartsSource.match(leadingWhitespace); + if (matchedWhitespace) { + postPartsWhitespace = matchedWhitespace[0]; + } + } + + let blockParamsSource = ''; + let postBlockParamsWhitespace = ''; + if (element.blockParams.length > 0) { + const blockParamStartIndex = nodeInfo.source.indexOf('as |'); + const blockParamsEndIndex = nodeInfo.source.indexOf( + '|', + blockParamStartIndex + 4, + ); + blockParamsSource = nodeInfo.source.substring( + blockParamStartIndex, + blockParamsEndIndex + 1, + ); + + // Match closing index after start of block params to avoid closing tag if /> or > encountered in string + const closeOpenIndex = + nodeInfo.source + .substring(blockParamStartIndex) + .indexOf(selfClosing ? '/>' : '>') + blockParamStartIndex; + postBlockParamsWhitespace = nodeInfo.source.substring( + blockParamsEndIndex + 1, + closeOpenIndex, + ); + } + + let closeOpen = selfClosing ? `/>` : `>`; + + let childrenSource = hadChildren + ? this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: element.children[0].loc.start, + // @ts-expect-error: Incorrect type + end: element.children[children.length - 1].loc.end, + }) + : ''; + + let closeSource = selfClosing + ? '' + : voidTagNames.has(element.tag) + ? '' + : ``; + + if (dirtyFields.has('tag')) { + openSource = `<${ast.tag}`; + + closeSource = selfClosing + ? '' + : voidTagNames.has(ast.tag) + ? '' + : ``; + + dirtyFields.delete('tag'); + } + + if (dirtyFields.has('children')) { + childrenSource = ast.children + .map((child) => this.print(child)) + .join(''); + + if (selfClosing) { + closeOpen = `>`; + closeSource = ``; + ast.selfClosing = false; + + if (originalOpenParts.length === 0 && postTagWhitespace === ' ') { + postTagWhitespace = ''; + } + + if (originalOpenParts.length > 0 && postPartsWhitespace === ' ') { + postPartsWhitespace = ''; + } + } + + dirtyFields.delete('children'); + } + + if ( + dirtyFields.has('attributes') || + dirtyFields.has('comments') || + dirtyFields.has('modifiers') + ) { + const openParts = [ + ...ast.attributes, + ...ast.modifiers, + ...ast.comments, + ].sort(sortByLoc); + + openPartsSource = openParts.reduce((acc, part, index, parts) => { + const partSource = this.print(part); + + if (index === parts.length - 1) { + return acc + partSource; + } + + let joinPartWith = this.sourceForLoc({ + // @ts-expect-error: Incorrect type + start: parts[index].loc.end, + // @ts-expect-error: Incorrect type + end: parts[index + 1].loc.start, + }); + + if (joinPartWith === '' || joinPartWith.trim() !== '') { + // if the autodetection above resulted in some non whitespace + // values, reset to `' '` + joinPartWith = ' '; + } + + return acc + partSource + joinPartWith; + }, ''); + + if (originalOpenParts.length === 0) { + postTagWhitespace = ' '; + } + + if (openParts.length === 0 && originalOpenParts.length > 0) { + postTagWhitespace = ''; + } + + if (openParts.length > 0 && ast.selfClosing) { + postPartsWhitespace = postPartsWhitespace || ' '; + } + + dirtyFields.delete('attributes'); + dirtyFields.delete('comments'); + dirtyFields.delete('modifiers'); + } + + if (dirtyFields.has('blockParams')) { + if (ast.blockParams.length === 0) { + blockParamsSource = ''; + postPartsWhitespace = ''; + } else { + blockParamsSource = `as |${ast.blockParams.join(' ')}|`; + + // ensure we have at least a space + postPartsWhitespace = postPartsWhitespace || ' '; + } + + dirtyFields.delete('blockParams'); + } + + output.push( + openSource, + postTagWhitespace, + openPartsSource, + postPartsWhitespace, + blockParamsSource, + postBlockParamsWhitespace, + closeOpen, + childrenSource, + closeSource, + ); + } + break; + case 'MustacheStatement': + case 'ElementModifierStatement': + case 'SubExpression': + { + this._updateNodeInfoForParamsHash(ast, nodeInfo); + + let openSource = this.sourceForLoc({ + start: original.loc.start, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: (original as any).path.loc.end, + }); + + let endSource = this.sourceForLoc({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + start: nodeInfo.hadHash + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (original as any).hash.loc.end + : nodeInfo.hadParams + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (original as any).params[(original as any).params.length - 1] + .loc.end + : // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (original as any).path.loc.end, + end: original.loc.end, + }).trimLeft(); + + if (dirtyFields.has('path')) { + openSource = + this.sourceForLoc({ + start: original.loc.start, + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + end: (original as any).path.loc.start, + }) + this.print(ast.path); + + dirtyFields.delete('path'); + } + + if (dirtyFields.has('type')) { + // we only support going from SubExpression -> MustacheStatement + if ( + original.type !== 'SubExpression' || + ast.type !== 'MustacheStatement' + ) { + throw new Error( + `ember-template-recast only supports updating the 'type' for SubExpression to MustacheStatement (you attempted to change ${original.type} to ${ast.type})`, + ); + } + + // TODO: this is a logic error, assumes ast.path is a PathExpression but it could be a number of other things + openSource = `{{${(ast.path as AST.PathExpression).original}`; + endSource = '}}'; + + dirtyFields.delete('type'); + } + + this._rebuildParamsHash(ast, nodeInfo, dirtyFields); + + output.push( + openSource, + nodeInfo.postPathWhitespace, + nodeInfo.paramsSource, + nodeInfo.postParamsWhitespace, + nodeInfo.hashSource, + nodeInfo.postHashWhitespace, + endSource, + ); + } + break; + case 'ConcatStatement': + { + let partsSource = this.sourceForLoc({ + start: { + line: original.loc.start.line, + column: original.loc.start.column + 1, + }, + + end: { + line: original.loc.end.line, + column: original.loc.end.column - 1, + }, + }); + + if (dirtyFields.has('parts')) { + partsSource = ast.parts.map((part) => this.print(part)).join(''); + + dirtyFields.delete('parts'); + } + + output.push(partsSource); + } + break; + case 'BlockStatement': + { + const block = original as AST.BlockStatement; + + this._updateNodeInfoForParamsHash(ast, nodeInfo); + + const hadProgram = block.program.body.length > 0; + const hadProgramBlockParams = block.program.blockParams.length > 0; + + let openSource = this.sourceForLoc({ + start: block.loc.start, + end: block.path.loc.end, + }); + + let blockParamsSource = ''; + let postBlockParamsWhitespace = ''; + if (hadProgramBlockParams) { + const blockParamsSourceScratch = this.sourceForLoc({ + start: nodeInfo.hadHash + ? block.hash.loc.end + : nodeInfo.hadParams + ? // @ts-expect-error: Incorrect type + block.params[block.params.length - 1].loc.end + : block.path.loc.end, + end: original.loc.end, + }); + + const indexOfAsPipe = blockParamsSourceScratch.indexOf('as |'); + const indexOfEndPipe = blockParamsSourceScratch.indexOf( + '|', + indexOfAsPipe + 4, + ); + + blockParamsSource = blockParamsSourceScratch.substring( + indexOfAsPipe, + indexOfEndPipe + 1, + ); + + const postBlockParamsWhitespaceMatch = blockParamsSourceScratch + .substring(indexOfEndPipe + 1) + .match(leadingWhitespace); + if (postBlockParamsWhitespaceMatch) { + postBlockParamsWhitespace = postBlockParamsWhitespaceMatch[0]; + } + } + + let openEndSource; + { + const openEndSourceScratch = this.sourceForLoc({ + start: nodeInfo.hadHash + ? block.hash.loc.end + : nodeInfo.hadParams + ? // @ts-expect-error: Incorrect type + block.params[block.params.length - 1].loc.end + : block.path.loc.end, + end: block.loc.end, + }); + + let startingOffset = 0; + if (hadProgramBlockParams) { + const indexOfAsPipe = openEndSourceScratch.indexOf('as |'); + const indexOfEndPipe = openEndSourceScratch.indexOf( + '|', + indexOfAsPipe + 4, + ); + + startingOffset = indexOfEndPipe + 1; + } + + const indexOfFirstCurly = openEndSourceScratch.indexOf('}'); + const indexOfSecondCurly = openEndSourceScratch.indexOf( + '}', + indexOfFirstCurly + 1, + ); + + openEndSource = openEndSourceScratch + .substring(startingOffset, indexOfSecondCurly + 1) + .trimLeft(); + } + + let programSource = hadProgram + ? this.sourceForLoc(block.program.loc) + : ''; + + let inversePreamble = ''; + if (block.inverse) { + if (hadProgram) { + inversePreamble = this.sourceForLoc({ + start: block.program.loc.end, + end: block.inverse.loc.start, + }); + } else { + const openEndSourceScratch = this.sourceForLoc({ + start: nodeInfo.hadHash + ? block.hash.loc.end + : nodeInfo.hadParams + ? // @ts-expect-error: Incorrect type + block.params[block.params.length - 1].loc.end + : block.path.loc.end, + end: block.loc.end, + }); + + const indexOfFirstCurly = openEndSourceScratch.indexOf('}'); + const indexOfSecondCurly = openEndSourceScratch.indexOf( + '}', + indexOfFirstCurly + 1, + ); + const indexOfThirdCurly = openEndSourceScratch.indexOf( + '}', + indexOfSecondCurly + 1, + ); + const indexOfFourthCurly = openEndSourceScratch.indexOf( + '}', + indexOfThirdCurly + 1, + ); + + inversePreamble = openEndSourceScratch.substring( + indexOfSecondCurly + 1, + indexOfFourthCurly + 1, + ); + } + } + + // GH #149 + // In the event we're dealing with a chain of if/else-if/else, the inverse + // should encompass the entirety of the chain. Sadly, the loc param of + // original.inverse in this case only captures the block of the first inverse + // not the entire chain. We instead look at the loc param of the nested body + // node, which does report the entire chain. + // In this case, because it also includes the preamble, we must also trim + // that from our final inverse source. + let inverseSource; + if (block.inverse && block.inverse.chained) { + // @ts-expect-error: Incorrect type + inverseSource = this.sourceForLoc(block.inverse.body[0].loc) || ''; + inverseSource = inverseSource.slice(inversePreamble.length); + } else { + inverseSource = block.inverse + ? this.sourceForLoc(block.inverse.loc) + : ''; + } + + let endSource = ''; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + if (!(ast as any).wasChained) { + const firstOpenCurlyFromEndIndex = nodeInfo.source.lastIndexOf('{'); + const secondOpenCurlyFromEndIndex = nodeInfo.source.lastIndexOf( + '{', + firstOpenCurlyFromEndIndex - 1, + ); + + endSource = nodeInfo.source.substring(secondOpenCurlyFromEndIndex); + } + + this._rebuildParamsHash(ast, nodeInfo, dirtyFields); + + if (dirtyFields.has('path')) { + openSource = + this.sourceForLoc({ + start: original.loc.start, + end: block.path.loc.start, + }) + _print(ast.path); + + // TODO: this is a logic error + const pathIndex = endSource.indexOf( + (block.path as AST.PathExpression).original, + ); + endSource = + endSource.slice(0, pathIndex) + + (ast.path as AST.PathExpression).original + + endSource.slice( + pathIndex + (block.path as AST.PathExpression).original.length, + ); + + dirtyFields.delete('path'); + } + + if (dirtyFields.has('program')) { + const programDirtyFields = new Set( + this.dirtyFields.get(ast.program), + ); + + if (programDirtyFields.has('blockParams')) { + if (ast.program.blockParams.length === 0) { + nodeInfo.postHashWhitespace = ''; + blockParamsSource = ''; + } else { + nodeInfo.postHashWhitespace = + nodeInfo.postHashWhitespace || ' '; + blockParamsSource = `as |${ast.program.blockParams.join(' ')}|`; + } + programDirtyFields.delete('blockParams'); + } + + if (programDirtyFields.has('body')) { + programSource = ast.program.body + .map((child) => this.print(child)) + .join(''); + + programDirtyFields.delete('body'); + } + + if (programDirtyFields.size > 0) { + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Unhandled mutations for ${ast.program.type}: ${Array.from(programDirtyFields)}`, + ); + } + + dirtyFields.delete('program'); + } + + if (dirtyFields.has('inverse')) { + if (!ast.inverse) { + inverseSource = ''; + inversePreamble = ''; + } else { + if (ast.inverse.chained) { + inversePreamble = ''; + const inverseBody = ast.inverse.body[0]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (inverseBody as any).wasChained = true; + inverseSource = this.print(inverseBody); + } else { + inverseSource = ast.inverse.body + .map((child) => this.print(child)) + .join(''); + } + + if (!block.inverse) { + // TODO: detect {{else}} vs {{else if foo}} + inversePreamble = '{{else}}'; + } + } + + dirtyFields.delete('inverse'); + } + + output.push( + openSource, + nodeInfo.postPathWhitespace, + nodeInfo.paramsSource, + nodeInfo.postParamsWhitespace, + nodeInfo.hashSource, + nodeInfo.postHashWhitespace, + blockParamsSource, + postBlockParamsWhitespace, + openEndSource, + programSource, + inversePreamble, + inverseSource, + endSource, + ); + } + break; + case 'HashPair': + { + const hashPair = original as AST.HashPair; + const { source } = nodeInfo; + const hashPairPartsResult = source.match(hashPairParts); + if (hashPairPartsResult === null) { + throw new Error('Could not match hash pair parts'); + } + // eslint-disable-next-line prefer-const + let [, keySource, postKeyWhitespace, postEqualsWhitespace] = + hashPairPartsResult; + let valueSource = this.sourceForLoc(hashPair.value.loc); + + if (dirtyFields.has('key')) { + keySource = ast.key; + + dirtyFields.delete('key'); + } + + if (dirtyFields.has('value')) { + valueSource = this.print(ast.value); + + dirtyFields.delete('value'); + } + + output.push( + keySource, + postKeyWhitespace, + '=', + postEqualsWhitespace, + valueSource, + ); + } + break; + case 'AttrNode': + { + const attrNode = original as AST.AttrNode; + const { source } = nodeInfo; + const attrNodePartsResults = source.match(attrNodeParts); + if (attrNodePartsResults === null) { + throw new Error(`Could not match attr node parts for ${source}`); + } + + let [ + , + nameSource, + // eslint-disable-next-line prefer-const + postNameWhitespace, + equals, + // eslint-disable-next-line prefer-const + postEqualsWhitespace, + quote, + ] = attrNodePartsResults; + let valueSource = this.sourceForLoc(attrNode.value.loc); + // Source of ConcatStatements includes their quotes, + // but source of an AttrNode's TextNode value does not. + // Normalize on not including them, then always printing them ourselves: + if (attrNode.value.type === 'ConcatStatement') { + valueSource = valueSource.slice(1, -1); + } + + const node = ast as AnnotatedAttrNode; + + if (dirtyFields.has('name')) { + nameSource = node.name; + dirtyFields.delete('name'); + } + + if (dirtyFields.has('quoteType')) { + // Ensure the quote type they've specified is valid for the value + if (node.value.type === 'MustacheStatement' && node.quoteType) { + throw new Error( + 'Mustache statements should not be quoted as attribute values', + ); + } else if ( + node.value.type === 'ConcatStatement' && + !node.quoteType + ) { + throw new Error( + 'ConcatStatements must be quoted as attribute values', + ); + } else if ( + node.value.type == 'TextNode' && + !node.quoteType && + node.value.chars.match(invalidUnquotedAttrValue) + ) { + throw new Error( + `\`${node.value.chars}\` is invalid as an unquoted attribute value. Alphanumeric, hyphens, and periods only`, + ); + } + quote = node.quoteType || ''; // null => empty string + } else if (dirtyFields.has('value')) { + // They updated the value without choosing a quote type. We'll use the previous quote + // type or default to double quote if necessary + if (node.value.type === 'MustacheStatement') { + quote = ''; + } else if ( + node.value.type === 'TextNode' && + node.quoteType === null && + !node.value.chars.match(invalidUnquotedAttrValue) + ) { + // If old value was unquoted, and new value is also ok as unquoted, preserve that. + quote = ''; + } else { + quote = quote || '"'; + } + } + dirtyFields.delete('quoteType'); + + if (dirtyFields.has('isValueless')) { + if (node.isValueless) { + equals = ''; + quote = ''; + valueSource = ''; + dirtyFields.delete('isValueless'); + dirtyFields.delete('value'); + } else { + equals = '='; + if (node.value.type !== 'MustacheStatement' && !quote) { + quote = '"'; + } + dirtyFields.delete('isValueless'); + } + } + + if (dirtyFields.has('value')) { + equals = '='; + // If they created a ConcatStatement node, we need to print it ourselves here. + // Otherwise, since it has no nodeInfo, it will print using the glimmer printer + // which hardcodes double quotes. + if (node.value.type === 'ConcatStatement') { + valueSource = node.value.parts + .map((part) => this.print(part)) + .join(''); + } else { + valueSource = this.print(node.value); + } + } + dirtyFields.delete('value'); + + output.push( + nameSource, + postNameWhitespace, + equals, + postEqualsWhitespace, + quote, + valueSource, + quote, + ); + } + break; + case 'PathExpression': + { + let { source } = nodeInfo; + + if (dirtyFields.has('original')) { + source = ast.original; + dirtyFields.delete('original'); + } + + output.push(source); + } + break; + case 'MustacheCommentStatement': + case 'CommentStatement': + { + const commentStatement = original as AST.CommentStatement; + const indexOfValue = nodeInfo.source.indexOf(commentStatement.value); + const openSource = nodeInfo.source.substring(0, indexOfValue); + let valueSource = commentStatement.value; + const endSource = nodeInfo.source.substring( + indexOfValue + valueSource.length, + ); + + if (dirtyFields.has('value')) { + valueSource = ast.value; + + dirtyFields.delete('value'); + } + + output.push(openSource, valueSource, endSource); + } + break; + case 'TextNode': + { + let { source } = nodeInfo; + + if (dirtyFields.has('chars')) { + source = ast.chars; + dirtyFields.delete('chars'); + } + + output.push(source); + } + break; + case 'StringLiteral': + { + const node = ast as AnnotatedStringLiteral; + output.push(node.quoteType, node.value, node.quoteType); + } + break; + case 'BooleanLiteral': + case 'NumberLiteral': + case 'NullLiteral': + { + let { source } = nodeInfo; + + if (dirtyFields.has('value')) { + source = ast.value?.toString() || ''; + dirtyFields.delete('value'); + } + + output.push(source); + } + break; + default: + throw new Error( + `ember-template-recast does not have the ability to update ${original.type}. Please open an issue so we can add support.`, + ); + } + + for (const field of dirtyFields.values()) { + if (field in Object.keys(original)) { + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `ember-template-recast could not handle the mutations of \`${Array.from( + dirtyFields, + )}\` on ${original.type}`, + ); + } + } + + return output.join(''); + } + + // User-created nodes will have no nodeInfo, but we support + // formatting properties that the glimmer printer does not. + // If the user-created node specifies no custom formatting, + // just use the glimmer printer. + // These overrides could go away if glimmer had a concrete + // syntax tree type and printer. + printUserSuppliedNode(_ast: AST.Node): string { + switch (_ast.type) { + case 'StringLiteral': + { + const quote = (_ast as AnnotatedStringLiteral).quoteType || '"'; + return quote + _ast.value + quote; + } + // @ts-expect-error: Incorrect type + break; + case 'AttrNode': + { + const node = _ast as AnnotatedAttrNode; + if (node.isValueless) { + if (node.value.type !== 'TextNode' || node.value.chars !== '') { + throw new Error( + 'The value property of a valueless attr must be an empty TextNode', + ); + } + return node.name; + } + if ( + node.isValueless === undefined && + node.value.type === 'TextNode' && + node.value.chars === '' + ) { + return node.name; + } + switch (node.value.type) { + case 'MustacheStatement': + return node.name + '=' + this.print(node.value); + // @ts-expect-error: Incorrect type + break; + case 'ConcatStatement': + { + const value = node.value.parts + .map((part) => this.print(part)) + .join(''); + const quote = node.quoteType || '"'; + return node.name + '=' + quote + value + quote; + } + // @ts-expect-error: Incorrect type + break; + case 'TextNode': + { + if ( + node.quoteType === null && + node.value.chars.match(invalidUnquotedAttrValue) + ) { + throw new Error( + `You specified a quoteless attribute \`${node.value.chars}\`, which is invalid without quotes`, + ); + } + let quote: string; + if (node.quoteType === null) { + quote = ''; + } else { + quote = node.quoteType || '"'; + } + return node.name + '=' + quote + node.value.chars + quote; + } + // @ts-expect-error: Incorrect type + break; + } + } + // @ts-expect-error: Incorrect type + break; + default: + return _print(_ast, { + entityEncoding: 'raw', + // @ts-expect-error: Incorrect type + override: (ast) => { + if (this.nodeInfo.has(ast) || useCustomPrinter(ast)) { + return this.print(ast); + } + }, + }); + } + } + + /* + Used to associate the original source with a given node (while wrapping AST nodes + in a proxy). + */ + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private sourceForLoc(loc: any) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return sourceForLoc(this.source, loc); + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any + private wrapNode(ancestor: any, node: any) { + this.ancestor.set(node, ancestor); + + const nodeInfo = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + node, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + original: JSON.parse(JSON.stringify(node)), + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + source: this.sourceForLoc(node.loc), + parse_result: this, + }; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.nodeInfo.set(node, nodeInfo); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const hasLocInfo = !!node.loc; + const propertyProxyMap = new Map(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const proxy = new Proxy(node, { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + get: (target, property) => { + if (propertyProxyMap.has(property)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return propertyProxyMap.get(property); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Reflect.get(target, property); + }, + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + set: (target, property, value) => { + if (propertyProxyMap.has(property)) { + propertyProxyMap.set(property, value); + } + + Reflect.set(target, property, value); + + if (hasLocInfo) { + this.markAsDirty(node, property); + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.markAsDirty(ancestor.node, ancestor.key); + } + + return true; + }, + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + deleteProperty: (target, property) => { + if (propertyProxyMap.has(property)) { + propertyProxyMap.delete(property); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const result = Reflect.deleteProperty(target, property); + + if (hasLocInfo) { + this.markAsDirty(node, property); + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.markAsDirty(ancestor.node, ancestor.key); + } + + return result; + }, + }); + + // this is needed in order to handle splicing of Template.body (which + // happens when during replacement) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.nodeInfo.set(proxy, nodeInfo); + + for (const key in node) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const value = node[key]; + + if (key !== 'loc' && typeof value === 'object' && value !== null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const propertyProxy = this.wrapNode({ node, key }, value); + + propertyProxyMap.set(key, propertyProxy); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return proxy; + } +} diff --git a/packages/ast/template/src/-private/glimmer-syntax/utils.ts b/packages/ast/template/src/-private/glimmer-syntax/utils.ts new file mode 100644 index 00000000..0b1d88e5 --- /dev/null +++ b/packages/ast/template/src/-private/glimmer-syntax/utils.ts @@ -0,0 +1,94 @@ +import type { AST } from '@glimmer/syntax'; + +export function getLines(source: string): string[] { + const result = source.match(/(.*?(?:\r\n?|\n|$))/gm); + + if (!result) { + throw new Error('could not parse source'); + } + + return result.slice(0, -1); +} + +function isSyntheticWithNoLocation(node: AST.Node): boolean { + if (node && node.loc) { + const { start, end } = node.loc; + + return ( + node.loc.module === '(synthetic)' && + start.column === end.column && + start.line === end.line + ); + } + + return false; +} + +export function sortByLoc(a: AST.Node, b: AST.Node): -1 | 0 | 1 { + // be conservative about the location where a new node is inserted + if (isSyntheticWithNoLocation(a) || isSyntheticWithNoLocation(b)) { + return 0; + } + + if (a.loc.start.line < b.loc.start.line) { + return -1; + } + + if ( + a.loc.start.line === b.loc.start.line && + a.loc.start.column < b.loc.start.column + ) { + return -1; + } + + if ( + a.loc.start.line === b.loc.start.line && + a.loc.start.column === b.loc.start.column + ) { + return 0; + } + + return 1; +} + +export function sourceForLoc( + source: string | string[], + loc?: AST.SourceLocation, +): string { + if (!loc) { + return ''; + } + + const sourceLines = Array.isArray(source) ? source : getLines(source); + + const firstLine = loc.start.line - 1; + const lastLine = loc.end.line - 1; + const firstColumn = loc.start.column; + const lastColumn = loc.end.column; + + const string = []; + let currentLine = firstLine - 1; + let line; + + while (currentLine < lastLine) { + currentLine++; + // for templates that are completely empty the outer Template loc is line + // 0, column 0 for both start and end defaulting to empty string prevents + // more complicated logic below + line = sourceLines[currentLine] || ''; + + if (currentLine === firstLine) { + if (firstLine === lastLine) { + string.push(line.slice(firstColumn, lastColumn)); + } else { + string.push(line.slice(firstColumn)); + } + } else if (currentLine === lastLine) { + string.push(line.slice(0, lastColumn)); + } else { + string.push(line); + } + } + + return string.join(''); +} diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts new file mode 100644 index 00000000..0032d306 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-nodes-by-their-line-numbers.test.ts @@ -0,0 +1,31 @@ +import { assert, test } from '@codemod-utils/tests'; +import { builders } from '@glimmer/syntax'; + +import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; + +test('-private | glimmer-syntax | utils | sortByLoc > sorts nodes by their line numbers', function () { + const a = builders.pair( + 'a', + builders.path('foo'), + builders.loc(1, 1, 1, 5, 'foo.hbs'), + ); + + const b = builders.pair( + 'b', + builders.path('foo'), + builders.loc(3, 1, 1, 5, 'foo.hbs'), + ); + + const c = builders.pair( + 'c', + builders.path('foo'), + builders.loc(2, 1, 1, 5, 'foo.hbs'), + ); + + const nodes = [a, b, c].sort(sortByLoc); + + assert.deepStrictEqual( + nodes.map((node) => node.key), + ['a', 'c', 'b'], + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts new file mode 100644 index 00000000..630050c0 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/sorts-synthetic-nodes-last.test.ts @@ -0,0 +1,21 @@ +import { assert, test } from '@codemod-utils/tests'; +import { builders } from '@glimmer/syntax'; + +import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; + +test('-private | glimmer-syntax | utils | sortByLoc > sorts synthetic nodes last', function () { + const a = builders.pair('a', builders.path('foo') /* no loc, "synthetic" */); + + const b = builders.pair( + 'b', + builders.path('foo'), + builders.loc(1, 1, 1, 5, 'foo.hbs'), + ); + + const nodes = [a, b].sort(sortByLoc); + + assert.deepStrictEqual( + nodes.map((node) => node.key), + ['a', 'b'], + ); +}); diff --git a/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts new file mode 100644 index 00000000..5e9a9e78 --- /dev/null +++ b/packages/ast/template/tests/-private/glimmer-syntax/utils/sortByLoc/when-start-line-matches-sorts-by-starting-column.test.ts @@ -0,0 +1,31 @@ +import { assert, test } from '@codemod-utils/tests'; +import { builders } from '@glimmer/syntax'; + +import { sortByLoc } from '../../../../../src/-private/glimmer-syntax/utils.js'; + +test('-private | glimmer-syntax | utils | sortByLoc > when start line matches, sorts by starting column', function () { + const a = builders.pair( + 'a', + builders.path('foo'), + builders.loc(1, 1, 1, 5, 'foo.hbs'), + ); + + const b = builders.pair( + 'b', + builders.path('foo'), + builders.loc(2, 1, 1, 5, 'foo.hbs'), + ); + + const c = builders.pair( + 'c', + builders.path('foo'), + builders.loc(1, 6, 1, 9, 'foo.hbs'), + ); + + const nodes = [a, b, c].sort(sortByLoc); + + assert.deepStrictEqual( + nodes.map((node) => node.key), + ['a', 'c', 'b'], + ); +}); From 549eb31aaddad9f7dad909bee4955ea68c6b1cbf Mon Sep 17 00:00:00 2001 From: Isaac Lee <16869656+ijlee2@users.noreply.github.com> Date: Wed, 6 May 2026 09:26:23 +0200 Subject: [PATCH 11/11] chore: Added changeset --- .changeset/lazy-papers-report.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lazy-papers-report.md diff --git a/.changeset/lazy-papers-report.md b/.changeset/lazy-papers-report.md new file mode 100644 index 00000000..b0a76409 --- /dev/null +++ b/.changeset/lazy-papers-report.md @@ -0,0 +1,5 @@ +--- +"@codemod-utils/ast-template": minor +--- + +Replaced ember-template-recast with @glimmer/syntax