Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active April 10, 2023 16:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save loilo/206242ac17af8ad1a50ed71a52b99f0c to your computer and use it in GitHub Desktop.
Save loilo/206242ac17af8ad1a50ed71a52b99f0c to your computer and use it in GitHub Desktop.
Node Path Resolver in PHP

Node Path Resolver

Implementation of the Node module resolution algorithm (aka require.resolve()) in PHP.

Depends on league/flysystem and illuminate/support.

Basic Usage

use League\Flysystem\Filesystem;
use League\Flysystem\Adapter\Local;

$filesystem = new Filesystem(new Local('/'));

$resolver = new NodePathResolver($filesystem, '/var/www/project');

// Returns a string to the absolute path to my-package's entry file, throws otherwise
$resolver->resolve('my-package');
<?php
use League\Flysystem\FilesystemInterface;
use Illuminate\Support\Str;
class NodePathResolver
{
/**
* @var FilesystemInterface
*/
protected $fs;
/**
* @var string
*/
protected $contextDir;
/**
* @var array
*/
protected $additionalPaths;
/**
* @var array
*/
protected $extensions;
const IS_WINDOWS = DIRECTORY_SEPARATOR === '\\';
const DIRECTORY_SEPARATOR = self::IS_WINDOWS ? '/\\' : '/';
const REGEX_DIRECTORY_SEPARATOR = self::IS_WINDOWS ? '[/\\\\]' : '[/]';
const REGEX_ABSOLUTE_PATH =
'@^(' .
(self::IS_WINDOWS
? '[a-zA-Z]:' . self::DIRECTORY_SEPARATOR . '|'
: ''
) .
self::REGEX_DIRECTORY_SEPARATOR .
')@';
const REGEX_RELATIVE_PATH =
'@^' .
'\\.\\.?' .
'(' . self::REGEX_DIRECTORY_SEPARATOR . '|$)' .
'@';
/**
* @param FilesystemInterface $fs The filesystem to operate on
* @param string $contextDir The directory to start resolving at
* @param array $options Optional settings to use:
* extensions => An array of additional extensions to try (besides .js and .json)
* paths => An array of additional module paths to search before the node_modules chain
*/
public function __construct(FilesystemInterface $fs, string $contextDir, array $options = [])
{
$this->fs = $fs;
$this->contextDir = $this->normalizePath($contextDir);
$this->additionalPaths = $options['paths'] ?? [];
$this->extensions = array_merge(['js', 'json'], $options['extensions'] ?? []);
}
/**
* Resolve a descriptor relative to the context directory
*
* @param string $descriptor The descriptor to resolve
* @return string
*
* @throws \InvalidArgumentException When descriptor cannot be resolved
*/
public function resolve(string $descriptor): string
{
$root = $this->contextDir . '/';
if ($this->isAbsolutePath($descriptor)) {
$root = static::IS_WINDOWS ? '' : '/';
}
if ($this->isAbsolutePath($descriptor) || $this->isRelativePath($descriptor)) {
$path = $this->loadAsFile($root . $descriptor);
if (!is_null($path)) {
return $path;
}
$path = $this->loadAsDirectory($root . $descriptor);
if (!is_null($path)) {
return $path;
}
}
$path = $this->loadFromModules($descriptor, $root);
if (!is_null($path)) {
return $path;
}
throw new \InvalidArgumentException(sprintf(
'Could not resolve descriptor "%s" in %s',
$descriptor,
$this->contextDir
));
}
/**
* Check if a path exists and is a directory
*
* @param string $path The path to check
* @return boolean
*/
protected function existsAndIsFile(string $path): bool
{
return $this->fs->has($path) && $this->fs->getMetadata($path)['type'] === 'file';
}
/**
* Check if a path exists and is a file
*
* @param string $path The path to check
* @return boolean
*/
protected function existsAndIsDir(string $path): bool
{
return $this->fs->has($path) && $this->fs->getMetadata($path)['type'] === 'dir';
}
/**
* Check if a descriptor points to an absolute path
*
* @param string $descriptor The descriptor to check
* @return boolean
*/
protected function isAbsolutePath(string $descriptor): bool
{
return preg_match(static::REGEX_ABSOLUTE_PATH, $descriptor);
}
/**
* Check if a descriptor points to a relative path
*
* @param string $descriptor The descriptor to check
* @return boolean
*/
protected function isRelativePath(string $descriptor): bool
{
return preg_match(static::REGEX_RELATIVE_PATH, $descriptor);
}
/**
* Load the given path as file
*
* @param string $path The path to load
* @param boolean $allowSelf If the path itself can be returned if it exists
* @return string|null
*/
protected function loadAsFile(string $path, bool $allowSelf = true): ?string
{
if ($allowSelf) {
if ($this->existsAndIsFile($path)) {
return $path;
}
}
foreach ($this->extensions as $extension) {
if ($this->existsAndIsFile($path . '.' . $extension)) {
return $path . '.' . $extension;
}
}
return null;
}
/**
* Load index file from folder
*
* @param string $directory The directory to check
* @return string|null
*/
protected function loadIndex(string $directory): ?string
{
return $this->loadAsFile($directory . '/index', false);
}
/**
* Find the entry point of a directory
*
* @param string $directory The path to the directory
* @return string|null
*/
protected function loadAsDirectory(string $directory): ?string
{
$packageJson = $directory . '/package.json';
if ($this->existsAndIsFile($packageJson)) {
$packageData = json_decode($this->fs->read($packageJson));
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \UnexpectedValueException(sprintf(
'Could not parse package.json in "%s", error: %s',
$directory,
json_last_error_msg()
));
}
$mainPath = $this->normalizePath(
$directory . '/' .
(isset($packageData->main) ? $packageData->main : '')
);
$path = $this->loadAsFile($mainPath);
if (!is_null($path)) {
return $path;
}
$path = $this->loadIndex($mainPath);
if (!is_null($path)) {
return $path;
}
}
$path = $this->loadIndex($directory);
if (!is_null($path)) {
return $path;
}
return null;
}
/**
* Load descriptor from node_modules
*
* @param string $descriptor The descriptor to load
* @param string $startDirectory The path to start looking for node_modules
* @return string|null
*/
protected function loadFromModules(string $descriptor, string $startDirectory): ?string
{
$moduleDirs = $this->getModuleDirs($startDirectory);
foreach ($moduleDirs as $moduleDir) {
$path = $this->loadAsFile($moduleDir . '/' . $descriptor);
if (!is_null($path)) {
return $path;
}
$path = $this->loadAsDirectory($moduleDir . '/' . $descriptor);
if (!is_null($path)) {
return $path;
}
}
return null;
}
/**
* The module directories to search
*
* @return array
*/
protected function getModuleDirs(string $startDirectory): array
{
$dirs = $this->additionalPaths;
$currentContextDir = $startDirectory;
do {
$currentModulesDir = $currentContextDir . '/node_modules';
if ($this->fs->has($currentModulesDir)) {
$dirs[] = $currentModulesDir;
}
$currentContextDir = dirname($currentContextDir);
} while ($currentContextDir !== dirname($currentContextDir));
return $dirs;
}
/**
* Normalize a path (remove ./ and ../)
*
* @param string $path The path to normalize
* @return string
*/
protected function normalizePath(string $path): string
{
$root = ($path[0] === '/') ? '/' : '';
$segments = preg_split(
static::REGEX_DIRECTORY_SEPARATOR,
trim($path, static::DIRECTORY_SEPARATOR)
);
$ret = [];
foreach ($segments as $segment) {
if (($segment == '.') || Str::length($segment) === 0) {
continue;
}
if ($segment == '..') {
array_pop($ret);
} else {
array_push($ret, $segment);
}
}
return $root . implode('/', $ret);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment