diff --git a/.horde.yml b/.horde.yml index 197e6b5..2b70b1d 100644 --- a/.horde.yml +++ b/.horde.yml @@ -32,3 +32,8 @@ dependencies: nocommands: - bin/demo-client.php vendor: horde +keywords: [] + +quality: + phpstan: + level: 5 diff --git a/src/ConfigFormatterInterface.php b/src/ConfigFormatterInterface.php new file mode 100644 index 0000000..89eb9a6 --- /dev/null +++ b/src/ConfigFormatterInterface.php @@ -0,0 +1,28 @@ + $config Configuration data to format + * @return string PHP code (without opening $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 $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 $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) + ); + } +} diff --git a/src/Formatter/ModernArrayFormatter.php b/src/Formatter/ModernArrayFormatter.php new file mode 100644 index 0000000..3345286 --- /dev/null +++ b/src/Formatter/ModernArrayFormatter.php @@ -0,0 +1,43 @@ + '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"; + } + } + return $output; + } +} diff --git a/src/LiteralCode.php b/src/LiteralCode.php new file mode 100644 index 0000000..72583fc --- /dev/null +++ b/src/LiteralCode.php @@ -0,0 +1,47 @@ + [ + * '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; + } +} diff --git a/src/PhpConfigFile.php b/src/PhpConfigFile.php index 3bc2488..4a8de02 100644 --- a/src/PhpConfigFile.php +++ b/src/PhpConfigFile.php @@ -4,6 +4,7 @@ namespace Horde\PhpConfigFile; +use Horde\PhpConfigFile\Formatter\ModernArrayFormatter; use Stringable; use RuntimeException; use InvalidArgumentException; @@ -18,7 +19,6 @@ use function trim; use function ltrim; use function get_defined_vars; -use function var_export; use function in_array; /** @@ -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 { @@ -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); @@ -136,25 +140,25 @@ public function parseContent(string $area = 'content'): array /** * Write the config file with the given array * @param array $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 = "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 = "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; diff --git a/test/fixtures/ClassicHordeFormat.php b/test/fixtures/ClassicHordeFormat.php index a17d26d..8a6d17b 100644 --- a/test/fixtures/ClassicHordeFormat.php +++ b/test/fixtures/ClassicHordeFormat.php @@ -1,7 +1,8 @@ 'subsubvalue2', ], ], -]; \ No newline at end of file +]; diff --git a/test/fixtures/WithPreHeaderAndPostFooterContent.php b/test/fixtures/WithPreHeaderAndPostFooterContent.php index f0bd150..93039f4 100644 --- a/test/fixtures/WithPreHeaderAndPostFooterContent.php +++ b/test/fixtures/WithPreHeaderAndPostFooterContent.php @@ -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'; - -?> \ No newline at end of file diff --git a/test/unit/ConfigurationSchemaTest.php b/test/unit/ConfigurationSchemaTest.php index e218aed..a08978f 100644 --- a/test/unit/ConfigurationSchemaTest.php +++ b/test/unit/ConfigurationSchemaTest.php @@ -10,6 +10,7 @@ use Horde\PhpConfigFile\StringElement; use Horde\PhpConfigFile\IntegerElement; use PHPUnit\Framework\Attributes\CoversNothing; + #[CoversNothing] class ConfigurationSchemaTest extends TestCase { @@ -37,4 +38,4 @@ public function testConfigurationSchemaWithElements(): void $this->assertInstanceOf(IntegerElement::class, $element2, 'Element2 should be an IntegerElement'); $this->assertEquals(42, $element2->getValue(), 'Element2 value should match'); } -} \ No newline at end of file +} diff --git a/test/unit/Formatter/ClassicHordeFormatterTest.php b/test/unit/Formatter/ClassicHordeFormatterTest.php new file mode 100644 index 0000000..2fd1e35 --- /dev/null +++ b/test/unit/Formatter/ClassicHordeFormatterTest.php @@ -0,0 +1,271 @@ +format(['conf' => ['key' => 'value']]); + $this->assertStringContainsString("\$conf['key'] = 'value';", $result); + } + + public function testFormatNestedArray(): void + { + $formatter = new ClassicHordeFormatter(); + $config = ['conf' => ['sql' => ['host' => 'localhost']]]; + $result = $formatter->format($config); + $this->assertStringContainsString("\$conf['sql']['host'] = 'localhost';", $result); + } + + public function testFormatBoolean(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['enabled' => true, 'disabled' => false]]); + $this->assertStringContainsString("\$conf['enabled'] = true;", $result); + $this->assertStringContainsString("\$conf['disabled'] = false;", $result); + } + + public function testFormatInteger(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['port' => 3306]]); + $this->assertStringContainsString("\$conf['port'] = 3306;", $result); + } + + public function testFormatFloat(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['ratio' => 1.5]]); + $this->assertStringContainsString("\$conf['ratio'] = 1.5;", $result); + } + + public function testFormatNull(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['nullable' => null]]); + $this->assertStringContainsString("\$conf['nullable'] = null;", $result); + } + + public function testFormatLiteralCode(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['log_level' => new LiteralCode('Horde_Log::DEBUG')]]); + $this->assertStringContainsString("\$conf['log_level'] = Horde_Log::DEBUG;", $result); + } + + public function testFormatLiteralCodeExpression(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['timeout' => new LiteralCode('60 * 60')]]); + $this->assertStringContainsString("\$conf['timeout'] = 60 * 60;", $result); + } + + public function testNumericKeysUnquoted(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['items' => [0 => 'first', 1 => 'second']]); + $this->assertStringContainsString("\$items[0] = 'first';", $result); + $this->assertStringContainsString("\$items[1] = 'second';", $result); + $this->assertStringNotContainsString("['0']", $result); + $this->assertStringNotContainsString("['1']", $result); + } + + public function testEscapesQuotesInKeys(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ["key'with'quotes" => 'value']]); + $this->assertStringContainsString("key\\'with\\'quotes", $result); + } + + public function testEscapesQuotesInValues(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['key' => "val'ue"]]); + $this->assertStringContainsString("val\\'ue", $result); + } + + public function testEscapesBackslashesInValues(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['path' => 'C:\\Windows\\System']]); + $this->assertStringContainsString("C:\\\\Windows\\\\System", $result); + } + + public function testAlphabeticalKeyOrdering(): void + { + $formatter = new ClassicHordeFormatter(); + $config = ['conf' => ['z' => 'last', 'a' => 'first', 'm' => 'middle']]; + $result = $formatter->format($config); + + // Extract line positions + $posA = strpos($result, "\$conf['a']"); + $posM = strpos($result, "\$conf['m']"); + $posZ = strpos($result, "\$conf['z']"); + + $this->assertLessThan($posM, $posA, 'Key "a" should come before "m"'); + $this->assertLessThan($posZ, $posM, 'Key "m" should come before "z"'); + } + + public function testAlphabeticalKeyOrderingNested(): void + { + $formatter = new ClassicHordeFormatter(); + $config = ['conf' => ['section' => ['z' => 'last', 'a' => 'first']]]; + $result = $formatter->format($config); + + $posA = strpos($result, "\$conf['section']['a']"); + $posZ = strpos($result, "\$conf['section']['z']"); + + $this->assertLessThan($posZ, $posA, 'Nested key "a" should come before "z"'); + } + + public function testEmptyArrayProducesNoOutput(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['empty' => []]]); + $this->assertStringNotContainsString('empty', $result, 'Empty arrays should produce no output'); + $this->assertEquals('', $result); + } + + public function testNestedEmptyArraysProduceNoOutput(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['section' => ['subsection' => []]]]); + $this->assertEquals('', $result); + } + + public function testThrowsExceptionForNonScalarLeafValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Only scalar values and LiteralCode are supported'); + + $formatter = new ClassicHordeFormatter(); + $formatter->format(['conf' => ['object' => new stdClass()]]); + } + + public function testDeepNesting(): void + { + $formatter = new ClassicHordeFormatter(); + $config = ['conf' => ['l1' => ['l2' => ['l3' => ['l4' => 'deep']]]]]; + $result = $formatter->format($config); + $this->assertStringContainsString("\$conf['l1']['l2']['l3']['l4'] = 'deep';", $result); + } + + public function testMixedNumericAndStringKeys(): void + { + $formatter = new ClassicHordeFormatter(); + $config = ['conf' => ['items' => [0 => 'zero', 'name' => 'value', 1 => 'one']]]; + $result = $formatter->format($config); + + // Numeric keys come first when sorted (0, 1, then 'name') + $this->assertStringContainsString("\$conf['items'][0] = 'zero';", $result); + $this->assertStringContainsString("\$conf['items'][1] = 'one';", $result); + $this->assertStringContainsString("\$conf['items']['name'] = 'value';", $result); + } + + public function testRealWorldHordeConfig(): void + { + $formatter = new ClassicHordeFormatter(); + $config = [ + 'conf' => [ + 'sql' => [ + 'hostspec' => 'localhost', + 'username' => 'horde', + 'password' => 'secret', + 'port' => 3306, + ], + 'readwritesplit' => true, + 'log_level' => new LiteralCode('Horde_Log::DEBUG'), + ], + ]; + + $result = $formatter->format($config); + + $this->assertStringContainsString("\$conf['log_level'] = Horde_Log::DEBUG;", $result); + $this->assertStringContainsString("\$conf['readwritesplit'] = true;", $result); + $this->assertStringContainsString("\$conf['sql']['hostspec'] = 'localhost';", $result); + $this->assertStringContainsString("\$conf['sql']['password'] = 'secret';", $result); + $this->assertStringContainsString("\$conf['sql']['port'] = 3306;", $result); + $this->assertStringContainsString("\$conf['sql']['username'] = 'horde';", $result); + } + + public function testEmptyStringValue(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['empty' => '']]); + $this->assertStringContainsString("\$conf['empty'] = '';", $result); + } + + public function testZeroIntegerValue(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['zero' => 0]]); + $this->assertStringContainsString("\$conf['zero'] = 0;", $result); + } + + public function testZeroFloatValue(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['zero' => 0.0]]); + $this->assertStringContainsString("\$conf['zero'] = 0;", $result); + } + + public function testNegativeNumbers(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['int' => -42, 'float' => -3.14]]); + $this->assertStringContainsString("\$conf['float'] = -3.14;", $result); + $this->assertStringContainsString("\$conf['int'] = -42;", $result); + } + + public function testUnicodeInKeys(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['key_with_émojis_🎉' => 'value']]); + $this->assertStringContainsString('key_with_émojis_🎉', $result); + } + + public function testUnicodeInValues(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['key' => 'Héllo Wörld 🌍']]); + $this->assertStringContainsString('Héllo Wörld 🌍', $result); + } + + public function testDoubleQuotesInValue(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['key' => 'value "quoted"']]); + // addslashes() escapes double quotes + $this->assertStringContainsString('value \"quoted\"', $result); + } + + public function testNewlineInValue(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['key' => "line1\nline2"]]); + // addslashes() does NOT escape newlines - they remain literal + $this->assertStringContainsString("line1\nline2", $result); + } + + public function testTabInValue(): void + { + $formatter = new ClassicHordeFormatter(); + $result = $formatter->format(['conf' => ['key' => "col1\tcol2"]]); + // addslashes() does NOT escape tabs - they remain literal + $this->assertStringContainsString("col1\tcol2", $result); + } +} diff --git a/test/unit/Formatter/ModernArrayFormatterTest.php b/test/unit/Formatter/ModernArrayFormatterTest.php new file mode 100644 index 0000000..56effea --- /dev/null +++ b/test/unit/Formatter/ModernArrayFormatterTest.php @@ -0,0 +1,54 @@ +format(['key' => 'value']); + $this->assertStringContainsString("\$key = 'value';", $result); + } + + public function testFormatNestedArray(): void + { + $formatter = new ModernArrayFormatter(); + $config = ['outer' => ['inner' => 'value']]; + $result = $formatter->format($config); + $this->assertStringContainsString("'inner' => 'value'", $result); + $this->assertStringContainsString('$outer = ', $result); + } + + public function testFormatMultipleKeys(): void + { + $formatter = new ModernArrayFormatter(); + $config = ['key1' => 'value1', 'key2' => 'value2']; + $result = $formatter->format($config); + $this->assertStringContainsString("\$key1 = 'value1';", $result); + $this->assertStringContainsString("\$key2 = 'value2';", $result); + } + + public function testFormatArrayWithVarExport(): void + { + $formatter = new ModernArrayFormatter(); + $config = ['config' => ['key' => 'value', 'num' => 42]]; + $result = $formatter->format($config); + $this->assertStringContainsString('array (', $result); + $this->assertStringContainsString("'key' => 'value'", $result); + } + + public function testFormatPreservesScalarBehavior(): void + { + $formatter = new ModernArrayFormatter(); + $result = $formatter->format(['simple' => 'test']); + $this->assertEquals("\$simple = 'test';\n", $result); + } +} diff --git a/test/unit/Integration/FormatterIntegrationTest.php b/test/unit/Integration/FormatterIntegrationTest.php new file mode 100644 index 0000000..0db11f1 --- /dev/null +++ b/test/unit/Integration/FormatterIntegrationTest.php @@ -0,0 +1,263 @@ +getTempFile(); + $file = new PhpConfigFile($path); + $file->writeConfigFile(['config' => ['key' => 'value']]); + + $content = file_get_contents($path); + $this->assertStringContainsString("'key' => 'value'", $content); + $this->assertStringNotContainsString("\$config['key']", $content); + unlink($path); + } + + public function testWriteWithConstructorFormatterUsesClassicFormat(): void + { + $path = $this->getTempFile(); + $file = new PhpConfigFile( + $path, + formatter: new ClassicHordeFormatter() + ); + $file->writeConfigFile(['conf' => ['key' => 'value']]); + + $content = file_get_contents($path); + $this->assertStringContainsString("\$conf['key'] = 'value';", $content); + unlink($path); + } + + public function testWriteWithMethodFormatterOverridesConstructor(): void + { + $path = $this->getTempFile(); + // Constructor has modern formatter + $file = new PhpConfigFile( + $path, + formatter: new ModernArrayFormatter() + ); + // Method call overrides with classic formatter + $file->writeConfigFile( + ['conf' => ['key' => 'value']], + new ClassicHordeFormatter() + ); + + $content = file_get_contents($path); + $this->assertStringContainsString("\$conf['key'] = 'value';", $content); + $this->assertStringNotContainsString("'key' => 'value'", $content); + unlink($path); + } + + public function testRoundTripClassicFormat(): void + { + $path = $this->getTempFile(); + $formatter = new ClassicHordeFormatter(); + $file = new PhpConfigFile($path, formatter: $formatter); + + $original = [ + 'conf' => [ + 'sql' => [ + 'host' => 'localhost', + 'port' => 3306, + ], + 'enabled' => true, + ], + ]; + + $file->writeConfigFile($original); + $file->readConfigFile(); + $parsed = $file->parseContent(); + + $this->assertEquals('localhost', $parsed['conf']['sql']['host']); + $this->assertEquals(3306, $parsed['conf']['sql']['port']); + $this->assertTrue($parsed['conf']['enabled']); + + unlink($path); + } + + public function testRoundTripModernFormat(): void + { + $path = $this->getTempFile(); + $file = new PhpConfigFile($path); + + $original = [ + 'config' => [ + 'key1' => 'value1', + 'key2' => 'value2', + ], + ]; + + $file->writeConfigFile($original); + $file->readConfigFile(); + $parsed = $file->parseContent(); + + $this->assertEquals('value1', $parsed['config']['key1']); + $this->assertEquals('value2', $parsed['config']['key2']); + + unlink($path); + } + + public function testRoundTripWithLiteralCode(): void + { + $path = $this->getTempFile(); + $formatter = new ClassicHordeFormatter(); + $file = new PhpConfigFile($path, formatter: $formatter); + + // Define constant for test + if (!defined('TEST_CONSTANT')) { + define('TEST_CONSTANT', 42); + } + + $original = [ + 'conf' => [ + 'value' => new LiteralCode('TEST_CONSTANT'), + ], + ]; + + $file->writeConfigFile($original); + $file->readConfigFile(); + $parsed = $file->parseContent(); + + $this->assertEquals(42, $parsed['conf']['value']); + + unlink($path); + } + + public function testPreservesContentBeforeHeader(): void + { + $path = $this->getTempFile(); + + // Write initial content with pre-header section + file_put_contents($path, "readConfigFile(); + $file->writeConfigFile(['conf' => ['key' => 'value']]); + + $content = file_get_contents($path); + $this->assertStringContainsString('// Pre-header comment', $content); + $this->assertStringContainsString("\$prevar = 'preserved';", $content); + $this->assertStringContainsString("\$conf['key'] = 'value';", $content); + + unlink($path); + } + + public function testPreservesContentAfterFooter(): void + { + $path = $this->getTempFile(); + $file = new PhpConfigFile( + $path, + header: '/* BEGIN */', + footer: '/* END */', + formatter: new ClassicHordeFormatter() + ); + + // Write initial content with footer content + file_put_contents( + $path, + "readConfigFile(); + $file->writeConfigFile(['conf' => ['key' => 'value']]); + + $content = file_get_contents($path); + $this->assertStringContainsString('// Post-footer comment', $content); + $this->assertStringContainsString("\$postvar = 'preserved';", $content); + $this->assertStringContainsString("\$conf['key'] = 'value';", $content); + + unlink($path); + } + + public function testAlphabeticalOrderingInOutput(): void + { + $path = $this->getTempFile(); + $file = new PhpConfigFile($path, formatter: new ClassicHordeFormatter()); + + $config = [ + 'conf' => [ + 'z_last' => 'last', + 'a_first' => 'first', + 'm_middle' => 'middle', + ], + ]; + + $file->writeConfigFile($config); + $content = file_get_contents($path); + + // Verify alphabetical order + $posA = strpos($content, "\$conf['a_first']"); + $posM = strpos($content, "\$conf['m_middle']"); + $posZ = strpos($content, "\$conf['z_last']"); + + $this->assertLessThan($posM, $posA); + $this->assertLessThan($posZ, $posM); + + unlink($path); + } + + public function testMultipleWritesWithSameFormatter(): void + { + $path = $this->getTempFile(); + $file = new PhpConfigFile($path, formatter: new ClassicHordeFormatter()); + + $file->writeConfigFile(['conf' => ['key1' => 'value1']]); + $content1 = file_get_contents($path); + $this->assertStringContainsString("\$conf['key1'] = 'value1';", $content1); + + // Second write with different data + $file->writeConfigFile(['conf' => ['key2' => 'value2']]); + $content2 = file_get_contents($path); + $this->assertStringContainsString("\$conf['key2'] = 'value2';", $content2); + $this->assertStringNotContainsString('key1', $content2); + + unlink($path); + } + + public function testSwitchFormatterBetweenWrites(): void + { + $path = $this->getTempFile(); + $file = new PhpConfigFile($path); + + // Write with classic format + $file->writeConfigFile(['conf' => ['key' => 'value']], new ClassicHordeFormatter()); + $content1 = file_get_contents($path); + $this->assertStringContainsString("\$conf['key'] = 'value';", $content1); + + // Write with modern format + $file->writeConfigFile(['config' => ['key' => 'value']], new ModernArrayFormatter()); + $content2 = file_get_contents($path); + $this->assertStringContainsString("'key' => 'value'", $content2); + $this->assertStringNotContainsString("\$config['key']", $content2); + + unlink($path); + } +} diff --git a/test/unit/LiteralCodeTest.php b/test/unit/LiteralCodeTest.php new file mode 100644 index 0000000..a4b5d7a --- /dev/null +++ b/test/unit/LiteralCodeTest.php @@ -0,0 +1,44 @@ +assertEquals('SomeClass::CONSTANT', $literal->getCode()); + } + + public function testToStringReturnsCode(): void + { + $literal = new LiteralCode('60 * 60'); + $this->assertEquals('60 * 60', (string) $literal); + } + + public function testSupportsExpression(): void + { + $literal = new LiteralCode('Horde_Log::DEBUG'); + $this->assertEquals('Horde_Log::DEBUG', $literal->getCode()); + } + + public function testSupportsComplexExpression(): void + { + $literal = new LiteralCode('defined("DEBUG") ? 1 : 0'); + $this->assertEquals('defined("DEBUG") ? 1 : 0', $literal->getCode()); + } + + public function testIsReadonly(): void + { + $literal = new LiteralCode('test'); + $reflection = new \ReflectionClass($literal); + $this->assertTrue($reflection->isReadOnly()); + } +} diff --git a/test/unit/PhpConfigFileTest.php b/test/unit/PhpConfigFileTest.php index 827c313..ba5d1e8 100644 --- a/test/unit/PhpConfigFileTest.php +++ b/test/unit/PhpConfigFileTest.php @@ -7,8 +7,9 @@ use Horde\PhpConfigFile\PhpConfigFile; use PHPUnit\Framework\TestCase; use Stringable; -use PHPUnit\Framework\Attributes\CoversNothing; -#[CoversNothing] +use PHPUnit\Framework\Attributes\CoversClass; + +#[CoversClass(PhpConfigFile::class)] class PhpConfigFileTest extends TestCase { public function testReadEmptyConfigFile(): void @@ -112,4 +113,43 @@ public function testReadFailure(): void ); $file->readConfigFile(); } + + public function testParseContentWithInvalidArea(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid area to parse from'); + + $file = new PhpConfigFile( + configFilePath: __DIR__ . '/../fixtures/EmptyConfigFile.php', + ); + $file->readConfigFile(); + $file->parseContent('invalid_area'); + } + + public function testWriteWithDefaultFormatter(): void + { + $file = new PhpConfigFile('deleteme_default'); + $file->writeConfigFile(['key' => 'value']); + + $this->assertFileExists('deleteme_default'); + $content = (string) file_get_contents('deleteme_default'); + // Should use ModernArrayFormatter by default + $this->assertStringContainsString("\$key = 'value';", $content); + unlink('deleteme_default'); + } + + public function testGettersReturnCorrectContent(): void + { + $file = new PhpConfigFile( + configFilePath: __DIR__ . '/../fixtures/WithPreHeaderAndPostFooterContent.php', + header: '/* Begin Horde Config File - do not edit */', + footer: '/* End Horde Config File - do not edit */' + ); + $file->readConfigFile(); + + $this->assertNotEmpty($file->getContent()); + $this->assertNotEmpty($file->getContentBeforeHeader()); + $this->assertNotEmpty($file->getContentAfterFooter()); + $this->assertNotEmpty($file->getContentBetweenHeaderAndFooter()); + } }