diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..770012e87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,47 @@ +--- +name: Bug report +about: If you think something is broken with Tailwind CSS IntelliSense itself, create a bug report. +title: '' +labels: '' +assignees: '' +--- + +**What version of VS Code are you using?** + +For example: v1.78.2 + +**What version of Tailwind CSS IntelliSense are you using?** + +For example: v0.7.0 + +**What version of Tailwind CSS are you using?** + +For example: v2.0.4 + +**What package manager are you using?** + +For example: npm, yarn + +**What operating system are you using?** + +For example: macOS, Windows + +**Tailwind CSS Stylesheet (v4) or config file (v3)** + +```js +// Paste the contents of your CSS file or config file here +``` + +**VS Code settings** + +```json +// Paste your VS Code settings in JSON format here +``` + +**Reproduction URL** + +A public GitHub repo that includes a minimal reproduction of the bug. **Please do not link to your actual project**, what we need instead is a _minimal_ reproduction in a fresh project without any unnecessary code. This means it doesn't matter if your real project is private/confidential, since we want a link to a separate, isolated reproduction anyways. + +**Describe your issue** + +Describe the problem you're seeing, any important steps to reproduce and what behavior you expect instead diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1f1449204..7c5bf993c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,3 @@ contact_links: - name: Feature Request url: https://github.com/tailwindlabs/tailwindcss/discussions/new?category=ideas&title=%5BIntelliSense%5D%20 about: 'Suggest any ideas you have using our discussion forums.' - - name: Bug Report - url: https://github.com/tailwindlabs/tailwindcss-intellisense/issues/new?body=**What%20version%20of%20VS%20Code%20are%20you%20using%3F**%0A%0AFor%20example%3A%20v1.78.2%0A%0A**What%20version%20of%20Tailwind%20CSS%20IntelliSense%20are%20you%20using%3F**%0A%0AFor%20example%3A%20v0.7.0%0A%0A**What%20version%20of%20Tailwind%20CSS%20are%20you%20using%3F**%0A%0AFor%20example%3A%20v2.0.4%0A%0A**What%20package%20manager%20are%20you%20using%3F**%0A%0AFor%20example%3A%20npm%2C%20yarn%0A%0A**What%20operating%20system%20are%20you%20using%3F**%0A%0AFor%20example%3A%20macOS%2C%20Windows%0A%0A**Tailwind%20config**%0A%0A%60%60%60js%0A%2F%2F%20Paste%20the%20contents%20of%20your%20Tailwind%20config%20file%20here%0A%60%60%60%0A%0A**VS%20Code%20settings**%0A%0A%60%60%60json%0A%2F%2F%20Paste%20your%20VS%20Code%20settings%20in%20JSON%20format%20here%0A%60%60%60%0A%0A**Reproduction%20URL**%0A%0AA%20public%20GitHub%20repo%20that%20includes%20a%20minimal%20reproduction%20of%20the%20bug.%20**Please%20do%20not%20link%20to%20your%20actual%20project**%2C%20what%20we%20need%20instead%20is%20a%20_minimal_%20reproduction%20in%20a%20fresh%20project%20without%20any%20unnecessary%20code.%20This%20means%20it%20doesn%27t%20matter%20if%20your%20real%20project%20is%20private%2Fconfidential%2C%20since%20we%20want%20a%20link%20to%20a%20separate%2C%20isolated%20reproduction%20anyways.%0A%0A**Describe%20your%20issue**%0A%0ADescribe%20the%20problem%20you%27re%20seeing%2C%20any%20important%20steps%20to%20reproduce%20and%20what%20behavior%20you%20expect%20instead. - about: If you think something is broken with Tailwind CSS IntelliSense itself, create a bug report. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..a01f382e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: Run Tests +on: + pull_request: + branches: + - main + +jobs: + tests: + strategy: + fail-fast: false + matrix: + node: [18, 20, 22, 24] + os: + - namespace-profile-default + - namespace-profile-macos-arm64 + - namespace-profile-windows-amd64 + + runs-on: ${{ matrix.os }} + name: Run Tests - Node v${{ matrix.node }} / ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + cache: 'pnpm' + node-version: ${{ matrix.node }} + + - name: Install dependencies + run: pnpm install + + - name: Run syntax tests + working-directory: packages/tailwindcss-language-syntax + run: pnpm run build && pnpm run test + + - name: Run service tests + working-directory: packages/tailwindcss-language-service + run: pnpm run build && pnpm run test + + - name: Run server tests + working-directory: packages/tailwindcss-language-server + run: pnpm run build && pnpm run test diff --git a/.vscode/launch.json b/.vscode/launch.json index fd173bdc4..044786528 100755 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,15 @@ "request": "launch", "name": "Launch Client", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}/packages/vscode-tailwindcss"], + "args": [ + // enable this flag if you want to activate the extension only when you are debugging the extension + // "--disable-extensions", + "--disable-updates", + "--disable-workspace-trust", + "--skip-release-notes", + "--skip-welcome", + "--extensionDevelopmentPath=${workspaceRoot}/packages/vscode-tailwindcss" + ], "stopOnEntry": false, "sourceMaps": true, "outFiles": ["${workspaceRoot}/packages/vscode-tailwindcss/dist/**/*.js"], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1abde264e..aec43080f 100755 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -23,6 +23,9 @@ "panel": "dedicated", "reveal": "never" }, + "options": { + "cwd": "${workspaceFolder}/packages/vscode-tailwindcss" + }, "problemMatcher": ["$tsc-watch"] } ] diff --git a/package.json b/package.json index ff4394fba..e89380519 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "@npmcli/package-json": "^5.0.0", "@types/culori": "^2.1.0", "culori": "^4.0.1", - "esbuild": "^0.25.0", + "esbuild": "^0.25.5", "minimist": "^1.2.8", "prettier": "^3.2.5", "semver": "^7.7.1" diff --git a/packages/tailwindcss-language-server/ThirdPartyNotices.txt b/packages/tailwindcss-language-server/ThirdPartyNotices.txt index 26d8efa89..ee6b7439d 100644 --- a/packages/tailwindcss-language-server/ThirdPartyNotices.txt +++ b/packages/tailwindcss-language-server/ThirdPartyNotices.txt @@ -1,3 +1,78 @@ +@csstools/css-parser-algorithms@2.1.1 + +The MIT License (MIT) + +Copyright 2022 Romain Menke, Antonio Laguna + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +@csstools/css-tokenizer@2.1.1 + +The MIT License (MIT) + +Copyright 2022 Romain Menke, Antonio Laguna + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +@csstools/media-query-list-parser@2.0.4 + +The MIT License (MIT) + +Copyright 2022 Romain Menke, Antonio Laguna + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + @parcel/watcher@2.0.3 MIT License @@ -343,7 +418,33 @@ SOFTWARE. ================================================================================ -chokidar@3.5.1 +braces@3.0.3 + +The MIT License (MIT) + +Copyright (c) 2014-present, Jon Schlinkert. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ + +chokidar@3.6.0 The MIT License (MIT) @@ -382,29 +483,28 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ -culori@0.20.1 - -MIT License +css.escape@1.5.1 -Copyright (c) 2018 Dan Burzo +Copyright Mathias Bynens -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ @@ -507,6 +607,20 @@ THE SOFTWARE. ================================================================================ +detect-indent@6.0.0 + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + dlv@1.1.3 # `dlv(obj, keypath)` [![NPM](https://img.shields.io/npm/v/dlv.svg)](https://npmjs.com/package/dlv) [![Build](https://travis-ci.org/developit/dlv.svg?branch=master)](https://travis-ci.org/developit/dlv) @@ -588,7 +702,7 @@ delve(obj, undefined, 'foo') === 'foo'; ================================================================================ -dset@3.1.2 +dset@3.1.4 The MIT License (MIT) @@ -614,31 +728,6 @@ THE SOFTWARE. ================================================================================ -enhanced-resolve@5.15.0 - -Copyright JS Foundation and other contributors - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -================================================================================ - fast-glob@3.2.4 The MIT License (MIT) @@ -679,11 +768,11 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ -is-builtin-module@3.2.1 +klona@2.0.4 MIT License -Copyright (c) Sindre Sorhus (sindresorhus.com) +Copyright (c) Luke Edwards (lukeed.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -693,37 +782,61 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ -klona@2.0.4 - -MIT License +line-column@1.0.2 -Copyright (c) Luke Edwards (lukeed.com) +Copyright (c) 2016 IRIDE Monad -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ================================================================================ -minimatch@5.1.4 +moo@0.5.1 -The ISC License +BSD 3-Clause License -Copyright (c) 2011-2023 Isaac Z. Schlueter and Contributors +Copyright (c) 2017, Tim Radvan (tjvr) +All rights reserved. -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================================================ @@ -819,7 +932,34 @@ OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -postcss@8.3.9 +postcss-value-parser@4.2.0 + +Copyright (c) Bogdan Chadkin + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +postcss@8.4.31 The MIT License (MIT) @@ -870,15 +1010,89 @@ SOFTWARE. ================================================================================ -stack-trace@0.0.10 +semver@7.7.1 -Copyright (c) 2011 Felix Geisendörfer (felix@debuggable.com) +The ISC License - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +================================================================================ + +sift-string@0.0.2 + +# sift + + Fast String Distance (SIFT) Algorithm. + +[![NPM](https://nodei.co/npm/sift-string.png)](https://nodei.co/npm/sift-string/) + +[![Dependency Status](https://david-dm.org/timoxley/sift.png)](https://david-dm.org/timoxley/sift) + +## Installation + +#### Browserify/Node + + $ npm install sift-string + + +#### Component + + $ component install timoxley/sift + +## Demo + +[Demo](http://timoxley.github.com/sift/examples/spellcheck/) + +or if you want to check it out locally: + +```bash +# run only once to install npm dev dependencies +npm install . +# this will install && build the components and open the demo web page +npm run c-demo +``` + +## API + +### sift(string, string) + +Return 'distance' between two strings. + +## TODO + +* Dictionary Helper supply it emits suggestions. + +## Credit + +Code extracted from [MailCheck](https://github.com/kicksend/mailcheck) + +## License + + MIT + +================================================================================ + +stack-trace@0.0.10 + +Copyright (c) 2011 Felix Geisendörfer (felix@debuggable.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in @@ -894,7 +1108,34 @@ Copyright (c) 2011 Felix Geisendörfer (felix@debuggable.com) ================================================================================ -tailwindcss@3.3.0 +stringify-object@3.3.0 + +Copyright (c) 2015, Yeoman team +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + +tailwindcss@3.4.17 MIT License @@ -920,7 +1161,33 @@ SOFTWARE. ================================================================================ -vscode-css-languageservice@5.4.1 +tmp-cache@1.1.0 + +The MIT License (MIT) + +Copyright (c) Luke Edwards (lukeed.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ + +vscode-css-languageservice@6.2.9 The MIT License (MIT) @@ -946,7 +1213,7 @@ SOFTWARE. ================================================================================ -vscode-languageserver-textdocument@1.0.7 +vscode-jsonrpc@8.2.0 Copyright (c) Microsoft Corporation @@ -962,7 +1229,7 @@ THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ -vscode-languageserver@8.0.2 +vscode-languageclient@8.1.0 Copyright (c) Microsoft Corporation @@ -978,238 +1245,46 @@ THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ -vscode-uri@3.0.2 - -The MIT License (MIT) - -Copyright (c) Microsoft - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +vscode-languageserver-textdocument@1.0.11 -================================================================================ - -css.escape@1.5.1 - -Copyright Mathias Bynens - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -================================================================================ +Copyright (c) Microsoft Corporation -detect-indent@6.0.0 +All rights reserved. MIT License -Copyright (c) Sindre Sorhus (sindresorhus.com) - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -================================================================================ - -line-column@1.0.2 - -Copyright (c) 2016 IRIDE Monad - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -moo@0.5.1 +vscode-languageserver@8.1.0 -BSD 3-Clause License +Copyright (c) Microsoft Corporation -Copyright (c) 2017, Tim Radvan (tjvr) All rights reserved. -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -================================================================================ - -semver@7.3.7 - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -================================================================================ - -sift-string@0.0.2 - -# sift - - Fast String Distance (SIFT) Algorithm. - -[![NPM](https://nodei.co/npm/sift-string.png)](https://nodei.co/npm/sift-string/) - -[![Dependency Status](https://david-dm.org/timoxley/sift.png)](https://david-dm.org/timoxley/sift) - -## Installation - -#### Browserify/Node - - $ npm install sift-string - - -#### Component - - $ component install timoxley/sift - -## Demo - -[Demo](http://timoxley.github.com/sift/examples/spellcheck/) - -or if you want to check it out locally: - -```bash -# run only once to install npm dev dependencies -npm install . -# this will install && build the components and open the demo web page -npm run c-demo -``` - -## API - -### sift(string, string) - -Return 'distance' between two strings. - -## TODO - -* Dictionary Helper supply it emits suggestions. - -## Credit - -Code extracted from [MailCheck](https://github.com/kicksend/mailcheck) - -## License - - MIT - -================================================================================ - -stringify-object@3.3.0 - -Copyright (c) 2015, Yeoman team -All rights reserved. +MIT License -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -tmp-cache@1.1.0 +vscode-uri@3.0.2 The MIT License (MIT) -Copyright (c) Luke Edwards (lukeed.com) +Copyright (c) Microsoft -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/tailwindcss-language-server/package.json b/packages/tailwindcss-language-server/package.json index f8736d195..e8bfd41d2 100644 --- a/packages/tailwindcss-language-server/package.json +++ b/packages/tailwindcss-language-server/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/language-server", - "version": "0.14.6", + "version": "0.14.23", "description": "Tailwind CSS Language Server", "license": "MIT", "repository": { @@ -34,14 +34,23 @@ "access": "public" }, "devDependencies": { - "@parcel/watcher": "2.0.3", + "@parcel/watcher": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", "@tailwindcss/aspect-ratio": "0.4.2", "@tailwindcss/container-queries": "0.1.0", "@tailwindcss/forms": "0.5.3", "@tailwindcss/language-service": "workspace:*", "@tailwindcss/line-clamp": "0.4.2", - "@tailwindcss/oxide": "^4.0.0-alpha.19", + "@tailwindcss/oxide": "^4.1.0", "@tailwindcss/typography": "0.5.7", + "@types/braces": "3.0.1", "@types/color-name": "^1.1.3", "@types/culori": "^2.1.0", "@types/debounce": "1.2.0", @@ -65,8 +74,7 @@ "dlv": "1.1.3", "dset": "3.1.4", "enhanced-resolve": "^5.16.1", - "esbuild": "^0.25.0", - "fast-glob": "3.2.4", + "esbuild": "^0.25.5", "find-up": "5.0.0", "jiti": "^2.3.3", "klona": "2.0.4", @@ -75,7 +83,7 @@ "normalize-path": "3.0.0", "picomatch": "^4.0.1", "pkg-up": "3.1.0", - "postcss": "8.4.31", + "postcss": "8.5.4", "postcss-import": "^16.1.0", "postcss-load-config": "3.0.1", "postcss-selector-parser": "6.0.2", @@ -83,18 +91,20 @@ "rimraf": "3.0.2", "stack-trace": "0.0.10", "tailwindcss": "3.4.17", - "tailwindcss-v4": "npm:tailwindcss@4.0.6", + "tailwindcss-v4": "npm:tailwindcss@4.1.1", + "tinyglobby": "^0.2.12", "tsconfck": "^3.1.4", "tsconfig-paths": "^4.2.0", - "typescript": "5.3.3", - "vite-tsconfig-paths": "^4.3.1", - "vitest": "^1.6.1", - "vscode-css-languageservice": "6.2.9", + "typescript": "5.8.3", + "vite": "^6.3.5", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.1", + "vscode-css-languageservice": "6.3.6", "vscode-jsonrpc": "8.2.0", "vscode-languageclient": "8.1.0", "vscode-languageserver": "8.1.0", "vscode-languageserver-protocol": "^3.17.5", - "vscode-languageserver-textdocument": "1.0.11", + "vscode-languageserver-textdocument": "1.0.12", "vscode-uri": "3.0.2" }, "engines": { diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts index d8364d061..a5e68f7df 100644 --- a/packages/tailwindcss-language-server/src/config.ts +++ b/packages/tailwindcss-language-server/src/config.ts @@ -1,6 +1,9 @@ import merge from 'deepmerge' import { isObject } from './utils' -import type { Settings } from '@tailwindcss/language-service/src/util/state' +import { + getDefaultTailwindSettings, + type Settings, +} from '@tailwindcss/language-service/src/util/state' import type { Connection } from 'vscode-languageserver' export interface SettingsCache { @@ -8,40 +11,6 @@ export interface SettingsCache { clear(): void } -function getDefaultSettings(): Settings { - return { - editor: { tabSize: 2 }, - tailwindCSS: { - inspectPort: null, - emmetCompletions: false, - classAttributes: ['class', 'className', 'ngClass', 'class:list'], - codeActions: true, - hovers: true, - suggestions: true, - validate: true, - colorDecorators: true, - rootFontSize: 16, - lint: { - cssConflict: 'warning', - invalidApply: 'error', - invalidScreen: 'error', - invalidVariant: 'error', - invalidConfigPath: 'error', - invalidTailwindDirective: 'error', - invalidSourceDirective: 'error', - recommendedVariantOrder: 'warning', - }, - showPixelEquivalents: true, - includeLanguages: {}, - files: { exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'] }, - experimental: { - classRegex: [], - configFile: null, - }, - }, - } -} - export function createSettingsCache(connection: Connection): SettingsCache { const cache: Map = new Map() @@ -73,7 +42,7 @@ export function createSettingsCache(connection: Connection): SettingsCache { tailwindCSS = isObject(tailwindCSS) ? tailwindCSS : {} return merge( - getDefaultSettings(), + getDefaultTailwindSettings(), { editor, tailwindCSS }, { arrayMerge: (_destinationArray, sourceArray, _options) => sourceArray }, ) diff --git a/packages/tailwindcss-language-server/src/css/extract-source-directives.ts b/packages/tailwindcss-language-server/src/css/extract-source-directives.ts index 9de33a9b3..a97e35594 100644 --- a/packages/tailwindcss-language-server/src/css/extract-source-directives.ts +++ b/packages/tailwindcss-language-server/src/css/extract-source-directives.ts @@ -1,12 +1,21 @@ import type { Plugin } from 'postcss' +import type { SourcePattern } from '../project-locator' -export function extractSourceDirectives(sources: string[]): Plugin { +export function extractSourceDirectives(sources: SourcePattern[]): Plugin { return { postcssPlugin: 'extract-at-rules', AtRule: { source: ({ params }) => { + let negated = /^not\s+/.test(params) + + if (negated) params = params.slice(4).trimStart() + if (params[0] !== '"' && params[0] !== "'") return - sources.push(params.slice(1, -1)) + + sources.push({ + pattern: params.slice(1, -1), + negated, + }) }, }, } diff --git a/packages/tailwindcss-language-server/src/language/css-server.ts b/packages/tailwindcss-language-server/src/language/css-server.ts index a146cea4f..73e967fcc 100644 --- a/packages/tailwindcss-language-server/src/language/css-server.ts +++ b/packages/tailwindcss-language-server/src/language/css-server.ts @@ -13,7 +13,7 @@ import { CompletionItemKind, Connection, } from 'vscode-languageserver/node' -import { TextDocument } from 'vscode-languageserver-textdocument' +import { Position, TextDocument } from 'vscode-languageserver-textdocument' import { Utils, URI } from 'vscode-uri' import { getLanguageModelCache } from './languageModelCache' import { Stylesheet } from 'vscode-css-languageservice' @@ -121,6 +121,7 @@ export class CssServer { async function withDocumentAndSettings( uri: string, callback: (result: { + original: TextDocument document: TextDocument settings: LanguageSettings | undefined }) => T | Promise, @@ -130,13 +131,64 @@ export class CssServer { return null } return await callback({ + original: document, document: createVirtualCssDocument(document), settings: await getDocumentSettings(document), }) } + function isInImportDirective(doc: TextDocument, pos: Position) { + let text = doc.getText({ + start: { line: pos.line, character: 0 }, + end: pos, + }) + + // Scan backwards to see if we're inside an `@import` directive + let foundImport = false + let foundDirective = false + + for (let i = text.length - 1; i >= 0; i--) { + let char = text[i] + if (char === '\n') break + + if (char === '(' && !foundDirective) { + if (text.startsWith(' source(', i - 7)) { + foundDirective = true + } + + // + else if (text.startsWith(' theme(', i - 6)) { + foundDirective = true + } + + // + else if (text.startsWith(' prefix(', i - 7)) { + foundDirective = true + } + } + + // + else if (char === '@' && !foundImport) { + if (text.startsWith('@import ', i)) { + foundImport = true + } + } + } + + return foundImport && foundDirective + } + connection.onCompletion(async ({ textDocument, position }, _token) => - withDocumentAndSettings(textDocument.uri, async ({ document, settings }) => { + withDocumentAndSettings(textDocument.uri, async ({ original, document, settings }) => { + // If we're inside source(…), prefix(…), or theme(…), don't show + // completions from the CSS language server + if (isInImportDirective(original, position)) { + return { + isIncomplete: false, + items: [], + } + } + let result = await cssLanguageService.doComplete2( document, position, @@ -225,7 +277,7 @@ export class CssServer { if (match) { symbol.name = `${match[1]} ${match[2]?.trim() ?? match[3]?.trim()}` } - } else if (symbol.name === `.placeholder`) { + } else if (/^\._+$/.test(symbol.name)) { let doc = documents.get(symbol.location.uri) let text = doc.getText(symbol.location.range) let match = text.trim().match(/^(@[^\s]+)(?:([^{]+)[{]|([^;{]+);)/) diff --git a/packages/tailwindcss-language-server/src/language/rewriting.test.ts b/packages/tailwindcss-language-server/src/language/rewriting.test.ts index 33596a2ec..23249932b 100644 --- a/packages/tailwindcss-language-server/src/language/rewriting.test.ts +++ b/packages/tailwindcss-language-server/src/language/rewriting.test.ts @@ -50,16 +50,22 @@ test('@utility', () => { '@utility foo-* {', ' color: red;', '}', + '@utility bar-* {', + ' color: --value(--font-*-line-height);', + '}', ] let output = [ // - '.placeholder {', // wrong + '._______ {', ' color: red;', '}', - '.placeholder {', // wrong + '._______ {', ' color: red;', '}', + '._______ {', + ' color: --value(--font-_-line-height);', + '}', ] expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) @@ -69,20 +75,36 @@ test('@theme', () => { let input = [ // '@theme {', - ' color: red;', + ' --color: red;', + ' --*: initial;', + ' --text*: initial;', + ' --font-*: initial;', + ' --font-weight-*: initial;', '}', '@theme inline reference static default {', - ' color: red;', + ' --color: red;', + ' --*: initial;', + ' --text*: initial;', + ' --font-*: initial;', + ' --font-weight-*: initial;', '}', ] let output = [ // - '.placeholder {', // wrong - ' color: red;', + '._____ {', + ' --color: red;', + ' --_: initial;', + ' --text_: initial;', + ' --font-_: initial;', + ' --font-weight-_: initial;', '}', - '.placeholder {', // wrong - ' color: red;', + '._____ {', + ' --color: red;', + ' --_: initial;', + ' --text_: initial;', + ' --font-_: initial;', + ' --font-weight-_: initial;', '}', ] @@ -102,8 +124,8 @@ test('@custom-variant', () => { let output = [ // - '@media (℘) {}', // wrong - '.placeholder {', // wrong + '@media(℘) {}', + '.______________ {', ' &:hover {', ' @slot;', ' }', @@ -125,7 +147,7 @@ test('@variant', () => { let output = [ // - '.placeholder {', // wrong + '._______ {', ' &:hover {', ' @slot;', ' }', diff --git a/packages/tailwindcss-language-server/src/language/rewriting.ts b/packages/tailwindcss-language-server/src/language/rewriting.ts index 4dc7a4293..1d6607097 100644 --- a/packages/tailwindcss-language-server/src/language/rewriting.ts +++ b/packages/tailwindcss-language-server/src/language/rewriting.ts @@ -14,9 +14,10 @@ function replaceWithAtRule(delta = 0) { } function replaceWithStyleRule(delta = 0) { - return (_match: string, p1: string) => { + return (_match: string, name: string, p1: string) => { + let className = '_'.repeat(name.length) let spaces = ' '.repeat(p1.length + delta) - return `.placeholder${spaces}{` + return `.${className}${spaces}{` } } @@ -36,16 +37,16 @@ export function rewriteCss(css: string) { css = css.replace(/@screen(\s+[^{]+){/g, replaceWithAtRule(-2)) css = css.replace(/@variants(\s+[^{]+){/g, replaceWithAtRule()) css = css.replace(/@responsive(\s*){/g, replaceWithAtRule()) - css = css.replace(/@utility(\s+[^{]+){/g, replaceWithStyleRule()) - css = css.replace(/@theme(\s+[^{]*){/g, replaceWithStyleRule()) + css = css.replace(/@(utility)(\s+[^{]+){/g, replaceWithStyleRule()) + css = css.replace(/@(theme)(\s+[^{]*){/g, replaceWithStyleRule()) - css = css.replace(/@custom-variant(\s+[^;{]+);/g, (match: string) => { - let spaces = ' '.repeat(match.length - 11) - return `@media (${MEDIA_MARKER})${spaces}{}` + css = css.replace(/@(custom-variant)(\s+[^;{]+);/g, (match: string, name: string) => { + let spaces = ' '.repeat(match.length - name.length + 3) + return `@media(${MEDIA_MARKER})${spaces}{}` }) - css = css.replace(/@custom-variant(\s+[^{]+){/g, replaceWithStyleRule()) - css = css.replace(/@variant(\s+[^{]+){/g, replaceWithStyleRule()) + css = css.replace(/@(custom-variant)(\s+[^{]+){/g, replaceWithStyleRule()) + css = css.replace(/@(variant)(\s+[^{]+){/g, replaceWithStyleRule()) css = css.replace(/@layer(\s+[^{]{2,}){/g, replaceWithAtRule(-3)) css = css.replace(/@reference\s*([^;]{2,})/g, '@import $1') @@ -73,8 +74,13 @@ export function rewriteCss(css: string) { return match.replace(/[*]/g, '_') }) + // Replace `--*` with `--_` + // Replace `--some-var*` with `--some-var_` // Replace `--some-var-*` with `--some-var-_` - css = css.replace(/--([a-zA-Z0-9]+)-[*]/g, '--$1_') + // Replace `--text-*-line-height` with `--text-_-line-height` + css = css.replace(/--([a-zA-Z0-9-*]*)/g, (match) => { + return match.replace(/[*]/g, '_') + }) return css } diff --git a/packages/tailwindcss-language-server/src/matching.ts b/packages/tailwindcss-language-server/src/matching.ts new file mode 100644 index 000000000..a373b116c --- /dev/null +++ b/packages/tailwindcss-language-server/src/matching.ts @@ -0,0 +1,24 @@ +import picomatch from 'picomatch' +import { DefaultMap } from './util/default-map' + +export interface PathMatcher { + anyMatches(pattern: string, paths: string[]): boolean + clear(): void +} + +export function createPathMatcher(): PathMatcher { + let matchers = new DefaultMap((pattern) => { + // Escape picomatch special characters so they're matched literally + pattern = pattern.replace(/[\[\]{}()]/g, (m) => `\\${m}`) + + return picomatch(pattern, { dot: true }) + }) + + return { + anyMatches: (pattern, paths) => { + let check = matchers.get(pattern) + return paths.some((path) => check(path)) + }, + clear: () => matchers.clear(), + } +} diff --git a/packages/tailwindcss-language-server/src/oxide.ts b/packages/tailwindcss-language-server/src/oxide.ts index bb8700ff4..4dd529dfc 100644 --- a/packages/tailwindcss-language-server/src/oxide.ts +++ b/packages/tailwindcss-language-server/src/oxide.ts @@ -36,8 +36,8 @@ declare namespace OxideV2 { } } -// This covers the Oxide API from v4.0.0-alpha.20+ -declare namespace OxideV3 { +// This covers the Oxide API from v4.0.0-alpha.30+ +declare namespace OxideV3And4 { interface GlobEntry { base: string pattern: string @@ -58,17 +58,44 @@ declare namespace OxideV3 { } } +// This covers the Oxide API from v4.1.0+ +declare namespace OxideV5 { + interface GlobEntry { + base: string + pattern: string + } + + interface SourceEntry { + base: string + pattern: string + negated: boolean + } + + interface ScannerOptions { + sources: Array + } + + interface ScannerConstructor { + new (options: ScannerOptions): Scanner + } + + interface Scanner { + get files(): Array + get globs(): Array + } +} + interface Oxide { scanDir?(options: OxideV1.ScanOptions): OxideV1.ScanResult scanDir?(options: OxideV2.ScanOptions): OxideV2.ScanResult - Scanner?: OxideV3.ScannerConstructor + Scanner?: OxideV3And4.ScannerConstructor | OxideV5.ScannerConstructor } async function loadOxideAtPath(id: string): Promise { let oxide = await import(id) // This is a much older, unsupported version of Oxide before v4.0.0-alpha.1 - if (!oxide.scanDir) return null + if (!oxide.scanDir && !oxide.Scanner) return null return oxide } @@ -78,11 +105,17 @@ interface GlobEntry { pattern: string } +interface SourceEntry { + base: string + pattern: string + negated: boolean +} + interface ScanOptions { oxidePath: string oxideVersion: string basePath: string - sources: Array + sources: Array } interface ScanResult { @@ -101,7 +134,7 @@ interface ScanResult { * For example, the `sources` option is ignored before v4.0.0-alpha.19. */ export async function scan(options: ScanOptions): Promise { - const oxide = await loadOxideAtPath(options.oxidePath) + let oxide = await loadOxideAtPath(options.oxidePath) if (!oxide) return null // V1 @@ -118,38 +151,58 @@ export async function scan(options: ScanOptions): Promise { } // V2 - if (lte(options.oxideVersion, '4.0.0-alpha.19')) { + else if (lte(options.oxideVersion, '4.0.0-alpha.19')) { let result = oxide.scanDir({ base: options.basePath, - sources: options.sources, + sources: options.sources.map((g) => ({ base: g.base, pattern: g.pattern })), }) return { files: result.files, - globs: result.globs, + globs: result.globs.map((g) => ({ base: g.base, pattern: g.pattern })), } } // V3 - if (lte(options.oxideVersion, '4.0.0-alpha.30')) { - let scanner = new oxide.Scanner({ + else if (lte(options.oxideVersion, '4.0.0-alpha.30')) { + let scanner = new (oxide.Scanner as OxideV3And4.ScannerConstructor)({ detectSources: { base: options.basePath }, - sources: options.sources, + sources: options.sources.map((g) => ({ base: g.base, pattern: g.pattern })), }) return { files: scanner.files, - globs: scanner.globs, + globs: scanner.globs.map((g) => ({ base: g.base, pattern: g.pattern })), } } // V4 - let scanner = new oxide.Scanner({ - sources: [{ base: options.basePath, pattern: '**/*' }, ...options.sources], - }) + else if (lte(options.oxideVersion, '4.0.9999')) { + let scanner = new (oxide.Scanner as OxideV3And4.ScannerConstructor)({ + sources: [ + { base: options.basePath, pattern: '**/*' }, + ...options.sources.map((g) => ({ base: g.base, pattern: g.pattern })), + ], + }) - return { - files: scanner.files, - globs: scanner.globs, + return { + files: scanner.files, + globs: scanner.globs.map((g) => ({ base: g.base, pattern: g.pattern })), + } + } + + // V5 + else { + let scanner = new (oxide.Scanner as OxideV5.ScannerConstructor)({ + sources: [ + { base: options.basePath, pattern: '**/*', negated: false }, + ...options.sources.map((g) => ({ base: g.base, pattern: g.pattern, negated: g.negated })), + ], + }) + + return { + files: scanner.files, + globs: scanner.globs.map((g) => ({ base: g.base, pattern: g.pattern })), + } } } diff --git a/packages/tailwindcss-language-server/src/project-locator.test.ts b/packages/tailwindcss-language-server/src/project-locator.test.ts index 6414a099c..7728d7957 100644 --- a/packages/tailwindcss-language-server/src/project-locator.test.ts +++ b/packages/tailwindcss-language-server/src/project-locator.test.ts @@ -4,12 +4,13 @@ import { ProjectLocator } from './project-locator' import { URL, fileURLToPath } from 'url' import { Settings } from '@tailwindcss/language-service/src/util/state' import { createResolver } from './resolver' -import { css, defineTest, js, json, scss, Storage, TestUtils } from './testing' +import { css, defineTest, html, js, json, scss, Storage, symlinkTo, TestUtils } from './testing' +import { normalizePath } from './utils' let settings: Settings = { tailwindCSS: { files: { - exclude: [], + exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'], }, }, } as any @@ -29,12 +30,14 @@ function testFixture(fixture: string, details: any[]) { let detail = details[i] - let configPath = path.relative(fixturePath, project.config.path) + let configPath = path.posix.relative(normalizePath(fixturePath), project.config.path) expect(configPath).toEqual(detail?.config) if (detail?.content) { - let expected = detail?.content.map((path) => path.replace('{URL}', fixturePath)).sort() + let expected = detail?.content + .map((path) => path.replace('{URL}', normalizePath(fixturePath))) + .sort() let actual = project.documentSelector .filter((selector) => selector.priority === 1 /** content */) @@ -45,7 +48,9 @@ function testFixture(fixture: string, details: any[]) { } if (detail?.selectors) { - let expected = detail?.selectors.map((path) => path.replace('{URL}', fixturePath)).sort() + let expected = detail?.selectors + .map((path) => path.replace('{URL}', normalizePath(fixturePath))) + .sort() let actual = project.documentSelector.map((selector) => selector.pattern).sort() @@ -114,27 +119,22 @@ testFixture('v4/workspaces', [ { config: 'packages/admin/app.css', selectors: [ - '{URL}/node_modules/tailwindcss/**', - '{URL}/node_modules/tailwindcss/index.css', - '{URL}/node_modules/tailwindcss/theme.css', - '{URL}/node_modules/tailwindcss/utilities.css', + '{URL}/packages/admin/*', '{URL}/packages/admin/**', '{URL}/packages/admin/app.css', '{URL}/packages/admin/package.json', + '{URL}/packages/admin/tw.css', ], }, { config: 'packages/web/app.css', selectors: [ - '{URL}/node_modules/tailwindcss/**', - '{URL}/node_modules/tailwindcss/index.css', - '{URL}/node_modules/tailwindcss/theme.css', - '{URL}/node_modules/tailwindcss/utilities.css', '{URL}/packages/style-export/**', '{URL}/packages/style-export/lib.css', '{URL}/packages/style-export/theme.css', '{URL}/packages/style-main-field/**', '{URL}/packages/style-main-field/lib.css', + '{URL}/packages/web/*', '{URL}/packages/web/**', '{URL}/packages/web/app.css', '{URL}/packages/web/package.json', @@ -142,68 +142,173 @@ testFixture('v4/workspaces', [ }, ]) -testFixture('v4/auto-content', [ - // - { - config: 'src/app.css', - content: [ - '{URL}/package.json', - '{URL}/src/index.html', - '{URL}/src/components/example.html', - '{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], +testLocator({ + name: 'automatic content detection with Oxide', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'src/index.html': html`
Test
`, + 'src/app.css': css` + @import 'tailwindcss'; + `, + 'src/components/example.html': html`
Test
`, }, -]) + expected: [ + { + config: '/src/app.css', + content: [ + '/*', + '/package.json', + '/src/**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/src/components/example.html', + '/src/index.html', + ], + }, + ], +}) -testFixture('v4/auto-content-split', [ - // - { - // TODO: This should _probably_ not be present - config: 'node_modules/tailwindcss/index.css', - content: [], - }, - { - config: 'src/app.css', - content: [ - '{URL}/package.json', - '{URL}/src/index.html', - '{URL}/src/components/example.html', - '{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], +testLocator({ + name: 'automatic content detection with Oxide using split config', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'src/index.html': html`
Test
`, + 'src/app.css': css` + @import 'tailwindcss/preflight' layer(base); + @import 'tailwindcss/theme' layer(theme); + @import 'tailwindcss/utilities' layer(utilities); + `, + 'src/components/example.html': html`
Test
`, }, -]) + expected: [ + { + config: '/src/app.css', + content: [ + '/*', + '/package.json', + '/src/**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/src/components/example.html', + '/src/index.html', + ], + }, + ], +}) -testFixture('v4/custom-source', [ - // - { - config: 'admin/app.css', - content: [ - '{URL}/admin/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - '{URL}/admin/**/*.bin', - '{URL}/admin/foo.bin', - '{URL}/package.json', - '{URL}/shared.html', - '{URL}/web/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], +testLocator({ + name: 'automatic content detection with custom sources', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'admin/app.css': css` + @import './tw.css'; + @import './ui.css'; + `, + 'admin/tw.css': css` + @import 'tailwindcss'; + @source './**/*.bin'; + `, + 'admin/ui.css': css` + @theme { + --color-potato: #907a70; + } + `, + 'admin/foo.bin': html`

Admin

`, + + 'web/app.css': css` + @import 'tailwindcss'; + @source './*.bin'; + `, + 'web/bar.bin': html`

Web

`, + + 'shared.html': html`

I belong to no one!

`, }, - { - config: 'web/app.css', - content: [ - '{URL}/admin/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - '{URL}/web/*.bin', - '{URL}/web/bar.bin', - '{URL}/package.json', - '{URL}/shared.html', - '{URL}/web/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', - ], + expected: [ + { + config: '/admin/app.css', + content: [ + '/*', + '/admin/foo.bin', + '/admin/tw.css', + '/admin/ui.css', + '/admin/{**/*.bin,**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}}', + '/package.json', + '/shared.html', + '/web/**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/web/app.css', + ], + }, + { + config: '/web/app.css', + content: [ + '/*', + '/admin/**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '/admin/app.css', + '/admin/tw.css', + '/admin/ui.css', + '/package.json', + '/shared.html', + '/web/bar.bin', + '/web/{**/*.{aspx,astro,bin,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue},*.bin}', + ], + }, + ], +}) + +testLocator({ + name: 'automatic content detection with negative custom sources', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "4.1.0", + "@tailwindcss/oxide": "4.1.0" + } + } + `, + 'src/app.css': css` + @import 'tailwindcss'; + @source './**/*.html'; + @source not './ignored.html'; + `, + 'src/index.html': html`
`, + 'src/ignored.html': html`
`, }, -]) + expected: [ + { + config: '/src/app.css', + content: [ + '/*', + '/package.json', + '/src/index.html', + '/src/{**/*.html,**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}}', + ], + }, + ], +}) testFixture('v4/missing-files', [ // { config: 'app.css', - content: ['{URL}/package.json'], + content: ['{URL}/*', '{URL}/i-exist.css', '{URL}/package.json'], }, ]) @@ -212,8 +317,10 @@ testFixture('v4/path-mappings', [ { config: 'app.css', content: [ + '{URL}/*', '{URL}/package.json', - '{URL}/src/**/*.{py,tpl,js,vue,php,mjs,cts,jsx,tsx,rhtml,slim,handlebars,twig,rs,njk,svelte,liquid,pug,md,ts,heex,mts,astro,nunjucks,rb,eex,haml,cjs,html,hbs,jade,aspx,razor,erb,mustache,mdx}', + '{URL}/src/**/*.{aspx,astro,cjs,css,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,json,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}', + '{URL}/src/a/file.css', '{URL}/src/a/my-config.ts', '{URL}/src/a/my-plugin.ts', '{URL}/tsconfig.json', @@ -225,7 +332,7 @@ testFixture('v4/invalid-import-order', [ // { config: 'tailwind.css', - content: ['{URL}/package.json'], + content: ['{URL}/*', '{URL}/a.css', '{URL}/b.css', '{URL}/package.json'], }, ]) @@ -237,7 +344,7 @@ testLocator({ 'package.json': json` { "dependencies": { - "tailwindcss": "^4.0.2" + "tailwindcss": "4.1.0" } } `, @@ -285,7 +392,7 @@ testLocator({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.6" + "tailwindcss": "4.1.0" } } `, @@ -314,13 +421,173 @@ testLocator({ }, expected: [ { - version: '4.0.6', + version: '4.1.0', config: '/src/articles/articles.css', content: [], }, ], }) +testLocator({ + name: 'Recursive symlinks do not cause infinite traversal loops', + fs: { + 'src/a/b/c/index.css': css` + @import 'tailwindcss'; + `, + 'src/a/b/c/z': symlinkTo('src', 'dir'), + 'src/a/b/x': symlinkTo('src', 'dir'), + 'src/a/b/y': symlinkTo('src', 'dir'), + 'src/a/b/z': symlinkTo('src', 'dir'), + 'src/a/x': symlinkTo('src', 'dir'), + + 'src/b/c/d/z': symlinkTo('src', 'dir'), + 'src/b/c/d/index.css': css``, + 'src/b/c/x': symlinkTo('src', 'dir'), + 'src/b/c/y': symlinkTo('src', 'dir'), + 'src/b/c/z': symlinkTo('src', 'dir'), + 'src/b/x': symlinkTo('src', 'dir'), + + 'src/c/d/e/z': symlinkTo('src', 'dir'), + 'src/c/d/x': symlinkTo('src', 'dir'), + 'src/c/d/y': symlinkTo('src', 'dir'), + 'src/c/d/z': symlinkTo('src', 'dir'), + 'src/c/x': symlinkTo('src', 'dir'), + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/a/b/c/index.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'File exclusions starting with `/` do not cause traversal to loop forever', + fs: { + 'index.css': css` + @import 'tailwindcss'; + `, + 'vendor/a.css': css` + @import 'tailwindcss'; + `, + 'vendor/nested/b.css': css` + @import 'tailwindcss'; + `, + 'src/vendor/c.css': css` + @import 'tailwindcss'; + `, + }, + settings: { + tailwindCSS: { + files: { + exclude: ['/vendor'], + }, + } as Settings['tailwindCSS'], + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/index.css', + content: [], + }, + { + version: '4.1.1 (bundled)', + config: '/src/vendor/c.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'Stylesheets that import Tailwind CSS are picked over ones that dont', + fs: { + 'a/foo.css': css` + @import './bar.css'; + .a { + color: red; + } + `, + 'a/bar.css': css` + .b { + color: red; + } + `, + 'src/app.css': css` + @import 'tailwindcss'; + `, + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/app.css', + content: [], + }, + { + version: '4.1.1 (bundled)', + config: '/a/foo.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'Stylesheets that import Tailwind CSS indirectly are picked over ones that dont', + fs: { + 'a/foo.css': css` + @import './bar.css'; + .a { + color: red; + } + `, + 'a/bar.css': css` + .b { + color: red; + } + `, + 'src/app.css': css` + @import './tw.css'; + `, + 'src/tw.css': css` + @import 'tailwindcss'; + `, + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/app.css', + content: [], + }, + { + version: '4.1.1 (bundled)', + config: '/a/foo.css', + content: [], + }, + ], +}) + +testLocator({ + name: 'Stylesheets that only have URL imports are not considered roots', + fs: { + 'a/fonts.css': css` + @import 'https://example.com/fonts/some-font.css'; + .a { + color: red; + } + `, + 'src/app.css': css` + @import 'tailwindcss'; + `, + }, + expected: [ + { + version: '4.1.1 (bundled)', + config: '/src/app.css', + content: [], + }, + ], +}) + // --- function testLocator({ @@ -361,7 +628,7 @@ function testLocator({ }) } -async function prepare({ root }: TestUtils) { +async function prepare({ root }: TestUtils) { let defaultSettings = { tailwindCSS: { files: { @@ -373,7 +640,7 @@ async function prepare({ root }: TestUtils) { } as Settings function adjustPath(filepath: string) { - filepath = filepath.replace(root, '{URL}') + filepath = filepath.replace(normalizePath(root), '{URL}') if (filepath.startsWith('{URL}/')) { filepath = filepath.slice(5) diff --git a/packages/tailwindcss-language-server/src/project-locator.ts b/packages/tailwindcss-language-server/src/project-locator.ts index 460b14233..bec102900 100644 --- a/packages/tailwindcss-language-server/src/project-locator.ts +++ b/packages/tailwindcss-language-server/src/project-locator.ts @@ -1,7 +1,6 @@ -import * as os from 'node:os' import * as path from 'node:path' import * as fs from 'node:fs/promises' -import glob from 'fast-glob' +import { glob } from 'tinyglobby' import picomatch from 'picomatch' import type { Settings } from '@tailwindcss/language-service/src/util/state' import { CONFIG_GLOB, CSS_GLOB } from './lib/constants' @@ -23,10 +22,17 @@ export interface ProjectConfig { folder: string /** The path to the config file (if it exists) */ - configPath?: string + configPath: string /** The list of documents that are related to this project */ - documentSelector?: DocumentSelector[] + documentSelector: DocumentSelector[] + + /** + * Additional selectors that should be matched with this project + * + * These are *never* reset + */ + additionalSelectors: DocumentSelector[] /** Whether or not this project was explicitly defined by the user */ isUserConfigured: boolean @@ -66,7 +72,7 @@ export class ProjectLocator { } if (projects.length === 1) { - projects[0].documentSelector.push({ + projects[0].additionalSelectors.push({ pattern: normalizePath(path.join(this.base, '**')), priority: DocumentSelectorPriority.ROOT_DIRECTORY, }) @@ -86,6 +92,10 @@ export class ProjectLocator { for (let selector of project.documentSelector) { selector.pattern = normalizeDriveLetter(selector.pattern) } + + for (let selector of project.additionalSelectors) { + selector.pattern = normalizeDriveLetter(selector.pattern) + } } return projects @@ -133,6 +143,7 @@ export class ProjectLocator { priority: DocumentSelectorPriority.USER_CONFIGURED, pattern: selector, })), + additionalSelectors: [], tailwind, } } @@ -207,62 +218,7 @@ export class ProjectLocator { // Look for the package root for the config config.packageRoot = await getPackageRoot(path.dirname(config.path), this.base) - let selectors: DocumentSelector[] = [] - - // selectors: - // - CSS files - for (let entry of config.entries) { - if (entry.type !== 'css') continue - selectors.push({ - pattern: entry.path, - priority: DocumentSelectorPriority.CSS_FILE, - }) - } - - // - Config File - selectors.push({ - pattern: config.path, - priority: DocumentSelectorPriority.CONFIG_FILE, - }) - - // - Content patterns from config - for await (let selector of contentSelectorsFromConfig( - config, - tailwind.features, - this.resolver, - )) { - selectors.push(selector) - } - - // - Directories containing the CSS files - for (let entry of config.entries) { - if (entry.type !== 'css') continue - selectors.push({ - pattern: normalizePath(path.join(path.dirname(entry.path), '**')), - priority: DocumentSelectorPriority.CSS_DIRECTORY, - }) - } - - // - Directory containing the config - selectors.push({ - pattern: normalizePath(path.join(path.dirname(config.path), '**')), - priority: DocumentSelectorPriority.CONFIG_DIRECTORY, - }) - - // - Root of package that contains the config - selectors.push({ - pattern: normalizePath(path.join(config.packageRoot, '**')), - priority: DocumentSelectorPriority.PACKAGE_DIRECTORY, - }) - - // Reorder selectors from most specific to least specific - selectors.sort((a, z) => a.priority - z.priority) - - // Eliminate duplicate selector patterns - selectors = selectors.filter( - ({ pattern }, index, documentSelectors) => - documentSelectors.findIndex(({ pattern: p }) => p === pattern) === index, - ) + let selectors = await calculateDocumentSelectors(config, tailwind.features, this.resolver) return { config, @@ -270,20 +226,32 @@ export class ProjectLocator { isUserConfigured: false, configPath: config.path, documentSelector: selectors, + additionalSelectors: [], tailwind, } } private async findConfigs(): Promise { + let ignore = this.settings.tailwindCSS.files.exclude + + // NOTE: This is a temporary workaround for a bug in the `fdir` package used + // by `tinyglobby`. It infinite loops when the ignore pattern starts with + // a `/`. This should be removed once the bug is fixed. + ignore = ignore.map((pattern) => { + if (!pattern.startsWith('/')) return pattern + + return pattern.slice(1) + }) + // Look for config files and CSS files - let files = await glob([`**/${CONFIG_GLOB}`, `**/${CSS_GLOB}`], { + let files = await glob({ + patterns: [`**/${CONFIG_GLOB}`, `**/${CSS_GLOB}`], cwd: this.base, - ignore: this.settings.tailwindCSS.files.exclude, + ignore, onlyFiles: true, absolute: true, - suppressErrors: true, + followSymbolicLinks: true, dot: true, - concurrency: Math.max(os.cpus().length, 1), }) let realpaths = await Promise.all(files.map((file) => fs.realpath(file))) @@ -390,6 +358,17 @@ export class ProjectLocator { // Resolve all @source directives await Promise.all(imports.map((file) => file.resolveSourceDirectives())) + let byRealPath: Record = {} + for (let file of imports) byRealPath[file.realpath] = file + + // TODO: Link every entry in the import graph + // This breaks things tho + // for (let file of imports) file.deps = file.deps.map((dep) => byRealPath[dep.realpath] ?? dep) + + // Check if each file has a direct or indirect tailwind import + // TODO: Remove the `byRealPath` argument and use linked deps instead + await Promise.all(imports.map((file) => file.resolveImportsTailwind(byRealPath))) + // Create a graph of all the CSS files that might (indirectly) use Tailwind let graph = new Graph() @@ -427,14 +406,20 @@ export class ProjectLocator { if (indexPath && themePath) graph.connect(indexPath, themePath) if (indexPath && utilitiesPath) graph.connect(indexPath, utilitiesPath) - // Sort the graph so potential "roots" appear first - // The entire concept of roots needs to be rethought because it's not always - // clear what the root of a project is. Even when imports are present a file - // may import a file that is the actual "root" of the project. let roots = Array.from(graph.roots()) roots.sort((a, b) => { - return a.meta.root === b.meta.root ? 0 : a.meta.root ? -1 : 1 + return ( + // Sort the graph so potential "roots" appear first + // The entire concept of roots needs to be rethought because it's not always + // clear what the root of a project is. Even when imports are present a file + // may import a file that is the actual "root" of the project. + Number(b.meta.root) - Number(a.meta.root) || + // Move stylesheets with an explicit tailwindcss import before others + Number(b.importsTailwind) - Number(a.importsTailwind) || + // Otherwise stylesheets are kept in discovery order + 0 + ) }) for (let root of roots) { @@ -535,13 +520,14 @@ function contentSelectorsFromConfig( entry: ConfigEntry, features: Feature[], resolver: Resolver, + actualConfig?: any, ): AsyncIterable { if (entry.type === 'css') { return contentSelectorsFromCssConfig(entry, resolver) } if (entry.type === 'js') { - return contentSelectorsFromJsConfig(entry, features) + return contentSelectorsFromJsConfig(entry, features, actualConfig) } } @@ -576,11 +562,18 @@ async function* contentSelectorsFromJsConfig( if (typeof item !== 'string') continue let filepath = item.startsWith('!') - ? `!${path.resolve(contentBase, item.slice(1))}` + ? path.resolve(contentBase, item.slice(1)) : path.resolve(contentBase, item) + filepath = normalizePath(filepath) + filepath = normalizeDriveLetter(filepath) + + if (item.startsWith('!')) { + filepath = `!${filepath}` + } + yield { - pattern: normalizePath(filepath), + pattern: filepath, priority: DocumentSelectorPriority.CONTENT_FILE, } } @@ -593,8 +586,11 @@ async function* contentSelectorsFromCssConfig( let auto = false for (let item of entry.content) { if (item.kind === 'file') { + let filepath = item.file + filepath = normalizePath(filepath) + filepath = normalizeDriveLetter(filepath) yield { - pattern: normalizePath(item.file), + pattern: filepath, priority: DocumentSelectorPriority.CONTENT_FILE, } } else if (item.kind === 'auto' && !auto) { @@ -623,25 +619,23 @@ async function* contentSelectorsFromCssConfig( async function* detectContentFiles( base: string, inputFile: string, - sources: string[], + sources: SourcePattern[], resolver: Resolver, ): AsyncIterable { try { - let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', path.dirname(base)) + let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', base) oxidePath = pathToFileURL(oxidePath).href - let oxidePackageJsonPath = await resolver.resolveJsId( - '@tailwindcss/oxide/package.json', - path.dirname(base), - ) + let oxidePackageJsonPath = await resolver.resolveJsId('@tailwindcss/oxide/package.json', base) let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8')) let result = await oxide.scan({ oxidePath, oxideVersion: oxidePackageJson.version, basePath: base, - sources: sources.map((pattern) => ({ + sources: sources.map((source) => ({ base: path.dirname(inputFile), - pattern, + pattern: source.pattern, + negated: source.negated, })), }) @@ -649,12 +643,16 @@ async function* detectContentFiles( if (!result) return for (let file of result.files) { - yield normalizePath(file) + file = normalizePath(file) + file = normalizeDriveLetter(file) + yield file } for (let { base, pattern } of result.globs) { // Do not normalize the glob itself as it may contain escape sequences - yield normalizePath(base) + '/' + pattern + base = normalizePath(base) + base = normalizeDriveLetter(base) + yield `${base}/${pattern}` } } catch { // @@ -675,11 +673,16 @@ type ConfigEntry = { content: ContentItem[] } +export interface SourcePattern { + pattern: string + negated: boolean +} + class FileEntry { content: string | null deps: FileEntry[] = [] realpath: string | null - sources: string[] = [] + sources: SourcePattern[] = [] meta: TailwindStylesheet | null = null constructor( @@ -752,7 +755,31 @@ class FileEntry { * Determine which Tailwind versions this file might be using */ async resolvePossibleVersions() { - this.meta = this.content ? analyzeStylesheet(this.content) : null + this.meta ??= this.content ? analyzeStylesheet(this.content) : null + } + + /** + * Determine if this entry or any of its dependencies import a Tailwind CSS + * stylesheet + */ + importsTailwind: boolean | null = null + + resolveImportsTailwind(byPath: Record) { + // Already calculated so nothing to do + if (this.importsTailwind !== null) return + + // We import it directly + let self = byPath[this.realpath] + + if (this.meta?.explicitImport || self?.meta?.explicitImport) { + this.importsTailwind = true + return + } + + // Maybe one of our deps does + for (let dep of this.deps) dep.resolveImportsTailwind(byPath) + + this.importsTailwind = this.deps.some((dep) => dep.importsTailwind) } /** @@ -780,3 +807,74 @@ function requiresPreprocessor(filepath: string) { return ext === '.scss' || ext === '.sass' || ext === '.less' || ext === '.styl' || ext === '.pcss' } + +export async function calculateDocumentSelectors( + config: ConfigEntry, + features: Feature[], + resolver: Resolver, + actualConfig?: any, +) { + let selectors: DocumentSelector[] = [] + + // selectors: + // - CSS files + for (let entry of config.entries) { + if (entry.type !== 'css') continue + + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(entry.path)), + priority: DocumentSelectorPriority.CSS_FILE, + }) + } + + // - Config File + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(config.path)), + priority: DocumentSelectorPriority.CONFIG_FILE, + }) + + // - Content patterns from config + for await (let selector of contentSelectorsFromConfig(config, features, resolver, actualConfig)) { + selectors.push(selector) + } + + // - Directories containing the CSS files + for (let entry of config.entries) { + if (entry.type !== 'css') continue + + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(path.join(path.dirname(entry.path), '**'))), + priority: DocumentSelectorPriority.CSS_DIRECTORY, + }) + } + + // - Directory containing the config + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(path.join(path.dirname(config.path), '**'))), + priority: DocumentSelectorPriority.CONFIG_DIRECTORY, + }) + + // - Root of package that contains the config + selectors.push({ + pattern: normalizeDriveLetter(normalizePath(path.join(config.packageRoot, '**'))), + priority: DocumentSelectorPriority.PACKAGE_DIRECTORY, + }) + + // Reorder selectors from most specific to least specific + selectors.sort((a, z) => a.priority - z.priority) + + // Eliminate duplicate selector patterns + selectors = selectors.filter( + ({ pattern }, index, documentSelectors) => + documentSelectors.findIndex(({ pattern: p }) => p === pattern) === index, + ) + + // Move all the negated patterns to the front + selectors = selectors.sort((a, z) => { + if (a.pattern.startsWith('!') && !z.pattern.startsWith('!')) return -1 + if (!a.pattern.startsWith('!') && z.pattern.startsWith('!')) return 1 + return 0 + }) + + return selectors +} diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index b55ee0781..1038d31bd 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -15,6 +15,8 @@ import type { Disposable, DocumentLinkParams, DocumentLink, + CodeLensParams, + CodeLens, } from 'vscode-languageserver/node' import { FileChangeType } from 'vscode-languageserver/node' import type { TextDocument } from 'vscode-languageserver-textdocument' @@ -35,6 +37,7 @@ import stackTrace from 'stack-trace' import extractClassNames from './lib/extractClassNames' import { klona } from 'klona/full' import { doHover } from '@tailwindcss/language-service/src/hoverProvider' +import { getCodeLens } from '@tailwindcss/language-service/src/codeLensProvider' import { Resolver } from './resolver' import { doComplete, @@ -77,7 +80,7 @@ import { normalizeDriveLetter, } from './utils' import type { DocumentService } from './documents' -import type { ProjectConfig } from './project-locator' +import { calculateDocumentSelectors, type ProjectConfig } from './project-locator' import { supportedFeatures } from '@tailwindcss/language-service/src/features' import { loadDesignSystem } from './util/v4' import { readCssFile } from './util/css' @@ -110,6 +113,7 @@ export interface ProjectService { onColorPresentation(params: ColorPresentationParams): Promise onCodeAction(params: CodeActionParams): Promise onDocumentLinks(params: DocumentLinkParams): Promise + onCodeLens(params: CodeLensParams): Promise sortClassLists(classLists: string[]): string[] dependencies(): Iterable @@ -212,6 +216,7 @@ export async function createProjectService( let state: State = { enabled: false, + features: [], completionItemData: { _projectKey: projectKey, }, @@ -281,7 +286,9 @@ export async function createProjectService( ) } - function onFileEvents(changes: Array<{ file: string; type: FileChangeType }>): void { + async function onFileEvents( + changes: Array<{ file: string; type: FileChangeType }>, + ): Promise { let needsInit = false let needsRebuild = false @@ -302,16 +309,11 @@ export async function createProjectService( projectConfig.configPath && (isConfigFile || isDependency) ) { - documentSelector = [ - ...documentSelector.filter( - ({ priority }) => priority !== DocumentSelectorPriority.CONTENT_FILE, - ), - ...getContentDocumentSelectorFromConfigFile( - projectConfig.configPath, - initialTailwindVersion, - projectConfig.folder, - ), - ] + documentSelector = await calculateDocumentSelectors( + projectConfig.config, + state.features, + resolver, + ) checkOpenDocuments() } @@ -462,6 +464,14 @@ export async function createProjectService( // and this should be determined there and passed in instead let features = supportedFeatures(tailwindcssVersion, tailwindcss) log(`supported features: ${JSON.stringify(features)}`) + state.features = features + + if (params.initializationOptions?.testMode) { + state.features = [ + ...state.features, + ...(params.initializationOptions.additionalFeatures ?? []), + ] + } if (!features.includes('css-at-theme')) { tailwindcss = tailwindcss.default ?? tailwindcss @@ -688,6 +698,15 @@ export async function createProjectService( state.v4 = true state.v4Fallback = true state.jit = true + state.features = features + + if (params.initializationOptions?.testMode) { + state.features = [ + ...state.features, + ...(params.initializationOptions.additionalFeatures ?? []), + ] + } + state.modules = { tailwindcss: { version: tailwindcssVersion, module: tailwindcss }, postcss: { version: null, module: null }, @@ -815,6 +834,7 @@ export async function createProjectService( ) state.designSystem = designSystem + state.blocklist = Array.from(designSystem.invalidCandidates ?? []) let deps = designSystem.dependencies() @@ -940,17 +960,12 @@ export async function createProjectService( ///////////////////// if (!projectConfig.isUserConfigured) { - documentSelector = [ - ...documentSelector.filter( - ({ priority }) => priority !== DocumentSelectorPriority.CONTENT_FILE, - ), - ...getContentDocumentSelectorFromConfigFile( - state.configPath, - tailwindcss.version, - projectConfig.folder, - originalConfig, - ), - ] + documentSelector = await calculateDocumentSelectors( + projectConfig.config, + state.features, + resolver, + originalConfig, + ) } ////////////////////// @@ -960,7 +975,9 @@ export async function createProjectService( if (typeof state.separator !== 'string') { state.separator = ':' } - state.blocklist = Array.isArray(state.config.blocklist) ? state.config.blocklist : [] + if (!state.v4) { + state.blocklist = Array.isArray(state.config.blocklist) ? state.config.blocklist : [] + } delete state.config.blocklist if (state.v4) { @@ -1061,6 +1078,11 @@ export async function createProjectService( refreshDiagnostics() updateCapabilities() + + let isTestMode = params.initializationOptions?.testMode ?? false + if (!isTestMode) return + + connection.sendNotification('@/tailwindCSS/projectReloaded') } for (let entry of projectConfig.config.entries) { @@ -1126,6 +1148,7 @@ export async function createProjectService( state.designSystem = designSystem state.classList = classList state.variants = getVariants(state) + state.blocklist = Array.from(designSystem.invalidCandidates ?? []) let deps = designSystem.dependencies() @@ -1142,15 +1165,20 @@ export async function createProjectService( let elapsed = process.hrtime.bigint() - start console.log(`---- RELOADED IN ${(Number(elapsed) / 1e6).toFixed(2)}ms ----`) + + let isTestMode = params.initializationOptions?.testMode ?? false + if (!isTestMode) return + + connection.sendNotification('@/tailwindCSS/projectReloaded') }, state, documentSelector() { - return documentSelector + return [...documentSelector, ...projectConfig.additionalSelectors] }, tryInit, async dispose() { - state = { enabled: false } + state = { enabled: false, features: [] } for (let disposable of disposables) { ;(await disposable).dispose() } @@ -1159,11 +1187,9 @@ export async function createProjectService( if (state.enabled) { refreshDiagnostics() } - if (settings.editor?.colorDecorators) { - updateCapabilities() - } else { - connection.sendNotification('@/tailwindCSS/clearColors') - } + + updateCapabilities() + connection.sendNotification('@/tailwindCSS/clearColors') }, onFileEvents, async onHover(params: TextDocumentPositionParams): Promise { @@ -1177,6 +1203,17 @@ export async function createProjectService( return doHover(state, document, params.position) }, null) }, + async onCodeLens(params: CodeLensParams): Promise { + return withFallback(async () => { + if (!state.enabled) return null + let document = documentService.getDocument(params.textDocument.uri) + if (!document) return null + let settings = await state.editor.getConfiguration(document.uri) + if (!settings.tailwindCSS.codeLens) return null + if (await isExcluded(state, document)) return null + return getCodeLens(state, document) + }, null) + }, async onCompletion(params: CompletionParams): Promise { return withFallback(async () => { if (!state.enabled) return null diff --git a/packages/tailwindcss-language-server/src/testing/index.ts b/packages/tailwindcss-language-server/src/testing/index.ts index 2435ca0f7..f4737e196 100644 --- a/packages/tailwindcss-language-server/src/testing/index.ts +++ b/packages/tailwindcss-language-server/src/testing/index.ts @@ -1,41 +1,67 @@ -import { onTestFinished, test, TestOptions } from 'vitest' +import { onTestFinished, test, TestContext, TestOptions } from 'vitest' +import * as os from 'node:os' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as proc from 'node:child_process' -import dedent from 'dedent' +import dedent, { type Dedent } from 'dedent' -export interface TestUtils { +export interface TestUtils> { /** The "cwd" for this test */ root: string + + /** + * The input for this test — taken from the `inputs` in the test config + * + * @see {TestConfig} + */ + input?: TestInput +} + +export interface StorageSymlink { + [IS_A_SYMLINK]: true + filepath: string + type: 'file' | 'dir' | undefined } export interface Storage { /** A list of files and their content */ - [filePath: string]: string | Uint8Array + [filePath: string]: string | Uint8Array | StorageSymlink } -export interface TestConfig { +export interface TestConfig> { name: string + inputs?: TestInput[] + + skipNPM?: boolean fs?: Storage - prepare?(utils: TestUtils): Promise - handle(utils: TestUtils & Extras): void | Promise + debug?: boolean + prepare?(utils: TestUtils): Promise + handle(utils: TestUtils & Extras): void | Promise options?: TestOptions } -export function defineTest(config: TestConfig) { - return test(config.name, config.options ?? {}, async ({ expect }) => { - let utils = await setup(config) +export function defineTest(config: TestConfig) { + async function runTest(ctx: TestContext, input?: I) { + let utils = await setup(config, input) let extras = await config.prepare?.(utils) await config.handle({ ...utils, ...extras, }) - }) + } + + if (config.inputs) { + return test.for(config.inputs ?? [])(config.name, config.options ?? {}, (input, ctx) => + runTest(ctx, input), + ) + } + + return test(config.name, config.options ?? {}, runTest) } -async function setup(config: TestConfig): Promise { +async function setup(config: TestConfig, input: I): Promise> { let randomId = Math.random().toString(36).substring(7) let baseDir = path.resolve(process.cwd(), `../../.debug/${randomId}`) @@ -45,17 +71,28 @@ async function setup(config: TestConfig): Promise { if (config.fs) { await prepareFileSystem(baseDir, config.fs) - await installDependencies(baseDir, config.fs) + + if (!config.skipNPM) { + await installDependencies(baseDir, config.fs) + } } - onTestFinished(async (result) => { + onTestFinished(async (ctx) => { // Once done, move all the files to a new location - await fs.rename(baseDir, doneDir) + try { + await fs.rename(baseDir, doneDir) + } catch { + // If it fails it doesn't really matter. It only fails on Windows and then + // only randomly so whatever + console.error('Failed to move test files to done directory') + } - if (result.state === 'fail') return + if (ctx.task.result?.state === 'fail') return if (path.sep === '\\') return + if (config.debug) return + // Remove the directory on *nix systems. Recursive removal on Windows will // randomly fail b/c its slow and buggy. await fs.rm(doneDir, { recursive: true }) @@ -63,6 +100,16 @@ async function setup(config: TestConfig): Promise { return { root: baseDir, + input, + } +} + +const IS_A_SYMLINK = Symbol('is-a-symlink') +export function symlinkTo(filepath: string, type?: 'file' | 'dir'): StorageSymlink { + return { + [IS_A_SYMLINK]: true as const, + filepath, + type, } } @@ -74,6 +121,20 @@ async function prepareFileSystem(base: string, storage: Storage) { for (let [filepath, content] of Object.entries(storage)) { let fullPath = path.resolve(base, filepath) await fs.mkdir(path.dirname(fullPath), { recursive: true }) + + if (typeof content === 'object' && IS_A_SYMLINK in content) { + let target = path.resolve(base, content.filepath) + + let type: string = content.type + + if (os.platform() === 'win32' && content.type === 'dir') { + type = 'junction' + } + + await fs.symlink(target, fullPath, type) + continue + } + await fs.writeFile(fullPath, content, { encoding: 'utf-8' }) } } @@ -103,8 +164,8 @@ async function installDependenciesIn(dir: string) { }) } -export const css = dedent -export const scss = dedent -export const html = dedent -export const js = dedent -export const json = dedent +export const css: Dedent = dedent +export const scss: Dedent = dedent +export const html: Dedent = dedent +export const js: Dedent = dedent +export const json: Dedent = dedent diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index efb12a34f..07d3956a9 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -19,6 +19,10 @@ import type { DocumentLink, InitializeResult, WorkspaceFolder, + CodeLensParams, + CodeLens, + ServerCapabilities, + ClientCapabilities, } from 'vscode-languageserver/node' import { CompletionRequest, @@ -30,10 +34,14 @@ import { FileChangeType, DocumentLinkRequest, TextDocumentSyncKind, + CodeLensRequest, + DidChangeConfigurationNotification, } from 'vscode-languageserver/node' import { URI } from 'vscode-uri' import normalizePath from 'normalize-path' import * as path from 'node:path' +import * as fs from 'node:fs/promises' +import * as fsSync from 'node:fs' import type * as chokidar from 'chokidar' import picomatch from 'picomatch' import * as parcel from './watcher/index.js' @@ -47,7 +55,8 @@ import { readCssFile } from './util/css' import { ProjectLocator, type ProjectConfig } from './project-locator' import type { TailwindCssSettings } from '@tailwindcss/language-service/src/util/state' import { createResolver, Resolver } from './resolver' -import { retry } from './util/retry' +import { analyzeStylesheet } from './version-guesser.js' +import { createPathMatcher, PathMatcher } from './matching.js' const TRIGGER_CHARACTERS = [ // class attributes @@ -96,12 +105,14 @@ export class TW { private watched: string[] = [] private settingsCache: SettingsCache + private pathMatcher: PathMatcher constructor(private connection: Connection) { this.documentService = new DocumentService(this.connection) this.projects = new Map() this.projectCounter = 0 this.settingsCache = createSettingsCache(connection) + this.pathMatcher = createPathMatcher() } async init(): Promise { @@ -111,38 +122,75 @@ export class TW { await this.initPromise } + private validateFolderUri(uri: URI): boolean { + if (uri.scheme !== 'file') { + console.warn( + `The workspace folder [${uri.toString()}] will be ignored: it does not use the file scheme.`, + ) + return false + } + + if (uri.fsPath === '/' || uri.fsPath === '\\\\') { + console.warn( + `The workspace folder [${uri.toString()}] will be ignored: it starts at the root of the filesystem which is most likely an error.`, + ) + return false + } + + return true + } + private getWorkspaceFolders(): WorkspaceFolder[] { if (this.initializeParams.workspaceFolders?.length) { - return this.initializeParams.workspaceFolders.map((folder) => ({ - uri: URI.parse(folder.uri).fsPath, - name: folder.name, - })) + return this.initializeParams.workspaceFolders.flatMap((folder) => { + let uri = URI.parse(folder.uri) + + if (!this.validateFolderUri(uri)) return [] + + return [ + { + uri: uri.fsPath, + name: folder.name, + }, + ] + }) } if (this.initializeParams.rootUri) { + let uri = URI.parse(this.initializeParams.rootUri) + + if (!this.validateFolderUri(uri)) return [] + return [ { - uri: URI.parse(this.initializeParams.rootUri).fsPath, + uri: uri.fsPath, name: 'Root', }, ] } if (this.initializeParams.rootPath) { + let uri = URI.file(this.initializeParams.rootPath) + + if (!this.validateFolderUri(uri)) return [] + return [ { - uri: URI.file(this.initializeParams.rootPath).fsPath, + uri: uri.fsPath, name: 'Root', }, ] } + console.warn(`No workspace folders detected`) + return [] } private async _init(): Promise { clearRequireCache() + this.pathMatcher.clear() let folders = this.getWorkspaceFolders().map((folder) => normalizePath(folder.uri)) if (folders.length === 0) { @@ -166,10 +214,35 @@ export class TW { } } + if (results.some((result) => result.status === 'fulfilled')) { + await this.updateCommonCapabilities() + } + await this.listenForEvents() } private async _initFolder(baseUri: URI): Promise { + // NOTE: We do this check because on Linux when using an LSP client that does + // not support watching files on behalf of the server, we'll use Parcel + // Watcher (if possible). If we start the watcher with a non-existent or + // inaccessible directory, it will throw an error with a very unhelpful + // message: "Bad file descriptor" + // + // The best thing we can do is an initial check for access to the directory + // and log a more helpful error message if it fails. + let base = baseUri.fsPath + + try { + // TODO: Change this to fs.constants after the node version bump + await fs.access(base, fsSync.constants.F_OK | fsSync.constants.R_OK) + } catch (err) { + console.error( + `Unable to access the workspace folder [${base}]. This may happen if the directory does not exist or the current user does not have the necessary permissions to access it.`, + ) + console.error(err) + return + } + let initUserLanguages = this.initializeParams.initializationOptions?.userLanguages ?? {} if (Object.keys(initUserLanguages).length > 0) { @@ -178,7 +251,6 @@ export class TW { ) } - let base = baseUri.fsPath let workspaceFolders: Array = [] let globalSettings = await this.settingsCache.get() let ignore = globalSettings.tailwindCSS.files.exclude @@ -279,7 +351,7 @@ export class TW { return { folder: workspace.folder, config: workspace.config.path, - selectors: workspace.documentSelector, + selectors: [...workspace.documentSelector, ...workspace.additionalSelectors], user: workspace.isUserConfigured, tailwind: workspace.tailwind, } @@ -293,6 +365,7 @@ export class TW { let needsRestart = false let needsSoftRestart = false + // TODO: This should use the server-level path matcher let isPackageMatcher = picomatch(`**/${PACKAGE_LOCK_GLOB}`, { dot: true }) let isCssMatcher = picomatch(`**/${CSS_GLOB}`, { dot: true }) let isConfigMatcher = picomatch(`**/${CONFIG_GLOB}`, { dot: true }) @@ -307,6 +380,7 @@ export class TW { normalizedFilename = normalizeDriveLetter(normalizedFilename) for (let ignorePattern of ignore) { + // TODO: This should use the server-level path matcher let isIgnored = picomatch(ignorePattern, { dot: true }) if (isIgnored(normalizedFilename)) { @@ -358,6 +432,13 @@ export class TW { for (let [, project] of this.projects) { if (!project.state.v4) continue + if ( + change.type === FileChangeType.Deleted && + changeAffectsFile(normalizedFilename, [project.projectConfig.configPath]) + ) { + continue + } + if (!changeAffectsFile(normalizedFilename, project.dependencies())) continue needsSoftRestart = true @@ -381,6 +462,31 @@ export class TW { needsRestart = true break } + + // + else { + // If the main CSS file in a project is deleted and then re-created + // the server won't restart because the project is gone by now and + // there's no concept of a "config file" for us to compare with + // + // So we'll check if the stylesheet could *potentially* create + // a new project but we'll only do so if no projects were found + // + // If we did this all the time we'd potentially restart the server + // unncessarily a lot while the user is editing their stylesheets + if (this.projects.size > 0) continue + + let content = await readCssFile(change.file) + if (!content) continue + + let stylesheet = analyzeStylesheet(content) + if (!stylesheet.root) continue + + if (!stylesheet.versions.includes('4')) continue + + needsRestart = true + break + } } let isConfigFile = isConfigMatcher(normalizedFilename) @@ -465,6 +571,10 @@ export class TW { } } } else if (parcel.getBinding()) { + console.log( + '[Global] Your LSP client does not support watching files on behalf of the server', + ) + console.log('[Global] Using bundled file watcher: @parcel/watcher') let typeMap = { create: FileChangeType.Created, update: FileChangeType.Changed, @@ -491,6 +601,10 @@ export class TW { }, }) } else { + console.log( + '[Global] Your LSP client does not support watching files on behalf of the server', + ) + console.log('[Global] Using bundled file watcher: chokidar') let watch: typeof chokidar.watch = require('chokidar').watch let chokidarWatcher = watch( [`**/${CONFIG_GLOB}`, `**/${PACKAGE_LOCK_GLOB}`, `**/${CSS_GLOB}`, `**/${TSCONFIG_GLOB}`], @@ -576,8 +690,6 @@ export class TW { console.log(`[Global] Initialized ${enabledProjectCount} projects`) - this.setupLSPHandlers() - this.disposables.push( this.connection.onDidChangeConfiguration(async ({ settings }) => { let previousExclude = globalSettings.tailwindCSS.files.exclude @@ -699,7 +811,7 @@ export class TW { this.connection, params, this.documentService, - () => this.updateCapabilities(), + () => this.updateProjectCapabilities(), () => { for (let document of this.documentService.getAllDocuments()) { let project = this.getProject(document) @@ -746,9 +858,7 @@ export class TW { } setupLSPHandlers() { - if (this.lspHandlersAdded) { - return - } + if (this.lspHandlersAdded) return this.lspHandlersAdded = true this.connection.onHover(this.onHover.bind(this)) @@ -757,6 +867,7 @@ export class TW { this.connection.onDocumentColor(this.onDocumentColor.bind(this)) this.connection.onColorPresentation(this.onColorPresentation.bind(this)) this.connection.onCodeAction(this.onCodeAction.bind(this)) + this.connection.onCodeLens(this.onCodeLens.bind(this)) this.connection.onDocumentLinks(this.onDocumentLinks.bind(this)) this.connection.onRequest(this.onRequest.bind(this)) } @@ -793,37 +904,106 @@ export class TW { } } - private updateCapabilities() { - if (!supportsDynamicRegistration(this.initializeParams)) { - return + // Common capabilities are always supported by the language server and do not + // require any project-specific information to know how to configure them. + // + // These capabilities will stay valid until/unless the server has to restart + // in which case they'll be unregistered and then re-registered once project + // discovery has completed + private commonRegistrations: BulkUnregistration | undefined + private async updateCommonCapabilities() { + let capabilities = BulkRegistration.create() + + let client = this.initializeParams.capabilities + + if (client.textDocument?.hover?.dynamicRegistration) { + capabilities.add(HoverRequest.type, { documentSelector: null }) } - if (this.registrations) { - this.registrations.then((r) => r.dispose()) + if (client.textDocument?.colorProvider?.dynamicRegistration) { + capabilities.add(DocumentColorRequest.type, { documentSelector: null }) } - let projects = Array.from(this.projects.values()) + if (client.textDocument?.codeAction?.dynamicRegistration) { + capabilities.add(CodeActionRequest.type, { documentSelector: null }) + } - let capabilities = BulkRegistration.create() + if (client.textDocument?.codeLens?.dynamicRegistration) { + capabilities.add(CodeLensRequest.type, { documentSelector: null }) + } + + if (client.textDocument?.documentLink?.dynamicRegistration) { + capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) + } + + if (client.workspace?.didChangeConfiguration?.dynamicRegistration) { + capabilities.add(DidChangeConfigurationNotification.type, undefined) + } + + this.commonRegistrations?.dispose() + this.commonRegistrations = await this.connection.client.register(capabilities) + } - capabilities.add(HoverRequest.type, { documentSelector: null }) - capabilities.add(DocumentColorRequest.type, { documentSelector: null }) - capabilities.add(CodeActionRequest.type, { documentSelector: null }) - capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) + // These capabilities depend on the projects we've found to appropriately + // configure them. This may mean collecting information from all discovered + // projects to determine what we can do and how + private updateProjectCapabilities() { + this.updateTriggerCharacters() + } + + private lastTriggerCharacters: Set | undefined + private completionRegistration: Promise | undefined + private async updateTriggerCharacters() { + // If the client does not suppory dynamic registration of completions then + // we cannot update the set of trigger characters + let client = this.initializeParams.capabilities + if (!client.textDocument?.completion?.dynamicRegistration) return + + // The new set of trigger characters is all the static ones plus + // any characters from any separator in v3 config + let chars = new Set(TRIGGER_CHARACTERS) + + for (let project of this.projects.values()) { + let sep = project.state.separator + if (typeof sep !== 'string') continue + + sep = sep.slice(-1) + if (!sep) continue + + chars.add(sep) + } - capabilities.add(CompletionRequest.type, { + // If the trigger characters haven't changed then we don't need to do anything + if ( + this.completionRegistration && + equal(Array.from(chars), Array.from(this.lastTriggerCharacters ?? [])) + ) { + return + } + + this.lastTriggerCharacters = chars + + let current = this.completionRegistration + this.completionRegistration = this.connection.client.register(CompletionRequest.type, { documentSelector: null, resolveProvider: true, - triggerCharacters: [ - ...TRIGGER_CHARACTERS, - ...projects - .map((project) => project.state.separator) - .filter((sep) => typeof sep === 'string') - .map((sep) => sep.slice(-1)), - ].filter(Boolean), + triggerCharacters: Array.from(chars), }) - this.registrations = this.connection.client.register(capabilities) + // NOTE: + // This weird setup works around a race condition where multiple projects + // with different separators update their capabilities at the same time. It + // is extremely unlikely but it could cause `CompletionRequest` to be + // registered more than once with the LSP client. + // + // We store the promises meaning everything up to this point is synchronous + // so it should be fine but really the proper fix here is to: + // - Refactor workspace folder initialization so discovery, initialization, + // file events, config watchers, etc… are all shared. + // - Remove the need for the "restart" concept in the server for as much as + // possible. Each project should be capable of reloading its modules. + await current?.then((r) => r.dispose()) + await this.completionRegistration } private getProject(document: TextDocumentIdentifier): ProjectService { @@ -832,6 +1012,15 @@ export class TW { let matchedPriority: number = Infinity let uri = URI.parse(document.uri) + + if (uri.scheme !== 'file') { + console.debug(`Cannot get project for a non-file document. They are unsupported.`, { + uri: uri.toString(), + }) + + return null + } + let fsPath = uri.fsPath let normalPath = uri.path @@ -846,44 +1035,20 @@ export class TW { continue } - let documentSelector = project - .documentSelector() - .concat() - // move all the negated patterns to the front - .sort((a, z) => { - if (a.pattern.startsWith('!') && !z.pattern.startsWith('!')) { - return -1 - } - if (!a.pattern.startsWith('!') && z.pattern.startsWith('!')) { - return 1 - } - return 0 - }) - - for (let selector of documentSelector) { - let pattern = selector.pattern.replace(/[\[\]{}()]/g, (m) => `\\${m}`) - - if (pattern.startsWith('!')) { - if (picomatch(pattern.slice(1), { dot: true })(fsPath)) { - break - } - - if (picomatch(pattern.slice(1), { dot: true })(normalPath)) { - break - } - } - - if (picomatch(pattern, { dot: true })(fsPath) && selector.priority < matchedPriority) { - matchedProject = project - matchedPriority = selector.priority - - continue + for (let selector of project.documentSelector()) { + if ( + selector.pattern.startsWith('!') && + this.pathMatcher.anyMatches(selector.pattern.slice(1), [fsPath, normalPath]) + ) { + break } - if (picomatch(pattern, { dot: true })(normalPath) && selector.priority < matchedPriority) { + if ( + selector.priority < matchedPriority && + this.pathMatcher.anyMatches(selector.pattern, [fsPath, normalPath]) + ) { matchedProject = project matchedPriority = selector.priority - continue } } @@ -931,6 +1096,11 @@ export class TW { return this.getProject(params.textDocument)?.onCodeAction(params) ?? null } + async onCodeLens(params: CodeLensParams): Promise { + await this.init() + return this.getProject(params.textDocument)?.onCodeLens(params) ?? null + } + async onDocumentLinks(params: DocumentLinkParams): Promise { await this.init() return this.getProject(params.textDocument)?.onDocumentLinks(params) ?? null @@ -940,44 +1110,58 @@ export class TW { this.connection.onInitialize(async (params: InitializeParams): Promise => { this.initializeParams = params - if (supportsDynamicRegistration(params)) { - return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - workspace: { - workspaceFolders: { - changeNotifications: true, - }, - }, - }, - } - } - this.setupLSPHandlers() return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - hoverProvider: true, - colorProvider: true, - codeActionProvider: true, - documentLinkProvider: {}, - completionProvider: { - resolveProvider: true, - triggerCharacters: [...TRIGGER_CHARACTERS, ':'], - }, - workspace: { - workspaceFolders: { - changeNotifications: true, - }, - }, - }, + capabilities: this.computeServerCapabilities(params.capabilities), } }) this.connection.onInitialized(() => this.init()) } + computeServerCapabilities(client: ClientCapabilities) { + let capabilities: ServerCapabilities = { + textDocumentSync: TextDocumentSyncKind.Full, + workspace: { + workspaceFolders: { + changeNotifications: true, + }, + }, + } + + if (!client.textDocument?.hover?.dynamicRegistration) { + capabilities.hoverProvider = true + } + + if (!client.textDocument?.colorProvider?.dynamicRegistration) { + capabilities.colorProvider = true + } + + if (!client.textDocument?.codeAction?.dynamicRegistration) { + capabilities.codeActionProvider = true + } + + if (!client.textDocument?.codeLens?.dynamicRegistration) { + capabilities.codeLensProvider = { + resolveProvider: false, + } + } + + if (!client.textDocument?.completion?.dynamicRegistration) { + capabilities.completionProvider = { + resolveProvider: true, + triggerCharacters: [...TRIGGER_CHARACTERS, ':'], + } + } + + if (!client.textDocument?.documentLink?.dynamicRegistration) { + capabilities.documentLinkProvider = {} + } + + return capabilities + } + listen() { this.connection.listen() } @@ -991,10 +1175,12 @@ export class TW { this.refreshDiagnostics() - if (this.registrations) { - this.registrations.then((r) => r.dispose()) - this.registrations = undefined - } + this.commonRegistrations?.dispose() + this.commonRegistrations = undefined + + this.lastTriggerCharacters?.clear() + this.completionRegistration?.then((r) => r.dispose()) + this.completionRegistration = undefined this.disposables.forEach((d) => d.dispose()) this.disposables.length = 0 @@ -1002,11 +1188,17 @@ export class TW { this.watched.length = 0 } - restart(): void { + async restart(): Promise { + let isTestMode = this.initializeParams.initializationOptions?.testMode ?? false + console.log('----------\nRESTARTING\n----------') this.dispose() this.initPromise = undefined - this.init() + await this.init() + + if (isTestMode) { + this.connection.sendNotification('@/tailwindCSS/serverRestarted') + } } async softRestart(): Promise { @@ -1021,13 +1213,3 @@ export class TW { } } } - -function supportsDynamicRegistration(params: InitializeParams): boolean { - return ( - params.capabilities.textDocument.hover?.dynamicRegistration && - params.capabilities.textDocument.colorProvider?.dynamicRegistration && - params.capabilities.textDocument.codeAction?.dynamicRegistration && - params.capabilities.textDocument.completion?.dynamicRegistration && - params.capabilities.textDocument.documentLink?.dynamicRegistration - ) -} diff --git a/packages/tailwindcss-language-server/src/util/isExcluded.ts b/packages/tailwindcss-language-server/src/util/isExcluded.ts index beb4115a8..635473049 100644 --- a/packages/tailwindcss-language-server/src/util/isExcluded.ts +++ b/packages/tailwindcss-language-server/src/util/isExcluded.ts @@ -3,6 +3,7 @@ import * as path from 'node:path' import type { TextDocument } from 'vscode-languageserver-textdocument' import type { State } from '@tailwindcss/language-service/src/util/state' import { getFileFsPath } from './uri' +import { normalizePath, normalizeDriveLetter } from '../utils' export default async function isExcluded( state: State, @@ -11,8 +12,16 @@ export default async function isExcluded( ): Promise { let settings = await state.editor.getConfiguration(document.uri) + file = normalizePath(file) + file = normalizeDriveLetter(file) + for (let pattern of settings.tailwindCSS.files.exclude) { - if (picomatch(path.join(state.editor.folder, pattern))(file)) { + pattern = path.join(state.editor.folder, pattern) + pattern = normalizePath(pattern) + pattern = normalizeDriveLetter(pattern) + + // TODO: This should use the server-level path matcher + if (picomatch(pattern)(file)) { return true } } diff --git a/packages/tailwindcss-language-server/src/util/resolveFrom.ts b/packages/tailwindcss-language-server/src/util/resolveFrom.ts index 2b62bbeb2..c4ef1a15c 100644 --- a/packages/tailwindcss-language-server/src/util/resolveFrom.ts +++ b/packages/tailwindcss-language-server/src/util/resolveFrom.ts @@ -56,5 +56,17 @@ export function resolveFrom(from?: string, id?: string): string { let result = resolver.resolveSync({}, from, id) if (result === false) throw Error() + + // The `enhanced-resolve` package supports resolving paths with fragment + // identifiers. For example, it can resolve `foo/bar#baz` to `foo/bar.js` + // However, it's also possible that a path contains a `#` character as part + // of the path itself. For example, `foo#bar` might point to a file named + // `foo#bar.js`. The resolver distinguishes between these two cases by + // escaping the `#` character with a NUL byte when it's part of the path. + // + // Since the real path doesn't actually contain NUL bytes, we need to remove + // them to get the correct path otherwise readFileSync will throw an error. + result = result.replace(/\0(.)/g, '$1') + return result } diff --git a/packages/tailwindcss-language-server/src/util/v4/design-system.ts b/packages/tailwindcss-language-server/src/util/v4/design-system.ts index f32305ce7..f312b95c0 100644 --- a/packages/tailwindcss-language-server/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-server/src/util/v4/design-system.ts @@ -9,6 +9,7 @@ import { Resolver } from '../../resolver' import { pathToFileURL } from '../../utils' import type { Jiti } from 'jiti/lib/types' import { assets } from './assets' +import { plugins } from './plugins' const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/ const HAS_V4_THEME = /@theme\s*\{/ @@ -58,6 +59,28 @@ function createLoader({ return await jiti.import(url.href, { default: true }) } catch (err) { + // If the request was to load a first-party plugin and we can't resolve it + // locally, then fall back to the built-in plugins that we know about. + if (resourceType === 'plugin' && id in plugins) { + console.log('Loading bundled plugin for: ', id) + return await plugins[id]() + } + + // This checks for an error thrown by enhanced-resolve + if (err && typeof err.details === 'string') { + let details: string = err.details + let pattern = /^resolve '([^']+)'/ + let match = details.match(pattern) + if (match) { + let [_, importee] = match + if (importee in plugins) { + console.log( + `[error] Cannot load '${id}' plugins inside configs or plugins is not currently supported`, + ) + } + } + } + return onError(id, err, resourceType) } } @@ -196,6 +219,14 @@ export async function loadDesignSystem( Object.assign(design, { dependencies: () => dependencies, + // TODOs: + // + // 1. Remove PostCSS parsing — its roughly 60% of the processing time + // ex: compiling 19k classes take 650ms and 400ms of that is PostCSS + // + // - Replace `candidatesToCss` with a `candidatesToAst` API + // First step would be to convert to a PostCSS AST by transforming the nodes directly + // Then it would be to drop the PostCSS AST representation entirely in all v4 code paths compile(classes: string[]): (postcss.Root | null)[] { let css = design.candidatesToCss(classes) let errors: any[] = [] diff --git a/packages/tailwindcss-language-server/src/util/v4/plugins.ts b/packages/tailwindcss-language-server/src/util/v4/plugins.ts new file mode 100644 index 000000000..16efc4a5b --- /dev/null +++ b/packages/tailwindcss-language-server/src/util/v4/plugins.ts @@ -0,0 +1,5 @@ +export const plugins = { + '@tailwindcss/forms': () => import('@tailwindcss/forms').then((m) => m.default), + '@tailwindcss/aspect-ratio': () => import('@tailwindcss/aspect-ratio').then((m) => m.default), + '@tailwindcss/typography': () => import('@tailwindcss/typography').then((m) => m.default), +} diff --git a/packages/tailwindcss-language-server/src/version-guesser.ts b/packages/tailwindcss-language-server/src/version-guesser.ts index a151dea77..51b7782bd 100644 --- a/packages/tailwindcss-language-server/src/version-guesser.ts +++ b/packages/tailwindcss-language-server/src/version-guesser.ts @@ -10,6 +10,11 @@ export interface TailwindStylesheet { * The likely Tailwind version used by the given file */ versions: TailwindVersion[] + + /** + * Whether or not this stylesheet explicitly imports Tailwind CSS + */ + explicitImport: boolean } // It's likely this is a v4 file if it has a v4 import: @@ -44,7 +49,8 @@ const HAS_TAILWIND = /@tailwind\s*[^;]+;/ const HAS_COMMON_DIRECTIVE = /@(config|apply)\s*[^;{]+[;{]/ // If it's got imports at all it could be either -const HAS_IMPORT = /@import\s*['"]/ +// Note: We only care about non-url imports +const HAS_NON_URL_IMPORT = /@import\s*['"](?!([a-z]+:|\/\/))/ /** * Determine the likely Tailwind version used by the given file @@ -60,6 +66,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: true, versions: ['4'], + explicitImport: true, } } @@ -71,6 +78,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: true, versions: ['4'], + explicitImport: false, } } @@ -78,6 +86,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { // This file MUST be imported by another file to be a valid root root: false, versions: ['4'], + explicitImport: false, } } @@ -87,6 +96,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { // This file MUST be imported by another file to be a valid root root: false, versions: ['4'], + explicitImport: false, } } @@ -96,6 +106,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { // Roots are only a valid concept in v4 root: false, versions: ['3'], + explicitImport: false, } } @@ -104,6 +115,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: true, versions: ['4', '3'], + explicitImport: false, } } @@ -112,14 +124,16 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: false, versions: ['4', '3'], + explicitImport: false, } } // Files that import other files could be either and are potentially roots - if (HAS_IMPORT.test(content)) { + if (HAS_NON_URL_IMPORT.test(content)) { return { root: true, versions: ['4', '3'], + explicitImport: false, } } @@ -127,5 +141,6 @@ export function analyzeStylesheet(content: string): TailwindStylesheet { return { root: false, versions: [], + explicitImport: false, } } diff --git a/packages/tailwindcss-language-server/src/watcher/index.js b/packages/tailwindcss-language-server/src/watcher/index.js index ecf582e6a..46cec8e84 100644 --- a/packages/tailwindcss-language-server/src/watcher/index.js +++ b/packages/tailwindcss-language-server/src/watcher/index.js @@ -13,21 +13,24 @@ const uv = (process.versions.uv || '').split('.')[0] const prebuilds = { 'darwin-arm64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/darwin-arm64/node.napi.glibc.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-darwin-arm64/watcher.node'), }, 'darwin-x64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/darwin-x64/node.napi.glibc.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-darwin-x64/watcher.node'), }, 'linux-x64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/linux-x64/node.napi.glibc.node'), - 'node.napi.musl.node': () => require('@parcel/watcher/prebuilds/linux-x64/node.napi.musl.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-linux-x64-glibc/watcher.node'), + 'node.napi.musl.node': () => require('@parcel/watcher-linux-x64-musl/watcher.node'), + }, + 'linux-arm64': { + 'node.napi.glibc.node': () => require('@parcel/watcher-linux-arm64-glibc/watcher.node'), + 'node.napi.musl.node': () => require('@parcel/watcher-linux-arm64-musl/watcher.node'), }, 'win32-x64': { - 'node.napi.glibc.node': () => - require('@parcel/watcher/prebuilds/win32-x64/node.napi.glibc.node'), + 'node.napi.glibc.node': () => require('@parcel/watcher-win32-x64/watcher.node'), + }, + 'win32-arm64': { + 'node.napi.glibc.node': () => require('@parcel/watcher-win32-arm64/watcher.node'), }, } diff --git a/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts b/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts new file mode 100644 index 000000000..55f6f22e6 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/code-lens/source-inline.test.ts @@ -0,0 +1,101 @@ +import { expect } from 'vitest' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' + +defineTest({ + name: 'Code lenses are displayed for @source inline(…)', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + features: ['source-inline'], + }), + }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'css', + text: css` + @import 'tailwindcss'; + @source inline("{,{hover,focus}:}{flex,underline,bg-red-{50,{100..900.100},950}}"); + `, + }) + + let lenses = await document.codeLenses() + + expect(lenses).toEqual([ + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 81 }, + }, + command: { + title: 'Generates 15 classes', + command: '', + }, + }, + ]) + }, +}) + +defineTest({ + name: 'The user is warned when @source inline(…) generates a lerge amount of CSS', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + features: ['source-inline'], + }), + }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'css', + text: css` + @import 'tailwindcss'; + @source inline("{,dark:}{,{sm,md,lg,xl,2xl}:}{,{hover,focus,active}:}{flex,underline,bg-red-{50,{100..900.100},950}{,/{0..100}}}"); + `, + }) + + let lenses = await document.codeLenses() + + expect(lenses).toEqual([ + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'Generates 14,784 classes', + command: '', + }, + }, + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'At least 3MB of CSS', + command: '', + }, + }, + { + range: { + start: { line: 1, character: 15 }, + end: { line: 1, character: 129 }, + }, + command: { + title: 'This may slow down your bundler/browser', + command: '', + }, + }, + ]) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/colors/colors.test.js b/packages/tailwindcss-language-server/tests/colors/colors.test.js index 5016bacc3..4780a4fb2 100644 --- a/packages/tailwindcss-language-server/tests/colors/colors.test.js +++ b/packages/tailwindcss-language-server/tests/colors/colors.test.js @@ -334,7 +334,7 @@ defineTest({ expect(c.project).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -373,7 +373,7 @@ defineTest({ expect(c.project).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) diff --git a/packages/tailwindcss-language-server/tests/completions/at-config.test.js b/packages/tailwindcss-language-server/tests/completions/at-config.test.js index 15d99ac69..60ee14952 100644 --- a/packages/tailwindcss-language-server/tests/completions/at-config.test.js +++ b/packages/tailwindcss-language-server/tests/completions/at-config.test.js @@ -271,6 +271,51 @@ withFixture('v4/dependencies', (c) => { }) }) + test.concurrent('@source not', async ({ expect }) => { + let result = await completion({ + text: '@source not "', + lang: 'css', + position: { + line: 0, + character: 13, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'index.html', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'index.html', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + { + label: 'sub-dir/', + kind: 19, + command: { command: 'editor.action.triggerSuggest', title: '' }, + data: expect.anything(), + textEdit: { + newText: 'sub-dir/', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + { + label: 'tailwind.config.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'tailwind.config.js', + range: { start: { line: 0, character: 13 }, end: { line: 0, character: 13 } }, + }, + }, + ], + }) + }) + test.concurrent('@source directory', async ({ expect }) => { let result = await completion({ text: '@source "./sub-dir/', @@ -297,6 +342,58 @@ withFixture('v4/dependencies', (c) => { }) }) + test.concurrent('@source not directory', async ({ expect }) => { + let result = await completion({ + text: '@source not "./sub-dir/', + lang: 'css', + position: { + line: 0, + character: 23, + }, + }) + + expect(result).toEqual({ + isIncomplete: false, + items: [ + { + label: 'colors.js', + kind: 17, + data: expect.anything(), + textEdit: { + newText: 'colors.js', + range: { start: { line: 0, character: 23 }, end: { line: 0, character: 23 } }, + }, + }, + ], + }) + }) + + test.concurrent('@source inline(…)', async ({ expect }) => { + let result = await completion({ + text: '@source inline("', + lang: 'css', + position: { + line: 0, + character: 16, + }, + }) + + expect(result).toEqual(null) + }) + + test.concurrent('@source not inline(…)', async ({ expect }) => { + let result = await completion({ + text: '@source not inline("', + lang: 'css', + position: { + line: 0, + character: 20, + }, + }) + + expect(result).toEqual(null) + }) + test.concurrent('@import "…" source(…)', async ({ expect }) => { let result = await completion({ text: '@import "tailwindcss" source("', diff --git a/packages/tailwindcss-language-server/tests/completions/completions.test.js b/packages/tailwindcss-language-server/tests/completions/completions.test.js index 2727fcb0f..090ec872e 100644 --- a/packages/tailwindcss-language-server/tests/completions/completions.test.js +++ b/packages/tailwindcss-language-server/tests/completions/completions.test.js @@ -1,5 +1,8 @@ -import { test } from 'vitest' +import { test, expect } from 'vitest' import { withFixture } from '../common' +import { css, defineTest, html, js } from '../../src/testing' +import { createClient } from '../utils/client' +import { CompletionItemKind } from 'vscode-languageserver' function buildCompletion(c) { return async function completion({ @@ -310,8 +313,8 @@ withFixture('v4/basic', (c) => { let result = await completion({ lang, text, position, settings }) let textEdit = expect.objectContaining({ range: { start: position, end: position } }) - expect(result.items.length).toBe(12314) - expect(result.items.filter((item) => item.label.endsWith(':')).length).toBe(304) + expect(result.items.length).not.toBe(0) + expect(result.items.filter((item) => item.label.endsWith(':')).length).not.toBe(0) expect(result).toEqual({ isIncomplete: false, items: expect.arrayContaining([ @@ -485,7 +488,7 @@ withFixture('v4/basic', (c) => { }) // Make sure `@slot` is NOT suggested by default - expect(result.items.length).toBe(7) + expect(result.items.length).toBe(8) expect(result.items).not.toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 14, label: '@slot', sortText: '-0000000' }), @@ -624,7 +627,7 @@ withFixture('v4/basic', (c) => { expect(resolved).toEqual({ ...item, - detail: 'background-color: oklch(0.637 0.237 25.331);', + detail: 'background-color: oklch(63.7% 0.237 25.331);', documentation: '#fb2c36', }) }) @@ -670,3 +673,379 @@ withFixture('v4/workspaces', (c) => { }) }) }) + +defineTest({ + name: 'v4: Completions show after a variant arbitrary value', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 23 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show after an arbitrary variant', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 22 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show after a variant with a bare value', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 31 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show after a variant arbitrary value, using prefixes', + fs: { + 'app.css': css` + @import 'tailwindcss' prefix(tw); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 26 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Variant and utility suggestions show prefix when one has been typed', + fs: { + 'app.css': css` + @import 'tailwindcss' prefix(tw); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 12 }) + + expect(completion?.items.length).not.toBe(0) + + // Verify that variants and utilities are all prefixed + let prefixed = completion.items.filter((item) => !item.label.startsWith('tw:')) + expect(prefixed).toHaveLength(0) + }, +}) + +defineTest({ + name: 'v4: Variant and utility suggestions hide prefix when it has been typed', + fs: { + 'app.css': css` + @import 'tailwindcss' prefix(tw); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let completion = await document.completions({ line: 0, character: 15 }) + + expect(completion?.items.length).not.toBe(0) + + // Verify that no variants and utilities have prefixes + let prefixed = completion.items.filter((item) => item.label.startsWith('tw:')) + expect(prefixed).toHaveLength(0) + }, +}) + +defineTest({ + name: 'v4: Completions show inside class functions in JS/TS files', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classFunctions: ['clsx'], + }, + }, + lang: 'javascript', + text: js` + let classes = clsx(''); + `, + }) + + // let classes = clsx(''); + // ^ + let completion = await document.completions({ line: 0, character: 20 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Completions show inside class functions in JS/TS contexts', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classFunctions: ['clsx'], + }, + }, + lang: 'html', + text: html` + + `, + }) + + // let classes = clsx('') + // ^ + let completion = await document.completions({ line: 1, character: 22 }) + + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4: Theme key completions show in var(…)', + fs: { + 'app.css': css` + @import 'tailwindcss'; + + @theme { + --color-custom: #000; + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classFunctions: ['clsx'], + }, + }, + lang: 'css', + text: css` + .foo { + color: var(); + } + `, + }) + + // color: var(); + // ^ + let completion = await document.completions({ line: 1, character: 13 }) + + expect(completion).toEqual({ + isIncomplete: false, + items: expect.arrayContaining([ + // From the default theme + expect.objectContaining({ label: '--font-sans' }), + + // From the `@theme` block in the CSS file + expect.objectContaining({ + label: '--color-custom', + + // And it's shown as a color + kind: CompletionItemKind.Color, + documentation: '#000000', + }), + ]), + }) + }, +}) + +defineTest({ + name: 'v4: class function completions mixed with class attribute completions work', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + settings: { + tailwindCSS: { + classAttributes: ['className'], + classFunctions: ['cn', 'cva'], + }, + }, + lang: 'javascriptreact', + text: js` + let x = cva("") + + export function Button() { + return + } + + export function Button2() { + return + } + + let y = cva("") + `, + }) + + // let x = cva(""); + // ^ + let completionA = await document.completions({ line: 0, character: 13 }) + + expect(completionA?.items.length).not.toBe(0) + + // return ; + // ^ + let completionB = await document.completions({ line: 3, character: 30 }) + + expect(completionB?.items.length).not.toBe(0) + + // return ; + // ^ + let completionC = await document.completions({ line: 7, character: 30 }) + + expect(completionC?.items.length).not.toBe(0) + + // let y = cva(""); + // ^ + let completionD = await document.completions({ line: 10, character: 13 }) + + expect(completionD?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'Completions for several utilities have simplified details', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: html`
`, + }) + + //
+ // ^ + let list = await document.completions({ line: 0, character: 12 }) + let items = list?.items ?? [] + + let map = { + 'border-0': 'border-width: 0px;', + 'outline-0': 'outline-width: 0px;', + 'leading-0': 'line-height: 0rem /* 0px */;', + 'duration-1000': 'transition-duration: 1000ms;', + 'font-bold': 'font-weight: 700;', + 'ease-linear': 'transition-timing-function: linear;', + 'ease-initial': '--tw-ease: initial;', + + 'space-x-0': + 'margin-inline-start: calc(0rem /* 0px */ * var(--tw-space-x-reverse)); margin-inline-end: calc(0rem /* 0px */ * calc(1 - var(--tw-space-x-reverse)));', + 'space-y-0': + 'margin-block-start: calc(0rem /* 0px */ * var(--tw-space-y-reverse)); margin-block-end: calc(0rem /* 0px */ * calc(1 - var(--tw-space-y-reverse)));', + 'divide-x-0': + 'border-inline-start-width: calc(0px * var(--tw-divide-x-reverse)); border-inline-end-width: calc(0px * calc(1 - var(--tw-divide-x-reverse)));', + 'divide-y-0': + 'border-top-width: calc(0px * var(--tw-divide-y-reverse)); border-bottom-width: calc(0px * calc(1 - var(--tw-divide-y-reverse)));', + + 'tracking-wide': 'letter-spacing: 0.025em;', + + 'from-red-500': '--tw-gradient-from: oklch(63.7% 0.237 25.331);', + 'via-red-500': '--tw-gradient-via: oklch(63.7% 0.237 25.331);', + 'to-red-500': '--tw-gradient-to: oklch(63.7% 0.237 25.331);', + + 'scale-100': '--tw-scale-x: 100%; --tw-scale-y: 100%; --tw-scale-z: 100%;', + 'scale-z-100': '--tw-scale-z: 100%;', + + 'translate-1': '--tw-translate-x: 0.25rem /* 4px */; --tw-translate-y: 0.25rem /* 4px */;', + 'translate-z-1': '--tw-translate-z: 0.25rem /* 4px */;', + + 'bg-conic-0': + '--tw-gradient-position: from 0deg in oklab; background-image: conic-gradient(var(--tw-gradient-stops));', + } + + let requests = await Promise.all( + Object.keys(map).map(async (label) => { + let item = items.find((item) => item.label === label) + if (!item) throw new Error(`Item not found for label: ${label}`) + + let resolved = await client.conn.sendRequest('completionItem/resolve', item) + + return [label, resolved.detail] + }), + ) + + expect(Object.fromEntries(requests)).toEqual(map) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/css/css-server.test.ts b/packages/tailwindcss-language-server/tests/css/css-server.test.ts index 02146cccb..388e94af7 100644 --- a/packages/tailwindcss-language-server/tests/css/css-server.test.ts +++ b/packages/tailwindcss-language-server/tests/css/css-server.test.ts @@ -38,7 +38,7 @@ defineTest({ uri: '{workspace:default}/file-1.css', range: { start: { line: 1, character: 0 }, - end: { line: 1, character: 31 }, + end: { line: 1, character: 30 }, }, }, }, @@ -219,6 +219,7 @@ defineTest({ @theme { --color-primary: #333; --leading-*: initial; + --font-weight-*: initial; } `, }) @@ -235,7 +236,7 @@ defineTest({ uri: '{workspace:default}/file-1.css', range: { start: { line: 1, character: 0 }, - end: { line: 4, character: 1 }, + end: { line: 5, character: 1 }, }, }, }, @@ -688,3 +689,49 @@ defineTest({ expect(await doc.diagnostics()).toEqual([]) }, }) + +defineTest({ + name: 'completions are hidden inside @import source(…)/theme(…)/prefix(…) functions', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @import './file.css' source(none); + @import './file.css' theme(inline); + @import './file.css' prefix(tw); + @import './file.css' source(none) theme(inline) prefix(tw); + `, + }) + + // @import './file.css' source(none) + // ^ + // @import './file.css' theme(inline); + // ^ + // @import './file.css' prefix(tw); + // ^ + let completionsA = await doc.completions({ line: 0, character: 29 }) + let completionsB = await doc.completions({ line: 1, character: 28 }) + let completionsC = await doc.completions({ line: 2, character: 29 }) + + expect(completionsA).toEqual({ isIncomplete: false, items: [] }) + expect(completionsB).toEqual({ isIncomplete: false, items: [] }) + expect(completionsC).toEqual({ isIncomplete: false, items: [] }) + + // @import './file.css' source(none) theme(inline) prefix(tw); + // ^ ^ ^ + let completionsD = await doc.completions({ line: 3, character: 29 }) + let completionsE = await doc.completions({ line: 3, character: 41 }) + let completionsF = await doc.completions({ line: 3, character: 56 }) + + expect(completionsD).toEqual({ isIncomplete: false, items: [] }) + expect(completionsE).toEqual({ isIncomplete: false, items: [] }) + expect(completionsF).toEqual({ isIncomplete: false, items: [] }) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js index 56fe9f7ff..afda88373 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js @@ -1,6 +1,8 @@ +import * as fs from 'node:fs/promises' import { expect, test } from 'vitest' import { withFixture } from '../common' -import * as fs from 'node:fs/promises' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' withFixture('basic', (c) => { function testFixture(fixture) { @@ -383,3 +385,43 @@ withFixture('v4/basic', (c) => { ], }) }) + +defineTest({ + name: 'Shows warning when using blocklisted classes', + fs: { + 'app.css': css` + @import 'tailwindcss'; + @source not inline("{,hover:}flex"); + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + let diagnostics = await doc.diagnostics() + + expect(diagnostics).toEqual([ + { + code: 'usedBlocklistedClass', + message: 'The class "flex" will not be generated as it has been blocklisted', + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 16 }, + }, + severity: 2, + }, + { + code: 'usedBlocklistedClass', + message: 'The class "hover:flex" will not be generated as it has been blocklisted', + range: { + start: { line: 0, character: 27 }, + end: { line: 0, character: 37 }, + }, + severity: 2, + }, + ]) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js index 861f74c9e..f187cc468 100644 --- a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js +++ b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js @@ -1,6 +1,7 @@ import { test } from 'vitest' -import { withFixture } from '../common' import * as path from 'path' +import { URI } from 'vscode-uri' +import { withFixture } from '../common' withFixture('basic', (c) => { async function testDocumentLinks(name, { text, lang, expected }) { @@ -19,9 +20,7 @@ withFixture('basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/basic/tailwind.config.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/basic/tailwind.config.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 28 } }, }, ], @@ -32,9 +31,7 @@ withFixture('basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/basic/does-not-exist.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/basic/does-not-exist.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, }, ], @@ -58,9 +55,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/tailwind.config.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/tailwind.config.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 28 } }, }, ], @@ -71,9 +66,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/does-not-exist.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, }, ], @@ -84,9 +77,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/plugin.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/plugin.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 19 } }, }, ], @@ -97,9 +88,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/does-not-exist.js') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.js')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 27 } }, }, ], @@ -110,9 +99,7 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/index.html') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/index.html')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 20 } }, }, ], @@ -123,14 +110,46 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path - .resolve('./tests/fixtures/v4/basic/does-not-exist.html') - .replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.html')).toString(), range: { start: { line: 0, character: 8 }, end: { line: 0, character: 29 } }, }, ], }) + testDocumentLinks('source not: file exists', { + text: '@source not "index.html";', + lang: 'css', + expected: [ + { + target: URI.file(path.resolve('./tests/fixtures/v4/basic/index.html')).toString(), + range: { start: { line: 0, character: 12 }, end: { line: 0, character: 24 } }, + }, + ], + }) + + testDocumentLinks('source not: file does not exist', { + text: '@source not "does-not-exist.html";', + lang: 'css', + expected: [ + { + target: URI.file(path.resolve('./tests/fixtures/v4/basic/does-not-exist.html')).toString(), + range: { start: { line: 0, character: 12 }, end: { line: 0, character: 33 } }, + }, + ], + }) + + testDocumentLinks('@source inline(…)', { + text: '@source inline("m-{1,2,3}");', + lang: 'css', + expected: [], + }) + + testDocumentLinks('@source not inline(…)', { + text: '@source not inline("m-{1,2,3}");', + lang: 'css', + expected: [], + }) + testDocumentLinks('Directories in source(…) show links', { text: ` @import "tailwindcss" source("../../"); @@ -139,11 +158,11 @@ withFixture('v4/basic', (c) => { lang: 'css', expected: [ { - target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures')).toString(), range: { start: { line: 1, character: 35 }, end: { line: 1, character: 43 } }, }, { - target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`, + target: URI.file(path.resolve('./tests/fixtures')).toString(), range: { start: { line: 2, character: 33 }, end: { line: 2, character: 41 } }, }, ], diff --git a/packages/tailwindcss-language-server/tests/env/capabilities.test.ts b/packages/tailwindcss-language-server/tests/env/capabilities.test.ts new file mode 100644 index 000000000..6799c0ba2 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/env/capabilities.test.ts @@ -0,0 +1,246 @@ +import { expect } from 'vitest' +import { defineTest, js } from '../../src/testing' +import { createClient } from '../utils/client' +import * as fs from 'node:fs/promises' + +defineTest({ + name: 'Changing the separator registers new trigger characters', + fs: { + 'tailwind.config.js': js` + module.exports = { + separator: ':', + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let countBeforeChange = client.serverCapabilities.length + let capabilitiesDidChange = Promise.race([ + new Promise((_, reject) => { + setTimeout(() => reject('capabilities did not change within 5s'), 5_000) + }), + + new Promise((resolve) => { + client.onServerCapabilitiesChanged(() => { + if (client.serverCapabilities.length !== countBeforeChange) return + resolve() + }) + }), + ]) + + await fs.writeFile( + `${root}/tailwind.config.js`, + js` + module.exports = { + separator: '_', + } + `, + ) + + // After changing the config + client.notifyChangedFiles({ + changed: [`${root}/tailwind.config.js`], + }) + + // We should see that the capabilities have changed + await capabilitiesDidChange + + // Capabilities are now registered + expect(client.serverCapabilities).toContainEqual( + expect.objectContaining({ + method: 'textDocument/hover', + }), + ) + + expect(client.serverCapabilities).toContainEqual( + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', '_'], + }, + }), + ) + + expect(client.serverCapabilities).not.toContainEqual( + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ) + }, +}) + +defineTest({ + name: 'Config updates do not register new trigger characters if the separator has not changed', + fs: { + 'tailwind.config.js': js` + module.exports = { + separator: ':', + theme: { + colors: { + primary: '#f00', + } + } + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let idsBefore = client.serverCapabilities.map((cap) => cap.id) + + await fs.writeFile( + `${root}/tailwind.config.js`, + js` + module.exports = { + separator: ':', + theme: { + colors: { + primary: '#0f0', + } + } + } + `, + ) + + let didReload = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/projectReloaded', resolve) + }) + + // After changing the config + client.notifyChangedFiles({ + changed: [`${root}/tailwind.config.js`], + }) + + // Wait for the project to finish building + await didReload + + // No capabilities should have changed + let idsAfter = client.serverCapabilities.map((cap) => cap.id) + + expect(idsBefore).toEqual(idsAfter) + }, +}) + +defineTest({ + name: 'Trigger characters are registered after a server restart', + fs: { + 'app.css': '@import "tailwindcss"', + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let didRestart = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }) + + // Force a server restart by telling the server tsconfig.json changed + client.notifyChangedFiles({ + changed: [`${root}/tsconfig.json`], + }) + + // Wait for the server initialization to finish + await didRestart + + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/env/ignored.test.ts b/packages/tailwindcss-language-server/tests/env/ignored.test.ts new file mode 100644 index 000000000..f5f7d01fb --- /dev/null +++ b/packages/tailwindcss-language-server/tests/env/ignored.test.ts @@ -0,0 +1,111 @@ +import { expect } from 'vitest' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' +import dedent from 'dedent' + +let ignored = css` + @import 'tailwindcss'; + @theme { + --color-primary: #c0ffee; + } +` + +let found = css` + @import 'tailwindcss'; + @theme { + --color-primary: rebeccapurple; + } +` + +defineTest({ + name: 'various build folders and caches are ignored by default', + fs: { + // All of these should be ignored + 'aaa/.git/app.css': ignored, + 'aaa/.hg/app.css': ignored, + 'aaa/.svn/app.css': ignored, + 'aaa/node_modules/app.css': ignored, + 'aaa/.yarn/app.css': ignored, + 'aaa/.venv/app.css': ignored, + 'aaa/venv/app.css': ignored, + 'aaa/.next/app.css': ignored, + 'aaa/.parcel-cache/app.css': ignored, + 'aaa/.svelte-kit/app.css': ignored, + 'aaa/.turbo/app.css': ignored, + 'aaa/__pycache__/app.css': ignored, + + // But this one should not be + 'zzz/app.css': found, + }, + + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .bg-primary { + background-color: var(--color-primary) /* rebeccapurple = #663399 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 22 }, + }, + }) + }, +}) + +defineTest({ + name: 'ignores can be overridden', + fs: { + 'aaa/app.css': ignored, + 'bbb/.git/app.css': found, + }, + + prepare: async ({ root }) => ({ + client: await createClient({ + root, + settings: { + tailwindCSS: { + files: { + exclude: ['**/aaa/**'], + }, + }, + }, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .bg-primary { + background-color: var(--color-primary) /* rebeccapurple = #663399 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 22 }, + }, + }) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js b/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js index 44b4cb64e..f9e7da2f9 100644 --- a/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js +++ b/packages/tailwindcss-language-server/tests/env/multi-config-content.test.js @@ -1,38 +1,85 @@ -import { test } from 'vitest' -import { withFixture } from '../common' +import { expect } from 'vitest' +import { css, defineTest, html, js, json, symlinkTo } from '../../src/testing' +import dedent from 'dedent' +import { createClient } from '../utils/client' -withFixture('multi-config-content', (c) => { - test.concurrent('multi-config with content config - 1', async ({ expect }) => { - let textDocument = await c.openDocument({ text: '
', dir: 'one' }) - let res = await c.sendRequest('textDocument/hover', { - textDocument, - position: { line: 0, character: 13 }, +defineTest({ + name: 'multi-config with content config', + fs: { + 'tailwind.config.one.js': js` + module.exports = { + content: ['./one/**/*'], + theme: { + extend: { + colors: { + foo: 'red', + }, + }, + }, + } + `, + 'tailwind.config.two.js': js` + module.exports = { + content: ['./two/**/*'], + theme: { + extend: { + colors: { + foo: 'blue', + }, + }, + }, + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let one = await client.open({ + lang: 'html', + name: 'one/index.html', + text: '
', }) - expect(res).toEqual({ + let two = await client.open({ + lang: 'html', + name: 'two/index.html', + text: '
', + }) + + //
+ // ^ + let hoverOne = await one.hover({ line: 0, character: 13 }) + let hoverTwo = await two.hover({ line: 0, character: 13 }) + + expect(hoverOne).toEqual({ contents: { language: 'css', - value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(255 0 0 / var(--tw-bg-opacity, 1)) /* #ff0000 */;\n}', + value: dedent` + .bg-foo { + --tw-bg-opacity: 1; + background-color: rgb(255 0 0 / var(--tw-bg-opacity, 1)) /* #ff0000 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 18 }, }, - range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, - }) - }) - - test.concurrent('multi-config with content config - 2', async ({ expect }) => { - let textDocument = await c.openDocument({ text: '
', dir: 'two' }) - let res = await c.sendRequest('textDocument/hover', { - textDocument, - position: { line: 0, character: 13 }, }) - expect(res).toEqual({ + expect(hoverTwo).toEqual({ contents: { language: 'css', - value: - '.bg-foo {\n --tw-bg-opacity: 1;\n background-color: rgb(0 0 255 / var(--tw-bg-opacity, 1)) /* #0000ff */;\n}', + value: dedent` + .bg-foo { + --tw-bg-opacity: 1; + background-color: rgb(0 0 255 / var(--tw-bg-opacity, 1)) /* #0000ff */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 18 }, }, - range: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 } }, }) - }) + }, }) diff --git a/packages/tailwindcss-language-server/tests/env/restart.test.ts b/packages/tailwindcss-language-server/tests/env/restart.test.ts new file mode 100644 index 000000000..fa56cfb76 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/env/restart.test.ts @@ -0,0 +1,268 @@ +import { expect } from 'vitest' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { css, defineTest } from '../../src/testing' +import dedent from 'dedent' +import { createClient } from '../utils/client' + +defineTest({ + name: 'The design system is reloaded when the CSS changes ($watcher)', + fs: { + 'app.css': css` + @import 'tailwindcss'; + + @theme { + --color-primary: #c0ffee; + } + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + capabilities(caps) { + caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false + }, + }), + }), + handle: async ({ root, client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #c0ffee */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + + let didReload = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/projectReloaded', resolve) + }) + + // Update the CSS + await fs.writeFile( + path.resolve(root, 'app.css'), + css` + @import 'tailwindcss'; + + @theme { + --color-primary: #bada55; + } + `, + ) + + await didReload + + //
+ // ^ + let hover2 = await doc.hover({ line: 0, character: 13 }) + + expect(hover2).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #bada55 */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + }, +}) + +defineTest({ + options: { + retry: 3, + + // This test passes on all platforms but it is super flaky + // The server needs some re-working to ensure everything is awaited + // properly with respect to messages and server responses + skip: true, + }, + name: 'Server is "restarted" when a config file is removed', + fs: { + 'app.css': css` + @import 'tailwindcss'; + + @theme { + --color-primary: #c0ffee; + } + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + capabilities(caps) { + caps.workspace!.didChangeWatchedFiles!.dynamicRegistration = false + }, + }), + }), + handle: async ({ root, client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #c0ffee */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + + expect(client.serverCapabilities).not.toEqual([]) + let ids1 = client.serverCapabilities.map((cap) => cap.id) + + // Remove the CSS file + let didRestart = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }) + await fs.unlink(path.resolve(root, 'app.css')) + await didRestart + + expect(client.serverCapabilities).not.toEqual([]) + let ids2 = client.serverCapabilities.map((cap) => cap.id) + + //
+ // ^ + let hover2 = await doc.hover({ line: 0, character: 13 }) + expect(hover2).toEqual(null) + + // Re-create the CSS file + let didRestartAgain = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }) + await fs.writeFile( + path.resolve(root, 'app.css'), + css` + @import 'tailwindcss'; + `, + ) + await didRestartAgain + + expect(client.serverCapabilities).not.toEqual([]) + let ids3 = client.serverCapabilities.map((cap) => cap.id) + + await new Promise((resolve) => setTimeout(resolve, 500)) + + //
+ // ^ + let hover3 = await doc.hover({ line: 0, character: 13 }) + expect(hover3).toEqual(null) + + expect(ids1).not.toContainEqual(expect.toBeOneOf(ids2)) + expect(ids1).not.toContainEqual(expect.toBeOneOf(ids3)) + + expect(ids2).not.toContainEqual(expect.toBeOneOf(ids1)) + expect(ids2).not.toContainEqual(expect.toBeOneOf(ids3)) + + expect(ids3).not.toContainEqual(expect.toBeOneOf(ids1)) + expect(ids3).not.toContainEqual(expect.toBeOneOf(ids2)) + }, +}) + +defineTest({ + name: 'Creating a CSS config in an empty folder initalizes a project', + fs: { + 'app.css': css` + /* this file is not a Tailwind CSS config yet */ + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ root, log: true }), + }), + handle: async ({ root, client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual(null) + + // Create a CSS config file + await fs.writeFile( + `${root}/app.css`, + css` + @import 'tailwindcss'; + + @theme { + --color-primary: #c0ffee; + } + `, + ) + + // Create a CSS config file + // Notify the server of the config change + let didRestart = Promise.race([ + new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Did not restart in time')), 5000), + ), + ]) + + await client.notifyChangedFiles({ + changed: [`${root}/app.css`], + }) + + await didRestart + + // TODO: Sending a shutdown request immediately after a restart + // gets lost + // await client.shutdown() + + //
+ // ^ + hover = await doc.hover({ line: 0, character: 13 }) + + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .text-primary { + color: var(--color-primary) /* #c0ffee */; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 24 }, + }, + }) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/env/v4.test.js b/packages/tailwindcss-language-server/tests/env/v4.test.js index 1ae5caf4a..6104ef7ae 100644 --- a/packages/tailwindcss-language-server/tests/env/v4.test.js +++ b/packages/tailwindcss-language-server/tests/env/v4.test.js @@ -1,7 +1,7 @@ // @ts-check import { expect } from 'vitest' -import { css, defineTest, html, js, json } from '../../src/testing' +import { css, defineTest, html, js, json, symlinkTo } from '../../src/testing' import dedent from 'dedent' import { createClient } from '../utils/client' @@ -21,7 +21,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -49,7 +49,46 @@ defineTest({ }, }) - expect(completion?.items.length).toBe(12288) + expect(completion?.items.length).not.toBe(0) + }, +}) + +defineTest({ + name: 'v4, no npm, bundled plugins', + fs: { + 'app.css': css` + @import 'tailwindcss'; + @plugin "@tailwindcss/aspect-ratio"; + @plugin "@tailwindcss/forms"; + @plugin "@tailwindcss/typography"; + `, + }, + + // Note this test MUST run in spawn mode because Vitest hooks into import, + // require, etc… already and we need to test that any hooks are working + // without outside interference. + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) + expect(hover).not.toEqual(null) + + //
+ // ^ + hover = await doc.hover({ line: 0, character: 25 }) + expect(hover).not.toEqual(null) + + //
+ // ^ + hover = await doc.hover({ line: 0, character: 37 }) + expect(hover).not.toEqual(null) }, }) @@ -98,7 +137,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -149,7 +188,7 @@ defineTest({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.1" + "tailwindcss": "4.1.1" } } `, @@ -166,7 +205,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.1', + version: '4.1.1', isDefaultVersion: false, }, }) @@ -194,7 +233,7 @@ defineTest({ }, }) - expect(completion?.items.length).toBe(12288) + expect(completion?.items.length).not.toBe(0) }, }) @@ -204,7 +243,7 @@ defineTest({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.1" + "tailwindcss": "4.1.1" } } `, @@ -231,7 +270,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.1', + version: '4.1.1', isDefaultVersion: false, }, }) @@ -283,7 +322,7 @@ defineTest({ expect(await client.project()).toMatchObject({ tailwind: { - version: '4.0.6', + version: '4.1.1', isDefaultVersion: true, }, }) @@ -315,7 +354,7 @@ defineTest({ 'package.json': json` { "dependencies": { - "tailwindcss": "4.0.1" + "tailwindcss": "4.1.1" } } `, @@ -566,6 +605,12 @@ defineTest({ }) defineTest({ + // This test sometimes takes a really long time on Windows because… Windows. + options: { + retry: 3, + timeout: 30_000, + }, + name: 'Plugins with a `#` in the name are loadable', fs: { 'app.css': css` @@ -611,6 +656,12 @@ defineTest({ }) defineTest({ + // This test sometimes takes a really long time on Windows because… Windows. + options: { + retry: 3, + timeout: 30_000, + }, + name: 'v3: Presets with a `#` in the name are loadable', fs: { 'package.json': json` @@ -666,3 +717,181 @@ defineTest({ }) }, }) + +defineTest({ + // This test sometimes takes a really long time on Windows because… Windows. + options: { + retry: 3, + timeout: 30_000, + }, + + // This test *always* passes inside Vitest because our custom version of + // `Module._resolveFilename` is not called. Our custom implementation is + // using enhanced-resolve under the hood which is affected by the `#` + // character issue being considered a fragment identifier. + // + // This most commonly happens when dealing with PNPM packages that point + // to a specific commit hash of a git repository. + // + // To simulate this, we need to: + // - Add a local package to package.json + // - Symlink that local package to a directory with `#` in the name + // - Then run the test in a separate process (`spawn` mode) + // + // We can't use `file:./a#b` because NPM considers `#` to be a fragment + // identifier and will not resolve the path the way we need it to. + name: 'v3: require() works when path is resolved to contain a `#`', + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "3.4.17", + "some-pkg": "file:./packages/some-pkg" + } + } + `, + 'tailwind.config.js': js` + module.exports = { + presets: [require('some-pkg/config/tailwind.config.js').default] + } + `, + 'packages/some-pkg': symlinkTo('packages/some-pkg#c3f1e', 'dir'), + 'packages/some-pkg#c3f1e/package.json': json` + { + "name": "some-pkg", + "version": "1.0.0", + "main": "index.js" + } + `, + 'packages/some-pkg#c3f1e/config/tailwind.config.js': js` + export default { + plugins: [ + function ({ addUtilities }) { + addUtilities({ + '.example': { + color: 'red', + }, + }) + } + ] + } + `, + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + mode: 'spawn', + }), + }), + handle: async ({ client }) => { + let document = await client.open({ + lang: 'html', + text: '
', + }) + + //
+ // ^ + let hover = await document.hover({ line: 0, character: 13 }) + + expect(hover).toEqual({ + contents: { + language: 'css', + value: dedent` + .example { + color: red; + } + `, + }, + range: { + start: { line: 0, character: 12 }, + end: { line: 0, character: 19 }, + }, + }) + }, +}) + +defineTest({ + name: 'regex literals do not break language boundaries', + fs: { + 'app.css': css` + @import 'tailwindcss'; + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'javascriptreact', + text: js` + export default function Page() { + let styles = "str".match(/ +
+
+ `, + }) + + let boundaries = getLanguageBoundaries(file.state, file.doc) + + expect(boundaries).toEqual([ + { + type: 'html', + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + }, + }, + { + type: 'css', + range: { + start: { line: 1, character: 2 }, + end: { line: 5, character: 2 }, + }, + }, + { + type: 'html', + range: { + start: { line: 5, character: 2 }, + end: { line: 7, character: 6 }, + }, + }, + ]) +}) + +test('script tags in HTML are treated as a separate boundary', ({ expect }) => { + let file = createDocument({ + name: 'file.html', + lang: 'html', + content: html` +
+ +
+
+ `, + }) + + let boundaries = getLanguageBoundaries(file.state, file.doc) + + expect(boundaries).toEqual([ + { + type: 'html', + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + }, + }, + { + type: 'js', + range: { + start: { line: 1, character: 2 }, + end: { line: 5, character: 2 }, + }, + }, + { + type: 'html', + range: { + start: { line: 5, character: 2 }, + end: { line: 7, character: 6 }, + }, + }, + ]) +}) + +test('Vue files detect