Преглед изворни кода

Upgrade to PHP 8.1 (#6711)

* Upgrade to PHP 8.1
As discussed in https://github.com/FreshRSS/FreshRSS/discussions/5474

https://www.php.net/releases/8.0/en.php
https://www.php.net/releases/8.1/en.php

Upgrade to available native type declarations
https://php.net/language.types.declarations

Upgrade to https://phpunit.de/announcements/phpunit-10.html which requires PHP 8.1+ (good timing, as version 9 was not maintained anymore)

Upgrade `:oldest` Docker dev image to oldest Alpine version supporting PHP 8.1: Alpine 3.16, which includes PHP 8.1.22.

* Include 6736
https://github.com/FreshRSS/FreshRSS/pull/6736
Alexandre Alapetite пре 1 година
родитељ
комит
a81656c3ed
75 измењених фајлова са 544 додато и 853 уклоњено
  1. 7 7
      Docker/Dockerfile-Oldest
  2. 1 1
      README.fr.md
  3. 1 1
      README.md
  4. 1 2
      app/Controllers/apiController.php
  5. 0 2
      app/Controllers/configureController.php
  6. 1 1
      app/Controllers/feedController.php
  7. 4 13
      app/Controllers/importExportController.php
  8. 1 1
      app/Controllers/updateController.php
  9. 6 12
      app/Models/CategoryDAO.php
  10. 1 1
      app/Models/Context.php
  11. 6 13
      app/Models/Entry.php
  12. 13 17
      app/Models/EntryDAO.php
  13. 3 4
      app/Models/EntryDAOSQLite.php
  14. 8 15
      app/Models/Feed.php
  15. 13 26
      app/Models/FeedDAO.php
  16. 2 4
      app/Models/FormAuth.php
  17. 3 3
      app/Models/Search.php
  18. 0 2
      app/Models/Share.php
  19. 1 1
      app/Models/StatsDAO.php
  20. 3 12
      app/Models/Tag.php
  21. 11 21
      app/Models/TagDAO.php
  22. 2 2
      app/Models/Themes.php
  23. 7 10
      app/Services/ExportService.php
  24. 4 8
      app/Services/ImportService.php
  25. 3 13
      app/Utils/dotNotationUtil.php
  26. 1 1
      app/Utils/feverUtil.php
  27. 2 4
      cli/_cli.php
  28. 1 2
      cli/check.translation.php
  29. 0 1
      cli/i18n/I18nFile.php
  30. 1 1
      cli/i18n/I18nValue.php
  31. 5 5
      composer.json
  32. 191 255
      composer.lock
  33. 1 3
      config.default.php
  34. 1 1
      constants.php
  35. 1 1
      docs/en/admins/02_Prerequisites.md
  36. 1 1
      docs/en/admins/10_ServerConfig.md
  37. 2 2
      docs/fr/users/01_Installation.md
  38. 5 7
      lib/Minz/Configuration.php
  39. 1 1
      lib/Minz/ConfigurationSetterInterface.php
  40. 4 12
      lib/Minz/Extension.php
  41. 1 1
      lib/Minz/ExtensionManager.php
  42. 1 2
      lib/Minz/FrontController.php
  43. 1 4
      lib/Minz/Helper.php
  44. 0 1
      lib/Minz/Log.php
  45. 0 4
      lib/Minz/Mailer.php
  46. 1 4
      lib/Minz/Migrator.php
  47. 0 2
      lib/Minz/ModelPdo.php
  48. 2 2
      lib/Minz/Paginator.php
  49. 4 18
      lib/Minz/Pdo.php
  50. 1 4
      lib/Minz/PdoMysql.php
  51. 1 4
      lib/Minz/PdoSqlite.php
  52. 2 2
      lib/Minz/Request.php
  53. 1 1
      lib/Minz/Session.php
  54. 1 3
      lib/Minz/Translate.php
  55. 2 9
      lib/Minz/Url.php
  56. 1 6
      lib/Minz/View.php
  57. 1 1
      lib/lib_date.php
  58. 4 39
      lib/lib_rss.php
  59. 6 16
      p/api/fever.php
  60. 26 54
      p/api/greader.php
  61. 2 4
      p/ext.php
  62. 0 1
      phpstan.neon
  63. 16 1
      tests/README.md
  64. 6 6
      tests/app/Models/CategoryTest.php
  65. 1 1
      tests/app/Models/FeedDAOTest.php
  66. 35 42
      tests/app/Models/SearchTest.php
  67. 18 18
      tests/app/Models/UserQueryTest.php
  68. 5 3
      tests/app/Utils/dotNotationUtilTest.php
  69. 52 77
      tests/cli/CliOptionsParserTest.php
  70. 1 1
      tests/cli/i18n/I18nCompletionValidatorTest.php
  71. 1 1
      tests/cli/i18n/I18nUsageValidatorTest.php
  72. 9 9
      tests/cli/i18n/I18nValueTest.php
  73. 1 1
      tests/lib/CssXPath/CssXPathTest.php
  74. 21 21
      tests/lib/Minz/MigratorTest.php
  75. 1 1
      tests/lib/PHPMailer/PHPMailerTest.php

+ 7 - 7
Docker/Dockerfile-Oldest

@@ -1,14 +1,14 @@
-FROM alpine:3.13
+FROM alpine:3.16
 
 ENV TZ UTC
 SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
 
 RUN apk add --no-cache \
 	tzdata \
-	apache2 php7-apache2 \
-	php7 php7-curl php7-gmp php7-intl php7-mbstring php7-xml php7-zip \
-	php7-ctype php7-dom php7-fileinfo php7-iconv php7-json php7-opcache php7-openssl php7-phar php7-session php7-simplexml php7-xmlreader php7-xmlwriter php7-xml php7-tokenizer php7-zlib \
-	php7-pdo_sqlite php7-pdo_mysql php7-pdo_pgsql
+	apache2 php81-apache2 \
+	php81 php81-curl php81-gmp php81-intl php81-mbstring php81-xml php81-zip \
+	php81-ctype php81-dom php81-fileinfo php81-iconv php81-json php81-opcache php81-openssl php81-phar php81-session php81-simplexml php81-xmlreader php81-xmlwriter php81-xml php81-tokenizer php81-zlib \
+	php81-pdo_sqlite php81-pdo_mysql php81-pdo_pgsql
 
 RUN mkdir -p /var/www/FreshRSS /run/apache2/
 WORKDIR /var/www/FreshRSS
@@ -39,8 +39,8 @@ RUN rm -f /etc/apache2/conf.d/languages.conf /etc/apache2/conf.d/info.conf \
 		/etc/apache2/httpd.conf && \
 	sed -r -i "/^\s*(CustomLog|ErrorLog|Listen) /s/^/#/" \
 		/etc/apache2/httpd.conf && \
-	if [ ! -f /usr/bin/php ]; then ln -s /usr/bin/php7 /usr/bin/php; else true; fi && \
-	echo 'memory_limit = 256M' > /etc/php7/conf.d/10_memory.ini && \
+	if [ ! -f /usr/bin/php ]; then ln -s /usr/bin/php81 /usr/bin/php; else true; fi && \
+	echo 'memory_limit = 256M' > /etc/php81/conf.d/10_memory.ini && \
 	# Disable built-in updates when using Docker, as the full image is supposed to be updated instead.
 	sed -r -i "\\#disable_update#s#^.*#\t'disable_update' => true,#" ./config.default.php && \
 	touch /var/www/FreshRSS/Docker/env.txt && \

+ 1 - 1
README.fr.md

@@ -62,7 +62,7 @@ FreshRSS n’est fourni avec aucune garantie.
 * Serveur modeste, par exemple sous Linux ou Windows
 	* Fonctionne même sur un Raspberry Pi 1 avec des temps de réponse < 1s (testé sur 150 flux, 22k articles)
 * Serveur Web Apache2.4+ (recommandé), ou nginx, lighttpd (non testé sur les autres)
-* PHP 7.4+
+* PHP 8.1+
 	* Extensions requises : [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype)
 	* Extensions recommandées : [PDO_SQLite](https://www.php.net/pdo-sqlite) (pour l’export/import), [GMP](https://www.php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](https://www.php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](https://www.php.net/mbstring) (pour le texte Unicode), [iconv](https://www.php.net/iconv) (pour conversion d’encodages), [ZIP](https://www.php.net/zip) (pour import/export), [zlib](https://www.php.net/zlib) (pour les flux compressés)
 	* Extension pour base de données : [PDO_PGSQL](https://www.php.net/pdo-pgsql) ou [PDO_SQLite](https://www.php.net/pdo-sqlite) ou [PDO_MySQL](https://www.php.net/pdo-mysql)

+ 1 - 1
README.md

@@ -62,7 +62,7 @@ FreshRSS comes with absolutely no warranty.
 * Light server running Linux or Windows
 	* It even works on Raspberry Pi 1 with response time under a second (tested with 150 feeds, 22k articles)
 * A web server: Apache2.4+ (recommended), nginx, lighttpd (not tested on others)
-* PHP 7.4+
+* PHP 8.1+
 	* Required extensions: [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype)
 	* Recommended extensions: [PDO_SQLite](https://www.php.net/pdo-sqlite) (for export/import), [GMP](https://www.php.net/gmp) (for API access on 32-bit platforms), [IDN](https://www.php.net/intl.idn) (for Internationalized Domain Names), [mbstring](https://www.php.net/mbstring) (for Unicode strings), [iconv](https://www.php.net/iconv) (for charset conversion), [ZIP](https://www.php.net/zip) (for import/export), [zlib](https://www.php.net/zlib) (for compressed feeds)
 	* Extension for database: [PDO_PGSQL](https://www.php.net/pdo-pgsql) or [PDO_SQLite](https://www.php.net/pdo-sqlite) or [PDO_MySQL](https://www.php.net/pdo-mysql)

+ 1 - 2
app/Controllers/apiController.php

@@ -9,9 +9,8 @@ class FreshRSS_api_Controller extends FreshRSS_ActionController {
 	/**
 	 * Update the user API password.
 	 * Return an error message, or `false` if no error.
-	 * @return false|string
 	 */
-	public static function updatePassword(string $apiPasswordPlain) {
+	public static function updatePassword(string $apiPasswordPlain): string|false {
 		$username = Minz_User::name();
 		if ($username == null) {
 			return _t('feedback.api.password.failed');

+ 0 - 2
app/Controllers/configureController.php

@@ -479,8 +479,6 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 	 *   - user category limit (default: 16384)
 	 *   - user feed limit (default: 16384)
 	 *   - user login duration for form auth (default: FreshRSS_Auth::DEFAULT_COOKIE_DURATION)
-	 *
-	 * The `force-email-validation` is ignored with PHP < 5.5
 	 */
 	public function systemAction(): void {
 		if (!FreshRSS_Auth::hasAccess('admin')) {

+ 1 - 1
app/Controllers/feedController.php

@@ -755,7 +755,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 	 * @param int $nbNewEntries The number of top recent entries to process.
 	 * @return int|false The number of new labels added, or false in case of error.
 	 */
-	private static function applyLabelActions(int $nbNewEntries) {
+	private static function applyLabelActions(int $nbNewEntries): int|false {
 		$tagDAO = FreshRSS_Factory::createTagDao();
 		$labels = FreshRSS_Context::labels();
 		$labels = array_filter($labels, static function (FreshRSS_Tag $label) {

+ 4 - 13
app/Controllers/importExportController.php

@@ -33,10 +33,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
 		FreshRSS_View::prependTitle(_t('sub.import_export.title') . ' · ');
 	}
 
-	/**
-	 * @return float|int|string
-	 */
-	private static function megabytes(string $size_str) {
+	private static function megabytes(string $size_str): float|int|string {
 		switch (substr($size_str, -1)) {
 			case 'M':
 			case 'm':
@@ -51,10 +48,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
 		return $size_str;
 	}
 
-	/**
-	 * @param string|int $mb
-	 */
-	private static function minimumMemory($mb): void {
+	private static function minimumMemory(int|string $mb): void {
 		$mb = (int)$mb;
 		$ini = self::megabytes(ini_get('memory_limit') ?: '0');
 		if ($ini < $mb) {
@@ -240,11 +234,8 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
 		return 'unknown';
 	}
 
-	/**
-	 * @return false|string
-	 */
-	private function ttrssXmlToJson(string $xml) {
-		$table = (array)simplexml_load_string($xml, null, LIBXML_NOBLANKS | LIBXML_NOCDATA);
+	private function ttrssXmlToJson(string $xml): string|false {
+		$table = (array)simplexml_load_string($xml, options: LIBXML_NOBLANKS | LIBXML_NOCDATA);
 		$table['items'] = $table['article'] ?? [];
 		unset($table['article']);
 		for ($i = count($table['items']) - 1; $i >= 0; $i--) {

+ 1 - 1
app/Controllers/updateController.php

@@ -93,7 +93,7 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController {
 	}
 
 	/** @return string|true */
-	public static function gitPull() {
+	public static function gitPull(): string|bool {
 		Minz_Log::notice(_t('admin.update.viaGit'));
 		$cwd = getcwd();
 		if ($cwd === false) {

+ 6 - 12
app/Models/CategoryDAO.php

@@ -100,9 +100,8 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 
 	/**
 	 * @param array{'name':string,'id'?:int,'kind'?:int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string|array<string,mixed>} $valuesTmp
-	 * @return int|false
 	 */
-	public function addCategory(array $valuesTmp) {
+	public function addCategory(array $valuesTmp): int|false {
 		// TRIM() to provide a type hint as text
 		// No tag of the same name
 		$sql = <<<'SQL'
@@ -136,8 +135,7 @@ SQL;
 		}
 	}
 
-	/** @return int|false */
-	public function addCategoryObject(FreshRSS_Category $category) {
+	public function addCategoryObject(FreshRSS_Category $category): int|false {
 		$cat = $this->searchByName($category->name());
 		if (!$cat) {
 			$values = [
@@ -153,9 +151,8 @@ SQL;
 
 	/**
 	 * @param array{'name':string,'kind':int,'attributes'?:array<string,mixed>|mixed|null} $valuesTmp
-	 * @return int|false
 	 */
-	public function updateCategory(int $id, array $valuesTmp) {
+	public function updateCategory(int $id, array $valuesTmp): int|false {
 		// No tag of the same name
 		$sql = <<<'SQL'
 UPDATE `_category` SET name=?, kind=?, attributes=? WHERE id=?
@@ -187,8 +184,7 @@ SQL;
 		}
 	}
 
-	/** @return int|false */
-	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
+	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0): int|false {
 		$sql = 'UPDATE `_category` SET `lastUpdate`=?, error=? WHERE id=?';
 		$values = [
 			$mtime <= 0 ? time() : $mtime,
@@ -206,8 +202,7 @@ SQL;
 		}
 	}
 
-	/** @return int|false */
-	public function deleteCategory(int $id) {
+	public function deleteCategory(int $id): int|false {
 		if ($id <= self::DEFAULTCATEGORYID) {
 			return false;
 		}
@@ -345,8 +340,7 @@ SQL;
 		}
 	}
 
-	/** @return int|bool */
-	public function checkDefault() {
+	public function checkDefault(): int|bool {
 		$def_cat = $this->searchById(self::DEFAULTCATEGORYID);
 
 		if ($def_cat == null) {

+ 1 - 1
app/Models/Context.php

@@ -274,7 +274,7 @@ final class FreshRSS_Context {
 	 * @phpstan-return ($asArray is true ? array{'a'|'c'|'f'|'i'|'s'|'t'|'T',bool|int} : string)
 	 * @return string|array{string,bool|int}
 	 */
-	public static function currentGet(bool $asArray = false) {
+	public static function currentGet(bool $asArray = false): string|array {
 		if (self::$current_get['all']) {
 			return $asArray ? ['a', true] : 'a';
 		} elseif (self::$current_get['important']) {

+ 6 - 13
app/Models/Entry.php

@@ -32,13 +32,10 @@ class FreshRSS_Entry extends Minz_Model {
 	private array $tags = [];
 
 	/**
-	 * @param int|string $pubdate
-	 * @param bool|int|null $is_read
-	 * @param bool|int|null $is_favorite
 	 * @param string|array<string> $tags
 	 */
 	public function __construct(int $feedId = 0, string $guid = '', string $title = '', string $authors = '', string $content = '',
-			string $link = '', $pubdate = 0, $is_read = false, $is_favorite = false, $tags = '') {
+			string $link = '', int|string $pubdate = 0, bool|int|null $is_read = false, bool|int|null $is_favorite = false, $tags = '') {
 		$this->_title($title);
 		$this->_authors($authors);
 		$this->_content($content);
@@ -149,7 +146,7 @@ class FreshRSS_Entry extends Minz_Model {
 	 * @phpstan-return ($asString is true ? string : array<string>)
 	 * @return string|array<string>
 	 */
-	public function authors(bool $asString = false) {
+	public function authors(bool $asString = false): string|array {
 		if ($asString) {
 			return $this->authors == null ? '' : ';' . implode('; ', $this->authors);
 		} else {
@@ -384,9 +381,8 @@ HTML;
 	}
 	/**
 	 * @phpstan-return ($raw is false ? string : int)
-	 * @return string|int
 	 */
-	public function date(bool $raw = false) {
+	public function date(bool $raw = false): int|string {
 		if ($raw) {
 			return $this->date;
 		}
@@ -402,9 +398,8 @@ HTML;
 
 	/**
 	 * @phpstan-return ($raw is false ? string : ($microsecond is true ? string : int))
-	 * @return int|string
 	 */
-	public function dateAdded(bool $raw = false, bool $microsecond = false) {
+	public function dateAdded(bool $raw = false, bool $microsecond = false): int|string {
 		if ($raw) {
 			if ($microsecond) {
 				return $this->date_added;
@@ -451,7 +446,7 @@ HTML;
 	 * @phpstan-return ($asString is true ? string : array<string>)
 	 * @return string|array<string>
 	 */
-	public function tags(bool $asString = false) {
+	public function tags(bool $asString = false): array|string {
 		if ($asString) {
 			return $this->tags == null ? '' : '#' . implode(' #', $this->tags);
 		} else {
@@ -719,9 +714,7 @@ HTML;
 			}
 		}
 		FreshRSS_Context::userConf()->applyFilterActions($this);
-		if ($feed->category() !== null) {
-			$feed->category()->applyFilterActions($this);
-		}
+		$feed->category()?->applyFilterActions($this);
 		$feed->applyFilterActions($this);
 	}
 

+ 13 - 17
app/Models/EntryDAO.php

@@ -291,9 +291,8 @@ SQL;
 	 * there is an other way to do that.
 	 *
 	 * @param numeric-string|array<numeric-string> $ids
-	 * @return int|false
 	 */
-	public function markFavorite($ids, bool $is_favorite = true) {
+	public function markFavorite($ids, bool $is_favorite = true): int|false {
 		if (!is_array($ids)) {
 			$ids = [$ids];
 		}
@@ -369,10 +368,9 @@ SQL;
 	 * Then the cache is updated.
 	 *
 	 * @param numeric-string|array<numeric-string> $ids
-	 * @param bool $is_read
 	 * @return int|false affected rows
 	 */
-	public function markRead($ids, bool $is_read = true) {
+	public function markRead(array|string $ids, bool $is_read = true): int|false {
 		if (is_array($ids)) {	//Many IDs at once
 			if (count($ids) < 6) {	//Speed heuristics
 				$affected = 0;
@@ -438,7 +436,7 @@ SQL;
 	 * @param numeric-string $idMax fail safe article ID
 	 * @return int|false affected rows
 	 */
-	public function markReadEntries(string $idMax = '0', bool $onlyFavorites = false, ?int $priorityMin = null, ?int $prioritMax = null,
+	public function markReadEntries(string $idMax = '0', bool $onlyFavorites = false, ?int $priorityMin = null, ?int $priorityMax = null,
 		?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
 		FreshRSS_UserDAO::touch();
 		if ($idMax == '0') {
@@ -451,15 +449,15 @@ SQL;
 		if ($onlyFavorites) {
 			$sql .= ' AND is_favorite=1';
 		}
-		if ($priorityMin !== null || $prioritMax !== null) {
+		if ($priorityMin !== null || $priorityMax !== null) {
 			$sql .= ' AND id_feed IN (SELECT f.id FROM `_feed` f WHERE 1=1';
 			if ($priorityMin !== null) {
 				$sql .= ' AND f.priority >= ?';
 				$values[] = $priorityMin;
 			}
-			if ($prioritMax !== null) {
+			if ($priorityMax !== null) {
 				$sql .= ' AND f.priority < ?';
-				$values[] = $prioritMax;
+				$values[] = $priorityMax;
 			}
 			$sql .= ')';
 		}
@@ -490,7 +488,7 @@ SQL;
 	 * @param numeric-string $idMax fail safe article ID
 	 * @return int|false affected rows
 	 */
-	public function markReadCat(int $id, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
+	public function markReadCat(int $id, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true): int|false {
 		FreshRSS_UserDAO::touch();
 		if ($idMax == '0') {
 			$idMax = time() . '000000';
@@ -531,7 +529,7 @@ SQL;
 	 * @param numeric-string $idMax fail safe article ID
 	 * @return int|false affected rows
 	 */
-	public function markReadFeed(int $id_feed, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
+	public function markReadFeed(int $id_feed, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true): int|false {
 		FreshRSS_UserDAO::touch();
 		if ($idMax == '0') {
 			$idMax = time() . '000000';
@@ -623,9 +621,8 @@ SQL;
 	/**
 	 * Remember to call updateCachedValues($id_feed) or updateCachedValues() just after.
 	 * @param array<string,bool|int|string> $options
-	 * @return int|false
 	 */
-	public function cleanOldEntries(int $id_feed, array $options = []) {
+	public function cleanOldEntries(int $id_feed, array $options = []): int|false {
 		$sql = 'DELETE FROM `_entry` WHERE id_feed = :id_feed1';	//No alias for MySQL / MariaDB
 		$params = [];
 		$params[':id_feed1'] = $id_feed;
@@ -1121,12 +1118,11 @@ SQL;
 	 * @phpstan-param 'a'|'A'|'s'|'S'|'i'|'c'|'f'|'t'|'T'|'ST' $type
 	 * @param 'ASC'|'DESC' $order
 	 * @param int $id category/feed/tag ID
-	 * @return PDOStatement|false
 	 * @throws FreshRSS_EntriesGetter_Exception
 	 */
 	private function listWhereRaw(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
 			string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
-			int $date_min = 0) {
+			int $date_min = 0): PDOStatement|false {
 		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
 
 		if ($order !== 'DESC' && $order !== 'ASC') {
@@ -1244,7 +1240,7 @@ SQL;
 	 * @param array<string> $guids
 	 * @return array<string>|false
 	 */
-	public function listHashForFeedGuids(int $id_feed, array $guids) {
+	public function listHashForFeedGuids(int $id_feed, array $guids): array|false {
 		$result = [];
 		if (count($guids) < 1) {
 			return $result;
@@ -1283,7 +1279,7 @@ SQL;
 	 * @param array<string> $guids
 	 * @return int|false The number of affected entries, or false if error
 	 */
-	public function updateLastSeen(int $id_feed, array $guids, int $mtime = 0) {
+	public function updateLastSeen(int $id_feed, array $guids, int $mtime = 0): int|false {
 		if (count($guids) < 1) {
 			return 0;
 		} elseif (count($guids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
@@ -1321,7 +1317,7 @@ SQL;
 	 * To be performed just before {@see FreshRSS_FeedDAO::updateLastUpdate()}
 	 * @return int|false The number of affected entries, or false in case of error
 	 */
-	public function updateLastSeenUnchanged(int $id_feed, int $mtime = 0) {
+	public function updateLastSeenUnchanged(int $id_feed, int $mtime = 0): int|false {
 		$sql = <<<'SQL'
 UPDATE `_entry` SET `lastSeen` = :mtime
 WHERE id_feed = :id_feed1 AND `lastSeen` = (

+ 3 - 4
app/Models/EntryDAOSQLite.php

@@ -77,12 +77,11 @@ SQL;
 	 * Toggle the read marker on one or more article.
 	 * Then the cache is updated.
 	 *
-	 * @param string|array<string> $ids
-	 * @param bool $is_read
+	 * @param numeric-string|array<numeric-string> $ids
 	 * @return int|false affected rows
 	 */
 	#[\Override]
-	public function markRead($ids, bool $is_read = true) {
+	public function markRead(array|string $ids, bool $is_read = true): int|false {
 		if (is_array($ids)) {	//Many IDs at once (used by API)
 			//if (true) {	//Speed heuristics	//TODO: Not implemented yet for SQLite (so always call IDs one by one)
 			$affected = 0;
@@ -128,7 +127,7 @@ SQL;
 	 * @return int|false affected rows
 	 */
 	#[\Override]
-	public function markReadTag($id = 0, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true) {
+	public function markReadTag($id = 0, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true): int|false {
 		FreshRSS_UserDAO::touch();
 		if ($idMax == 0) {
 			$idMax = time() . '000000';

+ 8 - 15
app/Models/Feed.php

@@ -116,10 +116,7 @@ class FreshRSS_Feed extends Minz_Model {
 	}
 
 	public function categoryId(): int {
-		if ($this->category !== null) {
-			return $this->category->id() ?: $this->categoryId;
-		}
-		return $this->categoryId;
+		return $this->category?->id() ?: $this->categoryId;
 	}
 
 	/**
@@ -155,7 +152,7 @@ class FreshRSS_Feed extends Minz_Model {
 	 * @phpstan-return ($raw is true ? string : array{'username':string,'password':string})
 	 * @return array{'username':string,'password':string}|string
 	 */
-	public function httpAuth(bool $raw = true) {
+	public function httpAuth(bool $raw = true): array|string {
 		if ($raw) {
 			return $this->httpAuth;
 		} else {
@@ -816,7 +813,7 @@ class FreshRSS_Feed extends Minz_Model {
 	/**
 	 * @return int|null The max number of unread articles to keep, or null if disabled.
 	 */
-	public function keepMaxUnread() {
+	public function keepMaxUnread(): ?int {
 		$keepMaxUnread = $this->attributeInt('keep_max_n_unread');
 		if ($keepMaxUnread === null) {
 			$keepMaxUnread = FreshRSS_Context::userConf()->mark_when['max_n_unread'];
@@ -827,7 +824,7 @@ class FreshRSS_Feed extends Minz_Model {
 	/**
 	 * @return int|false The number of articles marked as read, of false if error
 	 */
-	public function markAsReadMaxUnread() {
+	public function markAsReadMaxUnread(): int|false {
 		$keepMaxUnread = $this->keepMaxUnread();
 		if ($keepMaxUnread === null) {
 			return false;
@@ -842,7 +839,7 @@ class FreshRSS_Feed extends Minz_Model {
 	 * Remember to call `updateCachedValues($id_feed)` or `updateCachedValues()` just after.
 	 * @return int|false the number of lines affected, or false if not applicable
 	 */
-	public function markAsReadUponGone(bool $upstreamIsEmpty, int $minLastSeen = 0) {
+	public function markAsReadUponGone(bool $upstreamIsEmpty, int $minLastSeen = 0): int|false {
 		$readUponGone = $this->attributeBoolean('read_upon_gone');
 		if ($readUponGone === null) {
 			$readUponGone = FreshRSS_Context::userConf()->mark_when['gone'];
@@ -868,9 +865,8 @@ class FreshRSS_Feed extends Minz_Model {
 
 	/**
 	 * Remember to call `updateCachedValues($id_feed)` or `updateCachedValues()` just after
-	 * @return int|false
 	 */
-	public function cleanOldEntries() {
+	public function cleanOldEntries(): int|false {
 		/** @var array<string,bool|int|string>|null $archiving */
 		$archiving = $this->attributeArray('archiving');
 		if ($archiving === null) {
@@ -926,7 +922,7 @@ class FreshRSS_Feed extends Minz_Model {
 	}
 
 	/** @return int|false */
-	public function cacheModifiedTime() {
+	public function cacheModifiedTime(): int|false {
 		$filename = $this->cacheFilename();
 		clearstatcache(true, $filename);
 		return @filemtime($filename);
@@ -977,10 +973,7 @@ class FreshRSS_Feed extends Minz_Model {
 		return false;
 	}
 
-	/**
-	 * @return string|false
-	 */
-	public function pubSubHubbubPrepare() {
+	public function pubSubHubbubPrepare(): string|false {
 		$key = '';
 		if (Minz_Request::serverIsPublic(FreshRSS_Context::systemConf()->base_url) &&
 			$this->hubUrl && $this->selfUrl && @is_dir(PSHB_PATH)) {

+ 13 - 26
app/Models/FeedDAO.php

@@ -36,9 +36,8 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	/**
 	 * @param array{'url':string,'kind':int,'category':int,'name':string,'website':string,'description':string,'lastUpdate':int,'priority'?:int,
 	 * 	'pathEntries'?:string,'httpAuth':string,'error':int|bool,'ttl'?:int,'attributes'?:string|array<string|mixed>} $valuesTmp
-	 * @return int|false
 	 */
-	public function addFeed(array $valuesTmp) {
+	public function addFeed(array $valuesTmp): int|false {
 		$sql = 'INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
 				VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
 		$stm = $this->pdo->prepare($sql);
@@ -81,8 +80,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		}
 	}
 
-	/** @return int|false */
-	public function addFeedObject(FreshRSS_Feed $feed) {
+	public function addFeedObject(FreshRSS_Feed $feed): int|false {
 		// Add feed only if we don’t find it in DB
 		$feed_search = $this->searchByUrl($feed->url());
 		if (!$feed_search) {
@@ -141,9 +139,8 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	/**
 	 * @param array{'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
 	 * 	'pathEntries'?:string,'httpAuth'?:string,'error'?:int,'ttl'?:int,'attributes'?:string|array<string,mixed>} $valuesTmp $valuesTmp
-	 * @return int|false
 	 */
-	public function updateFeed(int $id, array $valuesTmp) {
+	public function updateFeed(int $id, array $valuesTmp): int|false {
 		$values = [];
 		$originalValues = $valuesTmp;
 		if (isset($valuesTmp['name'])) {
@@ -191,9 +188,8 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	/**
 	 * @param non-empty-string $key
 	 * @param string|array<mixed>|bool|int|null $value
-	 * @return int|false
 	 */
-	public function updateFeedAttribute(FreshRSS_Feed $feed, string $key, $value) {
+	public function updateFeedAttribute(FreshRSS_Feed $feed, string $key, $value): int|false {
 		$feed->_attribute($key, $value);
 		return $this->updateFeed(
 			$feed->id(),
@@ -202,10 +198,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	}
 
 	/**
-	 * @return int|false
 	 * @see updateCachedValues()
 	 */
-	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
+	public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0): int|false {
 		$sql = 'UPDATE `_feed` SET `lastUpdate`=?, error=? WHERE id=?';
 		$values = [
 			$mtime <= 0 ? time() : $mtime,
@@ -223,14 +218,12 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		}
 	}
 
-	/** @return int|false */
-	public function mute(int $id, bool $value = true) {
+	public function mute(int $id, bool $value = true): int|false {
 		$sql = 'UPDATE `_feed` SET ttl=' . ($value ? '-' : '') . 'ABS(ttl) WHERE id=' . intval($id);
 		return $this->pdo->exec($sql);
 	}
 
-	/** @return int|false */
-	public function changeCategory(int $idOldCat, int $idNewCat) {
+	public function changeCategory(int $idOldCat, int $idNewCat): int|false {
 		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$newCat = $catDAO->searchById($idNewCat);
 		if ($newCat === null) {
@@ -257,8 +250,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		}
 	}
 
-	/** @return int|false */
-	public function deleteFeed(int $id) {
+	public function deleteFeed(int $id): int|false {
 		$sql = 'DELETE FROM `_feed` WHERE id=?';
 		$stm = $this->pdo->prepare($sql);
 
@@ -275,9 +267,8 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 
 	/**
 	 * @param bool|null $muted to include only muted feeds
-	 * @return int|false
 	 */
-	public function deleteFeedByCategory(int $id, ?bool $muted = null) {
+	public function deleteFeedByCategory(int $id, ?bool $muted = null): int|false {
 		$sql = 'DELETE FROM `_feed` WHERE category=?';
 		if ($muted) {
 			$sql .= ' AND ttl < 0';
@@ -454,9 +445,8 @@ SQL;
 
 	/**
 	 * Update cached values for selected feeds, or all feeds if no feed ID is provided.
-	 * @return int|false
 	 */
-	public function updateCachedValues(int ...$feedIds) {
+	public function updateCachedValues(int ...$feedIds): int|false {
 		//2 sub-requests with FOREIGN KEY(e.id_feed), INDEX(e.is_read) faster than 1 request with GROUP BY or CASE
 		$sql = <<<SQL
 UPDATE `_feed`
@@ -480,7 +470,7 @@ SQL;
 	 * Remember to call updateCachedValues() after calling this function
 	 * @return int|false number of lines affected or false in case of error
 	 */
-	public function markAsReadMaxUnread(int $id, int $n) {
+	public function markAsReadMaxUnread(int $id, int $n): int|false {
 		//Double SELECT for MySQL workaround ERROR 1093 (HY000)
 		$sql = <<<'SQL'
 UPDATE `_entry` SET is_read=1
@@ -509,7 +499,7 @@ SQL;
 	 * Remember to call updateCachedValues() after calling this function
 	 * @return int|false number of lines affected or false in case of error
 	 */
-	public function markAsReadNotSeen(int $id, int $minLastSeen) {
+	public function markAsReadNotSeen(int $id, int $minLastSeen): int|false {
 		$sql = <<<'SQL'
 UPDATE `_entry` SET is_read=1
 WHERE id_feed=:id_feed AND is_read=0 AND (`lastSeen` + 10 < :min_last_seen)
@@ -527,10 +517,7 @@ SQL;
 		}
 	}
 
-	/**
-	 * @return int|false
-	 */
-	public function truncate(int $id) {
+	public function truncate(int $id): int|false {
 		$sql = 'DELETE FROM `_entry` WHERE id_feed=:id';
 		$stm = $this->pdo->prepare($sql);
 		$this->pdo->beginTransaction();

+ 2 - 4
app/Models/FormAuth.php

@@ -38,8 +38,7 @@ class FreshRSS_FormAuth {
 		return [];
 	}
 
-	/** @return string|false */
-	private static function renewCookie(string $token) {
+	private static function renewCookie(string $token): string|false {
 		$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
 		if (touch($token_file)) {
 			$limits = FreshRSS_Context::systemConf()->limits;
@@ -51,8 +50,7 @@ class FreshRSS_FormAuth {
 		return false;
 	}
 
-	/** @return string|false */
-	public static function makeCookie(string $username, string $password_hash) {
+	public static function makeCookie(string $username, string $password_hash): string|false {
 		do {
 			$token = sha1(FreshRSS_Context::systemConf()->salt . $username . uniqid('' . mt_rand(), true));
 			$token_file = DATA_PATH . '/tokens/' . $token . '.txt';

+ 3 - 3
app/Models/Search.php

@@ -135,11 +135,11 @@ class FreshRSS_Search {
 	}
 
 	/** @return array<int>|'*'|null */
-	public function getLabelIds() {
+	public function getLabelIds(): array|string|null {
 		return $this->label_ids;
 	}
 	/** @return array<int>|'*'|null */
-	public function getNotLabelIds() {
+	public function getNotLabelIds(): array|string|null {
 		return $this->not_label_ids;
 	}
 	/** @return array<string>|null */
@@ -242,7 +242,7 @@ class FreshRSS_Search {
 	 * @param array<string>|string $value
 	 * @return ($value is array ? array<string> : string)
 	 */
-	private static function decodeSpaces($value) {
+	private static function decodeSpaces($value): array|string {
 		if (is_array($value)) {
 			for ($i = count($value) - 1; $i >= 0; $i--) {
 				$value[$i] = self::decodeSpaces($value[$i]);

+ 0 - 2
app/Models/Share.php

@@ -106,9 +106,7 @@ class FreshRSS_Share {
 	 *        decentralized ones.
 	 * @param string $help_url is an optional url to give help on this option.
 	 * @param 'GET'|'POST' $method defines the sharing method (GET or POST)
-	 * @param string|null $field
 	 * @param 'button'|null $HTMLtag
-	 * @param bool $isDeprecated
 	 */
 	private function __construct(string $type, string $url_transform, array $transforms, string $form_type,
 		string $help_url, string $method, ?string $field, ?string $HTMLtag, bool $isDeprecated = false) {

+ 1 - 1
app/Models/StatsDAO.php

@@ -31,7 +31,7 @@ class FreshRSS_StatsDAO extends Minz_ModelPdo {
 	 *
 	 * @return array{'total':int,'count_unreads':int,'count_reads':int,'count_favorites':int}|false
 	 */
-	public function calculateEntryRepartitionPerFeed(?int $feed = null, bool $only_main = false) {
+	public function calculateEntryRepartitionPerFeed(?int $feed = null, bool $only_main = false): array|false {
 		$filter = '';
 		if ($only_main) {
 			$filter .= 'AND f.priority = 10';

+ 3 - 12
app/Models/Tag.php

@@ -17,10 +17,7 @@ class FreshRSS_Tag extends Minz_Model {
 		return $this->id;
 	}
 
-	/**
-	 * @param int|string $value
-	 */
-	public function _id($value): void {
+	public function _id(int|string $value): void {
 		$this->id = (int)$value;
 	}
 
@@ -40,10 +37,7 @@ class FreshRSS_Tag extends Minz_Model {
 		return $this->nbEntries;
 	}
 
-	/**
-	 * @param string|int $value
-	 */
-	public function _nbEntries($value): void {
+	public function _nbEntries(int|string $value): void {
 		$this->nbEntries = (int)$value;
 	}
 
@@ -55,10 +49,7 @@ class FreshRSS_Tag extends Minz_Model {
 		return $this->nbUnread;
 	}
 
-	/**
-	 * @param string|int $value
-	 */
-	public function _nbUnread($value): void {
+	public function _nbUnread(int|string $value): void {
 		$this->nbUnread = (int)$value;
 	}
 }

+ 11 - 21
app/Models/TagDAO.php

@@ -9,9 +9,8 @@ class FreshRSS_TagDAO extends Minz_ModelPdo {
 
 	/**
 	 * @param array{'id'?:int,'name':string,'attributes'?:array<string,mixed>} $valuesTmp
-	 * @return int|false
 	 */
-	public function addTag(array $valuesTmp) {
+	public function addTag(array $valuesTmp): int|false {
 		// TRIM() gives a text type hint to PostgreSQL
 		// No category of the same name
 		$sql = <<<'SQL'
@@ -41,8 +40,7 @@ SQL;
 		}
 	}
 
-	/** @return int|false */
-	public function addTagObject(FreshRSS_Tag $tag) {
+	public function addTagObject(FreshRSS_Tag $tag): int|false {
 		$tag0 = $this->searchByName($tag->name());
 		if (!$tag0) {
 			$values = [
@@ -54,8 +52,7 @@ SQL;
 		return $tag->id();
 	}
 
-	/** @return int|false */
-	public function updateTagName(int $id, string $name) {
+	public function updateTagName(int $id, string $name): int|false {
 		// No category of the same name
 		$sql = <<<'SQL'
 UPDATE `_tag` SET name = :name1 WHERE id = :id
@@ -79,9 +76,8 @@ SQL;
 
 	/**
 	 * @param array<string,mixed> $attributes
-	 * @return int|false
 	 */
-	public function updateTagAttributes(int $id, array $attributes) {
+	public function updateTagAttributes(int $id, array $attributes): int|false {
 		$sql = 'UPDATE `_tag` SET attributes=:attributes WHERE id=:id';
 		$stm = $this->pdo->prepare($sql);
 		if ($stm !== false &&
@@ -97,18 +93,13 @@ SQL;
 
 	/**
 	 * @param non-empty-string $key
-	 * @param mixed $value
-	 * @return int|false
 	 */
-	public function updateTagAttribute(FreshRSS_Tag $tag, string $key, $value) {
+	public function updateTagAttribute(FreshRSS_Tag $tag, string $key, mixed $value): int|false {
 		$tag->_attribute($key, $value);
 		return $this->updateTagAttributes($tag->id(), $tag->attributes());
 	}
 
-	/**
-	 * @return int|false
-	 */
-	public function deleteTag(int $id) {
+	public function deleteTag(int $id): int|false {
 		if ($id <= 0) {
 			return false;
 		}
@@ -155,8 +146,7 @@ SQL;
 		}
 	}
 
-	/** @return int|false */
-	public function updateEntryTag(int $oldTagId, int $newTagId) {
+	public function updateEntryTag(int $oldTagId, int $newTagId): int|false {
 		$sql = <<<'SQL'
 DELETE FROM `_entrytag` WHERE EXISTS (
 	SELECT 1 FROM `_entrytag` AS e
@@ -194,7 +184,7 @@ SQL;
 	}
 
 	/** @return array<int,FreshRSS_Tag>|false */
-	public function listTags(bool $precounts = false) {
+	public function listTags(bool $precounts = false): array|false {
 		if ($precounts) {
 			$sql = <<<'SQL'
 SELECT t.id, t.name, count(e.id) AS unreads
@@ -304,7 +294,7 @@ SQL;
 	 * @param array<array{id_tag:int,id_entry:string}> $addLabels Labels to insert as batch
 	 * @return int|false Number of new entries or false in case of error
 	 */
-	public function tagEntries(array $addLabels) {
+	public function tagEntries(array $addLabels): int|false {
 		$hasValues = false;
 		$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `_entrytag`(id_tag, id_entry) VALUES ';
 		foreach ($addLabels as $addLabel) {
@@ -332,7 +322,7 @@ SQL;
 	/**
 	 * @return array<int,array{'id':int,'name':string,'id_entry':string,'checked':bool}>|false
 	 */
-	public function getTagsForEntry(string $id_entry) {
+	public function getTagsForEntry(string $id_entry): array|false {
 		$sql = <<<'SQL'
 SELECT t.id, t.name, et.id_entry IS NOT NULL as checked
 FROM `_tag` t
@@ -360,7 +350,7 @@ SQL;
 	 * @param array<FreshRSS_Entry|numeric-string|array<string,string>> $entries
 	 * @return array<array{'id_entry':string,'id_tag':int,'name':string}>|false
 	 */
-	public function getTagsForEntries(array $entries) {
+	public function getTagsForEntries(array $entries): array|false {
 		$sql = <<<'SQL'
 SELECT et.id_entry, et.id_tag, t.name
 FROM `_tag` t

+ 2 - 2
app/Models/Themes.php

@@ -31,7 +31,7 @@ class FreshRSS_Themes extends Minz_Model {
 	/**
 	 * @return false|array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}
 	 */
-	public static function get_infos(string $theme_id) {
+	public static function get_infos(string $theme_id): array|false {
 		$theme_dir = PUBLIC_PATH . self::$themesUrl . $theme_id;
 		if (is_dir($theme_dir)) {
 			$json_filename = $theme_dir . '/metadata.json';
@@ -58,7 +58,7 @@ class FreshRSS_Themes extends Minz_Model {
 	/**
 	 * @return false|array{'id':string,'name':string,'author':string,'description':string,'version':float|string,'files':array<string>,'theme-color'?:string|array{'dark'?:string,'light'?:string,'default'?:string}}
 	 */
-	public static function load(string $theme_id) {
+	public static function load(string $theme_id): array|false {
 		$infos = self::get_infos($theme_id);
 		if (!$infos) {
 			if ($theme_id !== self::$defaultTheme) {	//Fall-back to default theme

+ 7 - 10
app/Services/ExportService.php

@@ -16,13 +16,13 @@ class FreshRSS_Export_Service {
 
 	private FreshRSS_TagDAO $tag_dao;
 
-	public const FRSS_NAMESPACE = 'https://freshrss.org/opml';
-	public const TYPE_HTML_XPATH = 'HTML+XPath';
-	public const TYPE_XML_XPATH = 'XML+XPath';
-	public const TYPE_RSS_ATOM = 'rss';
-	public const TYPE_JSON_DOTPATH = 'JSON+DotPath';	// Legacy 1.24.0-dev
-	public const TYPE_JSON_DOTNOTATION = 'JSON+DotNotation';
-	public const TYPE_JSONFEED = 'JSONFeed';
+	final public const FRSS_NAMESPACE = 'https://freshrss.org/opml';
+	final public const TYPE_HTML_XPATH = 'HTML+XPath';
+	final public const TYPE_XML_XPATH = 'XML+XPath';
+	final public const TYPE_RSS_ATOM = 'rss';
+	final public const TYPE_JSON_DOTPATH = 'JSON+DotPath';	// Legacy 1.24.0-dev
+	final public const TYPE_JSON_DOTNOTATION = 'JSON+DotNotation';
+	final public const TYPE_JSONFEED = 'JSONFeed';
 
 	/**
 	 * Initialize the service for the given user.
@@ -87,8 +87,6 @@ class FreshRSS_Export_Service {
 
 	/**
 	 * Generate the entries file content for the given feed.
-	 * @param int $feed_id
-	 * @param int $max_number_entries
 	 * @return array{0:string,1:string}|null First item is the filename, second item is the content.
 	 *                    It also can return null if the feed doesn’t exist.
 	 */
@@ -124,7 +122,6 @@ class FreshRSS_Export_Service {
 
 	/**
 	 * Generate the entries file content for all the feeds.
-	 * @param int $max_number_entries
 	 * @return array<string,string> Keys are filenames and values are contents.
 	 */
 	public function generateAllFeedEntries(int $max_number_entries): array {

+ 4 - 8
app/Services/ImportService.php

@@ -362,10 +362,8 @@ class FreshRSS_Import_Service {
 	 * This method is applied to a list of outlines. It merges the different
 	 * list of feeds from several outlines into one array.
 	 *
-	 * @param array<array<mixed>> $outlines
-	 *     The outlines from which to extract the outlines.
-	 * @param string $parent_category_name
-	 *     The name of the parent category of the current outlines.
+	 * @param array<array<mixed>> $outlines The outlines from which to extract the outlines.
+	 * @param string $parent_category_name The name of the parent category of the current outlines.
 	 * @return array{0:array<string,array<string,string>>,1:array<string,array<array<string,string>>>}
 	 */
 	private function loadFromOutlines(array $outlines, string $parent_category_name): array {
@@ -405,10 +403,8 @@ class FreshRSS_Import_Service {
 	 * exists), it will add the outline to an array accessible by its category
 	 * name.
 	 *
-	 * @param array<mixed> $outline
-	 *     The outline from which to extract the categories and feeds outlines.
-	 * @param string $parent_category_name
-	 *     The name of the parent category of the current outline.
+	 * @param array<mixed> $outline The outline from which to extract the categories and feeds outlines.
+	 * @param string $parent_category_name The name of the parent category of the current outline.
 	 *
 	 * @return array{0:array<string,array<string,string>>,1:array<array<string,array<string,string>>>}
 	 */

+ 3 - 13
app/Utils/dotNotationUtil.php

@@ -12,11 +12,8 @@ final class FreshRSS_dotNotation_Util
 	 * https://github.com/laravel/framework/blob/10.x/src/Illuminate/Collections/Arr.php#L302-L337
 	 *
 	 * @param \ArrayAccess<string,mixed>|array<string,mixed>|mixed $array
-	 * @param string|null $key
-	 * @param mixed $default
-	 * @return mixed
 	 */
-	public static function get($array, ?string $key, mixed $default = null) {
+	public static function get($array, ?string $key, mixed $default = null): mixed {
 		if (!static::accessible($array)) {
 			return static::value($default);
 		}
@@ -51,7 +48,6 @@ final class FreshRSS_dotNotation_Util
 	 * Get a string from an array using "dot" notation.
 	 *
 	 * @param \ArrayAccess<string,mixed>|array<string,mixed>|mixed $array
-	 * @param string|null $key
 	 */
 	public static function getString($array, ?string $key): ?string {
 		$result = self::get($array, $key, null);
@@ -60,11 +56,8 @@ final class FreshRSS_dotNotation_Util
 
 	/**
 	 * Determine whether the given value is array accessible.
-	 *
-	 * @param mixed $value
-	 * @return bool
 	 */
-	private static function accessible($value): bool {
+	private static function accessible(mixed $value): bool {
 		return is_array($value) || $value instanceof \ArrayAccess;
 	}
 
@@ -72,8 +65,6 @@ final class FreshRSS_dotNotation_Util
 	 * Determine if the given key exists in the provided array.
 	 *
 	 * @param \ArrayAccess<string,mixed>|array<string,mixed>|mixed $array
-	 * @param string $key
-	 * @return bool
 	 */
 	private static function exists($array, string $key): bool {
 		if ($array instanceof \ArrayAccess) {
@@ -85,8 +76,7 @@ final class FreshRSS_dotNotation_Util
 		return false;
 	}
 
-	/** @param mixed $value */
-	private static function value($value): mixed {
+	private static function value(mixed $value): mixed {
 		return $value instanceof Closure ? $value() : $value;
 	}
 

+ 1 - 1
app/Utils/feverUtil.php

@@ -38,7 +38,7 @@ class FreshRSS_fever_Util {
 	 * @return string|false the Fever key, or false if the update failed
 	 * @throws FreshRSS_Context_Exception
 	 */
-	public static function updateKey(string $username, string $passwordPlain) {
+	public static function updateKey(string $username, string $passwordPlain): string|false {
 		if (!self::checkFeverPath()) {
 			return false;
 		}

+ 2 - 4
cli/_cli.php

@@ -20,8 +20,7 @@ Minz_Translate::init('en');
 
 FreshRSS_Context::$isCli = true;
 
-/** @return never */
-function fail(string $message, int $exitCode = 1) {
+function fail(string $message, int $exitCode = 1): never {
 	fwrite(STDERR, $message . "\n");
 	die($exitCode);
 }
@@ -51,8 +50,7 @@ function accessRights(): void {
 		"\t", 'sudo cli/access-permissions.sh', "\n";
 }
 
-/** @return never */
-function done(bool $ok = true) {
+function done(bool $ok = true): never {
 	if (!$ok) {
 		fwrite(STDERR, (empty($_SERVER['argv'][0]) ? 'Process' : basename($_SERVER['argv'][0])) . ' failed!' . "\n");
 	}

+ 1 - 2
cli/check.translation.php

@@ -102,9 +102,8 @@ function findUsedTranslations(): array {
 
 /**
  * Output help message.
- * @return never
  */
-function checkHelp() {
+function checkHelp(): never {
 	$file = str_replace(__DIR__ . '/', '', __FILE__);
 
 	echo <<<HELP

+ 0 - 1
cli/i18n/I18nFile.php

@@ -82,7 +82,6 @@ class I18nFile {
 	 * Flatten an array of translation
 	 *
 	 * @param array<string,I18nValue|array<string,I18nValue>> $translation
-	 * @param string $prefix
 	 * @return array<string,I18nValue>
 	 */
 	private function flatten(array $translation, string $prefix = ''): array {

+ 1 - 1
cli/i18n/I18nValue.php

@@ -32,7 +32,7 @@ class I18nValue {
 		}
 	}
 
-	public function __clone() {
+	public function __clone(): void {
 		$this->markAsTodo();
 	}
 

+ 5 - 5
composer.json

@@ -17,7 +17,7 @@
         "WebSub"
     ],
     "require": {
-        "php": ">=7.4",
+        "php": ">=8.1",
         "ext-ctype": "*",
         "ext-curl": "*",
         "ext-dom": "*",
@@ -49,18 +49,18 @@
             "phpstan/extension-installer": false
         },
         "platform": {
-            "php": "7.4"
+            "php": "8.1"
         }
     },
     "require-dev": {
-        "php": ">=7.4",
+        "php": ">=8.1",
         "ext-phar": "*",
         "ext-tokenizer": "*",
         "ext-xmlwriter": "*",
         "phpstan/phpstan": "^1.11",
         "phpstan/phpstan-phpunit": "^1.4",
         "phpstan/phpstan-strict-rules": "^1.6",
-        "phpunit/phpunit": "^9",
+        "phpunit/phpunit": "^10",
         "squizlabs/php_codesniffer": "^3.9"
     },
     "scripts": {
@@ -70,7 +70,7 @@
         "phpcbf": "phpcbf . -p -s",
         "phpstan": "phpstan analyse --memory-limit 512M .",
         "phpstan-next": "phpstan analyse --memory-limit 512M -c phpstan-next.neon .",
-        "phpunit": "phpunit --bootstrap ./tests/bootstrap.php --verbose ./tests",
+        "phpunit": "phpunit --bootstrap ./tests/bootstrap.php --display-notices ./tests",
         "translations": "cli/manipulate.translation.php -a format",
         "test": [
             "@php-lint",

Разлика између датотеке није приказан због своје велике величине
+ 191 - 255
composer.lock


+ 1 - 3
config.default.php

@@ -42,8 +42,6 @@ return array(
 	# Force users to validate their email address. If `true`, an email with a
 	# validation URL is sent during registration, and users cannot access their
 	# feed if they didn’t access this URL.
-	# Note: it is recommended to not enable it with PHP < 5.5 (emails cannot be
-	# sent).
 	'force_email_validation' => false,
 
 	# Allow or not visitors without login to see the articles
@@ -173,7 +171,7 @@ return array(
 
 	],
 
-	# Configuration to send emails. Be aware that PHP < 5.5 are not supported.
+	# Configuration to send emails.
 	# These options are basically a mapping of the PHPMailer class attributes
 	# from the PHPMailer library.
 	#

+ 1 - 1
constants.php

@@ -3,7 +3,7 @@ declare(strict_types=1);
 //NB: Do not edit; use ./constants.local.php instead.
 
 //<Not customisable>
-const FRESHRSS_MIN_PHP_VERSION = '7.4.0';
+const FRESHRSS_MIN_PHP_VERSION = '8.1.0';
 const FRESHRSS_VERSION = '1.25.0-dev';
 const FRESHRSS_WEBSITE = 'https://freshrss.org';
 const FRESHRSS_WIKI = 'https://freshrss.github.io/FreshRSS/';

+ 1 - 1
docs/en/admins/02_Prerequisites.md

@@ -7,7 +7,7 @@ You need to verify that your server can run FreshRSS before installing it. If yo
 | Software      | Recommended             | Also Works With         |
 | ------------- | ----------------------- | ----------------------- |
 | Web server    | **Apache 2.4**          | nginx, lighttpd<br />minimal compatibility with Apache 2.2    |
-| PHP           | **PHP 7.4+**            | FreshRSS 1.21/1.22: PHP 7.2+; FreshRSS 1.23/1.24: PHP 7.4+    |
+| PHP           | **PHP 8.1+**            | FreshRSS 1.21/1.22: PHP 7.2+; FreshRSS 1.23/1.24: PHP 7.4+    |
 | PHP modules   | Required: libxml, cURL, JSON, PDO_MySQL, PCRE and ctype.<br />Required (32-bit only): GMP <br />Recommended: Zlib, mbstring, iconv, ZipArchive<br />*For the whole modules list see [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* | |
 | Database      | **PostgreSQL 10+**      | SQLite, MySQL 5.5.3+, MariaDB 5.5+   |
 | Browser       | **Firefox**             | Chrome, Opera, Safari, or Edge       |

+ 1 - 1
docs/en/admins/10_ServerConfig.md

@@ -95,7 +95,7 @@ server {
 	# php files handling
 	# this regex is mandatory because of the API
 	location ~ ^.+?\.php(/.*)?$ {
-		fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
+		fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
 		fastcgi_split_path_info ^(.+\.php)(/.*)$;
 		# By default, the variable PATH_INFO is not set under PHP-FPM
 		# But FreshRSS API greader.php need it. If you have a “Bad Request” error, double check this var!

+ 2 - 2
docs/fr/users/01_Installation.md

@@ -7,7 +7,7 @@ Il est toutefois de votre responsabilité de vérifier que votre hébergement pe
 | Logiciel         | Recommandé                                                                                                     | Fonctionne aussi avec          |
 | --------         | -----------                                                                                                    | ---------------------          |
 | Serveur web      | **Apache 2.4+**                                                                                                | nginx, lighttpd                |
-| PHP              | **PHP 7.4+**                                                                                                   |                                |
+| PHP              | **PHP 8.1+**                                                                                                   |                                |
 | Modules PHP      | Requis : libxml, cURL, JSON, PDO_MySQL, PCRE et ctype<br />Requis (32 bits seulement) : GMP<br />Recommandé : Zlib, mbstring et iconv, ZipArchive<br />*Pour une liste complète des modules nécessaires voir le [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* |                                |
 | Base de données  | **PostgreSQL 10+** | SQLite, MySQL 5.5.3+, MariaDB 5.5+ |
 | Navigateur       | **Firefox**                                                                                                    | Chrome, Opera, Safari, or Edge   |
@@ -115,7 +115,7 @@ server {
 	# gestion des fichiers php
 	# il est nécessaire d’utiliser cette expression régulière pour le bon fonctionnement de l’API
 	location ~ ^.+?\.php(/.*)?$ {
-		fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
+		fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
 		fastcgi_split_path_info ^(.+\.php)(/.*)$;
 		# Par défaut la variable PATH_INFO n’est pas définie sous PHP-FPM
 		# or l’API FreshRSS greader.php en a besoin. Si vous avez un “Bad Request”, vérifiez bien cette dernière !

+ 5 - 7
lib/Minz/Configuration.php

@@ -56,10 +56,9 @@ class Minz_Configuration {
 	 * Return the configuration related to a given namespace.
 	 *
 	 * @param string $namespace the name of the configuration to get.
-	 * @return static object
 	 * @throws Minz_ConfigurationNamespaceException if the namespace does not exist.
 	 */
-	public static function get(string $namespace) {
+	public static function get(string $namespace): static {
 		if (!isset(self::$config_list[$namespace])) {
 			throw new Minz_ConfigurationNamespaceException(
 				$namespace . ' namespace does not exist'
@@ -156,7 +155,7 @@ class Minz_Configuration {
 	 * @param mixed $default default value to return if key does not exist.
 	 * @return array|mixed value corresponding to the key.
 	 */
-	public function param(string $key, $default = null) {
+	public function param(string $key, mixed $default = null): mixed {
 		if (isset($this->data[$key])) {
 			return $this->data[$key];
 		} elseif (!is_null($default)) {
@@ -171,7 +170,7 @@ class Minz_Configuration {
 	 * A wrapper for param().
 	 * @return array|mixed
 	 */
-	public function __get(string $key) {
+	public function __get(string $key): mixed {
 		return $this->param($key);
 	}
 
@@ -181,7 +180,7 @@ class Minz_Configuration {
 	 * @param string $key the param name to set.
 	 * @param mixed $value the value to set. If null, the key is removed from the configuration.
 	 */
-	public function _param(string $key, $value = null): void {
+	public function _param(string $key, mixed $value = null): void {
 		if ($this->configuration_setter !== null && $this->configuration_setter->support($key)) {
 			$this->configuration_setter->handle($this->data, $key, $value);
 		} elseif (isset($this->data[$key]) && is_null($value)) {
@@ -193,9 +192,8 @@ class Minz_Configuration {
 
 	/**
 	 * A wrapper for _param().
-	 * @param mixed $value
 	 */
-	public function __set(string $key, $value): void {
+	public function __set(string $key, mixed $value): void {
 		$this->_param($key, $value);
 	}
 

+ 1 - 1
lib/Minz/ConfigurationSetterInterface.php

@@ -16,5 +16,5 @@ interface Minz_ConfigurationSetterInterface {
 	 * @param string $key the key to update.
 	 * @param mixed $value the value to set.
 	 */
-	public function handle(&$data, string $key, $value): void;
+	public function handle(array &$data, string $key, mixed $value): void;
 }

+ 4 - 12
lib/Minz/Extension.php

@@ -105,7 +105,7 @@ abstract class Minz_Extension {
 	 *
 	 * @return string|false html content from ext_dir/configure.phtml, false if it does not exist.
 	 */
-	final public function getConfigureView() {
+	final public function getConfigureView(): string|false {
 		$filename = $this->path . '/configure.phtml';
 		if (!file_exists($filename)) {
 			return false;
@@ -146,7 +146,7 @@ abstract class Minz_Extension {
 		return $this->version;
 	}
 	/** @return 'system'|'user' */
-	final public function getType() {
+	final public function getType(): string {
 		return $this->type;
 	}
 
@@ -296,11 +296,7 @@ abstract class Minz_Extension {
 		return [];
 	}
 
-	/**
-	 * @param mixed $default
-	 * @return mixed
-	 */
-	final public function getSystemConfigurationValue(string $key, $default = null) {
+	final public function getSystemConfigurationValue(string $key, mixed $default = null): mixed {
 		if (!is_array($this->system_configuration)) {
 			$this->system_configuration = $this->getSystemConfiguration();
 		}
@@ -311,11 +307,7 @@ abstract class Minz_Extension {
 		return $default;
 	}
 
-	/**
-	 * @param mixed $default
-	 * @return mixed
-	 */
-	final public function getUserConfigurationValue(string $key, $default = null) {
+	final public function getUserConfigurationValue(string $key, mixed $default = null): mixed {
 		if (!is_array($this->user_configuration)) {
 			$this->user_configuration = $this->getUserConfiguration();
 		}

+ 1 - 1
lib/Minz/ExtensionManager.php

@@ -369,7 +369,7 @@ final class Minz_ExtensionManager {
 	 * @return mixed|null final chained result of the hooks. If nothing is changed,
 	 *         the initial argument is returned.
 	 */
-	private static function callOneToOne(string $hook_name, $arg) {
+	private static function callOneToOne(string $hook_name, mixed $arg): mixed {
 		$result = $arg;
 		foreach (self::$hook_list[$hook_name]['list'] as $function) {
 			$result = call_user_func($function, $arg);

+ 1 - 2
lib/Minz/FrontController.php

@@ -79,9 +79,8 @@ class Minz_FrontController {
 
 	/**
 	 * Kills the programme
-	 * @return never
 	 */
-	public static function killApp(string $txt = '') {
+	public static function killApp(string $txt = ''): never {
 		header('HTTP/1.1 500 Internal Server Error', true, 500);
 		if (function_exists('errorMessageInfo')) {
 			//If the application has defined a custom error message function

+ 1 - 4
lib/Minz/Helper.php

@@ -18,11 +18,8 @@ final class Minz_Helper {
 	 * @phpstan-template T of mixed
 	 * @phpstan-param T $var
 	 * @phpstan-return T
-	 *
-	 * @param mixed $var
-	 * @return mixed
 	 */
-	public static function htmlspecialchars_utf8($var) {
+	public static function htmlspecialchars_utf8(mixed $var): mixed {
 		if (is_array($var)) {
 			// @phpstan-ignore argument.type, return.type
 			return array_map([self::class, 'htmlspecialchars_utf8'], $var);

+ 0 - 1
lib/Minz/Log.php

@@ -76,7 +76,6 @@ class Minz_Log {
 	 * This method can be called multiple times for one script execution, but its result will not change unless
 	 * you call clearstatcache() in between. We won’t do do that for performance reasons.
 	 *
-	 * @param string $file_name
 	 * @throws Minz_PermissionDeniedException
 	 */
 	protected static function ensureMaxLogSize(string $file_name): void {

+ 0 - 4
lib/Minz/Mailer.php

@@ -18,10 +18,6 @@ use PHPMailer\PHPMailer\Exception;
  * $this->view->_path('user_mailer/email_need_validation.txt.php')
  * ```
  *
- * Minz_Mailer uses the PHPMailer library under the hood. The latter requires
- * PHP >= 5.5 to work. If you instantiate a Minz_Mailer with PHP < 5.5, a
- * warning will be logged.
- *
  * The email is sent by calling the `mail` method.
  */
 class Minz_Mailer {

+ 1 - 4
lib/Minz/Migrator.php

@@ -19,9 +19,6 @@ class Minz_Migrator
 	/**
 	 * Execute a list of migrations, skipping versions indicated in a file
 	 *
-	 * @param string $migrations_path
-	 * @param string $applied_migrations_path
-	 *
 	 * @return true|string Returns true if execute succeeds to apply
 	 *                        migrations, or a string if it fails.
 	 * @throws DomainException if there is no migrations corresponding to the
@@ -31,7 +28,7 @@ class Minz_Migrator
 	 *
 	 * @throws BadFunctionCallException if a callback isn’t callable.
 	 */
-	public static function execute(string $migrations_path, string $applied_migrations_path) {
+	public static function execute(string $migrations_path, string $applied_migrations_path): string|bool {
 		$applied_migrations = @file_get_contents($applied_migrations_path);
 		if ($applied_migrations === false) {
 			return "Cannot open the {$applied_migrations_path} file";

+ 0 - 2
lib/Minz/ModelPdo.php

@@ -86,8 +86,6 @@ class Minz_ModelPdo {
 	/**
 	 * Create the connection to the database using the variables
 	 * HOST, BASE, USER and PASS variables defined in the configuration file
-	 * @param string|null $currentUser
-	 * @param Minz_Pdo|null $currentPdo
 	 * @throws Minz_ConfigurationException
 	 * @throws Minz_PDOConnectionException
 	 */

+ 2 - 2
lib/Minz/Paginator.php

@@ -64,7 +64,7 @@ class Minz_Paginator {
 	 * @param Minz_Model $item l'élément à retrouver
 	 * @return int|false la page à laquelle se trouve l’élément, false si non trouvé
 	 */
-	public function pageByItem($item) {
+	public function pageByItem($item): int|false {
 		$i = 0;
 
 		do {
@@ -82,7 +82,7 @@ class Minz_Paginator {
 	 * @param Minz_Model $item the element to search
 	 * @return int|false the position of the element, or false if not found
 	 */
-	public function positionByItem($item) {
+	public function positionByItem($item): int|false {
 		$i = 0;
 
 		do {

+ 4 - 18
lib/Minz/Pdo.php

@@ -37,58 +37,44 @@ abstract class Minz_Pdo extends PDO {
 		return $this->autoPrefix($statement);
 	}
 
-	// PHP8+: PDO::lastInsertId(?string $name = null): string|false
 	/**
-	 * @param string|null $name
-	 * @return string|false
 	 * @throws PDOException if the attribute `PDO::ATTR_ERRMODE` is set to `PDO::ERRMODE_EXCEPTION`
 	 */
 	#[\Override]
-	#[\ReturnTypeWillChange]
-	public function lastInsertId($name = null) {
+	public function lastInsertId(?string $name = null): string|false {
 		if ($name != null) {
 			$name = $this->preSql($name);
 		}
 		return parent::lastInsertId($name);
 	}
 
-	// PHP8+: PDO::prepare(string $query, array $options = []): PDOStatement|false
 	/**
-	 * @param string $query
 	 * @param array<int,string> $options
-	 * @return PDOStatement|false
 	 * @throws PDOException if the attribute `PDO::ATTR_ERRMODE` is set to `PDO::ERRMODE_EXCEPTION`
 	 * @phpstan-ignore method.childParameterType, throws.unusedType
 	 */
 	#[\Override]
-	#[\ReturnTypeWillChange]
-	public function prepare($query, $options = []) {
+	public function prepare(string $query, array $options = []): PDOStatement|false {
 		$query = $this->preSql($query);
 		return parent::prepare($query, $options);
 	}
 
-	// PHP8+: PDO::exec(string $statement): int|false
 	/**
-	 * @param string $statement
-	 * @return int|false
 	 * @throws PDOException if the attribute `PDO::ATTR_ERRMODE` is set to `PDO::ERRMODE_EXCEPTION`
 	 * @phpstan-ignore throws.unusedType
 	 */
 	#[\Override]
-	#[\ReturnTypeWillChange]
-	public function exec($statement) {
+	public function exec(string $statement): int|false {
 		$statement = $this->preSql($statement);
 		return parent::exec($statement);
 	}
 
 	/**
-	 * @return PDOStatement|false
 	 * @throws PDOException if the attribute `PDO::ATTR_ERRMODE` is set to `PDO::ERRMODE_EXCEPTION`
 	 * @phpstan-ignore throws.unusedType
 	 */
 	#[\Override]
-	#[\ReturnTypeWillChange]
-	public function query(string $query, ?int $fetch_mode = null, ...$fetch_mode_args) {
+	public function query(string $query, ?int $fetch_mode = null, ...$fetch_mode_args): PDOStatement|false {
 		$query = $this->preSql($query);
 		return $fetch_mode === null ? parent::query($query) : parent::query($query, $fetch_mode, ...$fetch_mode_args);
 	}

+ 1 - 4
lib/Minz/PdoMysql.php

@@ -22,13 +22,10 @@ class Minz_PdoMysql extends Minz_Pdo {
 	}
 
 	/**
-	 * @param string|null $name
-	 * @return string|false
 	 * @throws PDOException if the attribute `PDO::ATTR_ERRMODE` is set to `PDO::ERRMODE_EXCEPTION`
 	 */
 	#[\Override]
-	#[\ReturnTypeWillChange]
-	public function lastInsertId($name = null) {
+	public function lastInsertId(?string $name = null): string|false {
 		return parent::lastInsertId();	//We discard the name, only used by PostgreSQL
 	}
 }

+ 1 - 4
lib/Minz/PdoSqlite.php

@@ -22,13 +22,10 @@ class Minz_PdoSqlite extends Minz_Pdo {
 	}
 
 	/**
-	 * @param string|null $name
-	 * @return string|false
 	 * @throws PDOException if the attribute `PDO::ATTR_ERRMODE` is set to `PDO::ERRMODE_EXCEPTION`
 	 */
 	#[\Override]
-	#[\ReturnTypeWillChange]
-	public function lastInsertId($name = null) {
+	public function lastInsertId(?string $name = null): string|false {
 		return parent::lastInsertId();	//We discard the name, only used by PostgreSQL
 	}
 }

+ 2 - 2
lib/Minz/Request.php

@@ -44,7 +44,7 @@ class Minz_Request {
 	 * @return mixed value of the parameter
 	 * @deprecated use typed versions instead
 	 */
-	public static function param(string $key, $default = false, bool $specialchars = false) {
+	public static function param(string $key, mixed $default = false, bool $specialchars = false): mixed {
 		if (isset(self::$params[$key])) {
 			$p = self::$params[$key];
 			if (is_string($p) || is_array($p)) {
@@ -156,7 +156,7 @@ class Minz_Request {
 	}
 
 	/** @return array{c?:string,a?:string,params?:array<string,mixed>} */
-	public static function originalRequest() {
+	public static function originalRequest(): array {
 		return self::$originalRequest;
 	}
 

+ 1 - 1
lib/Minz/Session.php

@@ -63,7 +63,7 @@ class Minz_Session {
 	 * @return mixed|false the value of the session variable, false if doesn’t exist
 	 * @deprecated Use typed versions instead
 	 */
-	public static function param(string $p, $default = false) {
+	public static function param(string $p, $default = false): mixed {
 		return $_SESSION[$p] ?? $default;
 	}
 

+ 1 - 3
lib/Minz/Translate.php

@@ -255,9 +255,7 @@ class Minz_Translate {
 
 /**
  * Alias for Minz_Translate::t()
- * @param string $key
- * @param bool|float|int|string ...$args
  */
-function _t(string $key, ...$args): string {
+function _t(string $key, bool|float|int|string ...$args): string {
 	return Minz_Translate::t($key, ...$args);
 }

+ 2 - 9
lib/Minz/Url.php

@@ -13,11 +13,10 @@ class Minz_Url {
 	 *                    $url['params'] = array of additional parameters
 	 *             or as a string
 	 * @param string $encoding how to encode & (& ou &amp; pour html)
-	 * @param bool|string $absolute
 	 * @return string Formatted URL
 	 * @throws Minz_ConfigurationException
 	 */
-	public static function display($url = [], string $encoding = 'html', $absolute = false): string {
+	public static function display($url = [], string $encoding = 'html', bool|string $absolute = false): string {
 		$isArray = is_array($url);
 
 		if ($isArray) {
@@ -160,13 +159,7 @@ class Minz_Url {
 	}
 }
 
-/**
- * @param string $controller
- * @param string $action
- * @param string|int ...$args
- * @return string|false
- */
-function _url(string $controller, string $action, ...$args) {
+function _url(string $controller, string $action, int|string ...$args): string|false {
 	$nb_args = count($args);
 
 	if ($nb_args % 2 !== 0) {

+ 1 - 6
lib/Minz/View.php

@@ -232,8 +232,6 @@ class Minz_View {
 
 	/**
 	 * Append a `<link>` element referencing stylesheet.
-	 * @param string $url
-	 * @param string $media
 	 * @param bool $cond Conditional comment for IE, now deprecated and ignored @deprecated
 	 */
 	public static function appendStyle(string $url, string $media = 'all', bool $cond = false): void {
@@ -298,7 +296,6 @@ class Minz_View {
 	}
 	/**
 	 * Prepend a `<script>` element.
-	 * @param string $url
 	 * @param bool $cond Conditional comment for IE, now deprecated and ignored @deprecated
 	 * @param bool $defer Use `defer` flag
 	 * @param bool $async Use `async` flag
@@ -318,7 +315,6 @@ class Minz_View {
 
 	/**
 	 * Append a `<script>` element.
-	 * @param string $url
 	 * @param bool $cond Conditional comment for IE, now deprecated and ignored @deprecated
 	 * @param bool $defer Use `defer` flag
 	 * @param bool $async Use `async` flag
@@ -338,9 +334,8 @@ class Minz_View {
 
 	/**
 	 * Management of parameters added to the view
-	 * @param mixed $value
 	 */
-	public static function _param(string $key, $value): void {
+	public static function _param(string $key, mixed $value): void {
 		self::$params[$key] = $value;
 	}
 

+ 1 - 1
lib/lib_date.php

@@ -36,7 +36,7 @@ example('PT6M/');
 example('PT7S/');
 example('P1DT1H/');
 
-function example(string $dateInterval) {
+function example(string $dateInterval): void {
 	$dateIntervalArray = parseDateInterval($dateInterval);
 	echo $dateInterval, "\t=>\t",
 		$dateIntervalArray[0] == null ? 'null' : @date('c', $dateIntervalArray[0]), '/',

+ 4 - 39
lib/lib_rss.php

@@ -5,37 +5,12 @@ if (version_compare(PHP_VERSION, FRESHRSS_MIN_PHP_VERSION, '<')) {
 	die(sprintf('FreshRSS error: FreshRSS requires PHP %s+!', FRESHRSS_MIN_PHP_VERSION));
 }
 
-if (!function_exists('array_is_list')) {
-	/**
-	 * Polyfill for PHP <8.1
-	 * https://php.net/array-is-list#127044
-	 * @param array<mixed> $array
-	 */
-	function array_is_list(array $array): bool {
-		$i = -1;
-		foreach ($array as $k => $v) {
-			++$i;
-			if ($k !== $i) {
-				return false;
-			}
-		}
-		return true;
-	}
-}
-
 if (!function_exists('mb_strcut')) {
 	function mb_strcut(string $str, int $start, ?int $length = null, string $encoding = 'UTF-8'): string {
 		return substr($str, $start, $length) ?: '';
 	}
 }
 
-if (!function_exists('str_starts_with')) {
-	/** Polyfill for PHP <8.0 */
-	function str_starts_with(string $haystack, string $needle): bool {
-		return strncmp($haystack, $needle, strlen($needle)) === 0;
-	}
-}
-
 if (!function_exists('syslog')) {
 	if (COPY_SYSLOG_TO_STDERR && !defined('STDERR')) {
 		define('STDERR', fopen('php://stderr', 'w'));
@@ -149,14 +124,7 @@ function idn_to_puny(string $url): string {
 	if (function_exists('idn_to_ascii')) {
 		$idn = parse_url($url, PHP_URL_HOST);
 		if (is_string($idn) && $idn != '') {
-			// https://wiki.php.net/rfc/deprecate-and-remove-intl_idna_variant_2003
-			if (defined('INTL_IDNA_VARIANT_UTS46')) {
-				$puny = idn_to_ascii($idn, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
-			} elseif (defined('INTL_IDNA_VARIANT_2003')) {
-				$puny = idn_to_ascii($idn, IDNA_DEFAULT, INTL_IDNA_VARIANT_2003);
-			} else {
-				$puny = idn_to_ascii($idn);
-			}
+			$puny = idn_to_ascii($idn);
 			$pos = strpos($url, $idn);
 			if ($puny != false && $pos !== false) {
 				$url = substr_replace($url, $puny, $pos, strlen($idn));
@@ -166,10 +134,7 @@ function idn_to_puny(string $url): string {
 	return $url;
 }
 
-/**
- * @return string|false
- */
-function checkUrl(string $url, bool $fixScheme = true) {
+function checkUrl(string $url, bool $fixScheme = true): string|false {
 	$url = trim($url);
 	if ($url == '') {
 		return '';
@@ -178,7 +143,7 @@ function checkUrl(string $url, bool $fixScheme = true) {
 		$url = 'https://' . ltrim($url, '/');
 	}
 
-	$url = idn_to_puny($url);	//PHP bug #53474 IDN
+	$url = idn_to_puny($url);	// https://bugs.php.net/bug.php?id=53474
 	$urlRelaxed = str_replace('_', 'z', $url);	//PHP discussion #64948 Underscore
 
 	if (is_string(filter_var($urlRelaxed, FILTER_VALIDATE_URL))) {
@@ -279,7 +244,7 @@ function html_only_entity_decode(?string $text): string {
  * @param array<string,mixed>|string $log
  * @return array<string,mixed>|string
  */
-function sensitive_log($log) {
+function sensitive_log($log): array|string {
 	if (is_array($log)) {
 		foreach ($log as $k => $v) {
 			if (in_array($k, ['api_key', 'Passwd', 'T'], true)) {

+ 6 - 16
p/api/fever.php

@@ -426,33 +426,29 @@ final class FeverAPI
 
 	/**
 	 * @param numeric-string $id
-	 * @return int|false
 	 */
-	private function setItemAsRead(string $id) {
+	private function setItemAsRead(string $id): int|false {
 		return $this->entryDAO->markRead($id, true);
 	}
 
 	/**
 	 * @param numeric-string $id
-	 * @return int|false
 	 */
-	private function setItemAsUnread(string $id) {
+	private function setItemAsUnread(string $id): int|false {
 		return $this->entryDAO->markRead($id, false);
 	}
 
 	/**
 	 * @param numeric-string $id
-	 * @return int|false
 	 */
-	private function setItemAsSaved(string $id) {
+	private function setItemAsSaved(string $id): int|false {
 		return $this->entryDAO->markFavorite($id, true);
 	}
 
 	/**
 	 * @param numeric-string $id
-	 * @return int|false
 	 */
-	private function setItemAsUnsaved(string $id) {
+	private function setItemAsUnsaved(string $id): int|false {
 		return $this->entryDAO->markFavorite($id, false);
 	}
 
@@ -538,18 +534,12 @@ final class FeverAPI
 		return $beforeTimestamp == 0 ? '0' : $beforeTimestamp . '000000';
 	}
 
-	/**
-	 * @return int|false
-	 */
-	private function setFeedAsRead(int $id, int $before) {
+	private function setFeedAsRead(int $id, int $before): int|false {
 		$before = $this->convertBeforeToId($before);
 		return $this->entryDAO->markReadFeed($id, $before);
 	}
 
-	/**
-	 * @return int|false
-	 */
-	private function setGroupAsRead(int $id, int $before) {
+	private function setGroupAsRead(int $id, int $before): int|false {
 		$before = $this->convertBeforeToId($before);
 
 		// special case to mark all items as read

+ 26 - 54
p/api/greader.php

@@ -112,14 +112,12 @@ function debugInfo(): string {
 
 final class GReaderAPI {
 
-	/** @return never */
-	private static function noContent() {
+	private static function noContent(): never {
 		header('HTTP/1.1 204 No Content');
 		exit();
 	}
 
-	/** @return never */
-	private static function badRequest() {
+	private static function badRequest(): never {
 		Minz_Log::warning(__METHOD__, API_LOG);
 		Minz_Log::debug(__METHOD__ . ' ' . debugInfo(), API_LOG);
 		header('HTTP/1.1 400 Bad Request');
@@ -127,8 +125,7 @@ final class GReaderAPI {
 		die('Bad Request!');
 	}
 
-	/** @return never */
-	private static function unauthorized() {
+	private static function unauthorized(): never {
 		Minz_Log::warning(__METHOD__, API_LOG);
 		Minz_Log::debug(__METHOD__ . ' ' . debugInfo(), API_LOG);
 		header('HTTP/1.1 401 Unauthorized');
@@ -137,8 +134,7 @@ final class GReaderAPI {
 		die('Unauthorized!');
 	}
 
-	/** @return never */
-	private static function internalServerError() {
+	private static function internalServerError(): never {
 		Minz_Log::warning(__METHOD__, API_LOG);
 		Minz_Log::debug(__METHOD__ . ' ' . debugInfo(), API_LOG);
 		header('HTTP/1.1 500 Internal Server Error');
@@ -146,8 +142,7 @@ final class GReaderAPI {
 		die('Internal Server Error!');
 	}
 
-	/** @return never */
-	private static function notImplemented() {
+	private static function notImplemented(): never {
 		Minz_Log::warning(__METHOD__, API_LOG);
 		Minz_Log::debug(__METHOD__ . ' ' . debugInfo(), API_LOG);
 		header('HTTP/1.1 501 Not Implemented');
@@ -155,8 +150,7 @@ final class GReaderAPI {
 		die('Not Implemented!');
 	}
 
-	/** @return never */
-	private static function serviceUnavailable() {
+	private static function serviceUnavailable(): never {
 		Minz_Log::warning(__METHOD__, API_LOG);
 		Minz_Log::debug(__METHOD__ . ' ' . debugInfo(), API_LOG);
 		header('HTTP/1.1 503 Service Unavailable');
@@ -164,8 +158,7 @@ final class GReaderAPI {
 		die('Service Unavailable!');
 	}
 
-	/** @return never */
-	private static function checkCompatibility() {
+	private static function checkCompatibility(): never {
 		Minz_Log::warning(__METHOD__, API_LOG);
 		Minz_Log::debug(__METHOD__ . ' ' . debugInfo(), API_LOG);
 		header('Content-Type: text/plain; charset=UTF-8');
@@ -211,8 +204,7 @@ final class GReaderAPI {
 		return '';
 	}
 
-	/** @return never */
-	private static function clientLogin(string $email, string $pass) {
+	private static function clientLogin(string $email, string $pass): never {
 		//https://web.archive.org/web/20130604091042/http://undoc.in/clientLogin.html
 		if (FreshRSS_user_Controller::checkUsername($email)) {
 			FreshRSS_Context::initUser($email);
@@ -237,10 +229,7 @@ final class GReaderAPI {
 		}
 	}
 
-	/**
-	 * @return never
-	 */
-	private static function token(?FreshRSS_UserConfiguration $conf) {
+	private static function token(?FreshRSS_UserConfiguration $conf): never {
 		//http://blog.martindoms.com/2009/08/15/using-the-google-reader-api-part-1/
 		//https://github.com/ericmann/gReader-Library/blob/master/greader.class.php
 		$user = Minz_User::name();
@@ -271,8 +260,7 @@ final class GReaderAPI {
 		self::unauthorized();
 	}
 
-	/** @return never */
-	private static function userInfo() {
+	private static function userInfo(): never {
 		//https://github.com/theoldreader/api#user-info
 		if (!FreshRSS_Context::hasUserConf()) {
 			self::unauthorized();
@@ -286,8 +274,7 @@ final class GReaderAPI {
 			), JSON_OPTIONS));
 	}
 
-	/** @return never */
-	private static function tagList() {
+	private static function tagList(): never {
 		header('Content-Type: application/json; charset=UTF-8');
 
 		$tags = array(
@@ -320,8 +307,7 @@ final class GReaderAPI {
 		exit();
 	}
 
-	/** @return never */
-	private static function subscriptionExport() {
+	private static function subscriptionExport(): never {
 		$user = Minz_User::name() ?? Minz_User::INTERNAL_USER;
 		$export_service = new FreshRSS_Export_Service($user);
 		[$filename, $content] = $export_service->generateOpml();
@@ -331,8 +317,7 @@ final class GReaderAPI {
 		exit();
 	}
 
-	/** @return never */
-	private static function subscriptionImport(string $opml) {
+	private static function subscriptionImport(string $opml): never {
 		$user = Minz_User::name() ?? Minz_User::INTERNAL_USER;
 		$importService = new FreshRSS_Import_Service($user);
 		$importService->importOpml($opml);
@@ -345,8 +330,7 @@ final class GReaderAPI {
 		}
 	}
 
-	/** @return never */
-	private static function subscriptionList() {
+	private static function subscriptionList(): never {
 		if (!FreshRSS_Context::hasSystemConf()) {
 			self::internalServerError();
 		}
@@ -384,9 +368,8 @@ final class GReaderAPI {
 	/**
 	 * @param array<string> $streamNames
 	 * @param array<string> $titles
-	 * @return never
 	 */
-	private static function subscriptionEdit(array $streamNames, array $titles, string $action, string $add = '', string $remove = '') {
+	private static function subscriptionEdit(array $streamNames, array $titles, string $action, string $add = '', string $remove = ''): never {
 		//https://github.com/mihaip/google-reader-api/blob/master/wiki/ApiSubscriptionEdit.wiki
 		switch ($action) {
 			case 'subscribe':
@@ -474,8 +457,7 @@ final class GReaderAPI {
 		exit('OK');
 	}
 
-	/** @return never */
-	private static function quickadd(string $url) {
+	private static function quickadd(string $url): never {
 		try {
 			$url = htmlspecialchars($url, ENT_COMPAT, 'UTF-8');
 			if (str_starts_with($url, 'feed/')) {
@@ -497,8 +479,7 @@ final class GReaderAPI {
 		}
 	}
 
-	/** @return never */
-	private static function unreadCount() {
+	private static function unreadCount(): never {
 		//http://blog.martindoms.com/2009/10/16/using-the-google-reader-api-part-2/#unread-count
 		header('Content-Type: application/json; charset=UTF-8');
 
@@ -592,10 +573,9 @@ final class GReaderAPI {
 
 	/**
 	 * @param 'A'|'c'|'f'|'s' $type
-	 * @param string|int $streamId
 	 * @phpstan-return array{'A'|'c'|'f'|'s'|'t',int,int,FreshRSS_BooleanSearch}
 	 */
-	private static function streamContentsFilters(string $type, $streamId,
+	private static function streamContentsFilters(string $type, int|string $streamId,
 		string $filter_target, string $exclude_target, int $start_time, int $stop_time): array {
 		switch ($type) {
 			case 'f':	//feed
@@ -670,9 +650,8 @@ final class GReaderAPI {
 		return array($type, $streamId, $state, $searches);
 	}
 
-	/** @return never */
 	private static function streamContents(string $path, string $include_target, int $start_time, int $stop_time, int $count,
-		string $order, string $filter_target, string $exclude_target, string $continuation) {
+		string $order, string $filter_target, string $exclude_target, string $continuation): never {
 		//http://code.google.com/p/pyrfeed/wiki/GoogleReaderAPI
 		//http://blog.martindoms.com/2009/10/16/using-the-google-reader-api-part-2/#feed
 		header('Content-Type: application/json; charset=UTF-8');
@@ -728,9 +707,8 @@ final class GReaderAPI {
 		exit();
 	}
 
-	/** @return never */
 	private static function streamContentsItemsIds(string $streamId, int $start_time, int $stop_time, int $count,
-		string $order, string $filter_target, string $exclude_target, string $continuation) {
+		string $order, string $filter_target, string $exclude_target, string $continuation): never {
 		//http://code.google.com/p/google-reader-api/wiki/ApiStreamItemsIds
 		//http://code.google.com/p/pyrfeed/wiki/GoogleReaderAPI
 		//http://blog.martindoms.com/2009/10/16/using-the-google-reader-api-part-2/#feed
@@ -790,9 +768,8 @@ final class GReaderAPI {
 
 	/**
 	 * @param array<string> $e_ids
-	 * @return never
 	 */
-	private static function streamContentsItems(array $e_ids, string $order) {
+	private static function streamContentsItems(array $e_ids, string $order): never {
 		header('Content-Type: application/json; charset=UTF-8');
 
 		foreach ($e_ids as $i => $e_id) {
@@ -822,9 +799,8 @@ final class GReaderAPI {
 
 	/**
 	 * @param array<string> $e_ids
-	 * @return never
 	 */
-	private static function editTag(array $e_ids, string $a, string $r): void {
+	private static function editTag(array $e_ids, string $a, string $r): never {
 		foreach ($e_ids as $i => $e_id) {
 			if (!ctype_digit($e_id) || $e_id[0] === '0') {
 				$e_ids[$i] = hex2dec(basename($e_id));	//Strip prefix 'tag:google.com,2005:reader/item/'
@@ -898,8 +874,7 @@ final class GReaderAPI {
 		exit('OK');
 	}
 
-	/** @return never */
-	private static function renameTag(string $s, string $dest) {
+	private static function renameTag(string $s, string $dest): never {
 		if ($s != '' && strpos($s, 'user/-/label/') === 0 &&
 			$dest != '' && strpos($dest, 'user/-/label/') === 0) {
 			$s = substr($s, 13);
@@ -926,8 +901,7 @@ final class GReaderAPI {
 		self::badRequest();
 	}
 
-	/** @return never */
-	private static function disableTag(string $s) {
+	private static function disableTag(string $s): never {
 		if ($s != '' && strpos($s, 'user/-/label/') === 0) {
 			$s = substr($s, 13);
 			$s = htmlspecialchars($s, ENT_COMPAT, 'UTF-8');
@@ -954,9 +928,8 @@ final class GReaderAPI {
 
 	/**
 	 * @param numeric-string $olderThanId
-	 * @return never
 	 */
-	private static function markAllAsRead(string $streamId, string $olderThanId) {
+	private static function markAllAsRead(string $streamId, string $olderThanId): never {
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		if (strpos($streamId, 'feed/') === 0) {
 			$f_id = basename($streamId);
@@ -989,8 +962,7 @@ final class GReaderAPI {
 		exit('OK');
 	}
 
-	/** @return never */
-	public static function parse() {
+	public static function parse(): never {
 		global $ORIGINAL_INPUT;
 
 		header('Access-Control-Allow-Headers: Authorization');

+ 2 - 4
p/ext.php

@@ -83,14 +83,12 @@ function is_valid_path(string $path): bool {
 		|| is_valid_path_extension($path, USERS_PATH, false);
 }
 
-/** @return never */
-function sendBadRequestResponse(string $message = null) {
+function sendBadRequestResponse(string $message = null): never {
 	header('HTTP/1.1 400 Bad Request');
 	die($message);
 }
 
-/** @return never */
-function sendNotFoundResponse() {
+function sendNotFoundResponse(): never {
 	header('HTTP/1.1 404 Not Found');
 	die();
 }

+ 0 - 1
phpstan.neon

@@ -1,6 +1,5 @@
 parameters:
 	level: 9	# https://phpstan.org/user-guide/rule-levels
-	phpVersion: 80399	# TODO: Remove line when moving composer.json to PHP 8+
 	fileExtensions:
 		- php
 		- phtml

+ 16 - 1
tests/README.md

@@ -1,8 +1,23 @@
 # FreshRSS tests
 
+See our [documentation about running tests](https://freshrss.github.io/FreshRSS/en/developers/03_Running_tests.html).
+
+```sh
+make test-all
+```
+
+See [`test.yml`](../.github/workflows/tests.yml) for the GitHub Actions automated tests.
+
+See [`composer.json`](../composer.json) for the different tests and versions, to be run locally.
+
+## Details about this *tests* folder
+
+Unit tests are based on [PHPUnit](https://phpunit.de/).
+Here is an example of manual install:
+
 ```sh
 cd ./tests/
-wget -O phpunit.phar https://phar.phpunit.de/phpunit-9.phar
+wget -O phpunit.phar https://phar.phpunit.de/phpunit-10.phar
 php phpunit.phar --bootstrap bootstrap.php
 ```
 

+ 6 - 6
tests/app/Models/CategoryTest.php

@@ -1,24 +1,24 @@
 <?php
 declare(strict_types=1);
 
+use PHPUnit\Framework\Attributes\DataProvider;
+
 class CategoryTest extends PHPUnit\Framework\TestCase {
 
-	public function test__construct_whenNoParameters_createsObjectWithDefaultValues(): void {
+	public static function test__construct_whenNoParameters_createsObjectWithDefaultValues(): void {
 		$category = new FreshRSS_Category();
 		self::assertEquals(0, $category->id());
 		self::assertEquals('', $category->name());
 	}
 
-	/**
-	 * @dataProvider provideValidNames
-	 */
-	public function test_name_whenValidValue_storesModifiedValue(string $input, string $expected): void {
+	#[DataProvider('provideValidNames')]
+	public static function test_name_whenValidValue_storesModifiedValue(string $input, string $expected): void {
 		$category = new FreshRSS_Category($input);
 		self::assertEquals($expected, $category->name());
 	}
 
 	/** @return array<array{string,string}> */
-	public function provideValidNames(): array {
+	public static function provideValidNames(): array {
 		return [
 			['', ''],
 			['this string does not need trimming', 'this string does not need trimming'],

+ 1 - 1
tests/app/Models/FeedDAOTest.php

@@ -2,7 +2,7 @@
 declare(strict_types=1);
 
 class FeedDAOTest extends PHPUnit\Framework\TestCase {
-	public function test_ttl_min(): void {
+	public static function test_ttl_min(): void {
 		$feed = new FreshRSS_Feed('https://example.net/', false);
 		$feed->_ttl(-5);
 		self::assertEquals(-5, $feed->ttl(true));

+ 35 - 42
tests/app/Models/SearchTest.php

@@ -1,13 +1,14 @@
 <?php
 declare(strict_types=1);
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
 require_once(LIB_PATH . '/lib_date.php');
 
 class SearchTest extends PHPUnit\Framework\TestCase {
 
-	/**
-	 * @dataProvider provideEmptyInput
-	 */
-	public function test__construct_whenInputIsEmpty_getsOnlyNullValues(string $input): void {
+	#[DataProvider('provideEmptyInput')]
+	public static function test__construct_whenInputIsEmpty_getsOnlyNullValues(string $input): void {
 		$search = new FreshRSS_Search($input);
 		self::assertEquals('', $search->getRawInput());
 		self::assertNull($search->getIntitle());
@@ -25,7 +26,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	 * Here is the description of the values
 	 * @return array{array{''},array{' '}}
 	 */
-	public function provideEmptyInput(): array {
+	public static function provideEmptyInput(): array {
 		return [
 			[''],
 			[' '],
@@ -33,11 +34,11 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @dataProvider provideIntitleSearch
 	 * @param array<string>|null $intitle_value
 	 * @param array<string>|null $search_value
 	 */
-	public function test__construct_whenInputContainsIntitle_setsIntitleProperty(string $input, ?array $intitle_value, ?array $search_value): void {
+	#[DataProvider('provideIntitleSearch')]
+	public static function test__construct_whenInputContainsIntitle_setsIntitleProperty(string $input, ?array $intitle_value, ?array $search_value): void {
 		$search = new FreshRSS_Search($input);
 		self::assertEquals($intitle_value, $search->getIntitle());
 		self::assertEquals($search_value, $search->getSearch());
@@ -46,7 +47,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	/**
 	 * @return array<array<mixed>>
 	 */
-	public function provideIntitleSearch(): array {
+	public static function provideIntitleSearch(): array {
 		return [
 			['intitle:word1', ['word1'], null],
 			['intitle:word1-word2', ['word1-word2'], null],
@@ -70,11 +71,11 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @dataProvider provideAuthorSearch
 	 * @param array<string>|null $author_value
 	 * @param array<string>|null $search_value
 	 */
-	public function test__construct_whenInputContainsAuthor_setsAuthorValue(string $input, ?array $author_value, ?array $search_value): void {
+	#[DataProvider('provideAuthorSearch')]
+	public static function test__construct_whenInputContainsAuthor_setsAuthorValue(string $input, ?array $author_value, ?array $search_value): void {
 		$search = new FreshRSS_Search($input);
 		self::assertEquals($author_value, $search->getAuthor());
 		self::assertEquals($search_value, $search->getSearch());
@@ -83,7 +84,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	/**
 	 * @return array<array<mixed>>
 	 */
-	public function provideAuthorSearch(): array {
+	public static function provideAuthorSearch(): array {
 		return [
 			['author:word1', ['word1'], null],
 			['author:word1-word2', ['word1-word2'], null],
@@ -107,11 +108,11 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @dataProvider provideInurlSearch
 	 * @param array<string>|null $inurl_value
 	 * @param array<string>|null $search_value
 	 */
-	public function test__construct_whenInputContainsInurl_setsInurlValue(string $input, ?array $inurl_value, ?array $search_value): void {
+	#[DataProvider('provideInurlSearch')]
+	public static function test__construct_whenInputContainsInurl_setsInurlValue(string $input, ?array $inurl_value, ?array $search_value): void {
 		$search = new FreshRSS_Search($input);
 		self::assertEquals($inurl_value, $search->getInurl());
 		self::assertEquals($search_value, $search->getSearch());
@@ -120,7 +121,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	/**
 	 * @return array<array<mixed>>
 	 */
-	public function provideInurlSearch(): array {
+	public static function provideInurlSearch(): array {
 		return [
 			['inurl:word1', ['word1'], null],
 			['inurl: word1', [], ['word1']],
@@ -133,10 +134,8 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 		];
 	}
 
-	/**
-	 * @dataProvider provideDateSearch
-	 */
-	public function test__construct_whenInputContainsDate_setsDateValues(string $input, ?int $min_date_value, ?int $max_date_value): void {
+	#[DataProvider('provideDateSearch')]
+	public static function test__construct_whenInputContainsDate_setsDateValues(string $input, ?int $min_date_value, ?int $max_date_value): void {
 		$search = new FreshRSS_Search($input);
 		self::assertEquals($min_date_value, $search->getMinDate());
 		self::assertEquals($max_date_value, $search->getMaxDate());
@@ -145,7 +144,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	/**
 	 * @return array<array<mixed>>
 	 */
-	public function provideDateSearch(): array {
+	public static function provideDateSearch(): array {
 		return array(
 			array('date:2007-03-01T13:00:00Z/2008-05-11T15:30:00Z', 1172754000, 1210519800),
 			array('date:2007-03-01T13:00:00Z/P1Y2M10DT2H30M', 1172754000, 1210519799),
@@ -156,10 +155,8 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 		);
 	}
 
-	/**
-	 * @dataProvider providePubdateSearch
-	 */
-	public function test__construct_whenInputContainsPubdate_setsPubdateValues(string $input, ?int $min_pubdate_value, ?int $max_pubdate_value): void {
+	#[DataProvider('providePubdateSearch')]
+	public static function test__construct_whenInputContainsPubdate_setsPubdateValues(string $input, ?int $min_pubdate_value, ?int $max_pubdate_value): void {
 		$search = new FreshRSS_Search($input);
 		self::assertEquals($min_pubdate_value, $search->getMinPubdate());
 		self::assertEquals($max_pubdate_value, $search->getMaxPubdate());
@@ -168,7 +165,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	/**
 	 * @return array<array<mixed>>
 	 */
-	public function providePubdateSearch(): array {
+	public static function providePubdateSearch(): array {
 		return array(
 			array('pubdate:2007-03-01T13:00:00Z/2008-05-11T15:30:00Z', 1172754000, 1210519800),
 			array('pubdate:2007-03-01T13:00:00Z/P1Y2M10DT2H30M', 1172754000, 1210519799),
@@ -180,11 +177,11 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @dataProvider provideTagsSearch
 	 * @param array<string>|null $tags_value
 	 * @param array<string>|null $search_value
 	 */
-	public function test__construct_whenInputContainsTags_setsTagsValue(string $input, ?array $tags_value, ?array $search_value): void {
+	#[DataProvider('provideTagsSearch')]
+	public static function test__construct_whenInputContainsTags_setsTagsValue(string $input, ?array $tags_value, ?array $search_value): void {
 		$search = new FreshRSS_Search($input);
 		self::assertEquals($tags_value, $search->getTags());
 		self::assertEquals($search_value, $search->getSearch());
@@ -193,7 +190,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	/**
 	 * @return array<array<string|array<string>|null>>
 	 */
-	public function provideTagsSearch(): array {
+	public static function provideTagsSearch(): array {
 		return [
 			['#word1', ['word1'], null],
 			['# word1', null, ['#', 'word1']],
@@ -207,14 +204,14 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @dataProvider provideMultipleSearch
 	 * @param array<string>|null $author_value
 	 * @param array<string> $intitle_value
 	 * @param array<string>|null $inurl_value
 	 * @param array<string>|null $tags_value
 	 * @param array<string>|null $search_value
 	 */
-	public function test__construct_whenInputContainsMultipleKeywords_setsValues(string $input, ?array $author_value, ?int $min_date_value,
+	#[DataProvider('provideMultipleSearch')]
+	public static function test__construct_whenInputContainsMultipleKeywords_setsValues(string $input, ?array $author_value, ?int $min_date_value,
 			?int $max_date_value, ?array $intitle_value, ?array $inurl_value, ?int $min_pubdate_value,
 			?int $max_pubdate_value, ?array $tags_value, ?array $search_value): void {
 		$search = new FreshRSS_Search($input);
@@ -231,7 +228,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/** @return array<array<mixed>> */
-	public function provideMultipleSearch(): array {
+	public static function provideMultipleSearch(): array {
 		return array(
 			array(
 				'author:word1 date:2007-03-01/2008-05-11 intitle:word2 inurl:word3 pubdate:2007-03-01/2008-05-11 #word4 #word5',
@@ -284,15 +281,13 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 		);
 	}
 
-	/**
-	 * @dataProvider provideAddOrParentheses
-	 */
-	public function test__addOrParentheses(string $input, string $output): void {
+	#[DataProvider('provideAddOrParentheses')]
+	public static function test__addOrParentheses(string $input, string $output): void {
 		self::assertEquals($output, FreshRSS_BooleanSearch::addOrParentheses($input));
 	}
 
 	/** @return array<array{string,string}> */
-	public function provideAddOrParentheses(): array {
+	public static function provideAddOrParentheses(): array {
 		return [
 			['ab', 'ab'],
 			['ab cd', 'ab cd'],
@@ -304,15 +299,13 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 		];
 	}
 
-	/**
-	 * @dataProvider provideconsistentOrParentheses
-	 */
-	public function test__consistentOrParentheses(string $input, string $output): void {
+	#[DataProvider('provideconsistentOrParentheses')]
+	public static function test__consistentOrParentheses(string $input, string $output): void {
 		self::assertEquals($output, FreshRSS_BooleanSearch::consistentOrParentheses($input));
 	}
 
 	/** @return array<array{string,string}> */
-	public function provideconsistentOrParentheses(): array {
+	public static function provideconsistentOrParentheses(): array {
 		return [
 			['ab cd ef', 'ab cd ef'],
 			['(ab cd ef)', '(ab cd ef)'],
@@ -332,9 +325,9 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @dataProvider provideParentheses
 	 * @param array<string> $values
 	 */
+	#[DataProvider('provideParentheses')]
 	public function test__parentheses(string $input, string $sql, array $values): void {
 		[$filterValues, $filterSearch] = FreshRSS_EntryDAOPGSQL::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input));
 		self::assertEquals(trim($sql), trim($filterSearch));
@@ -342,7 +335,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/** @return array<array<mixed>> */
-	public function provideParentheses(): array {
+	public static function provideParentheses(): array {
 		return [
 			[
 				'f:1 (f:2 OR f:3 OR f:4) (f:5 OR (f:6 OR f:7))',

+ 18 - 18
tests/app/Models/UserQueryTest.php

@@ -6,13 +6,13 @@ declare(strict_types=1);
  */
 class UserQueryTest extends PHPUnit\Framework\TestCase {
 
-	public function test__construct_whenAllQuery_storesAllParameters(): void {
+	public static function test__construct_whenAllQuery_storesAllParameters(): void {
 		$query = array('get' => 'a');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertEquals('all', $user_query->getGetType());
 	}
 
-	public function test__construct_whenFavoriteQuery_storesFavoriteParameters(): void {
+	public static function test__construct_whenFavoriteQuery_storesFavoriteParameters(): void {
 		$query = array('get' => 's');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertEquals('favorite', $user_query->getGetType());
@@ -56,47 +56,47 @@ class UserQueryTest extends PHPUnit\Framework\TestCase {
 		self::assertEquals('feed', $user_query->getGetType());
 	}
 
-	public function test__construct_whenUnknownQuery_doesStoreParameters(): void {
+	public static function test__construct_whenUnknownQuery_doesStoreParameters(): void {
 		$query = array('get' => 'q');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertEmpty($user_query->getGetName());
 		self::assertEmpty($user_query->getGetType());
 	}
 
-	public function test__construct_whenName_storesName(): void {
+	public static function test__construct_whenName_storesName(): void {
 		$name = 'some name';
 		$query = array('name' => $name);
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertEquals($name, $user_query->getName());
 	}
 
-	public function test__construct_whenOrder_storesOrder(): void {
+	public static function test__construct_whenOrder_storesOrder(): void {
 		$order = 'some order';
 		$query = array('order' => $order);
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertEquals($order, $user_query->getOrder());
 	}
 
-	public function test__construct_whenState_storesState(): void {
+	public static function test__construct_whenState_storesState(): void {
 		$state = FreshRSS_Entry::STATE_NOT_READ | FreshRSS_Entry::STATE_FAVORITE;
 		$query = array('state' => $state);
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertEquals($state, $user_query->getState());
 	}
 
-	public function test__construct_whenUrl_storesUrl(): void {
+	public static function test__construct_whenUrl_storesUrl(): void {
 		$url = 'some url';
 		$query = array('url' => $url);
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertEquals($url, $user_query->getUrl());
 	}
 
-	public function testToArray_whenNoData_returnsEmptyArray(): void {
+	public static function testToArray_whenNoData_returnsEmptyArray(): void {
 		$user_query = new FreshRSS_UserQuery([], [], []);
 		self::assertCount(0, $user_query->toArray());
 	}
 
-	public function testToArray_whenData_returnsArray(): void {
+	public static function testToArray_whenData_returnsArray(): void {
 		$query = array(
 			'get' => 's',
 			'name' => 'some name',
@@ -110,7 +110,7 @@ class UserQueryTest extends PHPUnit\Framework\TestCase {
 		self::assertEquals($query, $user_query->toArray());
 	}
 
-	public function testHasSearch_whenSearch_returnsTrue(): void {
+	public static function testHasSearch_whenSearch_returnsTrue(): void {
 		$query = array(
 			'search' => 'some search',
 		);
@@ -118,24 +118,24 @@ class UserQueryTest extends PHPUnit\Framework\TestCase {
 		self::assertTrue($user_query->hasSearch());
 	}
 
-	public function testHasSearch_whenNoSearch_returnsFalse(): void {
+	public static function testHasSearch_whenNoSearch_returnsFalse(): void {
 		$user_query = new FreshRSS_UserQuery([], [], []);
 		self::assertFalse($user_query->hasSearch());
 	}
 
-	public function testHasParameters_whenAllQuery_returnsFalse(): void {
+	public static function testHasParameters_whenAllQuery_returnsFalse(): void {
 		$query = array('get' => 'a');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertFalse($user_query->hasParameters());
 	}
 
-	public function testHasParameters_whenNoParameter_returnsFalse(): void {
+	public static function testHasParameters_whenNoParameter_returnsFalse(): void {
 		$query = array();
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertFalse($user_query->hasParameters());
 	}
 
-	public function testHasParameters_whenParameter_returnTrue(): void {
+	public static function testHasParameters_whenParameter_returnTrue(): void {
 		$query = array('get' => 's');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertTrue($user_query->hasParameters());
@@ -153,7 +153,7 @@ class UserQueryTest extends PHPUnit\Framework\TestCase {
 		self::assertFalse($user_query->isDeprecated());
 	}
 
-	public function testIsDeprecated_whenCategoryDoesNotExist_returnTrue(): void {
+	public static function testIsDeprecated_whenCategoryDoesNotExist_returnTrue(): void {
 		$query = array('get' => 'c_1');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertTrue($user_query->isDeprecated());
@@ -193,19 +193,19 @@ class UserQueryTest extends PHPUnit\Framework\TestCase {
 		self::assertTrue($user_query->isDeprecated());
 	}
 
-	public function testIsDeprecated_whenAllQuery_returnFalse(): void {
+	public static function testIsDeprecated_whenAllQuery_returnFalse(): void {
 		$query = array('get' => 'a');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertFalse($user_query->isDeprecated());
 	}
 
-	public function testIsDeprecated_whenFavoriteQuery_returnFalse(): void {
+	public static function testIsDeprecated_whenFavoriteQuery_returnFalse(): void {
 		$query = array('get' => 's');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertFalse($user_query->isDeprecated());
 	}
 
-	public function testIsDeprecated_whenUnknownQuery_returnFalse(): void {
+	public static function testIsDeprecated_whenUnknownQuery_returnFalse(): void {
 		$query = array('get' => 'q');
 		$user_query = new FreshRSS_UserQuery($query, [], []);
 		self::assertFalse($user_query->isDeprecated());

+ 5 - 3
tests/app/Utils/dotNotationUtilTest.php

@@ -1,12 +1,14 @@
 <?php
 declare(strict_types=1);
 
+use PHPUnit\Framework\Attributes\DataProvider;
+
 class dotNotationUtilTest extends PHPUnit\Framework\TestCase {
 
 	/**
 	 * @return Traversable<array{array<string,mixed>,string,string}>
 	 */
-	public function provideJsonDots(): Traversable {
+	public static function provideJsonDots(): Traversable {
 		$json = <<<json
 		{
 			"hello": "world",
@@ -34,10 +36,10 @@ class dotNotationUtilTest extends PHPUnit\Framework\TestCase {
 	}
 
 	/**
-	 * @dataProvider provideJsonDots
 	 * @param array<string,mixed> $array
 	 */
-	public function testJsonDots(array $array, string $key, string $expected): void {
+	#[DataProvider('provideJsonDots')]
+	public static function testJsonDots(array $array, string $key, string $expected): void {
 		$value = FreshRSS_dotNotation_Util::get($array, $key);
 		self::assertEquals($expected, $value);
 	}

+ 52 - 77
tests/cli/CliOptionsParserTest.php

@@ -52,163 +52,138 @@ final class CliOptionsOptionalAndRequiredTest extends CliOptionsParser {
 
 class CliOptionsParserTest extends TestCase {
 
-	public function testInvalidOptionSetWithValueReturnsError(): void {
-		$result = $this->runOptionalOptions('--invalid=invalid');
-
+	public static function testInvalidOptionSetWithValueReturnsError(): void {
+		$result = self::runOptionalOptions('--invalid=invalid');
 		self::assertEquals(['invalid' => 'unknown option: invalid'], $result->errors);
 	}
 
-	public function testInvalidOptionSetWithoutValueReturnsError(): void {
-		$result = $this->runOptionalOptions('--invalid');
-
+	public static function testInvalidOptionSetWithoutValueReturnsError(): void {
+		$result = self::runOptionalOptions('--invalid');
 		self::assertEquals(['invalid' => 'unknown option: invalid'], $result->errors);
 	}
 
-	public function testValidOptionSetWithValidValueAndInvalidOptionSetWithValueReturnsValueForValidOptionAndErrorForInvalidOption(): void {
-		$result = $this->runOptionalOptions('--string=string --invalid=invalid');
-
+	public static function testValidOptionSetWithValidValueAndInvalidOptionSetWithValueReturnsValueForValidOptionAndErrorForInvalidOption(): void {
+		$result = self::runOptionalOptions('--string=string --invalid=invalid');
 		self::assertEquals('string', $result->string);
 		self::assertEquals(['invalid' => 'unknown option: invalid'], $result->errors);
 	}
 
-	public function testOptionWithValueTypeOfStringSetOnceWithValidValueReturnsValueAsString(): void {
-		$result = $this->runOptionalOptions('--string=string');
-
+	public static function testOptionWithValueTypeOfStringSetOnceWithValidValueReturnsValueAsString(): void {
+		$result = self::runOptionalOptions('--string=string');
 		self::assertEquals('string', $result->string);
 	}
 
-	public function testOptionWithRequiredValueTypeOfIntSetOnceWithValidValueReturnsValueAsInt(): void {
-		$result = $this->runOptionalOptions('--int=111');
-
+	public static function testOptionWithRequiredValueTypeOfIntSetOnceWithValidValueReturnsValueAsInt(): void {
+		$result = self::runOptionalOptions('--int=111');
 		self::assertEquals(111, $result->int);
 	}
 
-	public function testOptionWithRequiredValueTypeOfBoolSetOnceWithValidValueReturnsValueAsBool(): void {
-		$result = $this->runOptionalOptions('--bool=on');
-
+	public static function testOptionWithRequiredValueTypeOfBoolSetOnceWithValidValueReturnsValueAsBool(): void {
+		$result = self::runOptionalOptions('--bool=on');
 		self::assertEquals(true, $result->bool);
 	}
 
-	public function testOptionWithValueTypeOfArrayOfStringSetOnceWithValidValueReturnsValueAsArrayOfString(): void {
-		$result = $this->runOptionalOptions('--array-of-string=string');
-
+	public static function testOptionWithValueTypeOfArrayOfStringSetOnceWithValidValueReturnsValueAsArrayOfString(): void {
+		$result = self::runOptionalOptions('--array-of-string=string');
 		self::assertEquals(['string'], $result->arrayOfString);
 	}
 
-	public function testOptionWithValueTypeOfStringSetMultipleTimesWithValidValueReturnsLastValueSetAsString(): void {
-		$result = $this->runOptionalOptions('--string=first --string=second');
-
+	public static function testOptionWithValueTypeOfStringSetMultipleTimesWithValidValueReturnsLastValueSetAsString(): void {
+		$result = self::runOptionalOptions('--string=first --string=second');
 		self::assertEquals('second', $result->string);
 	}
 
-	public function testOptionWithValueTypeOfIntSetMultipleTimesWithValidValueReturnsLastValueSetAsInt(): void {
-		$result = $this->runOptionalOptions('--int=111 --int=222');
-
+	public static function testOptionWithValueTypeOfIntSetMultipleTimesWithValidValueReturnsLastValueSetAsInt(): void {
+		$result = self::runOptionalOptions('--int=111 --int=222');
 		self::assertEquals(222, $result->int);
 	}
 
-	public function testOptionWithValueTypeOfBoolSetMultipleTimesWithValidValueReturnsLastValueSetAsBool(): void {
-		$result = $this->runOptionalOptions('--bool=on --bool=off');
-
+	public static function testOptionWithValueTypeOfBoolSetMultipleTimesWithValidValueReturnsLastValueSetAsBool(): void {
+		$result = self::runOptionalOptions('--bool=on --bool=off');
 		self::assertEquals(false, $result->bool);
 	}
 
-	public function testOptionWithValueTypeOfArrayOfStringSetMultipleTimesWithValidValueReturnsAllSetValuesAsArrayOfString(): void {
-		$result = $this->runOptionalOptions('--array-of-string=first --array-of-string=second');
-
+	public static function testOptionWithValueTypeOfArrayOfStringSetMultipleTimesWithValidValueReturnsAllSetValuesAsArrayOfString(): void {
+		$result = self::runOptionalOptions('--array-of-string=first --array-of-string=second');
 		self::assertEquals(['first', 'second'], $result->arrayOfString);
 	}
 
-	public function testOptionWithValueTypeOfIntSetWithInvalidValueReturnsAnError(): void {
-		$result = $this->runOptionalOptions('--int=one');
-
+	public static function testOptionWithValueTypeOfIntSetWithInvalidValueReturnsAnError(): void {
+		$result = self::runOptionalOptions('--int=one');
 		self::assertEquals(['int' => 'invalid input: int must be an integer'], $result->errors);
 	}
 
-	public function testOptionWithValueTypeOfBoolSetWithInvalidValuesReturnsAnError(): void {
-		$result = $this->runOptionalOptions('--bool=bad');
-
+	public static function testOptionWithValueTypeOfBoolSetWithInvalidValuesReturnsAnError(): void {
+		$result = self::runOptionalOptions('--bool=bad');
 		self::assertEquals(['bool' => 'invalid input: bool must be a boolean'], $result->errors);
 	}
 
-	public function testOptionWithValueTypeOfIntSetMultipleTimesWithValidAndInvalidValuesReturnsLastValidValueSetAsIntAndError(): void {
-		$result = $this->runOptionalOptions('--int=111 --int=one --int=222 --int=two');
-
+	public static function testOptionWithValueTypeOfIntSetMultipleTimesWithValidAndInvalidValuesReturnsLastValidValueSetAsIntAndError(): void {
+		$result = self::runOptionalOptions('--int=111 --int=one --int=222 --int=two');
 		self::assertEquals(222, $result->int);
 		self::assertEquals(['int' => 'invalid input: int must be an integer'], $result->errors);
 	}
 
-	public function testOptionWithValueTypeOfBoolSetMultipleTimesWithWithValidAndInvalidValuesReturnsLastValidValueSetAsBoolAndError(): void {
-		$result = $this->runOptionalOptions('--bool=on --bool=good --bool=off --bool=bad');
-
+	public static function testOptionWithValueTypeOfBoolSetMultipleTimesWithWithValidAndInvalidValuesReturnsLastValidValueSetAsBoolAndError(): void {
+		$result = self::runOptionalOptions('--bool=on --bool=good --bool=off --bool=bad');
 		self::assertEquals(false, $result->bool);
 		self::assertEquals(['bool' => 'invalid input: bool must be a boolean'], $result->errors);
 	}
 
-	public function testNotSetOptionWithDefaultInputReturnsDefaultInput(): void {
-		$result = $this->runOptionalOptions('');
-
+	public static function testNotSetOptionWithDefaultInputReturnsDefaultInput(): void {
+		$result = self::runOptionalOptions('');
 		self::assertEquals('default', $result->defaultInput);
 	}
 
-	public function testOptionWithDefaultInputSetWithValidValueReturnsCorrectlyTypedValue(): void {
-		$result = $this->runOptionalOptions('--default-input=input');
-
+	public static function testOptionWithDefaultInputSetWithValidValueReturnsCorrectlyTypedValue(): void {
+		$result = self::runOptionalOptions('--default-input=input');
 		self::assertEquals('input', $result->defaultInput);
 	}
 
-	public function testOptionWithOptionalValueSetWithoutValueReturnsEmptyString(): void {
-		$result = $this->runOptionalOptions('--optional-value');
-
+	public static function testOptionWithOptionalValueSetWithoutValueReturnsEmptyString(): void {
+		$result = self::runOptionalOptions('--optional-value');
 		self::assertEquals('', $result->optionalValue);
 	}
 
-	public function testOptionWithOptionalValueDefaultSetWithoutValueReturnsOptionalValueDefault(): void {
-		$result = $this->runOptionalOptions('--optional-value-with-default');
-
+	public static function testOptionWithOptionalValueDefaultSetWithoutValueReturnsOptionalValueDefault(): void {
+		$result = self::runOptionalOptions('--optional-value-with-default');
 		self::assertEquals(true, $result->optionalValueWithDefault);
 	}
 
-	public function testNotSetOptionWithOptionalValueDefaultAndDefaultInputReturnsDefaultInput(): void {
-		$result = $this->runOptionalOptions('');
-
+	public static function testNotSetOptionWithOptionalValueDefaultAndDefaultInputReturnsDefaultInput(): void {
+		$result = self::runOptionalOptions('');
 		self::assertEquals('default', $result->defaultInputAndOptionalValueWithDefault);
 	}
 
-	public function testOptionWithOptionalValueDefaultAndDefaultInputSetWithoutValueReturnsOptionalValueDefault(): void {
-		$result = $this->runOptionalOptions('--default-input-and-optional-value-with-default');
-
+	public static function testOptionWithOptionalValueDefaultAndDefaultInputSetWithoutValueReturnsOptionalValueDefault(): void {
+		$result = self::runOptionalOptions('--default-input-and-optional-value-with-default');
 		self::assertEquals('optional', $result->defaultInputAndOptionalValueWithDefault);
 	}
 
-	public function testRequiredOptionNotSetReturnsError(): void {
-		$result = $this->runOptionalAndRequiredOptions('');
-
+	public static function testRequiredOptionNotSetReturnsError(): void {
+		$result = self::runOptionalAndRequiredOptions('');
 		self::assertEquals(['required' => 'invalid input: required cannot be empty'], $result->errors);
 	}
 
-	public function testOptionSetWithDeprecatedAliasGeneratesDeprecationWarningAndReturnsValue(): void {
-		$result = $this->runCommandReadingStandardError('--deprecated-string=string');
-
+	public static function testOptionSetWithDeprecatedAliasGeneratesDeprecationWarningAndReturnsValue(): void {
+		$result = self::runCommandReadingStandardError('--deprecated-string=string');
 		self::assertEquals('FreshRSS deprecation warning: the CLI option(s): deprecated-string are deprecated ' .
 				'and will be removed in a future release. Use: string instead',
 			$result
 		);
 
-		$result = $this->runOptionalOptions('--deprecated-string=string');
-
+		$result = self::runOptionalOptions('--deprecated-string=string');
 		self::assertEquals('string', $result->string);
 	}
 
-	public function testAlwaysReturnUsageMessageWithUsageInfoForAllOptions(): void {
-		$result = $this->runOptionalAndRequiredOptions('');
-
+	public static function testAlwaysReturnUsageMessageWithUsageInfoForAllOptions(): void {
+		$result = self::runOptionalAndRequiredOptions('');
 		self::assertEquals('Usage: cli-parser-test.php --required=<required> [-s --string=<string>] [-i --int=<int>] [-b --bool=<bool>] [-f --flag]',
 			$result->usage,
 		);
 	}
 
-	private function runOptionalOptions(string $cliOptions = ''): CliOptionsOptionalTest {
+	private static function runOptionalOptions(string $cliOptions = ''): CliOptionsOptionalTest {
 		$command = __DIR__ . '/cli-parser-test.php';
 		$className = CliOptionsOptionalTest::class;
 
@@ -219,7 +194,7 @@ class CliOptionsParserTest extends TestCase {
 		return $result;
 	}
 
-	private function runOptionalAndRequiredOptions(string $cliOptions = ''): CliOptionsOptionalAndRequiredTest {
+	private static function runOptionalAndRequiredOptions(string $cliOptions = ''): CliOptionsOptionalAndRequiredTest {
 		$command = __DIR__ . '/cli-parser-test.php';
 		$className = CliOptionsOptionalAndRequiredTest::class;
 
@@ -230,7 +205,7 @@ class CliOptionsParserTest extends TestCase {
 		return $result;
 	}
 
-	private function runCommandReadingStandardError(string $cliOptions = ''): string {
+	private static function runCommandReadingStandardError(string $cliOptions = ''): string {
 		$command = __DIR__ . '/cli-parser-test.php';
 		$className = CliOptionsOptionalTest::class;
 

+ 1 - 1
tests/cli/i18n/I18nCompletionValidatorTest.php

@@ -42,7 +42,7 @@ class I18nCompletionValidatorTest extends PHPUnit\Framework\TestCase {
 		$validator->displayReport();
 	}
 
-	public function testValidateWhenNoData(): void {
+	public static function testValidateWhenNoData(): void {
 		$validator = new I18nCompletionValidator([], []);
 		self::assertTrue($validator->validate());
 		self::assertEquals('', $validator->displayResult());

+ 1 - 1
tests/cli/i18n/I18nUsageValidatorTest.php

@@ -42,7 +42,7 @@ class I18nUsageValidatorTest extends PHPUnit\Framework\TestCase {
 		$validator->displayReport();
 	}
 
-	public function testValidateWhenNoData(): void {
+	public static function testValidateWhenNoData(): void {
 		$validator = new I18nUsageValidator([], []);
 		self::assertTrue($validator->validate());
 		self::assertEquals('', $validator->displayResult());

+ 9 - 9
tests/cli/i18n/I18nValueTest.php

@@ -3,35 +3,35 @@ declare(strict_types=1);
 require_once __DIR__ . '/../../../cli/i18n/I18nValue.php';
 
 class I18nValueTest extends PHPUnit\Framework\TestCase {
-	public function testConstructorWithoutState(): void {
+	public static function testConstructorWithoutState(): void {
 		$value = new I18nValue('some value');
 		self::assertEquals('some value', $value->getValue());
 		self::assertFalse($value->isIgnore());
 		self::assertFalse($value->isTodo());
 	}
 
-	public function testConstructorWithUnknownState(): void {
+	public static function testConstructorWithUnknownState(): void {
 		$value = new I18nValue('some value -> unknown');
 		self::assertEquals('some value', $value->getValue());
 		self::assertFalse($value->isIgnore());
 		self::assertFalse($value->isTodo());
 	}
 
-	public function testConstructorWithTodoState(): void {
+	public static function testConstructorWithTodoState(): void {
 		$value = new I18nValue('some value -> todo');
 		self::assertEquals('some value', $value->getValue());
 		self::assertFalse($value->isIgnore());
 		self::assertTrue($value->isTodo());
 	}
 
-	public function testConstructorWithIgnoreState(): void {
+	public static function testConstructorWithIgnoreState(): void {
 		$value = new I18nValue('some value -> ignore');
 		self::assertEquals('some value', $value->getValue());
 		self::assertTrue($value->isIgnore());
 		self::assertFalse($value->isTodo());
 	}
 
-	public function testClone(): void {
+	public static function testClone(): void {
 		$value = new I18nValue('some value');
 		$clonedValue = clone $value;
 		self::assertEquals('some value', $value->getValue());
@@ -42,21 +42,21 @@ class I18nValueTest extends PHPUnit\Framework\TestCase {
 		self::assertTrue($clonedValue->isTodo());
 	}
 
-	public function testEqualWhenValueIsIdentical(): void {
+	public static function testEqualWhenValueIsIdentical(): void {
 		$value = new I18nValue('some value');
 		$clonedValue = clone $value;
 		self::assertTrue($value->equal($clonedValue));
 		self::assertTrue($clonedValue->equal($value));
 	}
 
-	public function testEqualWhenValueIsDifferent(): void {
+	public static function testEqualWhenValueIsDifferent(): void {
 		$value = new I18nValue('some value');
 		$otherValue = new I18nValue('some other value');
 		self::assertFalse($value->equal($otherValue));
 		self::assertFalse($otherValue->equal($value));
 	}
 
-	public function testStates(): void {
+	public static function testStates(): void {
 		$reflectionProperty = new ReflectionProperty(I18nValue::class, 'state');
 		$reflectionProperty->setAccessible(true);
 
@@ -74,7 +74,7 @@ class I18nValueTest extends PHPUnit\Framework\TestCase {
 		self::assertEquals('todo', $reflectionProperty->getValue($value));
 	}
 
-	public function testToString(): void {
+	public static function testToString(): void {
 		$value = new I18nValue('some value');
 		self::assertEquals('some value', $value->__toString());
 		$value->markAsTodo();

+ 1 - 1
tests/lib/CssXPath/CssXPathTest.php

@@ -3,7 +3,7 @@ declare(strict_types=1);
 
 class CssXPathTest extends PHPUnit\Framework\TestCase
 {
-	public function testCssXPathTranslatorClassExists(): void {
+	public static function testCssXPathTranslatorClassExists(): void {
 		self::assertTrue(class_exists('Gt\\CssXPath\\Translator'));
 	}
 }

+ 21 - 21
tests/lib/Minz/MigratorTest.php

@@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase;
 
 class MigratorTest extends TestCase
 {
-	public function testAddMigration(): void {
+	public static function testAddMigration(): void {
 		$migrator = new Minz_Migrator();
 
 		$migrator->addMigration('foo', fn() => true);
@@ -15,7 +15,7 @@ class MigratorTest extends TestCase
 		self::assertTrue($result);
 	}
 
-	public function testMigrationsIsSorted(): void {
+	public static function testMigrationsIsSorted(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('2_foo', fn() => true);
 		$migrator->addMigration('10_foo', fn() => true);
@@ -27,7 +27,7 @@ class MigratorTest extends TestCase
 		self::assertSame($expected_versions, array_keys($migrations));
 	}
 
-	public function testSetAppliedVersions(): void {
+	public static function testSetAppliedVersions(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('foo', fn() => true);
 
@@ -36,7 +36,7 @@ class MigratorTest extends TestCase
 		self::assertSame(['foo'], $migrator->appliedVersions());
 	}
 
-	public function testSetAppliedVersionsTrimArgument(): void {
+	public static function testSetAppliedVersionsTrimArgument(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('foo', fn() => true);
 
@@ -54,7 +54,7 @@ class MigratorTest extends TestCase
 		$migrator->setAppliedVersions(['foo']);
 	}
 
-	public function testVersions(): void {
+	public static function testVersions(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('foo', fn() => true);
 		$migrator->addMigration('bar', fn() => true);
@@ -64,7 +64,7 @@ class MigratorTest extends TestCase
 		self::assertSame(['bar', 'foo'], $versions);
 	}
 
-	public function testMigrate(): void {
+	public static function testMigrate(): void {
 		$migrator = new Minz_Migrator();
 		$spy = false;
 		$migrator->addMigration('foo', function () use (&$spy) {
@@ -82,7 +82,7 @@ class MigratorTest extends TestCase
 		], $result);
 	}
 
-	public function testMigrateCallsMigrationsInSortedOrder(): void {
+	public static function testMigrateCallsMigrationsInSortedOrder(): void {
 		$migrator = new Minz_Migrator();
 		$spy_foo_1_is_called = false;
 		$migrator->addMigration('2_foo', function () use (&$spy_foo_1_is_called) {
@@ -102,7 +102,7 @@ class MigratorTest extends TestCase
 		], $result);
 	}
 
-	public function testMigrateDoesNotCallAppliedMigrations(): void {
+	public static function testMigrateDoesNotCallAppliedMigrations(): void {
 		$migrator = new Minz_Migrator();
 		$spy = false;
 		$migrator->addMigration('1_foo', function () use (&$spy) {
@@ -117,7 +117,7 @@ class MigratorTest extends TestCase
 		self::assertSame([], $result);
 	}
 
-	public function testMigrateCallNonAppliedBetweenTwoApplied(): void {
+	public static function testMigrateCallNonAppliedBetweenTwoApplied(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('1_foo', fn() => true);
 		$migrator->addMigration('2_foo', fn() => true);
@@ -132,7 +132,7 @@ class MigratorTest extends TestCase
 		], $result);
 	}
 
-	public function testMigrateWithMigrationReturningFalseDoesNotApplyVersion(): void {
+	public static function testMigrateWithMigrationReturningFalseDoesNotApplyVersion(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('1_foo', fn() => true);
 		$migrator->addMigration('2_foo', fn() => false);
@@ -146,7 +146,7 @@ class MigratorTest extends TestCase
 		], $result);
 	}
 
-	public function testMigrateWithMigrationReturningFalseDoesNotExecuteNextMigrations(): void {
+	public static function testMigrateWithMigrationReturningFalseDoesNotExecuteNextMigrations(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('1_foo', fn() => false);
 		$spy = false;
@@ -164,7 +164,7 @@ class MigratorTest extends TestCase
 		], $result);
 	}
 
-	public function testMigrateWithFailingMigration(): void {
+	public static function testMigrateWithFailingMigration(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('foo', function () {
 			throw new \Exception('Oops, it failed.');
@@ -178,7 +178,7 @@ class MigratorTest extends TestCase
 		], $result);
 	}
 
-	public function testUpToDate(): void {
+	public static function testUpToDate(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('foo', fn() => true);
 		$migrator->setAppliedVersions(['foo']);
@@ -188,7 +188,7 @@ class MigratorTest extends TestCase
 		self::assertTrue($upToDate);
 	}
 
-	public function testUpToDateIfRemainingMigration(): void {
+	public static function testUpToDateIfRemainingMigration(): void {
 		$migrator = new Minz_Migrator();
 		$migrator->addMigration('1_foo', fn() => true);
 		$migrator->addMigration('2_foo', fn() => true);
@@ -199,7 +199,7 @@ class MigratorTest extends TestCase
 		self::assertFalse($upToDate);
 	}
 
-	public function testUpToDateIfNoMigrations(): void {
+	public static function testUpToDateIfNoMigrations(): void {
 		$migrator = new Minz_Migrator();
 
 		$upToDate = $migrator->upToDate();
@@ -207,7 +207,7 @@ class MigratorTest extends TestCase
 		self::assertTrue($upToDate);
 	}
 
-	public function testConstructorLoadsDirectory(): void {
+	public static function testConstructorLoadsDirectory(): void {
 		$migrations_path = TESTS_PATH . '/fixtures/migrations/';
 		$migrator = new Minz_Migrator($migrations_path);
 		$expected_versions = ['2019_12_22_FooBar', '2019_12_23_Baz'];
@@ -217,7 +217,7 @@ class MigratorTest extends TestCase
 		self::assertSame($expected_versions, array_keys($migrations));
 	}
 
-	public function testExecute(): void {
+	public static function testExecute(): void {
 		$migrations_path = TESTS_PATH . '/fixtures/migrations/';
 		$applied_migrations_path = tempnam('/tmp', 'applied_migrations.txt');
 		self::assertIsString($applied_migrations_path);
@@ -229,7 +229,7 @@ class MigratorTest extends TestCase
 		@unlink($applied_migrations_path);
 	}
 
-	public function testExecuteWithAlreadyAppliedMigration(): void {
+	public static function testExecuteWithAlreadyAppliedMigration(): void {
 		$migrations_path = TESTS_PATH . '/fixtures/migrations/';
 		$applied_migrations_path = tempnam('/tmp', 'applied_migrations.txt');
 		self::assertIsString($applied_migrations_path);
@@ -243,7 +243,7 @@ class MigratorTest extends TestCase
 		@unlink($applied_migrations_path);
 	}
 
-	public function testExecuteWithAppliedMigrationInDifferentOrder(): void {
+	public static function testExecuteWithAppliedMigrationInDifferentOrder(): void {
 		$migrations_path = TESTS_PATH . '/fixtures/migrations/';
 		$applied_migrations_path = tempnam('/tmp', 'applied_migrations.txt');
 		self::assertIsString($applied_migrations_path);
@@ -258,7 +258,7 @@ class MigratorTest extends TestCase
 		@unlink($applied_migrations_path);
 	}
 
-	public function testExecuteFailsIfVersionPathDoesNotExist(): void {
+	public static function testExecuteFailsIfVersionPathDoesNotExist(): void {
 		$migrations_path = TESTS_PATH . '/fixtures/migrations/';
 		$applied_migrations_path = tempnam('/tmp', 'applied_migrations.txt');
 		$expected_result = "Cannot open the {$applied_migrations_path} file";
@@ -270,7 +270,7 @@ class MigratorTest extends TestCase
 		@unlink($applied_migrations_path);
 	}
 
-	public function testExecuteFailsIfAMigrationIsFailing(): void {
+	public static function testExecuteFailsIfAMigrationIsFailing(): void {
 		$migrations_path = TESTS_PATH . '/fixtures/migrations_with_failing/';
 		$applied_migrations_path = tempnam('/tmp', 'applied_migrations.txt');
 		$expected_result = 'A migration failed to be applied, please see previous logs.';

+ 1 - 1
tests/lib/PHPMailer/PHPMailerTest.php

@@ -5,7 +5,7 @@ use PHPMailer\PHPMailer\PHPMailer;
 
 class PHPMailerTest extends PHPUnit\Framework\TestCase
 {
-	public function testPHPMailerClassExists(): void {
+	public static function testPHPMailerClassExists(): void {
 		self::assertTrue(class_exists(PHPMailer::class));
 	}
 }

Неке датотеке нису приказане због велике количине промена