getPathname();
// Skip ignored directories
if (in_array($path, $ignore_dirs)) {
continue;
}
if ($item->isFile() || $item->isLink()) {
unlink($path);
} elseif ($item->isDir() && !in_array($path, $ignore_dirs)) {
@rmdir($path);
}
}
if ($delete_self) {
return @rmdir($dir);
}
return true;
}
/**
* Extract a list of values from a collection of arrays/objects
*
* @param array|object $array Collection to pluck from
* @param string|null $key Key to extract (null for first element)
* @return array Array of plucked values
*/
function array_pluck($array, $key = null)
{
$result = [];
if (is_object($array)) {
$array = (array) $array;
}
foreach ($array as $index => $item) {
if (is_object($item)) {
$item = (array) $item;
}
if ($key !== null) {
if (isset($item[$key])) {
$result[$index] = $item[$key];
}
} else {
// Get first element
$result[$index] = reset($item);
}
}
return $result;
}
/**
* Get only specified keys from an array
*
* @param array $array Source array
* @param array $keys Keys to keep
* @return array Filtered array
*/
function array_only($array, $keys)
{
return array_intersect_key($array, array_flip($keys));
}
/**
* Get all except specified keys from an array
*
* @param array $array Source array
* @param array $keys Keys to exclude
* @return array Filtered array
*/
function array_except($array, $keys)
{
return array_diff_key($array, array_flip($keys));
}
/**
* Recursively merge arrays, combining array values instead of replacing
*
* Ported from array_intersperse() in RSpade v3.
*
* This function performs a deep merge of two arrays. Unlike array_merge_recursive() which
* creates sub-arrays for duplicate keys, this function intelligently merges values:
* - For numeric keys: appends unique values only (no duplicates)
* - For string keys with array values: recursively merges the arrays
* - For string keys with scalar values: replaces the target value with source value
*
* Accepts arrays by reference for efficiency but creates and returns a new merged array.
* Does not modify the input arrays.
*
* @param array &$target Base array (not modified)
* @param array &$source Array to merge (not modified)
* @return array The merged result array
*
* @example
* $target = [
* 'config' => ['debug' => true, 'env' => 'local'],
* 'modules' => ['jquery', 'bootstrap'],
* 'version' => '1.0'
* ];
* $source = [
* 'config' => ['api_url' => '/api', 'env' => 'production'],
* 'modules' => ['jquery', 'vue'], // jquery won't be duplicated
* 'version' => '2.0'
* ];
* $result = array_merge_deep($target, $source);
* // Result in $result:
* // [
* // 'config' => ['debug' => true, 'env' => 'production', 'api_url' => '/api'],
* // 'modules' => ['jquery', 'bootstrap', 'vue'],
* // 'version' => '2.0'
* // ]
*/
function array_merge_deep(array &$target, array &$source): array
{
$result = $target;
foreach ($source as $key => $value) {
if (is_numeric($key)) {
// Numeric key - append if not already present
if (!in_array($value, $result, true)) {
$result[] = $value;
}
} elseif (is_array($value)) {
if (!isset($result[$key]) || !is_array($result[$key])) {
$result[$key] = $value;
} else {
$result[$key] = array_merge_deep($result[$key], $value);
}
} else {
$result[$key] = $value;
}
}
return $result;
}
/**
* Check if an array has string keys (is associative)
*
* @param array $array Array to check
* @return bool True if has string keys
*/
function is_associative_array($array)
{
if (!is_array($array) || empty($array)) {
return false;
}
return count(array_filter(array_keys($array), 'is_string')) > 0;
}
/**
* Recursively convert objects to arrays
*
* @param mixed $object Object or value to convert
* @return mixed Converted value
*/
function object_to_array_recursive($object)
{
if (is_object($object)) {
// Check for to_array method
if (method_exists($object, 'toArray')) {
$object = $object->toArray();
} elseif (method_exists($object, 'to_array')) {
$object = $object->to_array();
} else {
$object = (array) $object;
}
}
if (is_array($object)) {
foreach ($object as $key => $value) {
$object[$key] = object_to_array_recursive($value);
}
}
return $object;
}
/**
* Generate a cryptographically secure random hash
*
* @param int $bytes Number of random bytes (default: 32)
* @return string Hexadecimal hash
*/
function random_hash($bytes = 32)
{
return bin2hex(random_bytes($bytes));
}
/**
* Make a string safe for use as a variable/function name
*
* @param string $string Input string
* @param int $max_length Maximum length (default: 64)
* @return string Safe string
*/
function safe_string($string, $max_length = 64)
{
// Replace non-alphanumeric with underscores
$string = preg_replace('/[^a-zA-Z0-9_]+/', '_', $string);
// Ensure first character is not a number
if (empty($string) || is_numeric($string[0])) {
$string = '_' . $string;
}
// Trim to max length
return substr($string, 0, $max_length);
}
/**
* Get file extension handling double extensions (e.g., .blade.php)
*
* @param string $path File path
* @param bool $double Check for double extensions
* @return string Extension
*/
function file_extension($path, $double = true)
{
$filename = basename($path);
if ($double) {
// Check for known double extensions
$double_extensions = ['blade.php', 'spec.js', 'test.js', 'min.js', 'min.css', 'd.ts'];
foreach ($double_extensions as $ext) {
if (str_ends_with($filename, '.' . $ext)) {
return $ext;
}
}
}
return pathinfo($filename, PATHINFO_EXTENSION);
}
/**
* Get a view instance by RSX ID (path-agnostic identifier).
* This allows views to be referenced by their ID instead of file path.
*
* @param string $id The ID defined in the view file with @rsx_id directive
* @param array $data Data to pass to the view
* @param array $merge_data Data to merge with the view data
* @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory
* @throws \Exception if the view is not found
*/
function rsx_view($id, $data = [], $merge_data = [])
{
// Use manifest to find the view file by ID
$file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id);
if (!$file_path) {
throw new \InvalidArgumentException("RSX view not found: {$id}");
}
// Share the ID for use in layouts (for body class)
\Illuminate\Support\Facades\View::share('rsx_view_id', $id);
// Share the current view path for bundle validation
\Illuminate\Support\Facades\View::share('rsx_current_view_path', $file_path);
// In development mode, validate the view and layout chain
if (!app()->environment('production')) {
// Validate the entire layout chain and bundle placement
\App\RSpade\Core\Validation\LayoutChainValidator::validate_layout_chain($id);
// Get the metadata for the view
$metadata = \App\RSpade\Core\Manifest\Manifest::get_file($file_path);
// Validate the view doesn't have inline styles/scripts
\App\RSpade\Core\Validation\ViewValidator::validate_view($id, $file_path, $metadata ?? []);
// If the view extends a layout, validate each layout in the chain has body class
if (isset($metadata['rsx_extends'])) {
$current_extends = $metadata['rsx_extends'];
$visited = []; // Prevent infinite loops
while ($current_extends) {
if (in_array($current_extends, $visited)) {
break; // Circular dependency, let LayoutChainValidator handle it
}
$visited[] = $current_extends;
$layout_path = \App\RSpade\Core\Manifest\Manifest::find_view($current_extends);
if ($layout_path) {
// Validate this layout
\App\RSpade\Core\Validation\ViewValidator::validate_layout($layout_path);
// Get metadata to find next parent
$layout_metadata = \App\RSpade\Core\Manifest\Manifest::get_file($layout_path);
$current_extends = $layout_metadata['rsx_extends'] ?? null;
} else {
break;
}
}
}
}
// Convert to Laravel view name format
// Remove .blade.php extension
$view_name = preg_replace('/\.blade\.php$/', '', $file_path);
// Handle different view locations
if (str_starts_with($view_name, 'resources/views/')) {
$view_name = substr($view_name, strlen('resources/views/'));
} elseif (str_starts_with($view_name, 'app/RSpade/')) {
// For framework views, use namespace format
// The namespace 'rspade' is registered in Rsx_Framework_Provider
$view_name = 'rspade::' . substr($view_name, strlen('app/RSpade/'));
} elseif (str_starts_with($view_name, 'rsx/')) {
// For RSX views, use namespace format
// The namespace 'rsx' is registered in RsxServiceProvider
$view_name = str_replace('rsx/', 'rsx::', $view_name);
}
// Convert path separators to dots for Laravel
$view_name = str_replace('/', '.', $view_name);
return view($view_name, $data, $merge_data);
}
/**
* Get the Laravel view path for a view by RSX ID.
* Used internally by RSX Blade directives to resolve view paths.
*
* @param string $id The ID to look up
* @return string|null The Laravel view path in dot notation or null if not found
*/
function rsx_view_path($id)
{
// Remove quotes if passed from Blade directive
$id = trim($id, "'\"");
// Use manifest to find the view file
$file_path = \App\RSpade\Core\Manifest\Manifest::find_view($id);
if (!$file_path) {
return null;
}
// Convert to Laravel view path format
// Remove .blade.php extension
$view_path = preg_replace('/\.blade\.php$/', '', $file_path);
// Handle different view locations
if (str_starts_with($view_path, 'resources/views/')) {
$view_path = substr($view_path, strlen('resources/views/'));
} elseif (str_starts_with($view_path, 'rsx/')) {
// For RSX views, convert to namespace format
$view_path = str_replace('rsx/', 'rsx::', $view_path);
}
// Convert path separators to dots
return str_replace('/', '.', $view_path);
}
/**
* Create directory if it doesn't exist
*
* @param string $path Directory path
* @param int $permissions Directory permissions
* @return bool Success status
*/
function ensure_directory($path, $permissions = 0755)
{
if (!is_dir($path)) {
return mkdir($path, $permissions, true);
}
return true;
}
/**
* Get relative path from base path
*
* @param string $path Full path
* @param string|null $base Base path (defaults to base_path())
* @return string Relative path
*/
function relative_path($path, $base = null)
{
if ($base === null) {
$base = base_path();
}
$base = rtrim($base, '/') . '/';
if (str_starts_with($path, $base)) {
return substr($path, strlen($base));
}
return $path;
}
/**
* Copy directory recursively
*
* @param string $source Source directory
* @param string $destination Destination directory
* @param array $ignore Patterns to ignore
* @return bool Success status
*/
function copy_directory($source, $destination, $ignore = [])
{
if (!is_dir($source)) {
return false;
}
if (!is_dir($destination)) {
mkdir($destination, 0755, true);
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $item) {
$source_path = $item->getPathname();
$relative = relative_path($source_path, $source);
$dest_path = $destination . '/' . $relative;
// Check ignore patterns
foreach ($ignore as $pattern) {
if (fnmatch($pattern, $relative)) {
continue 2;
}
}
if ($item->isDir()) {
if (!is_dir($dest_path)) {
mkdir($dest_path, 0755, true);
}
} else {
copy($source_path, $dest_path);
}
}
return true;
}
/**
* Recursively sort array by keys
*
* @param array $array Array to sort (by reference)
* @param int $sort_flags Sort flags
* @return bool Success status
*/
function ksort_recursive(&$array, $sort_flags = SORT_REGULAR)
{
if (!is_array($array)) {
return false;
}
if (is_associative_array($array)) {
ksort($array, $sort_flags);
} else {
sort($array, $sort_flags);
}
foreach ($array as &$value) {
if (is_array($value)) {
ksort_recursive($value, $sort_flags);
}
}
return true;
}
/**
* Convert snake_case to camelCase
*
* @param string $string Snake case string
* @param bool $capitalize_first Whether to capitalize first letter
* @return string Camel case string
*/
function snake_to_camel($string, $capitalize_first = false)
{
$result = str_replace('_', '', ucwords($string, '_'));
if (!$capitalize_first) {
$result = lcfirst($result);
}
return $result;
}
/**
* Convert camelCase to snake_case
*
* @param string $string Camel case string
* @return string Snake case string
*/
function camel_to_snake($string)
{
return strtolower(preg_replace('/[A-Z]/', '_$0', lcfirst($string)));
}
/**
* Check if a value is "blank" (null, empty string, empty array, etc.)
*
* @param mixed $value Value to check
* @return bool True if blank
*/
function is_blank($value)
{
if (is_null($value)) {
return true;
}
if (is_string($value)) {
return trim($value) === '';
}
if (is_numeric($value) || is_bool($value)) {
return false;
}
if ($value instanceof \Countable) {
return count($value) === 0;
}
return empty($value);
}
/**
* Get nested array value using "dot" notation
*
* @param array $array Source array
* @param string $key Dot notation key
* @param mixed $default Default value if not found
* @return mixed Value or default
*/
function array_get($array, $key, $default = null)
{
if (is_null($key)) {
return $array;
}
if (isset($array[$key])) {
return $array[$key];
}
foreach (explode('.', $key) as $segment) {
if (!is_array($array) || !array_key_exists($segment, $array)) {
return $default;
}
$array = $array[$segment];
}
return $array;
}
/**
* Set nested array value using "dot" notation
*
* @param array $array Target [by reference]
* @param string $key Dot notation key
* @param mixed $value Value to set
* @return void
*/
function array_set(&$array, $key, $value)
{
if (is_null($key)) {
$array = $value;
return;
}
$keys = explode('.', $key);
while (count($keys) > 1) {
$key = array_shift($keys);
if (!isset($array[$key]) || !is_array($array[$key])) {
$array[$key] = [];
}
$array = &$array[$key];
}
$array[array_shift($keys)] = $value;
}
/**
* Flatten a multi-dimensional array into a single level
*
* @param array $array Array to flatten
* @param int $depth Maximum depth to flatten
* @return array Flattened array
*/
function array_flatten($array, $depth = INF)
{
$result = [];
foreach ($array as $item) {
if (!is_array($item)) {
$result[] = $item;
} elseif ($depth === 1) {
$result = array_merge($result, array_values($item));
} else {
$result = array_merge($result, array_flatten($item, $depth - 1));
}
}
return $result;
}
/**
* Get the first element of an array that matches a condition
*
* @param array $array Source array
* @param callable|null $callback Optional filter callback
* @param mixed $default Default value if not found
* @return mixed First element or default
*/
function array_first($array, $callback = null, $default = null)
{
if (is_null($callback)) {
if (empty($array)) {
return $default;
}
foreach ($array as $item) {
return $item;
}
}
foreach ($array as $key => $value) {
if ($callback($value, $key)) {
return $value;
}
}
return $default;
}
/**
* Get the last element of an array that matches a condition
*
* @param array $array Source array
* @param callable|null $callback Optional filter callback
* @param mixed $default Default value if not found
* @return mixed Last element or default
*/
function array_last($array, $callback = null, $default = null)
{
if (is_null($callback)) {
return empty($array) ? $default : end($array);
}
return array_first(array_reverse($array, true), $callback, $default);
}
/**
* Create a temporary directory
*
* @param string $prefix Directory prefix
* @param string $base Base directory (defaults to sys temp)
* @return string|false Path to created directory or false on failure
*/
function make_temp_directory($prefix = 'rsx_', $base = null)
{
if ($base === null) {
$base = sys_get_temp_dir();
}
$attempts = 10;
while ($attempts-- > 0) {
$path = $base . '/' . $prefix . random_hash(8);
if (mkdir($path, 0755)) {
return $path;
}
}
return false;
}
/**
* Get the current site or a specific attribute from the current site.
*
* @param string|null $key
* @return Site|mixed|null
*/
// Not yet implemented
// function site($key = null)
// {
// if (is_null($key)) {
// return \App\RSpade\Core\Session\Session::get_site();
// }
//
// $current_site = \App\RSpade\Core\Session\Session::get_site();
// return $current_site?->getAttribute($key);
// }
/**
* Generate a URL for a specific site.
* This function allows generating cross-site URLs by temporarily switching context.
*
* @param int $site_id
* @param string $route
* @param array $parameters
* @param bool $absolute
* @return string
*/
// Not yet implemented
// function site_route($site_id, $route, $parameters = [], $absolute = true)
// {
// $original_site_id = \App\RSpade\Core\Session\Session::get_site_id();
//
// try {
// // Temporarily switch to target site context
// \App\RSpade\Core\Session\Session::set_site_id($site_id);
//
// // Generate the route with the target site context
// $url = route($route, $parameters, $absolute);
//
// return $url;
//
// } finally {
// // Always restore original site context
// \App\RSpade\Core\Session\Session::set_site_id($original_site_id);
// }
// }
/**
* Execute a shell command with pretty console output formatting.
* Shows the command in gray with a carat prefix, then the output in default color.
*
* @param string $command The shell command to execute
* @param bool $real_time Whether to show output in real-time (true) or after completion (false)
* @param bool $throw_on_error Whether to throw an exception on non-zero exit code
* @return array Returns array with 'output', 'error', and 'exit_code'
*/
function shell_exec_pretty($command, $real_time = true, $throw_on_error = false)
{
// ANSI color codes
$gray = "\033[90m";
$reset = "\033[0m";
$red = "\033[31m";
// Display the command being run
echo $gray . '> ' . $command . $reset . PHP_EOL;
if ($real_time) {
// Use passthru() for real-time output without proc_open() pipe buffer issues
// Redirect to temp file to capture output for return value
$temp_file = storage_path('rsx-tmp/shell_exec_pretty_' . uniqid() . '.txt');
// Use script command wrapper to show real-time output AND capture to file
// passthru() shows output but doesn't capture it, so we use tee to do both
$full_command = "($command 2>&1) | tee " . escapeshellarg($temp_file);
// passthru() displays output in real-time and returns the exit code via $exit_code
passthru($full_command, $exit_code);
// Read captured output from file
$output = '';
$error = '';
if (file_exists($temp_file)) {
$output = file_get_contents($temp_file);
unlink($temp_file); // Clean up
}
} else {
// Use shell_exec for simple execution
$full_command = $command . ' 2>&1';
$output = shell_exec($full_command);
$exit_code = 0; // shell_exec doesn't provide exit codes
$error = '';
// Display output
if ($output !== null) {
echo $output;
}
}
// Check exit code
if ($exit_code !== 0 && $throw_on_error) {
$error_msg = "Command failed with exit code $exit_code: $command";
if ($error) {
$error_msg .= "\nError output: $error";
}
throw new RuntimeException($error_msg);
}
return [
'output' => trim($output),
'error' => trim($error),
'exit_code' => $exit_code,
];
}
/**
* Check if a command exists in the system PATH.
*
* @param string $command
* @return bool
*/
function command_exists($command)
{
$which = PHP_OS_FAMILY === 'Windows' ? 'where' : 'which';
$result = shell_exec("$which $command 2>&1");
return !empty($result) && !str_contains($result, 'not found') && !str_contains($result, 'Could not find');
}
/**
* Execute command without exec()'s output truncation issues
* Drop-in replacement for exec() using shell_exec() and file redirection
*
* exec() has a critical flaw: it reads command output line-by-line into an array,
* which can hit memory/buffer limits on large outputs (>1MB typical), causing
* SILENT TRUNCATION without throwing errors or exceptions.
*
* \exec_safe() uses shell_exec() internally which handles unlimited output without
* pipe buffer truncation issues, while maintaining the exact same signature as exec().
*
* Usage:
* // Before:
* exec('git status 2>&1', $output, $return_var);
*
* // After:
* \exec_safe('git status 2>&1', $output, $return_var);
*
* @param string $command Command to execute
* @param array &$output Output lines (populated by reference like exec())
* @param int &$return_var Return code (populated by reference like exec())
* @return string|false Last line of output, or false on failure (like exec())
*/
function exec_safe(string $command, array &$output = [], int &$return_var = 0): string|false
{
// Use shell_exec() for reliable output capture without pipe buffer truncation
// shell_exec() doesn't provide exit codes, so use exec() with file redirection for that
$temp_file = storage_path('rsx-tmp/exec_safe_' . uniqid() . '.txt');
// Redirect output to temp file to get both output and exit code reliably
$full_command = "($command) > " . escapeshellarg($temp_file) . " 2>&1; echo $?";
// Execute and capture just the exit code (last line)
$result = shell_exec($full_command);
$return_var = (int)trim($result);
// Read the full output from file
$combined = '';
if (file_exists($temp_file)) {
$combined = file_get_contents($temp_file);
unlink($temp_file); // Clean up
}
if ($combined === false) {
$return_var = -1;
$output = [];
return false;
}
// Split into lines like exec() does
$output = $combined ? explode("\n", trim($combined)) : [];
// Return last line like exec() does
return empty($output) ? '' : end($output);
}
/**
* Debug dump and die - Enhanced var_dump/die() replacement
*
* Outputs debug information with file/line location, values, and stack trace.
* This is the preferred method for temporary debugging output.
*
* @param mixed ...$values Any number of values to debug
* @return void (never returns - calls die())
*/
function rsx_dump_die(...$values)
{
\App\RSpade\Core\Debug\Debugger::rsx_dump_die(...$values);
}
/**
* Get the RSX ID of the current view for use as a body class
*
* This is used in layout files to add the view's RSX ID as a CSS class
* to the body tag, enabling view-specific styling.
*
* @return string The RSX ID or empty string if not set
*/
function rsx_body_class()
{
return \Illuminate\Support\Facades\View::shared('rsx_view_id', '');
}
/**
* Create an authentication required response
*
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
* - HTTP requests: Sets flash alert and redirects to login
* - AJAX requests: Returns JSON error with success:false
*
* @param string $reason The reason authentication is required
* @param string|null $redirect The URL to redirect to (default: /login)
* @return \App\RSpade\Core\Response\Auth_Required_Response
*/
function response_auth_required($reason = 'Authentication Required', $redirect = '/login')
{
return new \App\RSpade\Core\Response\Auth_Required_Response($reason, $redirect);
}
/**
* Create an unauthorized response
*
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
* - HTTP requests: Sets flash alert and redirects or throws exception
* - AJAX requests: Returns JSON error with success:false
*
* @param string $reason The reason for unauthorized access
* @param string|null $redirect The URL to redirect to (null throws exception)
* @return \App\RSpade\Core\Response\Unauthorized_Response
*/
function response_unauthorized($reason = 'Unauthorized', $redirect = null)
{
return new \App\RSpade\Core\Response\Unauthorized_Response($reason, $redirect);
}
/**
* Create a form error response
*
* Returns a special response object that Dispatch and Ajax_Endpoint handle differently:
* - HTTP requests: Sets flash alert and redirects back to same URL as GET
* - AJAX requests: Returns JSON error with success:false and details
*
* @param string $reason The error message
* @param array $details Additional error details
* @return \App\RSpade\Core\Response\Form_Error_Response
*/
function response_form_error($reason = 'An error has occurred.', $details = [])
{
return new \App\RSpade\Core\Response\Form_Error_Response($reason, $details);
}
/**
* Create a fatal error response
*
* Returns a special response object that always throws an exception
* in both HTTP and AJAX contexts
*
* @param string $reason The error message
* @param array $details Additional error details
* @return \App\RSpade\Core\Response\Fatal_Error_Response
*/
function response_fatal_error($reason = 'An error has occurred.', $details = [])
{
return new \App\RSpade\Core\Response\Fatal_Error_Response($reason, $details);
}
/**
* Check if the current request is from a loopback IP address
*
* Returns true only if:
* - Request is from localhost, 127.0.0.1, or ::1 (IPv6 loopback)
* - No proxy headers (X-Real-IP, X-Forwarded-For) are present
*
* Used to ensure Playwright test headers can only be used from local connections.
*
* @return bool True if request is from loopback without proxy headers
*/
function is_loopback_ip(): bool
{
$request = request();
if (!$request) {
return false;
}
// Check for proxy headers - if present, not a direct loopback connection
if ($request->hasHeader('X-Real-IP') ||
$request->hasHeader('X-Forwarded-For') ||
$request->hasHeader('X-Forwarded-Host') ||
$request->hasHeader('X-Forwarded-Proto')) {
return false;
}
// Get the client IP
$ip = $request->ip();
// Check for loopback addresses
// IPv4 loopback: 127.0.0.1
// IPv6 loopback: ::1
// Hostname: localhost
$loopback_addresses = [
'127.0.0.1',
'::1',
'localhost',
];
return in_array($ip, $loopback_addresses, true);
}
/**
* Generate a hash for a file suitable for build/cache invalidation
*
* Uses different strategies based on environment:
* - Development: Fast hash based on path, size, and modification time
* - Production: Thorough hash based on path and file contents
*
* This function is used by the manifest and bundle systems to detect
* when files have changed and need recompilation.
*
* @param string $file_path The absolute path to the file
* @return string A hash string representing the file state
*/
function _rsx_file_hash_for_build($file_path)
{
if (!file_exists($file_path)) {
shouldnt_happen("File does not exist for hashing: {$file_path}");
}
// In development mode, use a fast hash based on metadata
if (!app()->environment('production')) {
$hash_data = [
'path' => $file_path,
'size' => filesize($file_path),
'mtime' => filemtime($file_path),
];
return md5(json_encode($hash_data));
}
// In production mode, use a thorough hash based on content
$hash_data = [
'path' => $file_path,
'content' => file_get_contents($file_path),
];
return hash('sha512', json_encode($hash_data));
}
/**
* Convert text to HTML preserving whitespace and indentation
*
* Converts plain text to HTML that displays with proper formatting:
* - HTML special characters are escaped
* - Leading spaces on each line are converted to
* - Newlines are converted to
tags
* - Trailing whitespace on lines is trimmed
*
* This is useful for displaying source code or formatted text in HTML
* where you want to preserve the indentation and line breaks.
*
* @param string $text The plain text to convert
* @return string HTML-formatted text
*/
function text_to_html_with_whitespace(string $text): string
{
// First, escape HTML special characters to prevent XSS
$text = htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Split into lines
$lines = explode("\n", $text);
// Process each line
$processed_lines = [];
foreach ($lines as $line) {
// Trim trailing whitespace only (preserve leading spaces)
$line = rtrim($line);
// Count leading spaces
$leading_spaces = strlen($line) - strlen(ltrim($line));
if ($leading_spaces > 0) {
// Replace leading spaces with
$spaces_html = str_repeat(' ', $leading_spaces);
$line = $spaces_html . substr($line, $leading_spaces);
}
$processed_lines[] = $line;
}
// Join lines with
\n
return implode("
\n", $processed_lines);
}
/**
* Normalize a path resolving . and .. components without resolving symlinks
*
* Unlike PHP's realpath(), this function normalizes paths by resolving . and ..
* components but does NOT follow symlinks. This is important when working with
* symlinked directories where you need the logical path, not the physical path.
*
* Behavior:
* - Resolves . (current directory) and .. (parent directory) components
* - Does NOT resolve symlinks (unlike realpath())
* - Converts relative paths to absolute by prepending base_path()
* - Returns normalized absolute path, or false if file doesn't exist
*
* Examples:
* - rsxrealpath('/var/www/html/system/rsx/foo/../bar')
* => '/var/www/html/system/rsx/bar'
*
* - rsxrealpath('rsx/foo/../bar')
* => '/var/www/html/rsx/foo/../bar' (after base_path prepend)
* => '/var/www/html/rsx/bar'
*
* - If /var/www/html/system/rsx is a symlink to /var/www/html/rsx:
* rsxrealpath('/var/www/html/system/rsx/bar')
* => '/var/www/html/system/rsx/bar' (keeps symlink path, unlike realpath)
*
* @param string $path The path to normalize
* @return string|false The normalized absolute path, or false if file doesn't exist
*/
function rsxrealpath(string $path): string|false
{
// Convert relative path to absolute
if (!str_starts_with($path, '/')) {
$path = base_path() . '/' . $path;
}
// Split path into components
$parts = explode('/', $path);
$result = [];
foreach ($parts as $part) {
if ($part === '' || $part === '.') {
// Skip empty parts and current directory references
continue;
} elseif ($part === '..') {
// Go up one directory (remove last component)
if (!empty($result)) {
array_pop($result);
}
} else {
// Regular path component
$result[] = $part;
}
}
// Rebuild path with leading slash
$normalized = '/' . implode('/', $result);
// Check if path exists (like realpath does)
if (!file_exists($normalized)) {
return false;
}
return $normalized;
}
/**
* Convert bytes to human-readable format
*
* Converts a number of bytes into a human-readable string with appropriate unit suffix.
*
* @param int $bytes The number of bytes
* @param int $precision Number of decimal places (default: 2)
* @return string Formatted string (e.g., "1.5 MB", "342 B")
*/
function bytes_to_human($bytes, $precision = 2)
{
if (!is_numeric($bytes)) {
return "---";
}
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, $precision) . ' ' . $units[$i];
}
/**
* Converts a duration from seconds to a human-readable format.
*
* @param int $seconds The duration in seconds.
* @param bool $round_to_whole_value If true, returns only the largest unit
* @return string The formatted duration.
*
* The function returns:
* - "X seconds" if the duration is less than 60 seconds,
* - "X minutes and Y seconds" if less than 10 minutes,
* - "X minutes" if less than 1 hour,
* - "X hours and Y minutes" otherwise.
*/
function duration_to_human($seconds, $round_to_whole_value = false)
{
if (!is_numeric($seconds)) {
return "---";
}
$years = intdiv($seconds, 31536000); // 365 days
$months = intdiv($seconds % 31536000, 2592000); // 30 days
$weeks = intdiv($seconds % 2592000, 604800); // 7 days
$days = intdiv($seconds % 604800, 86400); // 24 hours
$hours = intdiv($seconds % 86400, 3600);
$minutes = intdiv($seconds % 3600, 60);
$remaining_seconds = $seconds % 60;
$parts = [];
if ($years > 0) {
$parts[] = $years . " year" . ($years != 1 ? "s" : "");
}
if ($months > 0) {
$parts[] = $months . " month" . ($months != 1 ? "s" : "");
}
if ($weeks > 0) {
$parts[] = $weeks . " week" . ($weeks != 1 ? "s" : "");
}
if ($days > 0) {
$parts[] = $days . " day" . ($days != 1 ? "s" : "");
}
if ($hours > 0) {
$parts[] = $hours . " hour" . ($hours != 1 ? "s" : "");
}
if ($minutes > 0) {
$parts[] = $minutes . " minute" . ($minutes != 1 ? "s" : "");
}
if ($remaining_seconds > 0) {
$parts[] = $remaining_seconds . " second" . ($remaining_seconds != 1 ? "s" : "");
}
if ($round_to_whole_value) {
return count($parts) > 0 ? $parts[0] : "less than a second";
} else {
return count($parts) > 1 ? $parts[0] . " and " . $parts[1] : (count($parts) > 0 ? $parts[0] : "less than a second");
}
}
/**
* Convert a full URL to short URL by removing protocol
*
* Strips http:// or https:// from the beginning of the URL if present.
* Leaves the URL alone if it doesn't start with either protocol.
* Removes trailing slash if there is no path.
*
* @param string|null $url URL to convert
* @return string|null Short URL without protocol
*/
function full_url_to_short_url(?string $url): ?string
{
if ($url === null || $url === '') {
return $url;
}
// Remove http:// or https:// from the beginning
if (stripos($url, 'http://') === 0) {
$url = substr($url, 7);
} elseif (stripos($url, 'https://') === 0) {
$url = substr($url, 8);
}
// Remove trailing slash if there is no path (just domain)
// Check if URL is just domain with trailing slash (no path after slash)
if (substr($url, -1) === '/' && substr_count($url, '/') === 1) {
$url = rtrim($url, '/');
}
return $url;
}
/**
* Convert a short URL to full URL by adding protocol
*
* Adds http:// to the beginning of the URL if it lacks a protocol.
* Leaves URLs with existing http:// or https:// unchanged.
* Adds trailing slash if there is no path.
*
* @param string|null $url URL to convert
* @return string|null Full URL with protocol
*/
function short_url_to_full_url(?string $url): ?string
{
if ($url === null || $url === '') {
return $url;
}
// Check if URL already has a protocol
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
$full_url = $url;
} else {
// Add http:// protocol
$full_url = 'http://' . $url;
}
// Add trailing slash if there is no path (just domain)
// Check if URL has no slash after the domain
$without_protocol = preg_replace('#^https?://#i', '', $full_url);
if (strpos($without_protocol, '/') === false) {
$full_url .= '/';
}
return $full_url;
}
/**
* Validate a short URL format
*
* Validates that a URL (without protocol) looks like a valid domain.
* Requirements:
* - Must contain at least one dot (.)
* - Must not contain spaces
* - Empty strings are considered valid (optional field)
*
* @param string|null $url Short URL to validate
* @return bool True if valid, false otherwise
*/
function validate_short_url(?string $url): bool
{
// Empty strings are valid (optional field)
if ($url === null || $url === '') {
return true;
}
// Must not contain spaces
if (strpos($url, ' ') !== false) {
return false;
}
// Must contain at least one dot (domain.extension)
if (strpos($url, '.') === false) {
return false;
}
return true;
}