Initial commit.

This commit is contained in:
flash 2023-10-20 21:21:17 +00:00
commit 8a849213e4
31 changed files with 3963 additions and 0 deletions

4
.gitattributes vendored Normal file
View file

@ -0,0 +1,4 @@
* text=auto
*.sh text eol=lf
*.php text eol=lf
*.bat text eol=crlf

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
[Tt]humbs.db
[Dd]esktop.ini
.DS_Store
.vscode/
.vs/
.idea/
docs/html/
.phpdoc*
.phpunit*
vendor/

30
LICENCE Normal file
View file

@ -0,0 +1,30 @@
Copyright (c) 2023, flashwave <me@flash.moe>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted (subject to the limitations in the disclaimer
below) provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

35
README.md Normal file
View file

@ -0,0 +1,35 @@
# Syokuhou
Syokuhou is a common library for configuration in my PHP projects.
It provides both a file interface and a database interface out of the box as well as scoping.
## Requirements and Dependencies
Syokuhou currently targets **PHP 8.2**.
### `Path to database interface`
A compatible `Index\Data\IDbConnection` must be provided.
## Versioning
Syokuhou versioning will follows the [Semantic Versioning specification v2.0.0](https://semver.org/spec/v2.0.0.html).
Previous major versions may be supported for a time with backports depending on what projects of mine still target older versions of PHP.
The version is stored in the root of the repository in a file called `VERSION` and can be read out within Syokuhou using `Syokuhou\SyokuhouInfo::getVersion()`.
## Contribution
By submitting code for inclusion in the main Syokuhou source tree you agree to transfer ownership of the code to the project owner.
The contributor will still be attributed for the contributed code, unless they ask for this attribution to be removed.
This is to avoid intellectual property rights traps and drama that could lead to blackmail situations.
If you do not agree with these terms, you are free to fork off.
## Licencing
Syokuhou is available under the BSD 3-Clause Clear License, a full version of which is enclosed in the LICENCE file.

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.0.0-dev

30
composer.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "flashwave/syokuhou",
"description": "Configuration library for PHP.",
"type": "library",
"homepage": "https://railgun.sh/syokuhou",
"license": "bsd-3-clause-clear",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"flashwave/index": "dev-master"
},
"require-dev": {
"phpunit/phpunit": "^10.4",
"phpstan/phpstan": "^1.10"
},
"authors": [
{
"name": "flashwave",
"email": "packagist@flash.moe",
"homepage": "https://flash.moe",
"role": "mom"
}
],
"autoload": {
"psr-4": {
"Syokuhou\\": "src"
}
}
}

1745
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

41
phpdoc.xml Normal file
View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" ?>
<phpdocumentor configVersion="3.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://www.phpdoc.org"
xsi:noNamespaceSchemaLocation="data/xsd/phpdoc.xsd">
<title>Syokuhou Documentation</title>
<template name="default"/>
<paths>
<output>docs/html</output>
</paths>
<version number="0.1">
<folder>latest</folder>
<api format="php">
<output>api</output>
<visibility>public</visibility>
<default-package-name>Syokuhou</default-package-name>
<source dsn=".">
<path>src</path>
</source>
<extensions>
<extension>php</extension>
</extensions>
<ignore hidden="true" symlinks="true">
<path>tests/**/*</path>
</ignore>
<ignore-tags>
<ignore-tag>template</ignore-tag>
<ignore-tag>template-extends</ignore-tag>
<ignore-tag>template-implements</ignore-tag>
<ignore-tag>extends</ignore-tag>
<ignore-tag>implements</ignore-tag>
</ignore-tags>
</api>
<guide format="rst">
<source dsn=".">
<path>docs</path>
</source>
<output>guide</output>
</guide>
</version>
</phpdocumentor>

7
phpstan.neon Normal file
View file

@ -0,0 +1,7 @@
parameters:
level: 9
checkUninitializedProperties: true
checkImplicitMixed: true
checkBenevolentUnionTypes: true
paths:
- src

23
phpunit.xml Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd"
colors="true"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true">
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
<coverage/>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

226
src/DbConfig.php Normal file
View file

@ -0,0 +1,226 @@
<?php
// DbConfig.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use InvalidArgumentException;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
/**
* Provides a configuration based on a {@see IDbConnection} instance.
*
* @todo provide table name in constructor
* @todo scan for vendor specific queries and generalise them
* @todo getValues() parsing should probably be done external so it can be reused
*/
class DbConfig implements IConfig {
use MutableConfigTrait, GetValueInfoTrait, GetValuesTrait;
private DbStatementCache $cache;
/** @var array<string, DbConfigValueInfo> */
private array $values = [];
public function __construct(
IDbConnection $dbConn,
private string $tableName,
private string $nameField = 'config_name',
private string $valueField = 'config_value'
) {
$this->cache = new DbStatementCache($dbConn);
}
public static function validateName(string $name): bool {
// this should better validate the format, this allows for a lot of shittery
return preg_match('#^([a-z][a-zA-Z0-9._]+)$#', $name) === 1;
}
/**
* Resets value cache.
*/
public function reset(): void {
$this->values = [];
}
/**
* Unloads specifics items from the local cache.
*
* @param string|string[] $names Names of values to unload.
*/
public function unload(string|array $names): void {
if(empty($names))
return;
if(is_string($names))
$names = [$names];
foreach($names as $name)
unset($this->values[$name]);
}
public function getSeparator(): string {
return '.';
}
public function scopeTo(string ...$prefix): IConfig {
return new ScopedConfig($this, $prefix);
}
public function hasValues(string|array $names): bool {
if(empty($names))
return true;
if(is_string($names))
$names = [$names];
$cachedNames = array_keys($this->values);
$names = array_diff($names, $cachedNames);
if(!empty($names)) {
// array_diff preserves keys, the for() later would fuck up without it
$names = array_values($names);
$nameCount = count($names);
$stmt = $this->cache->get(sprintf(
'SELECT COUNT(*) FROM %s WHERE %s IN (%s)',
$this->tableName, $this->nameField,
DbTools::prepareListString($nameCount)
));
for($i = 0; $i < $nameCount; ++$i)
$stmt->addParameter($i + 1, $names[$i]);
$stmt->execute();
$result = $stmt->getResult();
if($result->next())
return $result->getInteger(0) >= $nameCount;
}
return true;
}
public function removeValues(string|array $names): void {
if(empty($names))
return;
if(is_string($names))
$names = [$names];
foreach($names as $name)
unset($this->values[$name]);
$nameCount = count($names);
$stmt = $this->cache->get(sprintf(
'DELETE FROM %s WHERE %s IN (%s)',
$this->tableName, $this->nameField,
DbTools::prepareListString($nameCount)
));
for($i = 0; $i < $nameCount; ++$i)
$stmt->addParameter($i + 1, $names[$i]);
$stmt->execute();
}
public function getAllValueInfos(int $range = 0, int $offset = 0): array {
$this->reset();
$infos = [];
$hasRange = $range !== 0;
$query = sprintf('SELECT %s, %s FROM %s', $this->nameField, $this->valueField, $this->tableName);
if($hasRange) {
if($range < 0)
throw new InvalidArgumentException('$range must be a positive integer.');
if($offset < 0)
throw new InvalidArgumentException('$offset must be greater than zero if a range is specified.');
$query .= ' LIMIT ? OFFSET ?';
}
$stmt = $this->cache->get($query);
if($hasRange) {
$stmt->addParameter(1, $range);
$stmt->addParameter(2, $offset);
}
$stmt->execute();
$result = $stmt->getResult();
while($result->next()) {
$name = $result->getString(0);
$infos[] = $this->values[$name] = new DbConfigValueInfo($result);
}
return $infos;
}
public function getValueInfos(string|array $names): array {
if(empty($names))
return [];
if(is_string($names))
$names = [$names];
$infos = [];
$skip = [];
foreach($names as $name)
if(array_key_exists($name, $this->values)) {
$infos[] = $this->values[$name];
$skip[] = $name;
}
$names = array_diff($names, $skip);
if(!empty($names)) {
// array_diff preserves keys, the for() later would fuck up without it
$names = array_values($names);
$nameCount = count($names);
$stmt = $this->cache->get(sprintf(
'SELECT %s, %s FROM %s WHERE config_name IN (%s)',
$this->nameField, $this->valueField, $this->tableName,
DbTools::prepareListString($nameCount)
));
for($i = 0; $i < $nameCount; ++$i)
$stmt->addParameter($i + 1, $names[$i]);
$stmt->execute();
$result = $stmt->getResult();
while($result->next()) {
$name = $result->getString(0);
$infos[] = $this->values[$name] = new DbConfigValueInfo($result);
}
}
return $infos;
}
public function setValues(array $values): void {
if(empty($values))
return;
$stmt = $this->cache->get(sprintf(
'INSERT INTO %s (%s, %s) VALUES (?, ?)',
$this->tableName, $this->nameField, $this->valueField
));
foreach($values as $name => $value) {
if(!self::validateName($name))
throw new InvalidArgumentException('Invalid name encountered in $values.');
if(is_array($value)) {
foreach($value as $entry)
if(!is_scalar($entry))
throw new InvalidArgumentException('An array value in $values contains a non-scalar type.');
} elseif(!is_scalar($value))
throw new InvalidArgumentException('Invalid value type encountered in $values.');
$this->removeValues($name);
$stmt->addParameter(1, $name);
$stmt->addParameter(2, serialize($value));
$stmt->execute();
}
}
}

