diff --git a/nur_src/ref/ref_type.php b/nur_src/ref/ref_type.php index 4beb6c0..117499a 100644 --- a/nur_src/ref/ref_type.php +++ b/nur_src/ref/ref_type.php @@ -3,6 +3,7 @@ namespace nur\ref; use nur\data\types\Metadata; use nur\md; +use nur\sery\php\content\content; /** * Class ref_type: référence des types utilisables dans les schémas diff --git a/src/os/EOFException.php b/src/os/EOFException.php new file mode 100644 index 0000000..2754139 --- /dev/null +++ b/src/os/EOFException.php @@ -0,0 +1,18 @@ +file = $input; + $this->mode = $mode; + $fd = null; + $close = true; + } + parent::__construct($fd, $close, $throwOnError, $allowLocking); + } + + /** @var string */ + protected $file; + + /** @var string */ + protected $mode; + + function getResource() { + if ($this->fd === null && $this->file !== null) { + $this->fd = IOException::ensure_value(@fopen($this->file, $this->mode)); + } + return parent::getResource(); + } +} diff --git a/src/os/file/FileWriter.php b/src/os/file/FileWriter.php new file mode 100644 index 0000000..968d0ea --- /dev/null +++ b/src/os/file/FileWriter.php @@ -0,0 +1,43 @@ +file = $output; + $this->mode = $mode; + $fd = null; + $close = true; + } + parent::__construct($fd, $close, $throwOnError, $allowLocking); + } + + /** @var string */ + protected $file; + + /** @var string */ + protected $mode; + + function getResource() { + if ($this->fd === null && $this->file !== null) { + IOException::ensure_value(sh::mkdirof($this->file)); + $this->fd = IOException::ensure_value(@fopen($this->file, $this->mode)); + } + return parent::getResource(); + } +} diff --git a/src/os/file/IReader.php b/src/os/file/IReader.php new file mode 100644 index 0000000..9a6da2f --- /dev/null +++ b/src/os/file/IReader.php @@ -0,0 +1,52 @@ +fd = $fd; + $this->close = $close; + $this->throwOnError = $throwOnError; + if ($allowLocking === null) $allowLocking = static::ALLOW_LOCKING; + $this->allowLocking = $allowLocking; + } + + ############################################################################# + # File + + /** + * @return resource|null retourner la resource associée à ce fichier si cela + * a du sens + */ + function getResource() { + IOException::ensure_open($this->fd === null); + $this->_streamAppendFilters($this->fd); + return $this->fd; + } + + protected function lock(int $operation, ?int &$wouldBlock=null): bool { + $locked = flock($this->getResource(), $operation, $wouldBlock); + if ($locked) return true; + if ($operation & LOCK_NB) return false; + else throw IOException::error(); + } + + protected function unlock(bool $close=false): void { + if ($this->fd !== null) { + flock($this->fd, LOCK_UN); + if ($close) $this->close(); + } + } + + function isatty(): bool { + return stream_isatty($this->getResource()); + } + + /** obtenir des informations sur le fichier */ + function fstat(bool $reload=false): array { + if ($this->stat === null || $reload) { + $fd = $this->getResource(); + $this->stat = IOException::ensure_value(fstat($fd), $this->throwOnError); + } + return $this->stat; + } + + /** @throws IOException */ + function ftell(): int { + $fd = $this->getResource(); + return IOException::ensure_value(ftell($fd), $this->throwOnError); + } + + /** + * @return int la position après avoir déplacé le pointeur + * @throws IOException + */ + function fseek(int $offset, int $whence=SEEK_SET): int { + $fd = $this->getResource(); + IOException::ensure_value(fseek($fd, $offset, $whence), $this->throwOnError, -1); + return $this->ftell(); + } + + /** fermer le fichier si c'est nécessaire */ + function close(bool $close=true, ?int $ifSerial=null): void { + AbstractIterator::rewind(); + if ($this->fd !== null && $close && $this->close && ($ifSerial === null || $this->serial === $ifSerial)) { + fclose($this->fd); + $this->fd = null; + } + } + + ############################################################################# + # Reader + + /** @throws IOException */ + function fread(int $length): string { + $fd = $this->getResource(); + return IOException::ensure_value(fread($fd, $length), $this->throwOnError); + } + + /** @throws IOException */ + function fgets(?int $length=null): string { + $fd = $this->getResource(); + return EOFException::ensure_not_eof(fgets($fd, $length), $this->throwOnError); + } + + /** @throws IOException */ + function fpassthru(): int { + $fd = $this->getResource(); + return IOException::ensure_value(fpassthru($fd), $this->throwOnError); + } + + function readLine(): ?string { + return str::strip_nl($this->fgets()); + } + + /** + * essayer de verrouiller le fichier en lecture. retourner true si l'opération + * réussit. dans ce cas, il faut appeler {@link getReader()} avec l'argument + * true + */ + function canRead(): bool { + if ($this->allowLocking) return $this->lock(LOCK_SH + LOCK_NB); + else return true; + } + + /** + * verrouiller en mode partagé puis retourner un objet permettant de lire le + * fichier. + */ + function getReader(bool $lockedByCanRead=false): IReader { + if ($this->allowLocking && !$lockedByCanRead) $this->lock(LOCK_SH); + return new class($this->fd, ++$this->serial, $this) extends Stream { + function __construct($fd, int $serial, Stream $parent) { + $this->parent = $parent; + parent::__construct($fd); + } + + /** @var Stream */ + private $parent; + + function close(bool $close=true): void { + if ($this->parent !== null && $close) { + $this->parent->close(true, $this->serial); + $this->fd = null; + $this->parent = null; + } + } + }; + } + + /** retourner le contenu du fichier sous forme de chaine */ + function getContents(bool $close=true, bool $lockedByCanRead=false): string { + $allowLocking = $this->allowLocking; + if ($allowLocking && !$lockedByCanRead) $this->lock(LOCK_SH); + try { + return IOException::ensure_value(stream_get_contents($this->fd), $this->throwOnError); + } finally { + if ($allowLocking) $this->unlock($close); + elseif ($close) $this->close(); + } + } + + function unserialize(?array $options=null, bool $close=true, bool $lockedByCanRead=false) { + $args = [$this->getContents($lockedByCanRead)]; + if ($options !== null) $args[] = $options; + return unserialize(...$args); + } + + ############################################################################# + # Iterator + + protected function _setup(): void { + } + + protected function _next(&$key) { + return $this->fgets(); + } + + protected function _teardown(): void { + $this->fseek(0); + } + + ############################################################################# + # Writer + + /** @throws IOException */ + function fwrite(string $data, ?int $length=null): int { + $fd = $this->getResource(); + if ($length === null) $r = fwrite($fd, $data); + else $r = fwrite($fd, $data, $length); + return IOException::ensure_value($r, $this->throwOnError); + } + + /** @throws IOException */ + function fflush(): void { + $fd = $this->getResource(); + IOException::ensure_value(fflush($fd), $this->throwOnError); + } + + /** @throws IOException */ + function ftruncate(int $size): void { + $fd = $this->getResource(); + IOException::ensure_value(ftruncate($fd, $size), $this->throwOnError); + } + + function writeLines(?iterable $lines): IWriter { + if ($lines !== null) { + foreach ($lines as $line) { + $this->fwrite($line); + $this->fwrite("\n"); + } + } + return $this; + } + + /** + * essayer de verrouiller le fichier en écriture. retourner true si l'opération + * réussit. dans ce cas, il faut appeler {@link getWriter()} avec l'argument + * true + */ + function canWrite(): bool { + if ($this->allowLocking) return $this->lock(LOCK_EX + LOCK_NB); + else return true; + } + + /** + * verrouiller en mode exclusif puis retourner un objet permettant d'écrire + * dans le fichier + */ + function getWriter(bool $lockedByCanWrite=false): IWriter { + if ($this->allowLocking && !$lockedByCanWrite) $this->lock(LOCK_EX); + return new class($this->fd, ++$this->serial, $this) extends Stream { + function __construct($fd, int $serial, Stream $parent) { + $this->parent = $parent; + $this->serial = $serial; + parent::__construct($fd); + } + + /** @var Stream */ + private $parent; + + function close(bool $close=true): void { + if ($this->parent !== null && $close) { + $this->parent->close(true, $this->serial); + $this->fd = null; + $this->parent = null; + } + } + }; + } + + function putContents(string $contents, bool $close=true, bool $lockedByCanWrite=false): void { + $allowLocking = $this->allowLocking; + if ($allowLocking && !$lockedByCanWrite) $this->lock(LOCK_EX); + try { + $this->fwrite($contents); + } finally { + if ($allowLocking) $this->unlock($close); + elseif ($close) $this->close(); + } + } + + function serialize($object, bool $close=true, bool $lockedByCanWrite=false): void { + $this->putContents(serialize($object), $lockedByCanWrite); + } +} diff --git a/src/os/file/TStreamFilter.php b/src/os/file/TStreamFilter.php new file mode 100644 index 0000000..36a9c45 --- /dev/null +++ b/src/os/file/TStreamFilter.php @@ -0,0 +1,49 @@ +filters, [$filterName, $readWrite, $params]); + } + + function prependFilter(string $filterName, ?int $readWrite=null, $params=null): void { + A::prepend($this->filters, [$filterName, $readWrite, $params]); + } + + function setEncodingFilter(string $from, string $to): void { + if ($to !== $from) { + $this->appendFilter("convert.iconv.$from.$to"); + } + } + + /** + * @param $fd resource + * @throws IOException + */ + protected function _streamAppendFilters($fd): void { + if ($this->filters !== null) { + foreach ($this->filters as [$filterName, $readWrite, $params]) { + if (stream_filter_append($fd, $filterName, $readWrite, $params) === false) { + throw new IOException("unable to add filter $filterName"); + } + } + $this->filters = null; + } + } + + /** + * @param $file _IFile + */ + protected function _appendFilters($file): void { + if ($this->filters !== null) { + foreach ($this->filters as [$filterName, $readWrite, $params]) { + $file->appendFilter($filterName, $readWrite, $params); + } + } + } +} diff --git a/src/os/file/TmpfileWriter.php b/src/os/file/TmpfileWriter.php new file mode 100644 index 0000000..775263b --- /dev/null +++ b/src/os/file/TmpfileWriter.php @@ -0,0 +1,95 @@ +delete = true; + } elseif (is_file($destdir)) { + # si on spécifie un fichier qui existe le prendre comme "fichier + # temporaire" mais ne pas le supprimer automatiquement + $file = $destdir; + $this->delete = false; + } else { + # un chemin qui n'existe pas: ne le sélectionner que si le répertoire + # existe. dans ce cas, le fichier sera créé automatiquement, mais pas + # supprimé + if (!is_dir(dirname($destdir))) { + throw new IOException("$destdir: no such file or directory"); + } + $file = $destdir; + $this->delete = false; + } + parent::__construct($file, $mode, $throwOnError, $allowLocking); + } + + /** @var bool */ + protected $delete; + + function __destruct() { + $this->close(); + if ($this->delete) $this->delete(); + } + + /** supprimer le fichier. NB: le flux **n'est pas** fermé au préalable */ + function delete(): self { + if (file_exists($this->file)) unlink($this->file); + return $this; + } + + /** + * renommer le fichier. le flux est fermé d'abord + * + * @param int|null $defaultMode mode par défaut si le fichier destination + * n'existe pas. sinon, changer le mode du fichier temporaire à la valeur du + * fichier destination après renommage + * @param bool $setOwner si le propriétaire et/ou le groupe du fichier + * temporaire ne sont pas les mêmes que le fichier destination, tenter de + * changer le propriétaire et le groupe du fichier temporaire à la valeur + * du fichier destination après le renommage (nécessite les droits de root) + * @throws IOException + */ + function rename(string $dest, ?int $defaultMode=0644, bool $setOwner=true): void { + $this->close(); + $file = $this->file; + if (file_exists($dest)) { + $mode = fileperms($dest); + if ($setOwner) { + $tmpowner = fileowner($file); + $owner = fileowner($dest); + $tmpgroup = filegroup($file); + $group = filegroup($dest); + } else { + $owner = $group = null; + } + } else { + $mode = $defaultMode; + $owner = $group = null; + } + if (!rename($file, $dest)) { + throw new IOException("$file: unable to rename to $dest"); + } + $this->file = $dest; + if ($mode !== null) chmod($dest, $mode); + if ($owner !== null) { + if ($owner !== $tmpowner) chown($dest, $owner); + if ($group !== $tmpgroup) chgrp($dest, $group); + } + if ($mode !== null || $owner !== null) clearstatcache(true, $file); + } + + /** streamer le contenu du fichier en sortie */ + function readfile(?string $contentType=null, ?string $charset=null, ?string $filename=null, string $disposition=null): bool { + if ($contentType !== null) http::content_type($contentType, $charset); + if ($filename !== null) http::download_as($filename, $disposition); + return readfile($this->file) !== false; + } +} diff --git a/src/os/file/_IFile.php b/src/os/file/_IFile.php new file mode 100644 index 0000000..d9c842a --- /dev/null +++ b/src/os/file/_IFile.php @@ -0,0 +1,50 @@ + $part) { + $key = self::_quote($key); + $val = self::_quote($part); + $parts[] = "[$key]=$val"; + } + } + return "(".implode(" ", $parts).")"; + } else { + return self::_quote(strval($value)); + } + } + + /** + * obtenir une commande shell à partir du tableau des arguments. + * à utiliser avec exec() + */ + static final function join(array $parts): string { + $count = count($parts); + for($i = 0; $i < $count; $i++) { + $parts[$i] = self::_quote(strval($parts[$i])); + } + return implode(" ", $parts); + } + + private static final function add_redir(string &$cmd, ?string $redir, ?string $input, ?string $output): void { + if ($redir !== null) { + switch ($redir) { + case "outonly": + case "noerr": + $redir = "2>/dev/null"; + break; + case "erronly": + case "noout": + $redir = "2>&1 >/dev/null"; + break; + case "both": + case "err2out": + $redir = "2>&1"; + break; + case "none": + case "null": + $redir = ">/dev/null 2>&1"; + break; + case "default": + $redir = null; + break; + } + } + if ($input !== null) { + $redir = $redir !== null? "$redir ": ""; + $redir .= "<".escapeshellarg($input); + } + if ($output !== null) { + $redir = $redir !== null? "$redir ": ""; + $redir .= ">".escapeshellarg($output); + } + if ($redir !== null) $cmd .= " $redir"; + } + + /** + * Corriger la commande $cmd: + * - si c'est tableau, joindre les arguments avec {@link join()} + * - sinon, mettre les caractères en échappement avec {@link escapeshellarg()} + */ + static final function fix_cmd(&$cmd, ?string $redir=null, ?string $input=null, ?string $output=null): void { + if (is_array($cmd)) $cmd = self::join($cmd); + else $cmd = escapeshellcmd(strval($cmd)); + self::add_redir($cmd, $redir, $input, $output); + } + + /** + * Lancer la commande spécifiée avec passthru() et retourner le code de retour + * dans la variable $retcode. $cmd doit déjà être formaté comme il convient + * + * voici la différence entre system(), passthru() et exec() + * +----------------+-----------------+----------------+----------------+ + * | Command | Displays Output | Can Get Output | Gets Exit Code | + * +----------------+-----------------+----------------+----------------+ + * | passthru() | Yes (raw) | No | Yes | + * | system() | Yes (as text) | Last line only | Yes | + * | exec() | No | Yes (array) | Yes | + * +----------------+-----------------+----------------+----------------+ + * + * @return bool true si la commande s'est lancée sans erreur, false sinon + */ + static final function _passthru(string $cmd, int &$retcode=null): bool { + passthru($cmd, $retcode); + return $retcode == 0; + } + + /** + * Comme {@link _passthru()} mais lancer la commande spécifiée avec system(). + * cf la doc de {@link _passthru()} pour les autres détails + */ + static final function _system(string $cmd, string &$output=null, int &$retcode=null): bool { + $last_line = system($cmd, $retcode); + if ($last_line !== false) $output = $last_line; + return $retcode == 0; + } + + /** + * Comme {@link _passthru()} mais lancer la commande spécifiée avec exec(). + * cf la doc de {@link _passthru()} pour les autres détails + */ + static final function _exec(string $cmd, array &$output=null, int &$retcode=null): bool { + exec($cmd, $output, $retcode); + return $retcode == 0; + } + + /** + * Lancer la commande $cmd dans un processus fils via un shell et attendre la + * fin de son exécution. + * + * $cmd doit déjà être formaté comme il convient + */ + static final function _fork_exec(string $cmd, int &$retcode=null): bool { + $pid = pcntl_fork(); + if ($pid == -1) { + // parent, impossible de forker + throw new RuntimeException("unable to fork"); + } elseif ($pid) { + // parent, fork ok + pcntl_waitpid($pid, $status); + if (pcntl_wifexited($status)) { + $retcode = pcntl_wexitstatus($status); + } else { + $retcode = 127; + } + return $retcode == 0; + } + // child, fork ok + pcntl_exec("/bin/sh", ["-c", $cmd]); + return false; + } + + /** + * Corriger la commande spécifiée avec {@link fix_cmd()} puis la lancer + * avec passthru() et retourner le code de retour dans la variable $retcode + * + * $redir spécifie le type de redirection demandée: + * - "default" | null: $output reçoit STDOUT et STDERR n'est pas redirigé + * - "outonly" | "noerr": $output ne reçoit que STDOUT et STDERR est perdu + * - "erronly" | "noout": $output ne reçoit que STDERR et STDOUT est perdu + * - "both" | "err2out": $output reçoit STDOUT et STDERR + * - "none" | "null": STDOUT et STDERR sont perdus + * - sinon c'est une redirection spécifique, et la valeur est rajoutée telle + * quelle à la ligne de commande + * + * @return bool true si la commande s'est lancée sans erreur, false sinon + */ + static final function passthru($cmd, int &$retcode=null, ?string $redir=null): bool { + self::fix_cmd($cmd, $redir); + return self::_passthru($cmd, $retcode); + } + + /** + * Comme {@link passthru()} mais lancer la commande spécifiée avec system(). + * Cf la doc de {@link passthru()} pour les autres détails + */ + static final function system($cmd, string &$output=null, int &$retcode=null, ?string $redir=null): bool { + self::fix_cmd($cmd, $redir); + return self::_system($cmd, $output, $retcode); + } + + /** + * Comme {@link passthru()} mais lancer la commande spécifiée avec exec(). + * Cf la doc de {@link passthru()} pour les autres détails + */ + static final function exec($cmd, array &$output=null, int &$retcode=null, ?string $redir=null): bool { + self::fix_cmd($cmd, $redir); + return self::_exec($cmd, $output, $retcode); + } + + /** + * Corriger la commande spécifiée avec {@link fix_cmd()}, la préfixer de + * "exec" puis la lancer avec {@link _fork_exec()} + */ + static final function fork_exec($cmd, int &$retcode=null, ?string $redir=null): bool { + self::fix_cmd($cmd, $redir); + return self::_fork_exec("exec $cmd", $retcode); + } + + ############################################################################# + + /** retourner le répertoire $HOME */ + static final function homedir(): string { + $homedir = getenv("HOME"); + if ($homedir === false) { + $homedir = posix_getpwuid(posix_getuid())["dir"]; + } + return path::abspath($homedir); + } + + /** s'assurer que le répertoire $dir existe */ + static final function mkdirp(string $dir): bool { + if (is_dir($dir)) return true; + return mkdir($dir, 0777, true); + } + + /** créer le répertoire qui va contenir le fichier $file */ + static final function mkdirof(string $file): bool { + if (file_exists($file)) return true; + $dir = path::dirname($file); + if (file_exists($dir)) return true; + return mkdir($dir, 0777, true); + } + + /** + * créer un répertoire avec un nom unique. ce répertoire doit être supprimé + * manuellement quand il n'est plus utilisé. + * + * @return string le chemin du répertoire + * @throws IOException si une erreur se produit (impossible de créer un + * répertoire unique après 2560 essais) + */ + static final function mktempdir(?string $prefix=null, ?string $basedir=null): string { + if ($basedir === null) $basedir = sys_get_temp_dir(); + if ($prefix !== null) $prefix .= "-"; + $max = 2560; + do { + $dir = "$basedir/$prefix".uniqid(); + $r = @mkdir($dir); + $max--; + } while ($r === false && $max > 0); + if ($r === false) { + throw IOException::last_error("$dir: unable to create directory"); + } + return $dir; + } + + /** + * Supprimer un répertoire créé avec mktempdir + * + * un minimum de vérification est effectué qu'il s'agit bien d'un répertoire + * généré par mktempdir + */ + static final function rmtempdir(string $tmpdir, ?string $prefix=null, ?string $basedir=null): void { + if ($basedir === null) $basedir = sys_get_temp_dir(); + if ($prefix !== null) $prefix .= "-"; + // 13 '?' parce que c'est la taille d'une chaine générée par uniqid() + if (fnmatch("$basedir/$prefix?????????????", $tmpdir)) { + self::exec(["rm", "-rf", $tmpdir]); + } else { + throw new IOException("$tmpdir: n'est pas un répertoire temporaire"); + } + } + + /** + * supprimer tous les répertoires temporaires qui ont été créés avec le + * suffixe spécifié dans le répertoire $basedir + */ + static final function cleantempdirs(string $prefix, ?string $basedir=null): void { + if ($basedir === null) $basedir = sys_get_temp_dir(); + $prefix .= "-"; + // 13 '?' parce que c'est la taille d'une chaine générée par uniqid() + $tmpdirs = glob("$basedir/$prefix?????????????", GLOB_ONLYDIR); + if ($tmpdirs) { + self::exec(["rm", "-rf", ...$tmpdirs]); + } + } + + static final function is_diff_file(string $f, string $g): bool { + if (!is_file($f) || !is_file($g)) return true; + self::exec(array("diff", "-q", $f, $g), $output, $retcode); + return $retcode !== 0; + } + + static final function is_same_file(string $f, string $g): bool { + if (!is_file($f) || !is_file($g)) return false; + self::exec(array("diff", "-q", $f, $g), $output, $retcode); + return $retcode === 0; + } + + static final function is_diff_link(string $f, string $g): bool { + if (!is_link($f) || !is_link($g)) return true; + return @readlink($f) !== @readlink($g); + } + + static final function is_same_link(string $f, string $g): bool { + if (!is_link($f) || !is_link($g)) return false; + return @readlink($f) === @readlink($g); + } +} diff --git a/src/output/std/StdOutput.php b/src/output/std/StdOutput.php index 98ad43b..72cffc8 100644 --- a/src/output/std/StdOutput.php +++ b/src/output/std/StdOutput.php @@ -3,9 +3,10 @@ namespace nur\sery\output\std; use Exception; use nulib\cl; -use nur\sery\sys\content; -use nur\sery\sys\IContent; -use nur\sery\sys\IPrintable; +use nur\sery\os\file\Stream; +use nur\sery\php\content\content; +use nur\sery\php\content\IContent; +use nur\sery\php\content\IPrintable; /** * Class StdOutput: affichage sur STDOUT, STDERR ou dans un fichier quelconque @@ -87,6 +88,7 @@ class StdOutput { $indent = cl::get($params, "indent"); $flush = cl::get($params, "flush"); + if ($output instanceof Stream) $output = $output->getResource(); if ($output !== null) { if ($output === "php://stdout") { $outf = STDOUT; diff --git a/src/php/ICloseable.php b/src/php/ICloseable.php new file mode 100644 index 0000000..5973b8a --- /dev/null +++ b/src/php/ICloseable.php @@ -0,0 +1,10 @@ +rewind(); + } + + ############################################################################# + # Implémentation par défaut + + private $setup = false; + private $valid = false; + private $toredown = true; + + private $index = 0; + protected $key; + protected $item = null; + + function key() { + return $this->key; + } + + function current() { + return $this->item; + } + + function next(): void { + if ($this->toredown) return; + $this->valid = false; + try { + $item = $this->_next($key); + } catch (EOFException $e) { + $this->beforeClose(); + try { + $this->_teardown(); + } catch (Exception $e) { + } + $this->toredown = true; + return; + } + $this->cook($item); + $this->item = $item; + if ($key !== null) { + $this->key = $key; + } else { + $this->index++; + $this->key = $this->index; + } + $this->valid = true; + } + + function rewind(): void { + if ($this->setup) { + if (!$this->toredown) { + $this->beforeClose(); + try { + $this->_teardown(); + } catch (Exception $e) { + } + } + $this->setup = false; + $this->valid = false; + $this->toredown = true; + $this->index = 0; + $this->key = null; + $this->item = null; + } + } + + function valid(): bool { + if (!$this->setup) { + try { + $this->_setup(); + } catch (Exception $e) { + } + $this->setup = true; + $this->toredown = false; + $this->beforeIter(); + $this->next(); + } + return $this->valid; + } +} diff --git a/src/php/json/JsonException.php b/src/php/json/JsonException.php new file mode 100644 index 0000000..de6f0b1 --- /dev/null +++ b/src/php/json/JsonException.php @@ -0,0 +1,20 @@ +