diff --git a/packages/tailwindcss-language-server/src/testing/index.ts b/packages/tailwindcss-language-server/src/testing/index.ts index 2435ca0f..21296fc7 100644 --- a/packages/tailwindcss-language-server/src/testing/index.ts +++ b/packages/tailwindcss-language-server/src/testing/index.ts @@ -11,12 +11,13 @@ export interface TestUtils { export interface Storage { /** A list of files and their content */ - [filePath: string]: string | Uint8Array + [filePath: string]: string | Uint8Array | { [IS_A_SYMLINK]: true; filepath: string } } export interface TestConfig { name: string fs?: Storage + debug?: boolean prepare?(utils: TestUtils): Promise handle(utils: TestUtils & Extras): void | Promise @@ -56,6 +57,8 @@ async function setup(config: TestConfig): Promise { 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 }) @@ -66,6 +69,14 @@ async function setup(config: TestConfig): Promise { } } +const IS_A_SYMLINK = Symbol('is-a-symlink') +export const symlinkTo = function (filepath: string) { + return { + [IS_A_SYMLINK]: true as const, + filepath, + } +} + async function prepareFileSystem(base: string, storage: Storage) { // Create a temporary directory to store the test files await fs.mkdir(base, { recursive: true }) @@ -74,6 +85,13 @@ 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) + await fs.symlink(target, fullPath) + continue + } + await fs.writeFile(fullPath, content, { encoding: 'utf-8' }) } } diff --git a/packages/tailwindcss-language-server/src/util/resolveFrom.ts b/packages/tailwindcss-language-server/src/util/resolveFrom.ts index 2b62bbeb..c4ef1a15 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/tests/env/v4.test.js b/packages/tailwindcss-language-server/tests/env/v4.test.js index 1ae5caf4..6e5aa34c 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' @@ -666,3 +666,89 @@ defineTest({ }) }, }) + +defineTest({ + // 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'), + '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 }, + }, + }) + }, +}) diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 9889da1e..9da256e0 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Nothing yet! +- Don't throw when requiring() packages that resolve to a path containing a `#` character ([#1235](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1235)) # 0.14.7