91
src/DbConfigValueInfo.php Normal file
View file

@ -0,0 +1,91 @@
<?php
// DbConfigValueInfo.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use UnexpectedValueException;
use Index\Data\IDbResult;
/**
* Provides information about a databased configuration value.
*/
class DbConfigValueInfo implements IConfigValueInfo {
private string $name;
private string $value;
/** @internal */
public function __construct(IDbResult $result) {
$this->name = $result->getString(0);
$this->value = $result->getString(1);
}
public function getName(): string {
return $this->name;
}
public function getType(): string {
return match($this->value[0]) {
's' => 'string',
'a' => 'array',
'i' => 'int',
'b' => 'bool',
'd' => 'float',
default => 'unknown',
};
}
public function isString(): bool { return $this->value[0] === 's'; }
public function isInteger(): bool { return $this->value[0] === 'i'; }
public function isFloat(): bool { return $this->value[0] === 'd'; }
public function isBoolean(): bool { return $this->value[0] === 'b'; }
public function isArray(): bool { return $this->value[0] === 'a'; }
public function getValue(): mixed {
return unserialize($this->value);
}
public function getString(): string {
$value = $this->getValue();
if(!is_string($value))
throw new UnexpectedValueException('Value is not a string.');
return $value;
}
public function getInteger(): int {
$value = $this->getValue();
if(!is_int($value))
throw new UnexpectedValueException('Value is not an integer.');
return $value;
}
public function getFloat(): float {
$value = $this->getValue();
if(!is_float($value))
throw new UnexpectedValueException('Value is not a floating point number.');
return $value;
}
public function getBoolean(): bool {
$value = $this->getValue();
if(!is_bool($value))
throw new UnexpectedValueException('Value is not a boolean.');
return $value;
}
public function getArray(): array {
$value = $this->getValue();
if(!is_array($value))
throw new UnexpectedValueException('Value is not an array.');
return $value;
}
public function __toString(): string {
$value = $this->getValue();
if(is_array($value))
return implode(', ', $value);
return (string)$value; // @phpstan-ignore-line dude trust me
}
}

41
src/GetValueInfoTrait.php Normal file
View file

@ -0,0 +1,41 @@
<?php
// GetValueInfoTrait.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
/**
* Provides implementations for things that are essentially macros for {@see IConfig::getValueInfos}.
*/
trait GetValueInfoTrait {
public function getValueInfo(string $name): ?IConfigValueInfo {
$infos = $this->getValueInfos($name);
return empty($infos) ? null : $infos[0];
}
public function getString(string $name, string $default = ''): string {
$valueInfo = $this->getValueInfo($name);
return $valueInfo?->isString() ? $valueInfo->getString() : $default;
}
public function getInteger(string $name, int $default = 0): int {
$valueInfo = $this->getValueInfo($name);
return $valueInfo?->isInteger() ? $valueInfo->getInteger() : $default;
}
public function getFloat(string $name, float $default = 0): float {
$valueInfo = $this->getValueInfo($name);
return $valueInfo?->isFloat() ? $valueInfo->getFloat() : $default;
}
public function getBoolean(string $name, bool $default = false): bool {
$valueInfo = $this->getValueInfo($name);
return $valueInfo?->isBoolean() ? $valueInfo->getBoolean() : $default;
}
public function getArray(string $name, array $default = []): array {
$valueInfo = $this->getValueInfo($name);
return $valueInfo?->isArray() ? $valueInfo->getArray() : $default;
}
}

96
src/GetValuesTrait.php Normal file
View file

@ -0,0 +1,96 @@
<?php
// GetValuesTrait.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use InvalidArgumentException;
/**
* Provides implementation for {@see IConfig::getValues} based on {@see IConfig::getValueInfos}.
*/
trait GetValuesTrait {
/**
* Format described in {@see IConfig::getValues}.
*
* @param array<string|string[]> $specs
* @return array<string, mixed>
*/
public function getValues(array $specs): array {
$names = [];
$evald = [];
foreach($specs as $key => $spec) {
if(is_string($spec)) {
$name = $spec;
$default = null;
$alias = null;
} elseif(is_array($spec) && !empty($spec)) {
$name = $spec[0];
$default = $spec[1] ?? null;
$alias = $spec[2] ?? null;
} else
throw new InvalidArgumentException('$specs array contains an invalid entry.');
$nameLength = strlen($name);
if($nameLength > 3 && ($colon = strrpos($name, ':')) === $nameLength - 2) {
$type = substr($name, $colon + 1, 1);
$name = substr($name, 0, $colon);
} else $type = '';
$names[] = $name;
$evald[$key] = [
'name' => $name,
'type' => $type,
'default' => $default,
'alias' => $alias,
];
}
$infos = $this->getValueInfos($names);
$results = [];
foreach($evald as $spec) {
foreach($infos as $infoTest)
if($infoTest->getName() === $spec['name']) {
$info = $infoTest;
break;
}
$resultName = $spec['alias'] ?? $spec['name'];
if(!isset($info)) {
$defaultValue = $spec['default'] ?? null;
if($spec['type'] !== '')
settype($defaultValue, match($spec['type']) {
's' => 'string',
'a' => 'array',
'i' => 'int',
'b' => 'bool',
'f' => 'float',
'd' => 'double',
default => throw new InvalidArgumentException(sprintf('Invalid type letter encountered: "%s"', $spec['type'])),
});
$results[$resultName] = $defaultValue;
continue;
}
$results[$resultName] = match($spec['type']) {
's' => $info->getString(),
'a' => $info->getArray(),
'i' => $info->getInteger(),
'b' => $info->getBoolean(),
'f' => $info->getFloat(),
'd' => $info->getFloat(),
'' => $info->getValue(),
default => throw new InvalidArgumentException('Unknown type encountered in $specs.'),
};
unset($info);
}
return $results;
}
}

189
src/IConfig.php Normal file
View file

@ -0,0 +1,189 @@
<?php
// IConfig.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
/**
* Provides a common interface for configuration providers.
*/
interface IConfig {
/**
* Creates a scoped configuration instance that prepends a prefix to all names.
*
* @param non-empty-array<string> ...$prefix Parts of the desired.
* @return IConfig A scoped configuration instance.
*/
public function scopeTo(string ...$prefix): IConfig;
/**
* Gets the separator character used for scoping.
*
* @return string Separator character.
*/
public function getSeparator(): string;
/**
* Checks if configurations contains given names.
* An empty $names list will always return true.
*
* @param string|string[] $names Name or names to check for.
* @return bool Whether all given names are present or not.
*/
public function hasValues(string|array $names): bool;
/**
* Removes values with given names from the configuration, if writable.
*
* @param string|string[] $names Name or names to remove.
* @throws \RuntimeException If the configuration is read only.
*/
public function removeValues(string|array $names): void;
/**
* Lists all value informations from this configuration.
*
* @param int $range Amount of items to take, 0 for all. Must be a positive integer.
* @param int $offset Amount of items to skip. Must be a positive integer. Has no effect if $range is 0.
* @throws \InvalidArgumentException If $range or $offset are negative.
* @return IConfigValueInfo[] Configuration value infos.
*/
public function getAllValueInfos(int $range = 0, int $offset = 0): array;
/**
* Gets value informations for one or more items.
*
* @param string|string[] $names Name or names to retrieve.
* @return IConfigValueInfo[] Array with value informations.
*/
public function getValueInfos(string|array $names): array;
/**
* Gets value information for a single item.
*
* @param string $name Name or names to retrieve.
* @return ?IConfigValueInfo Value information, or null if not present.
*/
public function getValueInfo(string $name): ?IConfigValueInfo;
/**
* Gets multiple values of varying types at once.
*
* The format of a $specs entry can be one of the following:
* If the entry is a string:
* - The name of a value: 'value_name'
* - The name of a value followed by a requested type, separated by a colon: 'value_name:s', 'value_name:i', 'value_name:a'
* If the entry is an array, all except [0] are optional:
* - [0] follows to same format as the above described string
* - [1] is the default value to fall back on, MUST be the same type as the one specified in [0].
* - [2] is an alternative key for the output array.
*
* Available types are:
* :s - string
* :a - array
* :i - integer
* :b - boolean
* :f - float
* :d - float
*
* @param array<string|string[]> $specs Specification of what items to grab.
* @throws \InvalidArgumentException If $specs is malformed.
* @return array<string, mixed> An associative array containing the retrieved values.
*/
public function getValues(array $specs): array;
/**
* Gets a single string value.
*
* @param string $name Name of the value to fetch.
* @param string $default Default value to fall back on if the value is not present.
* @return string Configuration value for $name.
*/
public function getString(string $name, string $default = ''): string;
/**
* Gets a single integer value.
*
* @param string $name Name of the value to fetch.
* @param int $default Default value to fall back on if the value is not present.
* @return int Configuration value for $name.
*/
public function getInteger(string $name, int $default = 0): int;
/**
* Gets a single floating point value.
*
* @param string $name Name of the value to fetch.
* @param float $default Default value to fall back on if the value is not present.
* @return float Configuration value for $name.
*/
public function getFloat(string $name, float $default = 0): float;
/**
* Gets a single boolean value.
*
* @param string $name Name of the value to fetch.
* @param bool $default Default value to fall back on if the value is not present.
* @return bool Configuration value for $name.
*/
public function getBoolean(string $name, bool $default = false): bool;
/**
* Gets an array value.
*
* @param string $name Name of the value to fetch.
* @param mixed[] $default Default value to fall back on if the value is not present.
* @return mixed[] Configuration value for $name.
*/
public function getArray(string $name, array $default = []): array;
/**
* Sets multiple values at once using an associative array.
*
* @param array<string, mixed> $values Values to save.
* @throws \InvalidArgumentException If $values is malformed.
* @throws \RuntimeException If the configuration is read only.
*/
public function setValues(array $values): void;
/**
* Sets a single string value.
*
* @param string $name Name of the value to save.
* @param string $value Value to save.
*/
public function setString(string $name, string $value): void;
/**
* Sets a single integer value.
*
* @param string $name Name of the value to save.
* @param int $value Value to save.
*/
public function setInteger(string $name, int $value): void;
/**
* Sets a single floating point value.
*
* @param string $name Name of the value to save.
* @param float $value Value to save.
*/
public function setFloat(string $name, float $value): void;
/**
* Sets a single boolean value.
*
* @param string $name Name of the value to save.
* @param bool $value Value to save.
*/
public function setBoolean(string $name, bool $value): void;
/**
* Sets an array value.
*
* @param string $name Name of the value to save.
* @param mixed[] $value Value to save.
*/
public function setArray(string $name, array $value): void;
}

109
src/IConfigValueInfo.php Normal file
View file

@ -0,0 +1,109 @@
<?php
// IConfigValueInfo.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use Stringable;
/**
* Provides a common interface for configuration values.
*/
interface IConfigValueInfo extends Stringable {
/**
* Gets the name of this configuration value.
*
* @return string Configuration value name.
*/
public function getName(): string;
/**
* Gets the type of this configuration value.
*
* @return string Configuration value type name.
*/
public function getType(): string;
/**
* Checks whether the value is a string.
*
* @return bool True if the value is a string.
*/
public function isString(): bool;
/**
* Checks whether the value is an integer.
*
* @return bool True if the value is an integer.
*/
public function isInteger(): bool;
/**
* Checks whether the value is a floating point number.
*
* @return bool True if the value is a floating point number.
*/
public function isFloat(): bool;
/**
* Checks whether the value is a boolean.
*
* @return bool True if the value is a boolean.
*/
public function isBoolean(): bool;
/**
* Checks whether the value is an array.
*
* @return bool True if the value is an array.
*/
public function isArray(): bool;
/**
* Gets the raw value without any type validation.
*
* @return mixed The configuration value.
*/
public function getValue(): mixed;
/**
* Ensures the value is a string and returns the value.
*
* @throws \UnexpectedValueException If the value is not a string.
* @return string String configuration value.
*/
public function getString(): string;
/**
* Ensures the value is an integer and returns the value.
*
* @throws \UnexpectedValueException If the value is not an integer.
* @return int Integer configuration value.
*/
public function getInteger(): int;
/**
* Ensures the value is a floating point number and returns the value.
*
* @throws \UnexpectedValueException If the value is not a floating point number.
* @return float Floating point number configuration value.
*/
public function getFloat(): float;
/**
* Ensures the value is a boolean and returns the value.
*
* @throws \UnexpectedValueException If the value is not a boolean.
* @return bool Boolean configuration value.
*/
public function getBoolean(): bool;
/**
* Ensures the value is an array and returns the value.
*
* @throws \UnexpectedValueException If the value is not an array.
* @return mixed[] Array configuration value.
*/
public function getArray(): array;
}

