e22m4u 1 год назад
Сommit
8a94327bc6
21 измененных файлов с 1008 добавлено и 0 удалено
  1. 9 0
      .c8rc
  2. 5 0
      .commitlintrc
  3. 13 0
      .editorconfig
  4. 18 0
      .gitignore
  5. 1 0
      .husky/commit-msg
  6. 5 0
      .husky/pre-commit
  7. 4 0
      .mocharc.cjs
  8. 7 0
      .prettierrc
  9. 21 0
      LICENSE
  10. 63 0
      README.md
  11. 34 0
      eslint.config.js
  12. 49 0
      package.json
  13. 1 0
      src/index.d.ts
  14. 1 0
      src/index.js
  15. 26 0
      src/path-trie.d.ts
  16. 367 0
      src/path-trie.js
  17. 350 0
      src/path-trie.spec.js
  18. 15 0
      src/utils/create-debugger.js
  19. 9 0
      src/utils/create-debugger.spec.js
  20. 1 0
      src/utils/index.js
  21. 9 0
      tsconfig.json

+ 9 - 0
.c8rc

@@ -0,0 +1,9 @@
+{
+  "all": true,
+  "include": [
+    "src/**/*.js"
+  ],
+  "exclude": [
+    "src/**/*.spec.js"
+  ]
+}

+ 5 - 0
.commitlintrc

@@ -0,0 +1,5 @@
+{
+  "extends": [
+    "@commitlint/config-conventional"
+  ]
+}

+ 13 - 0
.editorconfig

@@ -0,0 +1,13 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
+max_line_length = 80

+ 18 - 0
.gitignore

@@ -0,0 +1,18 @@
+# OS
+.DS_Store
+
+# Editor
+.idea
+.vscode
+
+# Npm
+node_modules
+npm-debug.log
+package-lock.json
+
+# Yarn
+.yarn/
+.yarn*
+
+# c8
+coverage

+ 1 - 0
.husky/commit-msg

@@ -0,0 +1 @@
+npx --no -- commitlint --edit $1

+ 5 - 0
.husky/pre-commit

@@ -0,0 +1,5 @@
+npm run lint:fix
+npm run format
+npm run test
+
+git add -A

+ 4 - 0
.mocharc.cjs

@@ -0,0 +1,4 @@
+module.exports = {
+  extension: ['js'],
+  spec: 'src/**/*.spec.js',
+}

+ 7 - 0
.prettierrc

@@ -0,0 +1,7 @@
+{
+  "bracketSpacing": false,
+  "singleQuote": true,
+  "printWidth": 80,
+  "trailingComma": "all",
+  "arrowParens": "avoid"
+}

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 e22m4u@yandex.ru
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 63 - 0
README.md

