diff --git a/src/internal/constants.ts b/src/internal/constants.ts index 225e642f..da56ae3c 100644 --- a/src/internal/constants.ts +++ b/src/internal/constants.ts @@ -21,7 +21,7 @@ const DOT: string = '.' * * @const {RegExp} DRIVE_PATH_REGEX */ -const DRIVE_PATH_REGEX: RegExp = /^(?(?[a-z]):)/i +const DRIVE_PATH_REGEX: RegExp = /^(?(?[a-z]):)(?:\/|\\{2})?/i /** * Universal naming convention (UNC) path regex. @@ -32,6 +32,6 @@ const DRIVE_PATH_REGEX: RegExp = /^(?(?[a-z]):)/i * @const {RegExp} UNC_PATH_REGEX */ const UNC_PATH_REGEX: RegExp = - /^[/\\]{2,}(?[^/\\]+)(?:[/\\]+(?[^/\\]+))?(?:[/\\]+(?[^\n/\\]+))?(?:[/\\]+(?[^\n/\\]+))?[/\\]*(?:(?=\b$)|.+(?=\n?$))/ + /^(?(?[/\\]{2,})(?[^/\\]+)[/\\]+(?[^/\\]+))[/\\]*(?:(?.+?)[/\\]*(?[^/\\]+\..[^/\\]+)?[/\\]*(?=[/\\]*\n?$))?/ export { DOT, DRIVE_PATH_REGEX, UNC_PATH_REGEX } diff --git a/src/lib/__tests__/normalize.spec.ts b/src/lib/__tests__/normalize.spec.ts new file mode 100644 index 00000000..14076d01 --- /dev/null +++ b/src/lib/__tests__/normalize.spec.ts @@ -0,0 +1,110 @@ +/** + * @file Unit Tests - normalize + * @module pathe/lib/tests/unit/normalize + * @see https://github.com/nodejs/node/blob/main/test/parallel/test-path-normalize.js + */ + +import sep from '#src/lib/sep' +import { posix, win32 } from 'node:path' +import testSubject from '../normalize' + +describe('unit:lib/normalize', () => { + it('should return normalized path', () => { + // Arrange + const cases: Parameters[] = [ + [''], + [' '], + ['../.../.././.../../../bar'], + ['../.../../foobar/../../../bar/../../baz'], + ['../../../foo/../../../bar'], + ['../../../foo/../../../bar/../../'], + ['../foo../../../bar'], + ['../foobar/barfoo/foo/../../../bar/../../'], + ['./'], + ['./../'], + ['./a/..'], + ['./a/../'], + ['./fixtures///b/../b/c.js'], + ['///..//./foo/.//bar'], + ['/a/..'], + ['/a/b/c/../../../x/y/z'], + ['/foo/../../../bar'], + ['/foo/bar//baz/asdf/quux/..'], + ['a//b//.'], + ['a//b//../b'], + ['a//b//./c'], + ['bar/foo..'], + ['bar/foo../'], + ['bar/foo../..'], + ['bar/foo../../'], + ['bar/foo../../baz'], + [import.meta.url], + [posix.sep] + ] + + // Act + Expect + cases.forEach(([path]) => { + expect(testSubject(path)).to.equal(posix.normalize(path)) + }) + }) + + describe('windows', () => { + /** + * Converts Windows-style path separators (`\`) to POSIX (`/`). + * + * @param {string} path - Path to normalize + * @return {string} `path` normalized + */ + const ensurePosix = (path: string): string => path.replace(/\\/g, sep) + + it('should return normalized path', () => { + // Arrange + const cases: Parameters[] = [ + [''], + [' '], + ['../.../../foobar/../../../bar/../../baz'], + ['../../../foo/../../../bar'], + ['../../../foo/../../../bar/../../'], + ['../foobar/barfoo/foo/../../../bar/../../'], + ['..\\...\\..\\.\\...\\..\\..\\bar'], + ['..\\foo..\\..\\..\\bar'], + ['./fixtures///b/../b/c.js'], + ['//foo//bar'], + ['//foo/bar'], + ['//server/share/dir/file.ext'], + ['/a/b/c/../../../x/y/z'], + ['/foo/../../../bar'], + ['C:'], + ['C:..\\..\\abc\\..\\def'], + ['C:..\\abc'], + ['C:\\'], + ['C:\\.'], + ['C:\\temp\\..'], + ['\\\\.\\c:\\temp\\file\\..\\path'], + ['\\\\.\\foo\\bar'], + ['\\\\.\\host\\share\\dir\\file.txt\\'], + ['\\\\C:\\foo\\bar'], + ['\\\\server/share/file/../path'], + ['\\\\server\\share\\file\\..\\path'], + ['a//b//.'], + ['a//b//../b'], + ['a//b//./c'], + ['bar\\foo..'], + ['bar\\foo..\\'], + ['bar\\foo..\\..'], + ['bar\\foo..\\..\\'], + ['bar\\foo..\\..\\baz'], + ['file:stream'], + ['foo\\'], + ['foo\\bar\\baz'], + [import.meta.url], + [win32.sep] + ] + + // Act + Expect + cases.forEach(([path]) => { + expect(testSubject(path)).to.equal(ensurePosix(win32.normalize(path))) + }) + }) + }) +}) diff --git a/src/lib/index.ts b/src/lib/index.ts index 3c10e099..1049e173 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -9,4 +9,5 @@ export { default as dirname } from './dirname' export { default as extname } from './extname' export { default as format } from './format' export { default as isAbsolute } from './is-absolute' +export { default as normalize } from './normalize' export { default as sep } from './sep' diff --git a/src/lib/normalize.ts b/src/lib/normalize.ts new file mode 100644 index 00000000..ea9acada --- /dev/null +++ b/src/lib/normalize.ts @@ -0,0 +1,143 @@ +/** + * @file normalize + * @module pathe/lib/normalize + */ + +import { DOT } from '#src/internal/constants' +import ensurePosix from '#src/internal/ensure-posix' +import isDrivePath from '#src/internal/is-drive-path' +import isSep from '#src/internal/is-sep' +import normalizeString from '#src/internal/normalize-string' +import validateString from '#src/internal/validate-string' +import isAbsolute from './is-absolute' +import sep from './sep' + +/** + * Normalizes the given `path`, resolving `'..'` and `'.'` segments. + * + * When multiple, sequential path segment separation characters are found (e.g. + * `/` or `\`), they are replaced by a single instance of [`sep`][1]. Trailing + * separators are preserved. + * + * If the given `path` is a zero-length string, `'.'` is returned, representing + * the current working directory. + * + * [1]: {@link ../lib/sep.ts} + * + * @param {string} path - Path to normalize + * @return {string} Normalized `path` + * @throws {TypeError} If `path` is not a string + */ +const normalize = (path: string): string => { + validateString(path, 'path') + + // exit early if path is empty string + if (path.length === 0) return DOT + + // ensure path meets posix standards + path = ensurePosix(path) + + // exit early if path is one character + if (path.length === 1) return path + + /** + * Absolute path check. + * + * @const {boolean} absolute + */ + const absolute: boolean = isAbsolute(path) + + /** + * Trailing separator check. + * + * @var {string} trail + */ + const trail: string = isSep(path.charAt(path.length - 1)) ? sep : '' + + /** + * Drive letter, UNC path component, or empty string. + * + * @var {string} device + */ + let device: string = '' + + /** + * Index to begin path normalization. + * + * @var {number} offset + */ + let offset: number = 0 + + // adjust normalization offset if path is drive path + if (isDrivePath(path)) device = path.slice(0, (offset = 2)) + + // try adjusting normalization offset if path is possible unc path + if (isSep(path.charAt(0)) && isSep(path.charAt(1))) { + /** + * Current position in {@linkcode path}. + * + * @var {number} j + */ + let j: number = 2 + + /** + * Last visited position in {@linkcode path}. + * + * @var {number} last + */ + let last: number = j + + // match 1 or more non-path separators + while (j < path.length && !isSep(path.charAt(j))) j++ + + if (j < path.length && j !== last) { + /** + * Possible UNC path component. + * + * @const {string} host + */ + const host: string = path.slice(last, j) + + /** + * Checks if {@linkcode host} is actually a `".."` segment. + * + * @const {boolean} dotdot + */ + const dotdot: boolean = host === DOT.repeat(2) + + // set last visited position to end of host + last = j + + // match 1 or more path separators + while (j < path.length && isSep(path.charAt(j))) j++ + + if (j < path.length && j !== last) { + // set last visited position + last = j + + // match 1 or more non-path separators + while (j < path.length && !isSep(path.charAt(j))) j++ + + // matched unc root only => nothing left to process + if (j === path.length && !dotdot) { + return `${sep}${sep}${host}${sep}${path.slice(last)}${sep}` + } + + // matched unc root with leftovers + if (j !== last && !dotdot) { + device = `${sep}${sep}${host}${sep}${path.slice(last, j)}` + offset = j + } + } + } + } + + // normalize path + path = offset < path.length ? normalizeString(path.slice(offset)) : '' + + return path.length === 0 + ? `${device}${absolute ? sep : DOT + trail}` + : `${device}${absolute ? sep : ''}${path}${trail}` +} + +export default normalize