View file

@ -0,0 +1,41 @@
<?php
// ImmutableConfigTrait.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use RuntimeException;
/**
* Intercepts mutable methods required to be implemented by {@see IConfig} and returns exceptions.
*/
trait ImmutableConfigTrait {
public function removeValues(string|array $names): void {
throw new RuntimeException('This configuration is read only.');
}
public function setValues(array $values): void {
throw new RuntimeException('This configuration is read only.');
}
public function setString(string $name, string $value): void {
throw new RuntimeException('This configuration is read only.');
}
public function setInteger(string $name, int $value): void {
throw new RuntimeException('This configuration is read only.');
}
public function setFloat(string $name, float $value): void {
throw new RuntimeException('This configuration is read only.');
}
public function setBoolean(string $name, bool $value): void {
throw new RuntimeException('This configuration is read only.');
}
public function setArray(string $name, array $value): void {
throw new RuntimeException('This configuration is read only.');
}
}

View file

@ -0,0 +1,31 @@
<?php
// MutableConfigTrait.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
/**
* Defines set aliases so you don't have to.
*/
trait MutableConfigTrait {
public function setString(string $name, string $value): void {
$this->setValues([$name => $value]);
}
public function setInteger(string $name, int $value): void {
$this->setValues([$name => $value]);
}
public function setFloat(string $name, float $value): void {
$this->setValues([$name => $value]);
}
public function setBoolean(string $name, bool $value): void {
$this->setValues([$name => $value]);
}
public function setArray(string $name, array $value): void {
$this->setValues([$name => $value]);
}
}

67
src/NullConfig.php Normal file
View file

@ -0,0 +1,67 @@
<?php
// NullConfig.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
/**
* Provides a black hole configuration that will always return the default values.
*/
class NullConfig implements IConfig {
use GetValuesTrait;
public function __construct() {}
public function getSeparator(): string {
return "\0";
}
public function scopeTo(string ...$prefix): IConfig {
return $this;
}
public function hasValues(string|array $names): bool {
return is_array($names) && empty($names);
}
public function getAllValueInfos(int $range = 0, int $offset = 0): array {
return [];
}
public function getValueInfos(string|array $names): array {
return [];
}
public function getValueInfo(string $name): ?IConfigValueInfo {
return null;
}
public function getString(string $name, string $default = ''): string {
return $default;
}
public function getInteger(string $name, int $default = 0): int {
return $default;
}
public function getFloat(string $name, float $default = 0): float {
return $default;
}
public function getBoolean(string $name, bool $default = false): bool {
return $default;
}
public function getArray(string $name, array $default = []): array {
return $default;
}
public function removeValues(string|array $names): void {}
public function setValues(array $values): void {}
public function setString(string $name, string $value): void {}
public function setInteger(string $name, int $value): void {}
public function setFloat(string $name, float $value): void {}
public function setBoolean(string $name, bool $value): void {}
public function setArray(string $name, array $value): void {}
}

156
src/ScopedConfig.php Normal file
View file

@ -0,0 +1,156 @@
<?php
// ScopedConfig.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use InvalidArgumentException;
/**
* Provides a scoped configuration instead.
*/
class ScopedConfig implements IConfig {
private IConfig $config;
private string $prefix;
/** @var non-empty-array<string> */
private array $prefixRaw;
private int $prefixLength;
/** @param string[] $prefixRaw */
public function __construct(IConfig $config, array $prefixRaw) {
if(empty($prefixRaw))
throw new InvalidArgumentException('$prefix may not be empty.');
$scopeChar = $config->getSeparator();
$prefix = implode($scopeChar, $prefixRaw) . $scopeChar;
$this->config = $config;
$this->prefix = $prefix;
$this->prefixRaw = $prefixRaw;
$this->prefixLength = strlen($prefix);
}
/**
* @param string|string[] $names
* @return string[]
*/
private function prefixNames(string|array $names): array {
if(is_string($names))
return [$this->prefix . $names];
foreach($names as $key => $name)
$names[$key] = $this->prefix . $name;
return $names;
}
private function prefixName(string $name): string {
return $this->prefix . $name;
}
public function scopeTo(string ...$prefix): IConfig {
return $this->config->scopeTo(...array_merge($this->prefixRaw, $prefix));
}
public function getSeparator(): string {
return $this->config->getSeparator();
}
public function hasValues(string|array $names): bool {
return $this->config->hasValues($this->prefixNames($names));
}
public function removeValues(string|array $names): void {
$this->config->removeValues($this->prefixNames($names));
}
public function getAllValueInfos(int $range = 0, int $offset = 0): array {
$infos = $this->config->getAllValueInfos($range, $offset);
foreach($infos as $key => $info)
$infos[$key] = new ScopedConfigValueInfo($info, $this->prefixLength);
return $infos;
}
public function getValueInfos(string|array $names): array {
$infos = $this->config->getValueInfos($this->prefixNames($names));
foreach($infos as $key => $info)
$infos[$key] = new ScopedConfigValueInfo($info, $this->prefixLength);
return $infos;
}
public function getValueInfo(string $name): ?IConfigValueInfo {
$info = $this->config->getValueInfo($this->prefixName($name));
if($info !== null)
$info = new ScopedConfigValueInfo($info, $this->prefixLength);
return $info;
}
public function getValues(array $specs): array {
foreach($specs as $key => $spec) {
if(is_string($spec))
$specs[$key] = $this->prefixName($spec);
elseif(is_array($spec) && !empty($spec))
$specs[$key][0] = $this->prefixName($spec[0]);
else
throw new InvalidArgumentException('$specs array contains an invalid entry.');
}
$results = [];
foreach($this->config->getValues($specs) as $name => $result)
// prefix removal should probably be done with a whitelist of sorts
$results[str_starts_with($name, $this->prefix) ? substr($name, $this->prefixLength) : $name] = $result;
return $results;
}
public function getString(string $name, string $default = ''): string {
return $this->config->getString($this->prefixName($name), $default);
}
public function getInteger(string $name, int $default = 0): int {
return $this->config->getInteger($this->prefixName($name), $default);
}
public function getFloat(string $name, float $default = 0): float {
return $this->config->getFloat($this->prefixName($name), $default);
}
public function getBoolean(string $name, bool $default = false): bool {
return $this->config->getBoolean($this->prefixName($name), $default);
}
public function getArray(string $name, array $default = []): array {
return $this->config->getArray($this->prefixName($name), $default);
}
public function setValues(array $values): void {
if(empty($values))
return;
$prefixed = [];
foreach($values as $name => $value)
$prefixed[$this->prefixName($name)] = $value;
$this->config->setValues($values);
}
public function setString(string $name, string $value): void {
$this->config->setString($this->prefixName($name), $value);
}
public function setInteger(string $name, int $value): void {
$this->config->setInteger($this->prefixName($name), $value);
}
public function setFloat(string $name, float $value): void {
$this->config->setFloat($this->prefixName($name), $value);
}
public function setBoolean(string $name, bool $value): void {
$this->config->setBoolean($this->prefixName($name), $value);
}
public function setArray(string $name, array $value): void {
$this->config->setArray($this->prefixName($name), $value);
}
}