@@ -0,0 +1,63 @@
+## @e22m4u/js-path-trie
+
+ES-module of the path [trie](https://en.wikipedia.org/wiki/Trie) routing.
+
+- Uses [path-to-regexp](https://github.com/component/path-to-regexp) syntax.
+- Supports path parameters.
+
+## Installation
+
+```bash
+npm install @e22m4u/js-path-trie
+```
+
+## Example
+
+- `add(pathTemplate: string, value: unknown)`  
+  *\- adds a value by the path template*
+- `match(path: string)`  
+  *\- value lookup by the given path*
+
+```js
+const trie = new PathTrie();
+
+// add values to the trie
+trie.add('/foo/bar', yourValue1);
+trie.add('/foo/:p1/bar/:p2', yourValue2);
+
+// path matching
+trie.match('/foo/bar');
+// {
+//   value: yourValue1,
+//   params: {}
+// }
+
+// path matching (with parameters)
+trie.match('/foo/10/bar/20');
+// {
+//   value: yourValue2,
+//   params: {p1: 10, p2: 20}
+// }
+
+// if not matched
+trie.match('/foo/bar/baz');
+// undefined
+```
+
+## Debug
+
+Do set environment variable `DEBUG=jsPathTrie*` before start.
+
+```bash
+DEBUG=jsPathTrie* npm run test
+```
+
+## Tests
+
+```bash
+npm run test
+```
+
+## License
+
+MIT

+ 34 - 0
eslint.config.js

@@ -0,0 +1,34 @@
+import globals from 'globals';
+import eslintJs from '@eslint/js';
+import eslintJsdocPlugin from 'eslint-plugin-jsdoc';
+import eslintMochaPlugin from 'eslint-plugin-mocha';
+import eslintPrettierConfig from 'eslint-config-prettier';
+import eslintChaiExpectPlugin from 'eslint-plugin-chai-expect';
+
+export default [{
+  languageOptions: {
+    globals: {
+      ...globals.node,
+      ...globals.es2021,
+      ...globals.mocha,
+    },
+  },
+  plugins: {
+    'jsdoc': eslintJsdocPlugin,
+    'mocha': eslintMochaPlugin,
+    'chai-expect': eslintChaiExpectPlugin,
+  },
+  rules: {
+    ...eslintJs.configs.recommended.rules,
+    ...eslintPrettierConfig.rules,
+    ...eslintJsdocPlugin.configs['flat/recommended-error'].rules,
+    ...eslintMochaPlugin.configs.flat.recommended.rules,
+    ...eslintChaiExpectPlugin.configs['recommended-flat'].rules,
+    'no-unused-vars': ['error', {'caughtErrors': 'none'}],
+    'jsdoc/require-param-description': 0,
+    'jsdoc/require-returns-description': 0,
+    'jsdoc/require-property-description': 0,
+    'jsdoc/tag-lines': ['error', 'any', {startLines: 1}],
+  },
+  files: ['src/**/*.js'],
+}];

+ 49 - 0
package.json

@@ -0,0 +1,49 @@
+{
+  "name": "@e22m4u/js-path-trie",
+  "version": "0.0.1",
+  "description": "The path trie routing",
+  "type": "module",
+  "main": "src/index.js",
+  "scripts": {
+    "lint": "tsc && eslint .",
+    "lint:fix": "tsc && eslint . --fix",
+    "format": "prettier --write \"./src/**/*.js\"",
+    "test": "npm run lint && c8 --reporter=text-summary mocha",
+    "test:coverage": "npm run lint && c8 --reporter=text mocha",
+    "prepare": "husky"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/e22m4u/js-path-trie.git"
+  },
+  "keywords": [
+    "path",
+    "trie",
+    "router"
+  ],
+  "author": "e22m4u <e22m4u@yandex.ru>",
+  "license": "MIT",
+  "homepage": "https://github.com/e22m4u/js-path-trie",
+  "devDependencies": {
+    "@commitlint/cli": "~19.4.0",
+    "@commitlint/config-conventional": "~19.2.2",
+    "@eslint/js": "~9.9.0",
+    "c8": "~10.1.2",
+    "chai": "~5.1.1",
+    "eslint": "~9.9.0",
+    "eslint-config-prettier": "~9.1.0",
+    "eslint-plugin-chai-expect": "~3.1.0",
+    "eslint-plugin-jsdoc": "~50.2.2",
+    "eslint-plugin-mocha": "~10.5.0",
+    "globals": "~15.9.0",
+    "husky": "~9.1.4",
+    "mocha": "~10.7.3",
+    "prettier": "~3.3.3",
+    "typescript": "~5.5.4"
+  },
+  "dependencies": {
+    "@e22m4u/js-format": "~0.1.0",
+    "debug": "~4.3.6",
+    "path-to-regexp": "~7.1.0"
+  }
+}

+ 1 - 0
src/index.d.ts

@@ -0,0 +1 @@
+export * from './path-trie.js';

+ 1 - 0
src/index.js

@@ -0,0 +1 @@
+export * from './path-trie.js';

+ 26 - 0
src/path-trie.d.ts

@@ -0,0 +1,26 @@
+/**
+ * Resolved value.
+ */
+export type ResolvedValue<T = unknown> = {
+  value: T;
+  params: {[name: string]: unknown};
+}
+
+/**
+ * Path trie.
+ */
+export declare class PathTrie {
+  /**
+   * Add value.
+   *
+   * @param pathTemplate
+   * @param value
+   */
+  add<T>(pathTemplate: string, value: T): this;
+
+  /**
+   * Match value.
+   * @param path
+   */
+  match<T = unknown>(path: string): ResolvedValue<T> | undefined;
+}

+ 367 - 0
src/path-trie.js

@@ -0,0 +1,367 @@
+import {Errorf} from '@e22m4u/js-format';
+import {pathToRegexp} from 'path-to-regexp';
+import {createDebugger} from './utils/index.js';
+
+/**
+ * @typedef {{
+ *   token: string,
+ *   regexp: RegExp | undefined,
+ *   names: string[],
+ *   value: *,
+ *   children: {[token: string]: Node},
+ * }} Node
+ *
+ * @typedef {{value: *, params: object}} ResolvedValue
+ * @typedef {{node: Node, params: object}} ResolvedNode
+ */
+
+/**
+ * Debug.
+ *
+ * @type {Function}
+ */
+const debug = createDebugger();
+
+/**
+ * Path trie.
+ */
+export class PathTrie {
+  /**
+   * Root node.
+   *
+   * @type {Node}
+   * @private
+   */
+  _root = {
+    token: '',
+    regexp: undefined,
+    names: [],
+    value: undefined,
+    children: {},
+  };
+
+  /**
+   * Add value.
+   *
+   * @param {string} pathTemplate
+   * @param {*} value
+   * @returns {this}
+   */
+  add(pathTemplate, value) {
+    if (typeof pathTemplate !== 'string')
+      throw new Errorf(
+        'The first argument of PathTrie.add should be ' +
+          'a String, but %v given.',
+        pathTemplate,
+      );
+    if (value == null)
+      throw new Errorf(
+        'The second argument of PathTrie.add is required, but %v given.',
+        value,
+      );
+    debug('Adding the value to %v.', pathTemplate);
+    const tokens = pathTemplate.split('/').filter(Boolean);
+    this._createNode(tokens, 0, value, this._root);
+    return this;
+  }
+
+  /**
+   * Match value.
+   *
+   * @param {string} path
+   * @returns {ResolvedValue|undefined}
+   */
+  match(path) {
+    if (typeof path !== 'string')
+      throw new Errorf(
+        'The first argument of PathTrie.match should be ' +
+          'a String, but %v given.',
+        path,
+      );
+    debug('Matching a value with the path %v.', path);
+    const tokens = path.split('/').filter(Boolean);
+    const params = {};
+    const result = this._matchNode(tokens, 0, params, this._root);
+    if (!result || !result.node.value) return;
+    return {value: result.node.value, params};
+  }
+
+  /**
+   * Create node.
+   *
+   * @param {string[]} tokens
+   * @param {number} index
+   * @param {*} value
+   * @param {Node} parent
+   * @returns {Node}
+   * @private
+   */
+  _createNode(tokens, index, value, parent) {
+    // если массив токенов пуст, а индекс нулевой,
+    // то проверяем возможность установки значения
+    // в родителя
+    if (tokens.length === 0 && index === 0) {
+      // если корневой узел не имеет
+      // значения, то устанавливаем
+      if (parent.value == null) {
+        parent.value = value;
+      }
+      // если корневой узел имеет значение
+      // отличное от устанавливаемого,
+      // то выбрасываем ошибку
+      else if (parent.value !== value) {
+        throw new Errorf('The duplicate path "" has a different value.');
+      }
+      debug('The value has set to the root node.');
+      return parent;
+    }
+    // проверка существования токена
+    // по данному индексу
+    const token = tokens[index];
+    if (token == null)
+      throw new Errorf(
+        'Invalid index %v has passed to the PathTrie._createNode.',
+        index,
+      );
+    // если добавляемый узел является последним,
+    // а его токен уже существует, то проверяем
+    // наличие значения в существующем узле
+    const isLast = tokens.length - 1 === index;
+    let child = parent.children[token];
+    if (isLast && child != null) {
+      debug('The node %v already exist.', token);
+      // если существующий узел не имеет
+      // значения, то устанавливаем
+      if (child.value == null) {
+        child.value = value;
+      }
+      // если существующий узел имеет значение
+      // отличное от устанавливаемого,
+      // то выбрасываем ошибку
+      else if (child.value !== value) {
+        throw new Errorf(
+          'The duplicate path %v has a different value.',
+          '/' + tokens.join('/'),
+        );
+      }
+      // так как данный токен является последним,
+      // то возвращаем существующий узел
+      return child;
+    }
+    debug('The node %v does not exist.', token);
+    // создаем новый узел, и если токен является
+    // последним, то сразу устанавливаем значение
+    child = {
+      token,
+      regexp: undefined,
+      names: [],
+      value: undefined,
+      children: {},
+    };
+    if (isLast) {
+      debug('The node %v is last.', token);
+      child.value = value;
+    }
+    // если токен содержит параметры,
+    // то записываем их имена и регулярное
+    // выражение в создаваемый узел
+    if (token.indexOf(':') > -1) {
+      debug('The node %v has parameters.', token);
+      // если токен содержит неподдерживаемые
+      // модификаторы, то выбрасываем ошибку
+      const modifiers = /([?*+{}])/.exec(token);
+      if (modifiers)
+        throw new Errorf(
+          'The symbol %v is not supported in path %v.',
+          modifiers[0],
+          '/' + tokens.join('/'),
+        );
+      // создаем регулярное выражение
+      // используя текущий токен
+      let re;
+      try {
+        re = pathToRegexp(token);
+      } catch (error) {
+        // если параметры не найдены, то выбрасываем
+        // ошибку неправильного использования
+        // символа ":"
+        if (error.message.indexOf('Missing parameter') > -1)
+          throw new Errorf(
+            'The symbol ":" should be used to define path parameters, ' +
+              'but no parameters found in the path %v.',
+            '/' + tokens.join('/'),
+          );
+        // если ошибка неизвестна,
+        // то выбрасываем как есть
+        throw error;
+      }
+      // записываем имена параметров и регулярное
+      // выражение в создаваемый узел
+      if (Array.isArray(re.keys) && re.keys.length) {
+        child.names = re.keys.map(p => `${p.name}`);
+        child.regexp = re;
+      }
+      // если параметры не найдены, то выбрасываем
+      // ошибку неправильного использования
+      // символа ":"
+      else {
+        throw new Errorf(
+          'The symbol ":" should be used to define path parameters, ' +
+            'but no parameters found in the path %v.',
+          '/' + tokens.join('/'),
+        );
+      }
+      debug('Found parameters are %l.', child.names);
+    }
+    // записываем новый узел в родителя
+    parent.children[token] = child;
+    debug('The node %v has created.', token);
+    // если текущий узел является последним,
+    // то возвращаем его, или продолжаем
+    // смещать индекс
+    if (isLast) return child;
+    return this._createNode(tokens, index + 1, value, child);
+  }
+
+  /**
+   * Match node.
+   *
+   * @param {string[]} tokens
+   * @param {number} index
+   * @param {object} params
+   * @param {Node} parent
+   * @returns {ResolvedNode|undefined}
+   * @private
+   */
+  _matchNode(tokens, index, params, parent) {
+    // если массив токенов пуст, а индекс нулевой,
+    // то проверяем наличие значения в родителе
+    if (tokens.length === 0 && index === 0) {
+      if (parent.value) {
+        debug(
+          'The path %v matched with the root node.',
+          '/' + tokens.join('/'),
+        );
+        return {node: parent, params};
+      }
+      // если родительский узел не имеет
+      // значения, то возвращаем "undefined"
+      return;
+    }
+    // проверка существования токена
+    // по данному индексу
+    const token = tokens[index];
+    if (token == null)
+      throw new Errorf(
+        'Invalid index %v has passed to the PathTrie._matchNode.',
+        index,
+      );
+    // если текущий токен не соответствует
+    // ни одному узлу, то возвращаем "undefined"
+    const resolvedNodes = this._matchChildrenNodes(token, parent);
+    debug('%v nodes matches the token %v.', resolvedNodes.length, token);
+    if (!resolvedNodes.length) return;
+    // если текущий токен последний,
+    // то возвращаем первый дочерний
+    // узел, который имеет значение
+    const isLast = tokens.length - 1 === index;
+    if (isLast) {
+      debug('The token %v is last.', token);
+      for (const child of resolvedNodes) {
+        debug('The node %v matches the token %v.', child.node.token, token);
+        if (child.node.value) {
+          debug('The node %v has a value.', child.node.token);
+          const paramNames = Object.keys(child.params);
+          if (paramNames.length) {
+            paramNames.forEach(name => {
+              debug(
+                'The node %v has parameter %v with the value %v.',
+                child.node.token,
+                name,
+                child.params[name],
+              );
+            });
+          } else {
+            debug('The node %v has no parameters.', child.node.token);
+          }
+          Object.assign(params, child.params);
+          return {node: child.node, params};
+        }
+      }
+    }
+    // если токен промежуточный, то проходим
+    // вглубь каждого дочернего узла
+    else {
+      for (const child of resolvedNodes) {
+        const result = this._matchNode(tokens, index + 1, params, child.node);
+        if (result) {
+          debug('A value has found for the path %v.', '/' + tokens.join('/'));
+          const paramNames = Object.keys(child.params);
+          if (paramNames.length) {
+            paramNames.forEach(name => {
+              debug(
+                'The node %v has parameter %v with the value %v.',
+                child.node.token,
+                name,
+                child.params[name],
+              );
+            });
+          } else {
+            debug('The node %v has no parameters.', child.node.token);
+          }
+          Object.assign(params, child.params);
+          return result;
+        }
+      }
+    }
+    // если поиск по дочерним узлам
+    // родителя не привел к результату,
+    // то возвращаем "undefined"
+    debug('No matched nodes with the path %v.', '/' + tokens.join('/'));
+    return undefined;
+  }
+
+  /**
+   * Match children nodes.
+   *
+   * @param {string} token
+   * @param {Node} parent
+   * @returns {ResolvedNode[]}
+   * @private
+   */
+  _matchChildrenNodes(token, parent) {
+    const resolvedNodes = [];
+    // если найден узел по литералу токена,
+    // то нет необходимости продолжать поиск
+    // по узлам с параметрами, а можно немедленно
+    // вернуть его в качестве результата
+    let child = parent.children[token];
+    if (child) {
+      resolvedNodes.push({node: child, params: {}});
+      return resolvedNodes;
+    }
+    // поиск по узлам с параметрами выполняется
+    // путем сопоставления токена с регулярным
+    // выражением каждого узла
+    for (const key in parent.children) {
+      child = parent.children[key];
+      if (!child.names || !child.regexp) continue;
+      const match = child.regexp.exec(token);
+      if (match) {
+        const resolved = {node: child, params: {}};
+        // так как параметры имеют тот же порядок,
+        // что и вхождения, последовательно перебираем,
+        // и присваиваем вхождения с соответствующим
+        // индексом в качестве значений
+        let i = 0;
+        for (const name of child.names) {
+          const val = match[++i];
+          resolved.params[name] = decodeURIComponent(val);
+        }
+        // добавление узла к результату
+        resolvedNodes.push(resolved);
+      }
+    }
+    return resolvedNodes;
+  }
+}

+ 350 - 0
src/path-trie.spec.js

@@ -0,0 +1,350 @@
+import {expect} from 'chai';
+import {PathTrie} from './path-trie.js';
+import {format} from '@e22m4u/js-format';
+import {pathToRegexp} from 'path-to-regexp';
+
+const VALUE = 'myValue1';
+const ANOTHER_VALUE = 'myValue2';
+
+describe('PathTrie', function () {
+  describe('add', function () {
+    it('requires the first parameter to be a String', function () {
+      const trie = new PathTrie();
+      const throwable = v => () => trie.add(v, VALUE);
+      const error = v =>
+        format(
+          'The first argument of PathTrie.add should be ' +
+            'a String, but %s given.',
+          v,
+        );
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable('str')();
+      throwable('')();
+    });
+
+    it('requires the second parameter', function () {
+      const throwable = v => () => {
+        const trie = new PathTrie();
+        trie.add('foo', v);
+      };
+      const error = v =>
+        format(
+          'The second argument of PathTrie.add is required, but %s given.',
+          v,
+        );
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      expect(throwable(null)).to.throw(error('null'));
+      throwable('str')();
+      throwable('')();
+      throwable(10)();
+      throwable(0)();
+      throwable(true)();
+      throwable(false)();
+      throwable({})();
+      throwable([])();
+    });
+
+    it('adds the given value with the path "" to the root node', function () {
+      const trie = new PathTrie();
+      expect(trie['_root']).to.be.eql({
+        token: '',
+        regexp: undefined,
+        names: [],
+        value: undefined,
+        children: {},
+      });
+      trie.add('', VALUE);
+      expect(trie['_root']).to.be.eql({
+        token: '',
+        regexp: undefined,
+        names: [],
+        value: VALUE,
+        children: {},
+      });
+    });
+
+    it('adds the given value with the path "/" to the root node', function () {
+      const trie = new PathTrie();
+      expect(trie['_root']).to.be.eql({
+        token: '',
+        regexp: undefined,
+        names: [],
+        value: undefined,
+        children: {},
+      });
+      trie.add('/', VALUE);
+      expect(trie['_root']).to.be.eql({
+        token: '',
+        regexp: undefined,
+        names: [],
+        value: VALUE,
+        children: {},
+      });
+    });
+
+    it('throws an error for the duplicate path "" with a different value', function () {
+      const trie = new PathTrie();
+      trie.add('', VALUE);
+      const throwable = () => trie.add('', ANOTHER_VALUE);
+      expect(throwable).to.throw(
+        'The duplicate path "" has a different value.',
+      );
+    });
+
+    it('throws an error for the duplicate path "/" with a different value', function () {
+      const trie = new PathTrie();
+      trie.add('/', VALUE);
+      const throwable = () => trie.add('/', ANOTHER_VALUE);
+      expect(throwable).to.throw(
+        'The duplicate path "" has a different value.',
+      );
+    });
+
+    it('considers paths "" and "/" are the same', function () {
+      const trie = new PathTrie();
+      trie.add('', VALUE);
+      const throwable = () => trie.add('/', ANOTHER_VALUE);
+      expect(throwable).to.throw(
+        'The duplicate path "" has a different value.',
+      );
+    });
+
+    it('adds multiple nodes by the path which has multiple tokens', function () {
+      const trie = new PathTrie();
+      trie.add('foo/bar/baz', VALUE);
+      expect(trie['_root']).to.be.eql({
+        token: '',
+        regexp: undefined,
+        names: [],
+        value: undefined,
+        children: {
+          foo: {
+            token: 'foo',
+            regexp: undefined,
+            names: [],
+            value: undefined,
+            children: {
+              bar: {
+                token: 'bar',
+                regexp: undefined,
+                names: [],
+                value: undefined,
+                children: {
+                  baz: {
+                    token: 'baz',
+                    regexp: undefined,
+                    names: [],
+                    value: VALUE,
+                    children: {},
+                  },
+                },
+              },
+            },
+          },
+        },
+      });
+    });
+
+    it('resolves path parameters in the first node', function () {
+      const trie = new PathTrie();
+      trie.add(':date-:time', VALUE);
+      expect(trie['_root']).to.be.eql({
+        token: '',
+        regexp: undefined,
+        names: [],
+        value: undefined,
+        children: {
+          ':date-:time': {
+            token: ':date-:time',
+            regexp: pathToRegexp(':date-:time'),
+            names: ['date', 'time'],
+            value: VALUE,
+            children: {},
+          },
+        },
+      });
+    });
+
+    it('resolves path parameters in the middle node', function () {
+      const trie = new PathTrie();
+      trie.add('/foo/:id/bar', VALUE);
+      expect(trie['_root']).to.be.eql({
+        token: '',
+        regexp: undefined,
+        names: [],
+        value: undefined,
+        children: {
+          foo: {
+            token: 'foo',
+            regexp: undefined,
+            names: [],
+            value: undefined,
+            children: {
+              ':id': {
+                token: ':id',
+                regexp: pathToRegexp(':id'),
+                names: ['id'],
+                value: undefined,
+                children: {
+                  bar: {
+                    token: 'bar',
+                    regexp: undefined,
+                    names: [],
+                    value: VALUE,
+                    children: {},
+                  },
+                },
+              },
+            },
+          },
+        },
+      });
+    });
+
+    it('throws an error for unsupported modifiers', function () {
+      const modifiers = ['?', '*', '+', '{', '}'];
+      const trie = new PathTrie();
+      const throwable = v => () => trie.add(v, VALUE);
+      const error = v =>
+        format('The symbol %v is not supported in path "/foo/:id%s".', v, v);
+      modifiers.forEach(m => {
+        expect(throwable(`/foo/:id${m}`)).to.throw(error(m));
+      });
+    });
+
+    it('throws an error if no parameter name has specified', function () {
+      const trie = new PathTrie();
+      const throwable = () => trie.add('/:', VALUE);
+      expect(throwable).to.throw(
+        'The symbol ":" should be used to define path parameters, ' +
+          'but no parameters found in the path "/:".',
+      );
+    });
+  });
+
+  describe('match', function () {
+    it('requires the first parameter to be a String', function () {
+      const trie = new PathTrie();
+      const throwable = v => () => trie.match(v);
+      const error = v =>
+        format(
+          'The first argument of PathTrie.match should be ' +
+            'a String, but %s given.',
+          v,
+        );
+      expect(throwable(10)).to.throw(error('10'));
+      expect(throwable(0)).to.throw(error('0'));
+      expect(throwable(true)).to.throw(error('true'));
+      expect(throwable(false)).to.throw(error('false'));
+      expect(throwable(null)).to.throw(error('null'));
+      expect(throwable({})).to.throw(error('Object'));
+      expect(throwable([])).to.throw(error('Array'));
+      expect(throwable(undefined)).to.throw(error('undefined'));
+      throwable('str')();
+      throwable('')();
+    });
+
+    it('matches paths "" and "/" or returns undefined', function () {
+      const trie = new PathTrie();
+      trie.add('', VALUE);
+      const res1 = trie.match('');
+      const res2 = trie.match('/');
+      const res3 = trie.match('/test');
+      expect(res1).to.be.eql({value: VALUE, params: {}});
+      expect(res2).to.be.eql({value: VALUE, params: {}});
+      expect(res3).to.be.undefined;
+    });
+
+    it('returns undefined if not matched', function () {
+      const trie = new PathTrie();
+      trie.add('foo', VALUE);
+      const res = trie.match('bar');
+      expect(res).to.be.undefined;
+    });
+
+    it('matches the single token', function () {
+      const trie = new PathTrie();
+      trie.add('foo', VALUE);
+      const res = trie.match('foo');
+      expect(res).to.be.eql({value: VALUE, params: {}});
+    });
+
+    it('does not respects the prefix "/"', function () {
+      const trie = new PathTrie();
+      trie.add('/foo', VALUE);
+      const res1 = trie.match('/foo');
+      const res2 = trie.match('foo');
+      expect(res1).to.be.eql({value: VALUE, params: {}});
+      expect(res2).to.be.eql({value: VALUE, params: {}});
+    });
+
+    it('does not respects the postfix "/"', function () {
+      const trie = new PathTrie();
+      trie.add('/foo', VALUE);
+      const res1 = trie.match('foo/');
+      const res2 = trie.match('foo');
+      expect(res1).to.be.eql({value: VALUE, params: {}});
+      expect(res2).to.be.eql({value: VALUE, params: {}});
+    });
+
+    it('matches parameters of the first token', function () {
+      const trie = new PathTrie();
+      trie.add(':foo-:bar', VALUE);
+      const res = trie.match('baz-qux');
+      expect(res).to.be.eql({
+        value: VALUE,
+        params: {
+          foo: 'baz',
+          bar: 'qux',
+        },
+      });
+    });
+
+    it('matches parameters of the first token in the case of multiple tokens', function () {
+      const trie = new PathTrie();
+      trie.add(':foo-:bar/test', VALUE);
+      const res = trie.match('baz-qux/test');
+      expect(res).to.be.eql({
+        value: VALUE,
+        params: {
+          foo: 'baz',
+          bar: 'qux',
+        },
+      });
+    });
+
+    it('matches parameters of the second token', function () {
+      const trie = new PathTrie();
+      trie.add('/test/:foo-:bar', VALUE);
+      const res = trie.match('/test/baz-qux');
+      expect(res).to.be.eql({
+        value: VALUE,
+        params: {
+          foo: 'baz',
+          bar: 'qux',
+        },
+      });
+    });
+
+    it('does not match a path which has more tokens than needed', function () {
+      const trie = new PathTrie();
+      trie.add('/foo', VALUE);
+      const res = trie.match('/foo/bar');
+      expect(res).to.be.undefined;
+    });
+
+    it('does not match a path which has less tokens than needed', function () {
+      const trie = new PathTrie();
+      trie.add('/foo/bar', VALUE);
+      const res = trie.match('/foo');
+      expect(res).to.be.undefined;
+    });
+  });
+});

+ 15 - 0
src/utils/create-debugger.js

@@ -0,0 +1,15 @@
+import DebugFactory from 'debug';
+import {format} from '@e22m4u/js-format';
+
+/**
+ * Create debugger.
+ *
+ * @returns {Function}
+ */
+export function createDebugger() {
+  const debug = DebugFactory(`jsPathTrie`);
+  return function (message, ...args) {
+    const interpolatedMessage = format(message, ...args);
+    return debug(interpolatedMessage);
+  };
+}

+ 9 - 0
src/utils/create-debugger.spec.js

@@ -0,0 +1,9 @@
+import {expect} from 'chai';
+import {createDebugger} from './create-debugger.js';
+
+describe('createDebugger', function () {
+  it('returns a function', function () {
+    const res = createDebugger('name');
+    expect(typeof res).to.be.eq('function');
+  });
+});

+ 1 - 0
src/utils/index.js

@@ -0,0 +1 @@
+export * from './create-debugger.js';

+ 9 - 0
tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "rootDir": "src",
+    "noEmit": true,
+    "target": "es2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
+  }
+}