Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .horde.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ dependencies:
nocommands:
- bin/demo-client.php
vendor: horde
keywords: []

quality:
phpstan:
level: 5
28 changes: 28 additions & 0 deletions src/ConfigFormatterInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/**
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\PhpConfigFile;

/**
* Interface for configuration file formatters
*
* Formatters convert configuration arrays into PHP code strings.
*/
interface ConfigFormatterInterface
{
/**
* Format a configuration array into PHP code string
*
* @param array<string, mixed> $config Configuration data to format
* @return string PHP code (without opening <?php tag)
*/
public function format(array $config): string;
}
142 changes: 142 additions & 0 deletions src/Formatter/ClassicHordeFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

/**
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\PhpConfigFile\Formatter;

use Horde\PhpConfigFile\ConfigFormatterInterface;
use Horde\PhpConfigFile\LiteralCode;
use InvalidArgumentException;

use function is_array;
use function is_bool;
use function is_int;
use function is_float;
use function is_string;
use function ksort;
use function addslashes;
use function array_shift;
use function get_debug_type;

/**
* Classic Horde config formatter
*
* Outputs configuration in classic Horde format:
* $conf['key1']['key2'] = 'value';
*
* Features:
* - Alphabetical key sorting for deterministic output
* - Numeric keys unquoted: $var[0] not $var['0']
* - LiteralCode support for constants and expressions
* - Empty arrays produce no output
* - Only scalar values and LiteralCode supported at leaf level
*/
class ClassicHordeFormatter implements ConfigFormatterInterface
{
public function format(array $config): string
{
// Sort root keys alphabetically
ksort($config);

$output = '';
foreach ($config as $rootKey => $value) {
$output .= $this->formatValue($rootKey, [], $value);
}
return $output;
}

/**
* Recursively format a value into classic Horde notation
*
* @param string|int $currentKey Current key being processed
* @param array<string|int> $keyPath Path of keys leading to this point
* @param mixed $value Value to format
* @return string Formatted PHP code lines
*/
private function formatValue(string|int $currentKey, array $keyPath, mixed $value): string
{
$keyPath[] = $currentKey;

if (is_array($value)) {
// Sort keys alphabetically for deterministic output
ksort($value);

// Recursively process nested arrays
$output = '';
foreach ($value as $subKey => $subValue) {
$output .= $this->formatValue($subKey, $keyPath, $subValue);
}
return $output;
}

// Leaf value - generate assignment statement
return $this->formatLeafAssignment($keyPath, $value);
}

/**
* Format a leaf assignment: $var['key1'][2]['key3'] = value;
*
* @param array<string|int> $keyPath Full path of keys
* @param mixed $value Leaf value (scalar or LiteralCode)
* @return string Formatted assignment statement
*/
private function formatLeafAssignment(array $keyPath, mixed $value): string
{
// Build $var['key1'][2]['key3']...
$varPath = '$' . array_shift($keyPath);
foreach ($keyPath as $key) {
if (is_int($key)) {
// Numeric keys unquoted
$varPath .= "[{$key}]";
} else {
// String keys quoted and escaped
$varPath .= "['" . addslashes((string) $key) . "']";
}
}

// Format value based on type
$formattedValue = $this->formatScalar($value);

return "{$varPath} = {$formattedValue};\n";
}

/**
* Format a scalar value for PHP output
*
* @param mixed $value Value to format
* @return string Formatted value representation
* @throws InvalidArgumentException If value is not scalar or LiteralCode
*/
private function formatScalar(mixed $value): string
{
// Handle literal code expressions
if ($value instanceof LiteralCode) {
return $value->getCode();
}

if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (is_string($value)) {
return "'" . addslashes($value) . "'";
}
if ($value === null) {
return 'null';
}

// Reject non-scalar values
throw new InvalidArgumentException(
'Only scalar values and LiteralCode are supported. Got: ' . get_debug_type($value)
);
}
}
43 changes: 43 additions & 0 deletions src/Formatter/ModernArrayFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

/**
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\PhpConfigFile\Formatter;

use Horde\PhpConfigFile\ConfigFormatterInterface;

use function var_export;
use function is_array;

/**
* Modern PHP array formatter using var_export()
*
* Outputs configuration as modern PHP array syntax:
* $config = ['key' => 'value'];
*
* This is the default formatter, maintaining backward compatibility
* with the original PhpConfigFile behavior.
*/
class ModernArrayFormatter implements ConfigFormatterInterface
{
public function format(array $config): string
{
$output = '';
foreach ($config as $key => $value) {
if (is_array($value)) {
$output .= '$' . $key . ' = ' . var_export($value, true) . ";\n";
} else {
// Scalar values as string (original behavior)
$output .= '$' . $key . " = '" . $value . "';\n";

Check failure on line 38 in src/Formatter/ModernArrayFormatter.php

View workflow job for this annotation

GitHub Actions / CI

Binary operation "." between non-falsy-string and mixed results in an error.
}
}
return $output;
}
}
47 changes: 47 additions & 0 deletions src/LiteralCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

