From 774812f9701162aa002489b606258ddeeb51cf54 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Fri, 27 Mar 2026 19:33:45 +0530 Subject: [PATCH] fix(plugin): handle non-ASCII characters in project paths TypeScript URL-encodes non-ASCII chars in type import paths but the file system path stays un-encoded. This caused replaceImportPath to emit absolute paths instead of relative ones when the project directory contained non-ASCII characters. Decode URI components before computing relative paths. Closes #3695 --- lib/plugin/utils/plugin-utils.ts | 29 ++++++-- test/plugin/plugin-utils.spec.ts | 111 +++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 test/plugin/plugin-utils.spec.ts diff --git a/lib/plugin/utils/plugin-utils.ts b/lib/plugin/utils/plugin-utils.ts index fec729483..e60507643 100644 --- a/lib/plugin/utils/plugin-utils.ts +++ b/lib/plugin/utils/plugin-utils.ts @@ -173,12 +173,17 @@ export function replaceImportPath( importPath = convertPath(importPath); importPath = importPath.slice(2, importPath.length - 1); + // Decode any URL-encoded characters (e.g. non-ASCII) that TypeScript may + // have introduced in the import path so that posix.relative can correctly + // compute a relative path against the (non-encoded) file name. + const decodedImportPath = safeDecodeURIComponent(importPath); + try { - if (isAbsolute(importPath)) { + if (isAbsolute(decodedImportPath)) { throw {}; } - require.resolve(importPath); + require.resolve(decodedImportPath); if (!options.esmCompatible) { typeReference = typeReference.replace('import', 'require'); } @@ -189,10 +194,10 @@ export function replaceImportPath( }; } catch { const from = options?.readonly - ? convertPath(options.pathToSource) - : posix.dirname(convertPath(fileName)); + ? safeDecodeURIComponent(convertPath(options.pathToSource)) + : posix.dirname(safeDecodeURIComponent(convertPath(fileName))); - let relativePath = posix.relative(from, importPath); + let relativePath = posix.relative(from, decodedImportPath); relativePath = relativePath[0] !== '.' ? './' + relativePath : relativePath; const nodeModulesText = 'node_modules'; @@ -385,6 +390,20 @@ export function convertPath(windowsPath: string) { .replace(/\/\/+/g, '/'); } +/** + * Safely decodes URL-encoded characters in a path (e.g. non-ASCII characters + * that TypeScript may encode when generating type reference strings). + * Returns the original string if decoding fails. + * @param path + */ +export function safeDecodeURIComponent(path: string) { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Checks if a node can be directly referenced. * In the readonly mode, only literals can be referenced directly. diff --git a/test/plugin/plugin-utils.spec.ts b/test/plugin/plugin-utils.spec.ts new file mode 100644 index 000000000..469cde69c --- /dev/null +++ b/test/plugin/plugin-utils.spec.ts @@ -0,0 +1,111 @@ +import { + convertPath, + replaceImportPath, + safeDecodeURIComponent +} from '../../lib/plugin/utils/plugin-utils'; + +describe('plugin-utils', () => { + describe('convertPath', () => { + it('should convert Windows backslashes to posix forward slashes', () => { + expect(convertPath('C:\\Users\\test\\project\\src\\app.ts')).toBe( + 'C:/Users/test/project/src/app.ts' + ); + }); + + it('should collapse multiple slashes', () => { + expect(convertPath('/mnt//Data//project')).toBe('/mnt/Data/project'); + }); + + it('should pass through paths with non-ASCII characters unchanged', () => { + expect( + convertPath('/mnt/Data/testnéstcli/testcli/src/dto/test.dto') + ).toBe('/mnt/Data/testnéstcli/testcli/src/dto/test.dto'); + }); + }); + + describe('safeDecodeURIComponent', () => { + it('should decode URL-encoded non-ASCII characters', () => { + expect( + safeDecodeURIComponent( + '/mnt/Data/testn%C3%A9stcli/testcli/src/dto/test.dto' + ) + ).toBe('/mnt/Data/testnéstcli/testcli/src/dto/test.dto'); + }); + + it('should return the original string if already decoded', () => { + expect( + safeDecodeURIComponent( + '/mnt/Data/testnéstcli/testcli/src/dto/test.dto' + ) + ).toBe('/mnt/Data/testnéstcli/testcli/src/dto/test.dto'); + }); + + it('should decode CJK characters', () => { + expect( + safeDecodeURIComponent('/home/%E4%B8%AD%E6%96%87/project/src/app.ts') + ).toBe('/home/\u4e2d\u6587/project/src/app.ts'); + }); + + it('should not throw on invalid percent sequences', () => { + expect(safeDecodeURIComponent('/mnt/Data/100%/src/app.ts')).toBe( + '/mnt/Data/100%/src/app.ts' + ); + }); + }); + + describe('replaceImportPath', () => { + it('should produce relative path when import path contains URL-encoded non-ASCII characters', () => { + // Simulates what TypeScript produces when the project path contains non-ASCII chars. + // TypeScript may URL-encode the path in the type reference string. + const typeReference = + 'import("/mnt/Data/testn%C3%A9stcli/testcli/src/entities/test.entity").TestEnum'; + const fileName = + '/mnt/Data/testnéstcli/testcli/src/dto/test.dto.ts'; + const options = {}; + + const result = replaceImportPath(typeReference, fileName, options); + + // The path should be relative, not absolute + expect(result.typeReference).not.toContain('/mnt/Data'); + expect(result.typeReference).toContain('../entities/test.entity'); + }); + + it('should produce relative path when both import and file contain non-ASCII characters without encoding', () => { + const typeReference = + 'import("/mnt/Data/testnéstcli/testcli/src/entities/test.entity").TestEnum'; + const fileName = + '/mnt/Data/testnéstcli/testcli/src/dto/test.dto.ts'; + const options = {}; + + const result = replaceImportPath(typeReference, fileName, options); + + expect(result.typeReference).not.toContain('/mnt/Data'); + expect(result.typeReference).toContain('../entities/test.entity'); + }); + + it('should produce relative path when file name contains URL-encoded non-ASCII characters', () => { + const typeReference = + 'import("/mnt/Data/testnéstcli/testcli/src/entities/test.entity").TestEnum'; + const fileName = + '/mnt/Data/testn%C3%A9stcli/testcli/src/dto/test.dto.ts'; + const options = {}; + + const result = replaceImportPath(typeReference, fileName, options); + + expect(result.typeReference).not.toContain('/mnt/Data'); + expect(result.typeReference).toContain('../entities/test.entity'); + }); + + it('should handle paths without non-ASCII characters normally', () => { + const typeReference = + 'import("/mnt/Data/testcli/src/entities/test.entity").TestEnum'; + const fileName = '/mnt/Data/testcli/src/dto/test.dto.ts'; + const options = {}; + + const result = replaceImportPath(typeReference, fileName, options); + + expect(result.typeReference).not.toContain('/mnt/Data'); + expect(result.typeReference).toContain('../entities/test.entity'); + }); + }); +});