View file

@ -0,0 +1,82 @@
<?php
// ScopedConfigValueInfo.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
/**
* Provides information about a scoped configuration value.
*/
class ScopedConfigValueInfo implements IConfigValueInfo {
/** @internal */
public function __construct(
private IConfigValueInfo $info,
private int $prefixLength
) {}
public function getName(): string {
return substr($this->info->getName(), $this->prefixLength);
}
/**
* Gets the real name of the configuration value, without removing the prefix.
*
* @return string Unprefixed configuration value.
*/
public function getRealName(): string {
return $this->info->getName();
}
public function getType(): string {
return $this->info->getType();
}
public function isString(): bool {
return $this->info->isString();
}
public function isInteger(): bool {
return $this->info->isInteger();
}
public function isFloat(): bool {
return $this->info->isFloat();
}
public function isBoolean(): bool {
return $this->info->isBoolean();
}
public function isArray(): bool {
return $this->info->isArray();
}
public function getValue(): mixed {
return $this->info->getValue();
}
public function getString(): string {
return $this->info->getString();
}
public function getInteger(): int {
return $this->info->getInteger();
}
public function getFloat(): float {
return $this->info->getFloat();
}
public function getBoolean(): bool {
return $this->info->getBoolean();
}
public function getArray(): array {
return $this->info->getArray();
}
public function __toString(): string {
return (string)$this->info;
}
}

136
src/SharpConfig.php Normal file
View file

@ -0,0 +1,136 @@
<?php
// SharpConfig.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use InvalidArgumentException;
use Index\IO\FileStream;
use Index\IO\Stream;
/**
* Provides a configuration in SharpChat format.
*/
class SharpConfig implements IConfig {
use ImmutableConfigTrait, GetValueInfoTrait, GetValuesTrait;
/**
* @param array<string, SharpConfigValueInfo> $values
*/
public function __construct(private array $values) {}
public function getSeparator(): string {
return ':';
}
public function scopeTo(string ...$prefix): IConfig {
return new ScopedConfig($this, $prefix);
}
public function hasValues(string|array $names): bool {
if(is_string($names))
return array_key_exists($names, $this->values);
foreach($names as $name)
if(!array_key_exists($name, $this->values))
return false;
return true;
}
public function getAllValueInfos(int $range = 0, int $offset = 0): array {
if($range === 0)
return array_values($this->values);
if($range < 0)
throw new InvalidArgumentException('$range must be a positive integer.');
if($offset < 0)
throw new InvalidArgumentException('$offset must be greater than zero if a range is specified.');
return array_slice($this->values, $offset, $range);
}
public function getValueInfos(string|array $names): array {
if(is_string($names))
return array_key_exists($names, $this->values) ? [$this->values[$names]] : [];
$infos = [];
foreach($names as $name)
if(array_key_exists($name, $this->values))
$infos[] = $this->values[$name];
return $infos;
}
/**
* Creates an instance of SharpConfig from an array of lines.
*
* @param string[] $lines Config lines.
* @return SharpConfig
*/
public static function fromLines(array $lines): self {
$values = [];
foreach($lines as $line) {
$line = trim($line);
if($line === '' || $line[0] === '#' || $line[0] === ';')
continue;
$info = new SharpConfigValueInfo(...explode(' ', $line, 2));
$values[$info->getName()] = $info;
}
return new SharpConfig($values);
}
/**
* Creates an instance of SharpConfig from a string.
*
* @param string $lines Config lines.
* @param non-empty-string $newLine Line separator character.
* @return SharpConfig
*/
public static function fromString(string $lines, string $newLine = "\n"): self {
return self::fromLines(explode($newLine, $lines));
}
/**
* Creates an instance of SharpConfig from a file.
*
* @param string $path Config file path.
* @throws InvalidArgumentException If $path does not exist.
* @return SharpConfig
*/
public static function fromFile(string $path): self {
if(!is_file($path))
throw new InvalidArgumentException('$path does not exist.');
return self::fromStream(FileStream::openRead($path));
}
/**
* Creates an instance of SharpConfig from a readable stream.
*
* @param Stream $stream Config file stream.
* @throws InvalidArgumentException If $stream is not readable.
* @return SharpConfig
*/
public static function fromStream(Stream $stream): self {
if(!$stream->canRead())
throw new InvalidArgumentException('$stream must be readable.');
$values = [];
while(($line = $stream->readLine()) !== null) {
$line = trim($line);
if($line === '' || $line[0] === '#' || $line[0] === ';')
continue;
$info = new SharpConfigValueInfo(...explode(' ', $line, 2));
$values[$info->getName()] = $info;
}
return new SharpConfig($values);
}
}

View file

@ -0,0 +1,75 @@
<?php
// SharpConfigValueInfo.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
class SharpConfigValueInfo implements IConfigValueInfo {
private string $name;
private string $value;
/** @internal */
public function __construct(string $name, string $value = '') {
$this->name = $name;
$this->value = trim($value);
}
public function getName(): string {
return $this->name;
}
public function getType(): string {
// SharpChat config format is just all strings and casts on demand
return 'string';
}
public function isString(): bool {
return true;
}
public function isInteger(): bool {
return true;
}
public function isFloat(): bool {
return true;
}
public function isBoolean(): bool {
return true;
}
public function isArray(): bool {
return true;
}
public function getValue(): mixed {
return $this->value;
}
public function getString(): string {
return $this->value;
}
public function getInteger(): int {
return (int)$this->value;
}
public function getFloat(): float {
return (float)$this->value;
}
public function getBoolean(): bool {
return $this->value !== '0'
&& strcasecmp($this->value, 'false') !== 0;
}
public function getArray(): array {
return explode(' ', $this->value);
}
public function __toString(): string {
return $this->value;
}
}

45
src/SyokuhouInfo.php Normal file
View file

