<?php
declare(strict_types=1);
namespace Butschster\ContextGenerator\Application;
final class FSPath implements \Stringable
{
/**
* Override for the directory separator - used for testing only
*/
private static ?string $overriddenDirectorySeparator = null;
/**
* @param string $path The filesystem path
*/
private function __construct(
private readonly string $path,
) {}
/**
* Create a new path object
*/
public static function create(string $path = ''): self
{
return new self(self::normalizePath($path));
}
/**
* Create a path object representing the current working directory
*/
public static function cwd(): self
{
return new self(\getcwd() ?: '.');
}
/**
* Create temporary directory
*/
public static function temp(): self
{
return new self(\sys_get_temp_dir());
}
/**
* Get the directory separator - can be overridden for testing
*/
public static function getDirectorySeparator(): string
{
return self::$overriddenDirectorySeparator ?? \DIRECTORY_SEPARATOR;
}
/**
* Set an override for the directory separator - for testing only
*/
public static function setDirectorySeparator(?string $separator): void
{
self::$overriddenDirectorySeparator = $separator;
}
/**
* Join this path with one or more path components
*/
public function join(string ...$paths): self
{
$result = !$this->isFile() ? $this->path : (string) $this->parent();
foreach ($paths as $path) {
if (empty($path)) {
continue;
}
if (self::_isAbsolute($path)) {
$result = $path;
continue;
}
if ($result !== '' && !\str_ends_with($result, self::getDirectorySeparator())) {
$result .= self::getDirectorySeparator();
}
$result .= \ltrim($path, self::getDirectorySeparator());
}
// We return the raw string, not a normalized path, since it's already normalized
return new self(self::normalizePath($result));
}
/**
* Return a new path with the file name changed
*/
public function withName(string $name): self
{
if ($this->isRoot()) {
return $this;
}
return $this->parent()->join($name);
}
/**
* Return a new path with the extension changed
*/
public function withExt(string $suffix): self
{
if ($this->isRoot()) {
return $this;
}
$stem = $this->stem();
if (!\str_starts_with($suffix, '.') && !empty($suffix)) {
$suffix = '.' . $suffix;
}
return $this->withName($stem . $suffix);
}
/**
* Return a new path with the stem changed (file name without extension)
*/
public function withStem(string $stem): self
{
if ($this->isRoot()) {
return $this;
}
$suffix = $this->extension();
// Fix: Add period before extension if it's not empty
if (!empty($suffix)) {
$suffix = '.' . $suffix;
}
return $this->withName($stem . $suffix);
}
/**
* Return the file name (the final path component)
*/
public function name(): string
{
$parts = $this->parts();
// If there are no parts, return the current directory
if (empty($parts)) {
return '';
}
// If the path is a single part, return the current directory
if (\count($parts) === 1) {
return $parts[0];
}
// get the last part of the path
return \array_pop($parts);
}
/**
* Return the file stem (the file name without its extension)
*/
public function stem(): string
{
$name = $this->name();
$pos = \strrpos($name, '.');
if ($pos === false || $pos === 0) {
return $name;
}
return \substr($name, 0, $pos);
}
/**
* Return the file suffix (extension)
*/
public function extension(): string
{
$name = $this->name();
return \pathinfo($name, PATHINFO_EXTENSION);
}
/**
* Return the parent directory path
*/
public function parent(): self
{
// If this is a root path, return it unchanged
if ($this->isRoot()) {
return $this;
}
$parts = $this->parts();
// If there are no parts, return the current directory
if (empty($parts)) {
return self::create('.');
}
// If the path is a single part, return the current directory
if (\count($parts) === 1) {
return self::create('.');
}
// Remove the last part to get the parent directory
$parts = \array_slice($parts, 0, -1);
$isAbsPath = \str_starts_with($this->path, self::getDirectorySeparator());
// If the path is absolute, ensure the first part is preserved
$path = \implode(self::getDirectorySeparator(), $parts);
if ($isAbsPath) {
$path = self::getDirectorySeparator() . $path;
}
// Use dirname for a simple, reliable solution
return new self($path);
}
/**
* Return an array of the path's components
*/
public function parts(): array
{
$normalizedPath = \str_replace(['\\', '/'], self::getDirectorySeparator(), $this->path);
return \array_values(\array_filter(\explode(self::getDirectorySeparator(), $normalizedPath), \strlen(...)));
}
/**
* Return whether this path is absolute
*/
public function isAbsolute(): bool
{
return self::_isAbsolute($this->path);
}
/**
* Return whether this path is relative
*/
public function isRelative(): bool
{
return !$this->isAbsolute();
}
/**
* Check if the path exists
*/
public function exists(): bool
{
return \file_exists($this->path);
}
/**
* Check if the path is a directory
*/
public function isDir(): bool
{
return \is_dir($this->path);
}
/**
* Check if the path is a file
*/
public function isFile(): bool
{
return \is_file($this->path);
}
/**
* Return a new path that is a relative path from the given path to this path
*/
public function relativeTo(self $other): self
{
// If paths are on different drives (Windows), return the absolute path
if (self::getDirectorySeparator() === '\\') {
$thisRoot = $this->_getWindowsDrive($this->path);
$otherRoot = $this->_getWindowsDrive($other->path);
if ($thisRoot !== $otherRoot) {
return $this;
}
}
// Normalize both paths for comparison
$thisPath = $this->path;
$otherPath = $other->path;
// If paths are the same, return current directory
if ($thisPath === $otherPath) {
return self::create('.');
}
// Split paths into parts
$thisParts = $this->parts();
$otherParts = $other->parts();
// Find the common prefix
$commonLength = 0;
$minLength = \min(\count($thisParts), \count($otherParts));
while ($commonLength < $minLength && $thisParts[$commonLength] === $otherParts[$commonLength]) {
$commonLength++;
}
// Build the relative path
$relParts = [];
// Add '..' for each directory level to go up
$upCount = \count($otherParts) - $commonLength;
if ($upCount > 0) {
$relParts = \array_fill(0, $upCount, '..');
}
// Add path components to go down
if ($commonLength < \count($thisParts)) {
$relParts = [...$relParts, ...\array_slice($thisParts, $commonLength)];
}
if (empty($relParts)) {
return self::create('.');
}
return self::create(\implode(self::getDirectorySeparator(), $relParts));
}
/**
* Return a normalized absolute version of this path
*/
public function absolute(): self
{
if ($this->isAbsolute()) {
return $this;
}
return self::cwd()->join($this->path);
}
/**
* Trim the path to the given path
*/
public function trim(string $path): self
{
return self::create(\substr(\str_replace($path, '', $this->toString()), 1));
}
/**
* Return the string representation of this path
*/
public function toString(): string
{
return $this->path;
}
public function __toString(): string
{
return $this->toString();
}
/**
* Check if a path is absolute
*/
private static function _isAbsolute(string $path): bool
{
// Windows absolute path
if (self::getDirectorySeparator() === '\\') {
// Drive letter, UNC path, or absolute path
return (bool) \preg_match('~^(?:[a-zA-Z]:(?:\\\\|/)|\\\\\\\\|/)~', $path);
}
// Unix-like absolute path
return \str_starts_with($path, '/');
}
/**
* Normalize a path by converting directory separators and resolving special path segments
*/
private static function normalizePath(string $path): string
{
// Normalize directory separators
$path = \str_replace(['\\', '/'], self::getDirectorySeparator(), $path);
// Normalize multiple separators
$path = \preg_replace(
'~' . \preg_quote(self::getDirectorySeparator(), '~') . '{2,}~',
self::getDirectorySeparator(),
$path,
);
// Empty path becomes current directory
if ($path === '') {
return '.';
}
// Extract Windows drive letter if present
$driveLetter = '';
$isWindowsPath = false;
if (self::getDirectorySeparator() === '\\' && \preg_match('~^([a-zA-Z]:)~', (string) $path, $matches) === 1) {
$driveLetter = $matches[1];
$path = \substr((string) $path, 2); // Remove drive letter for normalization
$isWindowsPath = true;
}
// Determine if the path is absolute
$isAbsolute = self::_isAbsolute($path);
/**
* Resolve special path segments
* @psalm-suppress RedundantCast
*/
$parts = \array_filter(
\explode(self::getDirectorySeparator(), (string) $path),
static fn($part) => $part !== '',
);
$result = [];
foreach ($parts as $part) {
if ($part === '.') {
continue;
}
if ($part === '..') {
if (!empty($result)) {
\array_pop($result);
} elseif (!$isAbsolute) {
$result[] = '..';
}
continue;
}
$result[] = $part;
}
// Reconstruct the path
$normalizedPath = \implode(self::getDirectorySeparator(), $result);
// Handle Windows paths specifically
if ($isWindowsPath) {
// For Windows, construct with drive letter and separator
if (empty($normalizedPath)) {
// If path is just the root (e.g., C:\)
return $driveLetter . self::getDirectorySeparator();
}
// For normal Windows paths (e.g., C:\Users\test)
return $driveLetter . self::getDirectorySeparator() . $normalizedPath;
}
// Handle Unix paths or Windows paths without drive letters
if ($isAbsolute) {
$normalizedPath = self::getDirectorySeparator() . $normalizedPath;
}
return $normalizedPath ?: '.';
}
/**
* Return whether this path is the root directory
*/
private function isRoot(): bool
{
$normalized = $this->path;
// Unix-like root
if ($normalized === '/') {
return true;
}
// Windows root (C:\ or similar)
if (self::getDirectorySeparator() === '\\' && \preg_match('~^[a-zA-Z]:[\\\\/]?$~', $normalized)) {
return true;
}
return false;
}
/**
* Extract Windows drive letter if present
*/
private function _getWindowsDrive(string $path): string
{
if (\preg_match('~^([a-zA-Z]:)~', $path, $matches)) {
return $matches[1];
}
return '';
}
}