Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions lib/plugin/utils/plugin-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
111 changes: 111 additions & 0 deletions test/plugin/plugin-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});