@ -0,0 +1,45 @@
<?php
// SyokuhouInfo.php
// Created: 2023-10-20
// Updated: 2023-10-20
namespace Syokuhou;
use UnexpectedValueException;
use Index\Version;
/**
* Retrieves library info.
*/
final class SyokuhouInfo {
private static ?string $versionString = null;
private static ?Version $version = null;
/**
* Returns the current version of the library.
*
* @return Version
*/
public static function getVersion(): Version {
if(self::$version === null)
self::$version = Version::parse(self::getVersionString());
return self::$version;
}
/**
* Returns the current version of the library as a string.
*
* @return string
*/
public static function getVersionString(): string {
if(self::$versionString === null) {
$body = file_get_contents(__DIR__ . '/../VERSION');
if($body === false)
throw new UnexpectedValueException('Was unable to read VERSION file.');
self::$versionString = trim($body);
}
return self::$versionString;
}
}

147
tests/DbConfigTest.php Normal file
View file

@ -0,0 +1,147 @@
<?php
// DbConfigTest.php
// Created: 2023-10-20
// Updated: 2023-10-20
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers \Syokuhou\DbConfigTest
* @covers \Syokuhou\DbConfigValueInfo
* @covers \Syokuhou\MutableConfigTrait
* @covers \Syokuhou\GetValueInfoTrait
* @covers \Syokuhou\GetValuesTrait
*/
final class DbConfigTest extends TestCase {
private \Syokuhou\DbConfig $config;
private const VALUES = [
'private.allow_password_reset' => 'b:1;',
'private.enable' => 'b:0;',
'private.msg' => 's:71:"Things are happening. Check back later for something new... eventually.";',
'private.perm.cat' => 's:4:"user";',
'private.perm.val' => 'i:1;',
'site.desc' => 's:38:"The internet\'s last convenience store.";',
'site.ext_logo' => 's:51:"https://static.flash.moe/images/flashii-logo-v3.png";',
'site.name' => 's:5:"Edgii";',
'site.social.bsky' => 's:36:"https://bsky.app/profile/flashii.net";',
'site.url' => 's:18:"https://edgii.net/";',
'test.array' => 'a:5:{i:0;i:1234;i:1;d:56.789;i:2;s:6:"Mewow!";i:3;b:1;i:4;s:4:"jeff";}',
'test.bool' => 'b:1;',
'test.float' => 'd:9876.4321;',
'test.int' => 'i:243230;',
];
protected function setUp(): void {
$dbConn = \Index\Data\DbTools::create('sqlite::memory:');
$dbConn->execute('CREATE TABLE skh_config (config_name TEXT NOT NULL COLLATE NOCASE, config_value BLOB NOT NULL, PRIMARY KEY (config_name))');
$stmt = $dbConn->prepare('INSERT INTO skh_config (config_name, config_value) VALUES (?, ?)');
foreach(self::VALUES as $name => $value) {
$stmt->addParameter(1, $name);
$stmt->addParameter(2, $value);
$stmt->execute();
}
$this->config = new \Syokuhou\DbConfig($dbConn, 'skh_config');
}
public function testScoping(): void {
$this->assertEquals('user', $this->config->getString('private.perm.cat'));
$this->assertEquals('Edgii', $this->config->getString('site.name'));
$scoped = $this->config->scopeTo('private', 'perm');
$this->assertEquals('user', $scoped->getString('cat'));
}
public function testHasValues(): void {
// hasValues should always return true when the list is empty
$this->assertTrue($this->config->hasValues([]));
$this->assertFalse($this->config->hasValues('meow'));
$this->assertTrue($this->config->hasValues('site.desc'));
$this->assertTrue($this->config->hasValues(['site.ext_logo', 'site.url']));
$this->assertFalse($this->config->hasValues(['site.ext_logo', 'site.url', 'site.gun']));
}
public function testGetAllValueInfos(): void {
$all = $this->config->getAllValueInfos();
$expected = array_keys(self::VALUES);
$values = [];
foreach($all as $info)
$values[] = $info->getName();
$this->assertEquals($expected, $values);
$subset = $this->config->getAllValueInfos(2, 3);
$expected = [
'private.perm.cat',
'private.perm.val',
];
$values = [];
foreach($subset as $info)
$values[] = $info->getName();
$this->assertEquals($expected, $values);
}
public function testGetValues(): void {
$this->assertNull($this->config->getValueInfo('doesnotexist'));
$scoped = $this->config->scopeTo('private', 'perm');
$expected = ['private.perm.cat' => 'user', 'private.perm.val' => 1];
$values = [];
$valueInfos = $scoped->getValueInfos(['cat', 'val', 'poop']);
foreach($valueInfos as $valueInfo)
$values[$valueInfo->getRealName()] = $valueInfo->getValue();
$this->assertEquals($expected, $values);
$scoped = $this->config->scopeTo('site')->scopeTo('social');
$expected = [
'bsky' => 'https://bsky.app/profile/flashii.net',
'bsky_show' => true,
'twitter' => '',
'twitterShow' => false,
];
$values = $scoped->getValues([
'bsky',
['bsky_show:b', true],
'twitter:s',
['twitter_show:b', false, 'twitterShow'],
]);
$this->assertEquals($expected, $values);
$this->assertEquals('', $this->config->getString('none.string'));
$this->assertEquals('test', $this->config->getString('none.string', 'test'));
$this->assertEquals('https://edgii.net/', $this->config->getString('site.url'));
$this->assertEquals(0, $this->config->getInteger('site.url'));
$this->assertEquals(0, $this->config->getInteger('none.int'));
$this->assertEquals(10, $this->config->getInteger('none.int', 10));
$this->assertEquals(243230, $this->config->getInteger('test.int'));
$this->assertEquals('', $this->config->getString('test.int'));
$this->assertEquals(0, $this->config->getFloat('none.float'));
$this->assertEquals(0.1, $this->config->getFloat('none.float', 0.1));
$this->assertEquals(9876.4321, $this->config->getFloat('test.float'));
$this->assertEmpty($this->config->getArray('test.float'));
$this->assertEquals(false, $this->config->getBoolean('none.bool'));
$this->assertEquals(true, $this->config->getBoolean('none.bool', true));
$this->assertEquals(true, $this->config->getBoolean('test.bool'));
$this->assertEquals(false, $this->config->getBoolean('private.msg'));
$this->assertEquals(0, $this->config->getFloat('test.bool'));
$this->assertEmpty($this->config->getArray('none.array'));
$this->assertEquals(['de', 'het', 'een'], $this->config->getArray('none.array', ['de', 'het', 'een']));
$this->assertEquals([1234, 56.789, 'Mewow!', true, 'jeff'], $this->config->getArray('test.array'));
$this->assertEquals(false, $this->config->getBoolean('test.array'));
}
}

80
tests/NullConfigTest.php Normal file
View file

