From 75c224b92254dd8ec850c32dc0508fce1ebfebf3 Mon Sep 17 00:00:00 2001 From: William Hilton Date: Mon, 1 Jul 2019 20:44:34 -0400 Subject: [PATCH] feat: add a few utils (#2) --- .circleci/config.yml | 1 + .gitignore | 1 + package.json | 4 +- rollup.config.js | 7 +- src/__tests__/dirname.spec.ts | 58 ++++++++ src/__tests__/extname.spec.ts | 52 +++++++ src/__tests__/isAbsolute.spec.ts | 26 ++++ src/__tests__/join.spec.ts | 76 ++++++++++ src/__tests__/normalize.spec.ts | 60 ++++++++ src/__tests__/relative.spec.ts | 74 ++++++++++ src/__tests__/resolve.spec.ts | 36 +++++ src/__tests__/startsWithWindowsDrive.spec.ts | 17 +++ src/basename.ts | 11 ++ src/dirname.ts | 9 ++ src/extname.ts | 11 ++ src/format.ts | 21 +++ src/grammar.pegjs | 143 +++++++++++++++++++ src/index.ts | 14 ++ src/isAbsolute.ts | 6 + src/isURL.ts | 6 + src/join.ts | 23 +++ src/normalize.ts | 27 ++++ src/parse.ts | 24 ++++ src/parseBase.ts | 16 +++ src/relative.ts | 43 ++++++ src/resolve.ts | 17 +++ src/sep.ts | 1 + src/startsWithWindowsDrive.ts | 6 + src/toFSPath.ts | 1 + src/types.ts | 7 + tsconfig.build.json | 16 --- yarn.lock | 51 ++++++- 32 files changed, 846 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/dirname.spec.ts create mode 100644 src/__tests__/extname.spec.ts create mode 100644 src/__tests__/isAbsolute.spec.ts create mode 100644 src/__tests__/join.spec.ts create mode 100644 src/__tests__/normalize.spec.ts create mode 100644 src/__tests__/relative.spec.ts create mode 100644 src/__tests__/resolve.spec.ts create mode 100644 src/__tests__/startsWithWindowsDrive.spec.ts create mode 100644 src/basename.ts create mode 100644 src/dirname.ts create mode 100644 src/extname.ts create mode 100644 src/format.ts create mode 100644 src/grammar.pegjs create mode 100644 src/index.ts create mode 100644 src/isAbsolute.ts create mode 100644 src/isURL.ts create mode 100644 src/join.ts create mode 100644 src/normalize.ts create mode 100644 src/parse.ts create mode 100644 src/parseBase.ts create mode 100644 src/relative.ts create mode 100644 src/resolve.ts create mode 100644 src/sep.ts create mode 100644 src/startsWithWindowsDrive.ts create mode 100644 src/toFSPath.ts create mode 100644 src/types.ts delete mode 100644 tsconfig.build.json diff --git a/.circleci/config.yml b/.circleci/config.yml index de0546d..50b0796 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -16,6 +16,7 @@ jobs: name: cc-before command: | ./cc-test-reporter before-build + - run: yarn build - run: yarn test.prod - run: name: cc-after diff --git a/.gitignore b/.gitignore index f2f5e49..6d7462c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ *.log* *-debug.log* *-error.log* +src/grammar.js diff --git a/package.json b/package.json index 85d7191..c7b4961 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "node": ">=10" }, "scripts": { - "build": "sl-scripts build", + "build": "pegjs --optimize size src/grammar.pegjs && sl-scripts build", "commit": "git-cz", "lint": "tslint -c tslint.json 'src/**/*.ts?'", "lint.fix": "yarn lint --fix", @@ -41,7 +41,9 @@ "@types/jest": "24.x.x", "jest": "24.x.x", "nodemon": "1.x.x", + "pegjs": "^0.10.0", "prettier": "1.x.x", + "rollup-plugin-commonjs": "^10.0.1", "ts-jest": "24.x.x", "tslint": "5.17.0", "tslint-config-prettier": "1.18.x", diff --git a/rollup.config.js b/rollup.config.js index 84ce158..0e95b0e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,3 +1,8 @@ import config from '@stoplight/scripts/rollup.config'; -export default config; // you can customize your Rollup configuration here +// We need to add commonjs support to import 'grammar.js' generated by PEG.js +import commonjs from 'rollup-plugin-commonjs'; + +config.plugins.push(commonjs()) + +export default config; diff --git a/src/__tests__/dirname.spec.ts b/src/__tests__/dirname.spec.ts new file mode 100644 index 0000000..18d9544 --- /dev/null +++ b/src/__tests__/dirname.spec.ts @@ -0,0 +1,58 @@ +import { dirname } from '../dirname'; + +describe('posix dirname', () => { + it.each` + path | expected + ${'/a/b/'} | ${'/a'} + ${'/a/b'} | ${'/a'} + ${'/a'} | ${'/'} + ${''} | ${'.'} + ${'/'} | ${'/'} + ${'////'} | ${'/'} + ${'//a'} | ${'/'} + ${'foo'} | ${'.'} + `('computes dirname of $path', ({ path, expected }) => { + expect(dirname(path)).toBe(expected); + }); +}); + +describe('win32 dirname', () => { + it.each` + path | expected + ${'c:\\'} | ${'c:/'} + ${'c:\\foo'} | ${'c:/'} + ${'c:\\foo\\'} | ${'c:/'} + ${'c:\\foo\\bar'} | ${'c:/foo'} + ${'c:\\foo\\bar\\'} | ${'c:/foo'} + ${'c:\\foo\\bar\\baz'} | ${'c:/foo/bar'} + ${'\\'} | ${'/'} + ${'\\foo'} | ${'/'} + ${'\\foo\\'} | ${'/'} + ${'\\foo\\bar'} | ${'/foo'} + ${'\\foo\\bar\\'} | ${'/foo'} + ${'\\foo\\bar\\baz'} | ${'/foo/bar'} + ${'c:'} | ${'.'} + ${'c:foo'} | ${'.'} + ${'c:foo\\'} | ${'.'} + ${'c:foo\\bar'} | ${'c:foo'} + ${'c:foo\\bar\\'} | ${'c:foo'} + ${'c:foo\\bar\\baz'} | ${'c:foo/bar'} + ${'file:stream'} | ${'.'} + ${'dir\\file:stream'} | ${'dir'} + ${'\\\\unc\\share'} | ${'/unc'} + ${'\\\\unc\\share\\foo'} | ${'/unc/share'} + ${'\\\\unc\\share\\foo\\'} | ${'/unc/share'} + ${'\\\\unc\\share\\foo\\bar'} | ${'/unc/share/foo'} + ${'\\\\unc\\share\\foo\\bar\\'} | ${'/unc/share/foo'} + ${'\\\\unc\\share\\foo\\bar\\baz'} | ${'/unc/share/foo/bar'} + ${'/a/b/'} | ${'/a'} + ${'/a/b'} | ${'/a'} + ${'/a'} | ${'/'} + ${''} | ${'.'} + ${'/'} | ${'/'} + ${'////'} | ${'/'} + ${'foo'} | ${'.'} + `('computes dirname of $path', ({ path, expected }) => { + expect(dirname(path)).toBe(expected); + }); +}); diff --git a/src/__tests__/extname.spec.ts b/src/__tests__/extname.spec.ts new file mode 100644 index 0000000..700ad08 --- /dev/null +++ b/src/__tests__/extname.spec.ts @@ -0,0 +1,52 @@ +import { extname } from '../extname'; + +describe('extname', () => { + it.each` + path | expected + ${__filename} | ${'.ts'} + ${''} | ${''} + ${'/path/to/file'} | ${''} + ${'/path/to/file.ext'} | ${'.ext'} + ${'/path.to/file.ext'} | ${'.ext'} + ${'/path.to/file'} | ${''} + ${'/path.to/.file'} | ${''} + ${'/path.to/.file.ext'} | ${'.ext'} + ${'/path/to/f.ext'} | ${'.ext'} + ${'/path/to/..ext'} | ${'.ext'} + ${'/path/to/..'} | ${''} + ${'file'} | ${''} + ${'file.ext'} | ${'.ext'} + ${'.file'} | ${''} + ${'.file.ext'} | ${'.ext'} + ${'/file'} | ${''} + ${'/file.ext'} | ${'.ext'} + ${'/.file'} | ${''} + ${'/.file.ext'} | ${'.ext'} + ${'.path/file.ext'} | ${'.ext'} + ${'file.ext.ext'} | ${'.ext'} + ${'file.'} | ${'.'} + ${'.'} | ${''} + ${'./'} | ${''} + ${'.file.ext'} | ${'.ext'} + ${'.file'} | ${''} + ${'.file.'} | ${'.'} + ${'.file..'} | ${'.'} + ${'..'} | ${''} + ${'../'} | ${''} + ${'..file.ext'} | ${'.ext'} + ${'..file'} | ${'.file'} + ${'..file.'} | ${'.'} + ${'..file..'} | ${'.'} + ${'...'} | ${'.'} + ${'...ext'} | ${'.ext'} + ${'....'} | ${'.'} + ${'file.ext/'} | ${'.ext'} + ${'file.ext//'} | ${'.ext'} + ${'file/'} | ${''} + ${'file//'} | ${''} + ${'file./'} | ${'.'} + ${'file.//'} | ${'.'} + `('computes extname of $path', ({ path, expected }) => { + expect(extname(path)).toBe(expected); + }); +}); diff --git a/src/__tests__/isAbsolute.spec.ts b/src/__tests__/isAbsolute.spec.ts new file mode 100644 index 0000000..5b78024 --- /dev/null +++ b/src/__tests__/isAbsolute.spec.ts @@ -0,0 +1,26 @@ +import { isAbsolute } from '../isAbsolute'; + +describe('isAbsolute', () => { + it.each([ + '\\foo\\bar.json', + 'c:\\foo\\bar.json', + 'c:\\', + 'c:/', + 'c:/foo/bar.json', + '/home/test', + '/', + '//', + '/var/lib/test/', + '/var/bin.d', + 'http://example.com/is/absolute', + 'https://stoplight.io', + 'file:///this/is/also/absolute', + 'file://c:/and/this/is/../absolute', + ])('treats %s path as absolute', filepath => { + expect(isAbsolute(filepath)).toBe(true); + }); + + it.each(['foo/bar', 'test', ''])('treats %s path as non-absolute', filepath => { + expect(isAbsolute(filepath)).toBe(false); + }); +}); diff --git a/src/__tests__/join.spec.ts b/src/__tests__/join.spec.ts new file mode 100644 index 0000000..ec6e9c9 --- /dev/null +++ b/src/__tests__/join.spec.ts @@ -0,0 +1,76 @@ +import { join } from '../'; + +describe('join', () => { + it('does some basic resolving', () => { + expect(join('/foo/bar', '..', 'baz')).toEqual('/foo/baz'); + expect(join('c:/foo/bar', '..', 'baz')).toEqual('c:/foo/baz'); + }); + + it('handles mixed slashes', () => { + expect(join('/test\\baz', 'foo/d')).toEqual('/test/baz/foo/d'); + }); + + it('handles URLs', () => { + expect(join('https://foo.com/pets', '..', 'users', '123')).toEqual('https://foo.com/users/123'); + expect(join('https://foo.test', 'com', 'baz')).toEqual('https://foo.test/com/baz'); + }); + + it.each` + args | result + ${['.', 'x/b', '..', 'b/c.js']} | ${'x/b/c.js'} + ${[]} | ${'.'} + ${['/.', 'x/b', '..', 'b/c.js']} | ${'/x/b/c.js'} + ${['/foo', '../../../bar']} | ${'/bar'} + ${['foo', '../../../bar']} | ${'../../bar'} + ${['foo/', '../../../bar']} | ${'../../bar'} + ${['foo/x', '../../../bar']} | ${'../bar'} + ${['foo/x', './bar']} | ${'foo/x/bar'} + ${['foo/x/', './bar']} | ${'foo/x/bar'} + ${['foo/x/', '.', 'bar']} | ${'foo/x/bar'} + ${['./']} | ${'.'} + ${['.', './']} | ${'.'} + ${['.', '.', '.']} | ${'.'} + ${['.', './', '.']} | ${'.'} + ${['.']} | ${'.'} + ${['', '.']} | ${'.'} + ${['', 'foo']} | ${'foo'} + ${['foo', './bar']} | ${'foo/bar'} + ${['', '', 'foo']} | ${'foo'} + ${['foo', '']} | ${'foo'} + ${['foo/', '']} | ${'foo'} + ${['foo', '', 'bar']} | ${'foo/bar'} + ${['./', '..', 'foo']} | ${'../foo'} + ${['./', '..', '..', './foo']} | ${'../../foo'} + ${['.', '..', '..', 'foo']} | ${'../../foo'} + ${['', '..', '..', 'foo']} | ${'../../foo'} + ${['/']} | ${'/'} + ${['/', '.']} | ${'/'} + ${['/', '..']} | ${'/'} + ${['/', '..', '..']} | ${'/'} + ${['']} | ${'.'} + ${['', '']} | ${'.'} + ${[' /foo']} | ${' /foo'} + ${[' ', 'foo']} | ${' /foo'} + ${[' ', '.']} | ${' '} + ${[' ', '']} | ${' '} + ${['/', 'foo']} | ${'/foo'} + `('joins $args', ({ args, result }) => { + expect(join(...args)).toBe(result); + }); + + it.each` + args + ${['.', '/./', '.']} + ${['.', '/////./', '.']} + ${['', '/foo']} + ${['', '', '/foo']} + ${[' ', '/']} + ${['/', '/foo']} + ${['/', '//foo']} + ${['/', '', '/foo']} + ${['', '/', 'foo']} + ${['', '/', '/foo']} + `('join($args) to throw', ({ args }) => { + expect(() => join(...args)).toThrow(); + }); +}); diff --git a/src/__tests__/normalize.spec.ts b/src/__tests__/normalize.spec.ts new file mode 100644 index 0000000..332ebf5 --- /dev/null +++ b/src/__tests__/normalize.spec.ts @@ -0,0 +1,60 @@ +import { normalize } from '../'; + +describe('normalize', () => { + describe('replaces Windows-like slashes with POSIX-compatible ones', () => { + it.each` + path | result + ${'c:\\foo\\bar'} | ${'c:/foo/bar'} + `("normalize('$path')", ({ path, result }) => { + expect(normalize(path)).toEqual(result); + }); + }); + + describe('ignores POSIX slashes', () => { + it.each` + path | result + ${'/d/foo'} | ${'/d/foo'} + `("normalize('$path')", ({ path, result }) => { + expect(normalize(path)).toEqual(result); + }); + }); + + describe('does some basic resolving', () => { + it.each` + path | result + ${'/foo/bar/boom/../../baz/.././a'} | ${'/foo/a'} + ${'/foo/bar/boom/../a'} | ${'/foo/bar/a'} + ${'/foo/bar/boom/..'} | ${'/foo/bar'} + `("normalize('$path')", ({ path, result }) => { + expect(normalize(path)).toEqual(result); + }); + }); + + describe('handles URLs', () => { + it.each` + path | result + ${'https://foo.com/baz/bar'} | ${'https://foo.com/baz/bar'} + ${'htTps://foo.com/baz/bar'} | ${'https://foo.com/baz/bar'} + ${'htTps://foo.com/baz/bar/../foo'} | ${'https://foo.com/baz/foo'} + `("normalize('$path')", ({ path, result }) => { + expect(normalize(path)).toEqual(result); + }); + }); + + describe('resolves .. in absolute paths differently from relative paths', () => { + it.each` + path | result + ${'../../foo'} | ${'../../foo'} + ${'../../foo/../bar'} | ${'../../bar'} + ${'../../foo/../bar/../..'} | ${'../../..'} + ${'/../../foo'} | ${'/foo'} + ${'/../../foo/../bar'} | ${'/bar'} + ${'/../../foo/../bar/../..'} | ${'/'} + ${'https://foo.com/../../foo'} | ${'https://foo.com/foo'} + ${'https://foo.com/../../foo/../bar'} | ${'https://foo.com/bar'} + ${'https://foo.com/../../foo/../bar/../..'} | ${'https://foo.com/'} + `("normalize('$path')", ({ path, result }) => { + expect(normalize(path)).toEqual(result); + }); + }); +}); diff --git a/src/__tests__/relative.spec.ts b/src/__tests__/relative.spec.ts new file mode 100644 index 0000000..4c01089 --- /dev/null +++ b/src/__tests__/relative.spec.ts @@ -0,0 +1,74 @@ +import { relative } from '../relative'; + +describe('relative', () => { + describe('handles POSIX paths', () => { + it.each` + from | to | result + ${'/test/bar/a'} | ${'/test/foo/c'} | ${'../../foo/c'} + ${'/var/lib'} | ${'/var'} | ${'..'} + ${'/var/lib'} | ${'/bin'} | ${'../../bin'} + ${'/var/lib'} | ${'/var/lib'} | ${'.'} + ${'/var/lib'} | ${'/var/apache'} | ${'../apache'} + ${'/var/'} | ${'/var/lib'} | ${'lib'} + ${'/'} | ${'/var/lib'} | ${'var/lib'} + ${'/foo/test'} | ${'/foo/test/bar/package.json'} | ${'bar/package.json'} + ${'/Users/a/web/b/test/mails'} | ${'/Users/a/web/b'} | ${'../..'} + ${'/foo/bar/baz-quux'} | ${'/foo/bar/baz'} | ${'../baz'} + ${'/foo/bar/baz'} | ${'/foo/bar/baz-quux'} | ${'../baz-quux'} + ${'/baz-quux'} | ${'/baz'} | ${'../baz'} + ${'/baz'} | ${'/baz-quux'} | ${'../baz-quux'} + `("handles relative('$from', '$to')", ({ from, to, result }) => { + expect(relative(from, to)).toEqual(result); + }); + }); + + describe('handles Windows URIs', () => { + it.each` + from | to | result + ${'c:\\test\\baz'} | ${'C:\\test\\foo'} | ${'../foo'} + ${'c:/blah\\blah'} | ${'d:/games'} | ${'d:/games'} + ${'c:/aaaa/bbbb'} | ${'c:/aaaa'} | ${'..'} + ${'c:/aaaa/bbbb'} | ${'c:/cccc'} | ${'../../cccc'} + ${'c:/aaaa/bbbb'} | ${'c:/aaaa/bbbb'} | ${'.'} + ${'c:/aaaa/bbbb'} | ${'c:/aaaa/cccc'} | ${'../cccc'} + ${'c:/aaaa/'} | ${'c:/aaaa/cccc'} | ${'cccc'} + ${'c:/'} | ${'c:\\aaaa\\bbbb'} | ${'aaaa/bbbb'} + ${'c:/aaaa/bbbb'} | ${'d:\\'} | ${'d:/'} + ${'c:/AaAa/bbbb'} | ${'c:/aaaa/bbbb'} | ${'../../aaaa/bbbb'} + ${'c:/aaaaa/'} | ${'c:/aaaa/cccc'} | ${'../aaaa/cccc'} + ${'C:\\foo\\bar\\baz\\quux'} | ${'C:\\'} | ${'../../../..'} + ${'C:\\foo\\test'} | ${'C:\\foo\\test\\bar\\package.json'} | ${'bar/package.json'} + ${'C:\\foo\\bar\\baz-quux'} | ${'C:\\foo\\bar\\baz'} | ${'../baz'} + ${'C:\\foo\\bar\\baz'} | ${'C:\\foo\\bar\\baz-quux'} | ${'../baz-quux'} + ${'\\\\foo\\bar'} | ${'\\\\foo\\bar\\baz'} | ${'baz'} + ${'\\\\foo\\bar\\baz'} | ${'\\\\foo\\bar'} | ${'..'} + ${'\\\\foo\\bar\\baz-quux'} | ${'\\\\foo\\bar\\baz'} | ${'../baz'} + ${'\\\\foo\\bar\\baz'} | ${'\\\\foo\\bar\\baz-quux'} | ${'../baz-quux'} + ${'C:\\baz-quux'} | ${'C:\\baz'} | ${'../baz'} + ${'C:\\baz'} | ${'C:\\baz-quux'} | ${'../baz-quux'} + ${'\\\\foo\\baz-quux'} | ${'\\\\foo\\baz'} | ${'../baz'} + ${'\\\\foo\\baz'} | ${'\\\\foo\\baz-quux'} | ${'../baz-quux'} + ${'C:\\baz'} | ${'\\\\foo\\bar\\baz'} | ${'/foo/bar/baz'} + ${'\\\\foo\\bar\\baz'} | ${'C:\\baz'} | ${'c:/baz'} + `("handles relative('$from', '$to')", ({ from, to, result }) => { + expect(relative(from, to)).toEqual(result); + }); + }); + + it('handles mixed slashes', () => { + expect(relative('/test\\baz', '/test\\foo')).toEqual('../foo'); + expect(relative('c:/test\\baz', 'C:/test\\foo')).toEqual('../foo'); + }); + + it('handles URLs', () => { + expect(relative('http://stoplight.io/', 'http://stoplight.io/bar/foo')).toEqual('bar/foo'); + expect(relative('http://stoplight.io/bar/z', 'http://stoplight.io/bar/foo/baz')).toEqual('../foo/baz'); + }); + + it('handles different origins', () => { + expect(relative('/a/bar/c', '/x/foo/c')).toEqual('../../../x/foo/c'); + expect(relative('https://stop.bar/bar/z/x', 'http://stoplight.io/bar/foo/baz')).toEqual( + 'http://stoplight.io/bar/foo/baz', + ); + }); +}); diff --git a/src/__tests__/resolve.spec.ts b/src/__tests__/resolve.spec.ts new file mode 100644 index 0000000..ee628a9 --- /dev/null +++ b/src/__tests__/resolve.spec.ts @@ -0,0 +1,36 @@ +import { resolve } from '../resolve'; + +describe('resolve', () => { + describe('handles POSIX paths', () => { + it.each` + segments | result + ${['/var/lib', '../', 'file/']} | ${'/var/file'} + ${['a/b/c/', '../../..']} | ${'.'} + ${['.']} | ${'.'} + ${['/some/dir', '.', '/absolute/']} | ${'/absolute'} + ${['/foo/tmp.3/', '../tmp.3/cycles/root.js']} | ${'/foo/tmp.3/cycles/root.js'} + `('handles resolve($segments)', ({ segments, result }) => { + expect(resolve(...segments)).toEqual(result); + }); + }); + + describe('handles win32 paths', () => { + it.each` + segments | result + ${['c:/blah\\blah', '../a']} | ${'c:/blah/a'} + ${['d:\\a/b\\c/d', 'e.exe']} | ${'d:/a/b/c/d/e.exe'} + ${['c:/some/file']} | ${'c:/some/file'} + ${['d:/ignore', 'some/dir//']} | ${'d:/ignore/some/dir'} + ${['.']} | ${'.'} + ${['//server/share', '..', 'relative\\']} | ${'/server/relative'} + ${['c:/', '//']} | ${'/'} + ${['c:/', '//dir']} | ${'/dir'} + ${['c:/', '//server/share']} | ${'/server/share'} + ${['c:/', '//server//share']} | ${'/server/share'} + ${['c:/', '///some//dir']} | ${'/some/dir'} + ${['C:\\foo\\tmp.3\\', '..\\tmp.3\\cycles\\root.js']} | ${'c:/foo/tmp.3/cycles/root.js'} + `('handles resolve($segments)', ({ segments, result }) => { + expect(resolve(...segments)).toEqual(result); + }); + }); +}); diff --git a/src/__tests__/startsWithWindowsDrive.spec.ts b/src/__tests__/startsWithWindowsDrive.spec.ts new file mode 100644 index 0000000..5c03cc4 --- /dev/null +++ b/src/__tests__/startsWithWindowsDrive.spec.ts @@ -0,0 +1,17 @@ +import { startsWithWindowsDrive } from '../'; + +describe('startsWithWindowsDrive', () => { + it.each(['c:\\foo\\bar.json', 'c:\\', 'c:/', 'c:/foo/bar.json', 'c:\\', 'Z:\\', 'A:/'])( + 'recognizes driver letter in %s', + filepath => { + expect(startsWithWindowsDrive(filepath)).toBe(true); + }, + ); + + it.each(['0:\\foo\\bar.json', ' :\\', 'c:a', 'z\\\\', '', 'c:', 'c'])( + 'does not detect driver letter in %s', + filepath => { + expect(startsWithWindowsDrive(filepath)).toBe(false); + }, + ); +}); diff --git a/src/basename.ts b/src/basename.ts new file mode 100644 index 0000000..bd41ba0 --- /dev/null +++ b/src/basename.ts @@ -0,0 +1,11 @@ +import { normalizeParsed } from './normalize'; +import { parse } from './parse'; +import { parseBase } from './parseBase'; + +export const basename = (path: string) => { + const parsed = normalizeParsed(parse(path)); + const base = parsed.path.pop(); + if (!base) return ''; + const { name } = parseBase(base); + return name; +}; diff --git a/src/dirname.ts b/src/dirname.ts new file mode 100644 index 0000000..bb116ae --- /dev/null +++ b/src/dirname.ts @@ -0,0 +1,9 @@ +import { format } from './format'; +import { normalizeParsed } from './normalize'; +import { parse } from './parse'; + +export const dirname = (path: string) => { + const parsed = normalizeParsed(parse(path)); + parsed.path.pop(); + return format(normalizeParsed(parsed)); +}; diff --git a/src/extname.ts b/src/extname.ts new file mode 100644 index 0000000..9e523f9 --- /dev/null +++ b/src/extname.ts @@ -0,0 +1,11 @@ +import { normalizeParsed } from './normalize'; +import { parse } from './parse'; +import { parseBase } from './parseBase'; + +export const extname = (path: string) => { + const parsed = normalizeParsed(parse(path)); + const base = parsed.path.pop(); + if (!base) return ''; + const { ext } = parseBase(base); + return ext; +}; diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..e38d29d --- /dev/null +++ b/src/format.ts @@ -0,0 +1,21 @@ +import { IPath } from './types'; + +export function format(parsed: IPath): string { + let path = ''; + if (parsed.absolute) { + if (parsed.protocol === 'file') { + if (parsed.drive) { + path += parsed.drive; + } + path += '/'; + } else { + path += parsed.protocol + '://'; + if (parsed.origin) { + path += parsed.origin + '/'; + } + } + } + path += parsed.path.join('/'); + if (path === '') path = '.'; + return path; +} diff --git a/src/grammar.pegjs b/src/grammar.pegjs new file mode 100644 index 0000000..eb9072b --- /dev/null +++ b/src/grammar.pegjs @@ -0,0 +1,143 @@ +// Filepath Parsing Grammar +// ========================== +// +// Paste into https://pegjs.org/online + +Path + = RemotePath + / FileSchemaPath + / AbsolutePath + / RelativePath + +RemotePath + = protocol:RemoteProtocol origin:Origin root:PosixRoot path:PathWrapper { + return { + protocol, + origin, + absolute: true, + ...root, + ...path + } + } + / protocol:RemoteProtocol origin:Origin root:ImplicitRoot { + return { + protocol, + origin, + absolute: true, + ...root, + path: [] + } + } + + RemoteProtocol + = HttpProtocol + / HttpsProtocol + + HttpProtocol + = raw:"http://"i { + return 'http' + } + + HttpsProtocol + = raw:"https://"i { + return 'https' + } + + Origin + = $ NotSep+ + / "" { return null } + +FileSchemaPath + = protocol:FileProtocol root:Root path:PathWrapper { + return { + protocol, + origin: null, + absolute: true, + ...root, + ...path + } + } + + FileProtocol + = raw:("file://"i / "file:"i) { + return 'file' + } + +AbsolutePath + = root:Root path:PathWrapper { + return { + protocol: 'file', + origin: null, + absolute: true, + ...root, + ...path + } +} + + Root + = PosixRoot + / WindowsRoot + + PosixRoot + = Sep { + return { + drive: null + } + } + + WindowsRoot + = drive:[A-Za-z] ":" Sep { + return { + drive: drive.toLowerCase() + ':' + } + } + + ImplicitRoot + = "" { + return { + drive: null + } + } + +RelativePath + = Cwd path:PathWrapper { + return { + protocol: null, + origin: null, + absolute: false, + drive: null, + ...path + } + } + +PathWrapper + = path:PathSeq { + return { + path, + } + } + +PathSeq + = head:Directory Sep tail:PathSeq { return [head, ...tail] } + / head:Directory { return [head] } + +Cwd + = ExplicitCwd + / ImplicitCwd + +Directory + = $ NotSep+ + / "" + +ExplicitCwd + = "." Sep + +ImplicitCwd + = "" + +Sep + = "/" + / "\\" + +NotSep + = [^/\\] diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b7a8ef9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +export * from './basename'; +export * from './dirname'; +export * from './extname'; +export * from './format'; +export * from './isAbsolute'; +export * from './join'; +export { normalize } from './normalize'; +export { parse } from './parse'; +export * from './relative'; +export * from './resolve'; +export * from './sep'; +export * from './startsWithWindowsDrive'; +export * from './toFSPath'; +export * from './types'; diff --git a/src/isAbsolute.ts b/src/isAbsolute.ts new file mode 100644 index 0000000..5052a8b --- /dev/null +++ b/src/isAbsolute.ts @@ -0,0 +1,6 @@ +import { parse } from './parse'; + +export function isAbsolute(filepath: string) { + const parsed = parse(filepath); + return parsed.absolute; +} diff --git a/src/isURL.ts b/src/isURL.ts new file mode 100644 index 0000000..7f0174f --- /dev/null +++ b/src/isURL.ts @@ -0,0 +1,6 @@ +import { parse } from './parse'; + +export function isURL(filepath: string) { + const parsed = parse(filepath); + return parsed.protocol === 'http' || parsed.protocol === 'https'; +} diff --git a/src/join.ts b/src/join.ts new file mode 100644 index 0000000..d22f490 --- /dev/null +++ b/src/join.ts @@ -0,0 +1,23 @@ +import { format } from './format'; +import { normalizeParsed } from './normalize'; +import { parse } from './parse'; +import { IPath } from './types'; + +export const join = (...parts: string[]) => { + // edge case + if (parts.length === 0) return '.'; + + const parsedParts = parts.map(parse); + const newRoot: IPath = { ...parsedParts[0] }; + + for (let i = 1; i < parsedParts.length; i++) { + const parsed = parsedParts[i]; + if (parsed.absolute) { + throw new Error('Cannot join an absolute path "' + parts[i] + '" in the middle of other paths.'); + } + for (const segment of parsed.path) { + newRoot.path.push(segment); + } + } + return format(normalizeParsed(newRoot)); +}; diff --git a/src/normalize.ts b/src/normalize.ts new file mode 100644 index 0000000..83e2feb --- /dev/null +++ b/src/normalize.ts @@ -0,0 +1,27 @@ +import { format } from './format'; +import { parse } from './parse'; +import { IPath } from './types'; + +export function normalize(filepath: string) { + return format(normalizeParsed(parse(filepath))); +} + +export function normalizeParsed(parsed: IPath): IPath { + let path = parsed.path; + + // Replace consecutive '/' and replace '/./' with '/' + path = path.filter(segment => segment !== '' && segment !== '.'); + + // Collapse '..' where possible + const stack = []; + for (const segment of path) { + if (segment === '..' && stack.length && stack[stack.length - 1] !== '..') { + stack.pop(); + } else if (segment !== '..' || !parsed.absolute) { + stack.push(segment); + } + } + parsed.path = stack; + + return parsed; +} diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..d6b354f --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,24 @@ +// @ts-ignore +import * as grammar from './grammar'; +import { IPath } from './types'; + +export function parse(path: string): IPath { + return grammar.parse(path, {}); +} + +export function parseBase(base: string) { + let split = base.lastIndexOf('.'); + // ignore edge cases + if (base === '..') split = -1; + if (base === '.') split = -1; + + let name = base; + let ext = ''; + // we check > 0 instead of > -1 so that filenames starting with dots aren't + // interpreted as file extensions. + if (split > 0) { + name = base.slice(0, split); + ext = base.slice(split); + } + return { name, ext }; +} diff --git a/src/parseBase.ts b/src/parseBase.ts new file mode 100644 index 0000000..1992cd7 --- /dev/null +++ b/src/parseBase.ts @@ -0,0 +1,16 @@ +export function parseBase(base: string) { + let split = base.lastIndexOf('.'); + // ignore edge cases + if (base === '..') split = -1; + if (base === '.') split = -1; + + let name = base; + let ext = ''; + // we check > 0 instead of > -1 so that filenames starting with dots aren't + // interpreted as file extensions. + if (split > 0) { + name = base.slice(0, split); + ext = base.slice(split); + } + return { name, ext }; +} diff --git a/src/relative.ts b/src/relative.ts new file mode 100644 index 0000000..c5dfb2c --- /dev/null +++ b/src/relative.ts @@ -0,0 +1,43 @@ +import { format } from './format'; +import { normalizeParsed } from './normalize'; +import { parse } from './parse'; + +export function relative(fromDir: string, to: string): string { + const toParsed = normalizeParsed(parse(to)); + + // If `to` is already relative, just return that normalized + if (!toParsed.absolute) { + return format(toParsed); + } + + const fromParsed = normalizeParsed(parse(fromDir)); + + // If a relative URL is not possible, return an absolute one. + if (toParsed.origin !== fromParsed.origin) return format(toParsed); + if (!fromParsed.absolute) return format(toParsed); + if (fromParsed.drive !== toParsed.drive) return format(toParsed); + + // Toss away common path segments + const maxIter = Math.min(fromParsed.path.length, toParsed.path.length); + for (let _ = 0; _ < maxIter; _++) { + if (fromParsed.path[0] === toParsed.path[0]) { + fromParsed.path.shift(); + toParsed.path.shift(); + } else { + break; + } + } + + // Convert remaining path segments into '..' + toParsed.path.unshift(...fromParsed.path.fill('..')); + + const newPath = { + origin: null, + drive: null, + absolute: false, + protocol: null, + path: toParsed.path, + }; + + return format(newPath); +} diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..2e02c8d --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,17 @@ +import { format } from './format'; +import { join } from './join'; +import { normalizeParsed } from './normalize'; +import { parse } from './parse'; + +export function resolve(...pathSegments: string[]) { + // Edge case + if (pathSegments.length === 0) return '.'; + + // If the last segment is absolute, return the last segment + const toPath = pathSegments[pathSegments.length - 1]; + const toParsed = normalizeParsed(parse(toPath)); + if (toParsed.absolute) return format(toParsed); + + // Otherwise, join all the segments + return join(...pathSegments); +} diff --git a/src/sep.ts b/src/sep.ts new file mode 100644 index 0000000..8c29b58 --- /dev/null +++ b/src/sep.ts @@ -0,0 +1 @@ +export const sep = '/'; diff --git a/src/startsWithWindowsDrive.ts b/src/startsWithWindowsDrive.ts new file mode 100644 index 0000000..ee40d27 --- /dev/null +++ b/src/startsWithWindowsDrive.ts @@ -0,0 +1,6 @@ +import { parse } from './parse'; + +export const startsWithWindowsDrive = (str: string) => { + const parsed = parse(str); + return parsed.drive !== null; +}; diff --git a/src/toFSPath.ts b/src/toFSPath.ts new file mode 100644 index 0000000..3dbd41a --- /dev/null +++ b/src/toFSPath.ts @@ -0,0 +1 @@ +export { normalize as toFSPath } from './normalize'; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a1c5247 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +export interface IPath { + protocol: 'file' | 'http' | 'https' | null; + origin: string | null; + absolute: boolean; + drive: string | null; + path: string[]; +} diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index a80a38f..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,16 +0,0 @@ -// This config is used by `sl-scripts build` -{ - "extends": "./tsconfig.json", - // NOTE: must only include one element, otherwise build process into dist folder ends up with incorrect structure - "include": [ - "src" - ], - // Ignore dev folders like __tests__ and __stories__ when building for distribution - "exclude": [ - "**/__*__/**" - ], - "compilerOptions": { - "outDir": "dist", - "moduleResolution": "node" - } -} diff --git a/yarn.lock b/yarn.lock index 418b8ac..d24e96a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2438,7 +2438,7 @@ estraverse@^4.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= -estree-walker@^0.6.0: +estree-walker@^0.6.0, estree-walker@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== @@ -3658,6 +3658,13 @@ is-redirect@^1.0.0: resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= +is-reference@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.1.2.tgz#01cf91517d21db66a34642287ed6e70d53dcbe5c" + integrity sha512-Kn5g8c7XHKejFOpTf2QN9YjiHHKl5xRj+2uAZf9iM2//nkBNi/NNeB5JMoun28nEaUVHyPUzqzhfRlfAirEjXg== + dependencies: + "@types/estree" "0.0.39" + is-regex@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" @@ -4810,6 +4817,13 @@ macos-release@^2.2.0: resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA== +magic-string@^0.25.2: + version "0.25.2" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.2.tgz#139c3a729515ec55e96e69e82a11fe890a293ad9" + integrity sha512-iLs9mPjh9IuTtRsqqhNGYcZXGei0Nh/A4xirrsqW7c+QhKVFL2vm7U09ru6cHRD22azaP/wMDgI+HCqbETMTtg== + dependencies: + sourcemap-codec "^1.4.4" + make-dir@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" @@ -5989,6 +6003,11 @@ path-type@^3.0.0: dependencies: pify "^3.0.0" +pegjs@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/pegjs/-/pegjs-0.10.0.tgz#cf8bafae6eddff4b5a7efb185269eaaf4610ddbd" + integrity sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0= + performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -6596,6 +6615,13 @@ resolve@1.x, resolve@^1.1.6, resolve@^1.10.0, resolve@^1.3.2: dependencies: path-parse "^1.0.6" +resolve@^1.11.0: + version "1.11.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e" + integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw== + dependencies: + path-parse "^1.0.6" + restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -6631,6 +6657,17 @@ rimraf@2, rimraf@2.6.3, rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6 dependencies: glob "^7.1.3" +rollup-plugin-commonjs@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.0.1.tgz#fbfcadf4ce2e826068e056a9f5c19287d9744ddf" + integrity sha512-x0PcCVdEc4J8igv1qe2vttz8JKAKcTs3wfIA3L8xEty3VzxgORLrzZrNWaVMc+pBC4U3aDOb9BnWLAQ8J11vkA== + dependencies: + estree-walker "^0.6.1" + is-reference "^1.1.2" + magic-string "^0.25.2" + resolve "^1.11.0" + rollup-pluginutils "^2.8.1" + rollup-plugin-terser@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-4.0.4.tgz#6f661ef284fa7c27963d242601691dc3d23f994e" @@ -6659,6 +6696,13 @@ rollup-pluginutils@2.6.0: estree-walker "^0.6.0" micromatch "^3.1.10" +rollup-pluginutils@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.1.tgz#8fa6dd0697344938ef26c2c09d2488ce9e33ce97" + integrity sha512-J5oAoysWar6GuZo0s+3bZ6sVZAC0pfqKz68De7ZgDi5z63jOVZn1uJL/+z1jeKHNbGII8kAyHF5q8LnxSX5lQg== + dependencies: + estree-walker "^0.6.1" + rollup@^1.12.2: version "1.15.6" resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.15.6.tgz#caf0ed28d2d78e3a59c1398e5a3695fb600a0ef0" @@ -7027,6 +7071,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +sourcemap-codec@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.4.tgz#c63ea927c029dd6bd9a2b7fa03b3fec02ad56e9f" + integrity sha512-CYAPYdBu34781kLHkaW3m6b/uUSyMOC2R61gcYMWooeuaGtjof86ZA/8T+qVPPt7np1085CR9hmMGrySwEc8Xg== + spawn-error-forwarder@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz#1afd94738e999b0346d7b9fc373be55e07577029"