/**
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\PhpConfigFile;

use Stringable;

/**
* Wrapper for literal PHP code expressions that should be output as-is
*
* Use this when you need to output PHP constants, class constants, or other
* expressions that shouldn't be quoted or escaped.
*
* @example
* $config = [
* 'conf' => [
* 'log_level' => new LiteralCode('Horde_Log::DEBUG'),
* 'timeout' => new LiteralCode('60 * 60'), // 1 hour
* ],
* ];
* // Output: $conf['log_level'] = Horde_Log::DEBUG;
* // $conf['timeout'] = 60 * 60;
*/
final readonly class LiteralCode implements Stringable
{
public function __construct(
private string $code,
) {}

public function __toString(): string
{
return $this->code;
}

public function getCode(): string
{
return $this->code;
}
}
44 changes: 24 additions & 20 deletions src/PhpConfigFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Horde\PhpConfigFile;

use Horde\PhpConfigFile\Formatter\ModernArrayFormatter;
use Stringable;
use RuntimeException;
use InvalidArgumentException;
Expand All @@ -18,7 +19,6 @@
use function trim;
use function ltrim;
use function get_defined_vars;
use function var_export;
use function in_array;

/**
Expand All @@ -32,12 +32,16 @@ class PhpConfigFile
private string $contentBetweenHeaderAndFooter = '';
private string $content = '';
private string $untrustedContent = '';
private ?ConfigFormatterInterface $defaultFormatter = null;

public function __construct(
public readonly string|Stringable $configFilePath,
public readonly string|Stringable $header = '/* This file is auto-generated. Do not edit anything below this line! */',
public readonly string|Stringable $footer = '/* This file is auto-generated. Do not edit anything above this line! */',
) {}
?ConfigFormatterInterface $formatter = null,
) {
$this->defaultFormatter = $formatter;
}

public function getContent(): string
{
Expand Down Expand Up @@ -119,10 +123,10 @@ public function parseContent(string $area = 'content'): array
// Set up safe defaults for CLI context to prevent undefined key warnings
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
// Preserve existing $_SERVER values but provide safe defaults for missing keys
$_SERVER['SERVER_NAME'] = $_SERVER['SERVER_NAME'] ?? 'localhost';
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? 'localhost';
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$_SERVER['SERVER_NAME'] ??= 'localhost';
$_SERVER['HTTP_HOST'] ??= 'localhost';
$_SERVER['REQUEST_URI'] ??= '/';
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
}

eval($this->untrustedContent);
Expand All @@ -136,25 +140,25 @@ public function parseContent(string $area = 'content'): array
/**
* Write the config file with the given array
* @param array<mixed> $config The config array to write
* @param ConfigFormatterInterface|null $formatter Optional formatter override
* @return self
*/
public function writeConfigFile(array $config): self
public function writeConfigFile(array $config, ?ConfigFormatterInterface $formatter = null): self
{
// Determine which formatter to use (priority: parameter > constructor > default)
$activeFormatter = $formatter
?? $this->defaultFormatter
?? new ModernArrayFormatter();

// Convert the array back to a string
$configContent = "<?php\n" .
$this->contentBeforeHeader . "\n" .
$this->header . "\n";
foreach ($config as $key => $value) {
if (is_array($value)) {
$configContent .= '$' . $key . ' = ' . var_export($value, true) . ";\n";
} else {
$configContent .= '$' . $key . ' = \'' . $value . "';\n";
}
}
$configContent .= "\n" .
$configContent = "<?php\n"
. $this->contentBeforeHeader . "\n"
. $this->header . "\n"
. $activeFormatter->format($config)
. "\n"
. $this->footer . "\n"
. $this->contentAfterFooter;

$this->footer . "\n" .
$this->contentAfterFooter;
// Write the content back to the file
file_put_contents((string) $this->configFilePath, $configContent);
return $this;
Expand Down
3 changes: 2 additions & 1 deletion test/fixtures/ClassicHordeFormat.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?php

/* Horde Begin */
$conf['sql']['hostspec'] = 'localhost';
$conf['sql']['username'] = 'horde';
$conf['sql']['password'] = 'horde';
$conf['readwritesplit'] = true;
/* Horde End */
/* Horde End */
2 changes: 1 addition & 1 deletion test/fixtures/NestedModernArrayFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
'subsubkey2' => 'subsubvalue2',
],
],
];
];
2 changes: 0 additions & 2 deletions test/fixtures/WithPreHeaderAndPostFooterContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,3 @@
// Values after the footer overwrite managed content in 'content' mode. In 'contentBetweenHeaderAndFooter' mode, these vars are ignored altogether.
$something = 'overwritten';
$footer_only = 'footer only';

?>
Loading
Loading