@ -0,0 +1,80 @@
<?php
// NullConfigTest.php
// Created: 2023-10-20
// Updated: 2023-10-20
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers \Syokuhou\NullConfig
* @covers \Syokuhou\GetValuesTrait
*/
final class NullConfigTest extends TestCase {
public function testNullConfig(): void {
$config = new \Syokuhou\NullConfig;
// no-ops but run anyway to ensure no screaming
$config->removeValues('test');
$config->setString('stringval', 'the');
$config->setInteger('intval', 1234);
$config->setFloat('floatval', 56.78);
$config->setBoolean('boolval', true);
$config->setArray('arrval', ['meow']);
$config->setValues([
'stringval' => 'the',
'intval' => 1234,
'floatval' => 56.78,
'boolval' => true,
'arrval' => ['meow'],
]);
// NullConfig currently returns itself when scoping
// might change this depending on whether the scope prefix will be exposed or not
$scoped = $config->scopeTo('scoped');
$this->assertEquals($config, $scoped);
// hasValues should always return true when the list is empty
$this->assertTrue($config->hasValues([]));
$this->assertFalse($config->hasValues('anything'));
$this->assertFalse($config->hasValues(['manything1', 'manything2']));
$this->assertEmpty($config->getAllValueInfos());
$this->assertEmpty($config->getValueInfos('the'));
$expected = [
'test_no_type' => null,
'test_yes_type' => false,
'test_no_type_yes_default' => 1234,
'test_yes_type_yes_default' => 56.78,
'aliased' => null,
];
$values = $config->getValues([
'test_no_type',
'test_yes_type:b',
['test_no_type_yes_default', 1234],
['test_yes_type_yes_default:d', 56.78],
['test_no_default_yes_alias', null, 'aliased'],
]);
$this->assertEqualsCanonicalizing($expected, $values);
$this->assertNull($config->getValueInfo('value'));
$this->assertEquals('', $config->getString('string'));
$this->assertEquals('default', $config->getString('string', 'default'));
$this->assertEquals(0, $config->getInteger('int'));
$this->assertEquals(960, $config->getInteger('int', 960));
$this->assertEquals(0, $config->getFloat('float'));
$this->assertEquals(67.7, $config->getFloat('float', 67.7));
$this->assertFalse($config->getBoolean('bool'));
$this->assertTrue($config->getBoolean('bool', true));
$this->assertEmpty($config->getArray('arr'));
$this->assertEqualsCanonicalizing(['de', 'het', 'een'], $config->getArray('the', ['de', 'het', 'een']));
}
}

190
tests/SharpConfigTest.php Normal file
View file

@ -0,0 +1,190 @@
<?php
// SharpConfigTest.php
// Created: 2023-10-20
// Updated: 2023-10-20
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers \Syokuhou\SharpConfig
* @covers \Syokuhou\SharpConfigValueInfo
* @covers \Syokuhou\ImmutableConfigTrait
* @covers \Syokuhou\GetValueInfoTrait
* @covers \Syokuhou\GetValuesTrait
*/
final class SharpConfigTest extends TestCase {
public function testImmutableRemove(): void {
$this->expectException(\RuntimeException::class);
\Syokuhou\SharpConfig::fromString('test value')->removeValues('test');
}
public function testImmutableSetString(): void {
$this->expectException(\RuntimeException::class);
\Syokuhou\SharpConfig::fromString('test value')->setString('test', 'the');
}
public function testImmutableSetInteger(): void {
$this->expectException(\RuntimeException::class);
\Syokuhou\SharpConfig::fromString('test 1234')->setInteger('test', 5678);
}
public function testImmutableSetFloat(): void {
$this->expectException(\RuntimeException::class);
\Syokuhou\SharpConfig::fromString('test 56.78')->setFloat('test', 12.34);
}
public function testImmutableSetBoolean(): void {
$this->expectException(\RuntimeException::class);
\Syokuhou\SharpConfig::fromString('test true')->setBoolean('test', false);
}
public function testImmutableSetArray(): void {
$this->expectException(\RuntimeException::class);
\Syokuhou\SharpConfig::fromString('test words words words')->setArray('test', ['meow', 'meow', 'meow']);
}
public function testImmutableSetValues(): void {
$this->expectException(\RuntimeException::class);
\Syokuhou\SharpConfig::fromString('')->setValues([
'stringval' => 'the',
'intval' => 1234,
'floatval' => 56.78,
'boolval' => true,
'arrval' => ['meow'],
]);
}
public function testScoping(): void {
$config = \Syokuhou\SharpConfig::fromLines([
'test Inaccessible',
'scoped:test Accessible',
]);
$this->assertEquals('Inaccessible', $config->getString('test'));
$this->assertEquals('Accessible', $config->getString('scoped:test'));
$scoped = $config->scopeTo('scoped');
$this->assertEquals('Accessible', $scoped->getString('test'));
}
public function testHasValues(): void {
$config = \Syokuhou\SharpConfig::fromLines([
'test 123',
'scoped:test true',
'scoped:meow meow',
]);
// hasValues should always return true when the list is empty
$this->assertTrue($config->hasValues([]));
$this->assertFalse($config->hasValues('meow'));
$this->assertTrue($config->hasValues('test'));
$this->assertFalse($config->hasValues(['test', 'meow']));
$this->assertTrue($config->hasValues(['scoped:test', 'scoped:meow']));
}
public function testGetAllValueInfos(): void {
$config = \Syokuhou\SharpConfig::fromFile(__DIR__ . '/sharpchat.cfg');
$all = $config->getAllValueInfos();
$expected = [
'chat:port',
'chat:msgMaxLength',
'chat:floodKickLength',
'chat:channels',
'chat:channels:lounge:name',
'chat:channels:lounge:autoJoin',
'chat:channels:prog:name',
'chat:channels:games:name',
'chat:channels:splat:name',
'chat:channels:passwd:name',
'chat:channels:passwd:password',
'chat:channels:staff:name',
'chat:channels:staff:minRank',
'msz:secret',
'msz:url',
'mariadb:host',
'mariadb:user',
'mariadb:pass',
'mariadb:db',
];
$values = [];
foreach($all as $info)
$values[] = $info->getName();
$this->assertEquals($expected, $values);
$subset = $config->getAllValueInfos(3, 6);
$expected = [
'chat:channels:prog:name',
'chat:channels:games:name',
'chat:channels:splat:name',
];
$values = [];
foreach($subset as $info)
$values[] = $info->getName();
$this->assertEquals($expected, $values);
}
public function testGetValues(): void {
$config = \Syokuhou\SharpConfig::fromFile(__DIR__ . '/sharpchat.cfg');
$this->assertNull($config->getValueInfo('doesnotexist'));
$scoped = $config->scopeTo('chat')->scopeTo('channels', 'passwd');
$expected = ['chat:channels:passwd:name' => 'Password', 'chat:channels:passwd:password' => 'meow'];
$values = [];
$valueInfos = $scoped->getValueInfos(['name', 'password', 'minRank']);
foreach($valueInfos as $valueInfo)
$values[$valueInfo->getRealName()] = $valueInfo->getValue();
$this->assertEquals($expected, $values);
$scoped = $config->scopeTo('chat', 'channels', 'lounge');
$expected = [
'name' => 'Lounge',
'auto_join' => true,
'minRank' => 0,
];
$values = $scoped->getValues([
'name',
['autoJoin:b', false, 'auto_join'],
'minRank:i',
]);
$this->assertEquals($expected, $values);
$this->assertEquals('', $config->getString('msz:url2'));
$this->assertEquals('test', $config->getString('msz:url2', 'test'));
$this->assertEquals('https://flashii.net/_sockchat', $config->getString('msz:url'));
$this->assertEquals(0, $config->getInteger('chat:connMaxCount'));
$this->assertEquals(10, $config->getInteger('chat:connMaxCount', 10));
$this->assertEquals(30, $config->getInteger('chat:floodKickLength'));
$this->assertEquals('30', $config->getString('chat:floodKickLength'));
$this->assertEquals(0, $config->getFloat('boat'));
$this->assertEquals(0.1, $config->getFloat('boat', 0.1));
$this->assertEquals(192.168, $config->getFloat('mariadb:host'));
$this->assertEquals('192.168.0.123', $config->getString('mariadb:host'));
$this->assertEquals(false, $config->getBoolean('nonexist'));
$this->assertEquals(true, $config->getBoolean('nonexist', true));
$this->assertEquals(true, $config->getBoolean('chat:channels:lounge:autoJoin'));
$this->assertEquals('true', $config->getString('chat:channels:lounge:autoJoin'));
$this->assertEquals(true, $config->getBoolean('mariadb:db'));
$this->assertEmpty($config->getArray('nonexist'));
$this->assertEquals(['de', 'het', 'een'], $config->getArray('nonexist', ['de', 'het', 'een']));
$this->assertEquals(['lounge', 'prog', 'games', 'splat', 'passwd', 'staff'], $config->getArray('chat:channels'));
$this->assertEquals('lounge prog games splat passwd staff', $config->getString('chat:channels'));
$this->assertEquals(['fake', 'secret', 'meow'], $config->getArray('msz:secret'));
$this->assertEquals('fake secret meow', $config->getString('msz:secret'));
}
}

19
tests/SyokuhouTest.php Normal file
View file

@ -0,0 +1,19 @@
<?php
// SyokuhouTest.php
// Created: 2023-10-20
// Updated: 2023-10-20
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* @covers \Syokuhou\SyokuhouInfo
*/
final class SyokuhouTest extends TestCase {
public function testVersionDecode(): void {
$expected = trim(file_get_contents(__DIR__ . '/../VERSION'));
$this->assertEquals($expected, \Syokuhou\SyokuhouInfo::getVersionString());
$this->assertEquals($expected, (string)\Syokuhou\SyokuhouInfo::getVersion());
}
}

36
tests/sharpchat.cfg Normal file
View file

@ -0,0 +1,36 @@
# and ; can be used at the start of a line for comments.
# General Configuration
chat:port 6770
chat:msgMaxLength 5000
#chat:connMaxCount 5
chat:floodKickLength 30
# Channels
chat:channels lounge prog games splat passwd staff
# Lounge channel settings
chat:channels:lounge:name Lounge
chat:channels:lounge:autoJoin true
chat:channels:prog:name Programming
chat:channels:games:name Games
chat:channels:splat:name Splatoon
# Passworded channel
chat:channels:passwd:name Password
chat:channels:passwd:password meow
# Staff channel settings
chat:channels:staff:name Staff
chat:channels:staff:minRank 5
# Misuzu integration settings
msz:secret fake secret meow
msz:url https://flashii.net/_sockchat
# MariaDB configuration
mariadb:host 192.168.0.123
mariadb:user chat
mariadb:pass nyaa
mariadb:db chat

8
tools/precommit.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/bash
pushd .
cd $(dirname "$0")
php update-headers.php
popd

172
tools/update-headers.php Normal file
View file

@ -0,0 +1,172 @@
<?php
// the point of index was so that i wouldn't have to copy things between projects
// here i am copying things from index
date_default_timezone_set('utc');
function git_changes(): array {
$files = [];
$dir = getcwd();
try {
chdir(__DIR__ . '/..');
$output = explode("\n", trim(shell_exec('git status --short --porcelain=v1 --untracked-files=all --no-column')));
foreach($output as $line) {
$line = trim($line);
$file = realpath(explode(' ', $line)[1]);
$files[] = $file;
}
} finally {
chdir($dir);
}
return $files;
}
function collect_files(string $directory): array {
$files = [];
$dir = glob(realpath($directory) . DIRECTORY_SEPARATOR . '*');
foreach($dir as $file) {
if(is_dir($file)) {
$files = array_merge($files, collect_files($file));
continue;
}
$files[] = $file;
}
return $files;
}
echo 'Indexing changed files according to git...' . PHP_EOL;
$changed = git_changes();
echo 'Collecting files...' . PHP_EOL;
$sources = collect_files(__DIR__ . '/../src');
$tests = collect_files(__DIR__ . '/../tests');
$files = array_merge($sources, $tests);
$topDir = dirname(__DIR__) . DIRECTORY_SEPARATOR;
$now = date('Y-m-d');
foreach($files as $file) {
echo 'Scanning ' . str_replace($topDir, '', $file) . '...' . PHP_EOL;
try {
$handle = fopen($file, 'rb');
$checkPHP = trim(fgets($handle)) === '<?php';
if(!$checkPHP) {
echo 'File is not PHP.' . PHP_EOL;
continue;
}
$headerLines = [];
$expectLine = '// ' . basename($file);
$nameLine = trim(fgets($handle));
if($nameLine !== $expectLine) {
echo ' File name is missing or invalid, queuing update...' . PHP_EOL;
$headerLines['name'] = $expectLine;
}
$createdPrefix = '// Created: ';
$createdLine = trim(fgets($handle));
if(strpos($createdLine, $createdPrefix) !== 0) {
echo ' Creation date is missing, queuing update...' . PHP_EOL;
$headerLines['created'] = $createdPrefix . $now;
}
$updatedPrefix = '// Updated: ';
$updatedLine = trim(fgets($handle));
$updatedDate = substr($updatedLine, strlen($updatedPrefix));
if(strpos($updatedLine, $updatedPrefix) !== 0 || (in_array($file, $changed) && $updatedDate !== $now)) {
echo ' Updated date is inaccurate, queuing update...' . PHP_EOL;
$headerLines['updated'] = $updatedPrefix . $now;
}
$blankLine = trim(fgets($handle));
if(!empty($blankLine)) {
echo ' Trailing newline missing, queuing update...' . PHP_EOL;
$headerLines['blank'] = '';
}
if(!empty($headerLines)) {
fclose($handle);
try {
$tmpName = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ndx-uh-' . bin2hex(random_bytes(8)) . '.tmp';
copy($file, $tmpName);
$read = fopen($tmpName, 'rb');
$handle = fopen($file, 'wb');
fwrite($handle, fgets($read));
$insertAfter = [];
if(empty($headerLines['name']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['name'] . "\n");
}
if(empty($headerLines['created']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// Created: ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['created'] . "\n");
}
if(empty($headerLines['updated']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// Updated: ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['updated'] . "\n");
}
if(!isset($headerLines['blank']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(!empty($line)) {
$insertAfter[] = $line;
fwrite($handle, "\n");
}
}
foreach($insertAfter as $line)
fwrite($handle, $line);
while(($line = fgets($read)) !== false)
fwrite($handle, $line);
} finally {
if(is_resource($read))
fclose($read);
if(is_file($tmpName))
unlink($tmpName);
}
}
} finally {
fclose($handle);
}
}