diff --git a/.phan/config.php b/.phan/config.php new file mode 100644 index 0000000..a95647b --- /dev/null +++ b/.phan/config.php @@ -0,0 +1,10 @@ +=7.2.9" }, "require-dev": { "mediawiki/mediawiki-codesniffer": "38.0.0", + "mediawiki/mediawiki-phan-config": "0.11.1", "mediawiki/minus-x": "1.1.1", "ockcyp/covers-validator": "1.3.3", "php-parallel-lint/php-console-highlighter": "0.5.0", "php-parallel-lint/php-parallel-lint": "1.3.1", "phpmd/phpmd": "~2.3", "phpunit/phpunit": "^8.5" }, "autoload": { "psr-4": { "Wikimedia\\Purtle\\": "src/", "Wikimedia\\Purtle\\Tests\\": "tests/phpunit/" } }, "extra": { "branch-alias": { "dev-master": "1.0.x-dev" } }, "scripts": { "test": [ "composer validate --no-interaction", "parallel-lint . --exclude vendor", "phpunit", "covers-validator", + "phan --allow-polyfill-parser --long-progress-bar", "minus-x check .", "composer cs" ], "cs": [ "phpcs -p -s", "phpmd src/ text phpmd.xml" ], "ci": [ "@cs", "@test" ], "fix": [ "minus-x fix .", "phpcbf" ], + "phan": "phan --allow-polyfill-parser --long-progress-bar", "phpcs": "phpcs -sp --cache" } } diff --git a/src/JsonLdRdfWriter.php b/src/JsonLdRdfWriter.php index 216d90d..a844718 100644 --- a/src/JsonLdRdfWriter.php +++ b/src/JsonLdRdfWriter.php @@ -1,484 +1,487 @@ graph to null in * #finishJson() to ensure that the deferred callback in #finishDocument() * doesn't later emit "@graph". * * @see https://www.w3.org/TR/json-ld/#named-graphs * * @var array[]|null */ private $graph = []; /** * A collection of predicates about a specific subject. The * subject is identified by the "@id" key in this array; the other * keys identify JSON-LD properties. * * @see https://www.w3.org/TR/json-ld/#dfn-edge * * @var array */ private $predicates = []; /** * A sequence of zero or more IRIs, nodes, or values, which are the * destination targets of the current predicates. * * @see https://www.w3.org/TR/json-ld/#dfn-list * * @var array */ private $values = []; /** * True iff we have written the opening of the "@graph" field. * * @var bool */ private $wroteGraph = false; /** * JSON-LD objects describing a single node can omit the "@graph" field; * this variable remains false only so long as we can guarantee that * only a single node has been described. * * @var bool */ private $disableGraphOpt = false; /** * The IRI for the RDF `type` property. */ private const RDF_TYPE_IRI = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; /** * The type internally used for "default type", which is a string or * otherwise default-coerced type. */ private const DEFAULT_TYPE = '@purtle@default@'; /** * @param string $role * @param BNodeLabeler|null $labeler */ public function __construct( $role = parent::DOCUMENT_ROLE, BNodeLabeler $labeler = null ) { parent::__construct( $role, $labeler ); // The following named methods are protected, not private, so we // can invoke them directly w/o function wrappers. $this->transitionTable[self::STATE_START][self::STATE_DOCUMENT] = [ $this, 'beginJson' ]; $this->transitionTable[self::STATE_DOCUMENT][self::STATE_FINISH] = [ $this, 'finishJson' ]; $this->transitionTable[self::STATE_OBJECT][self::STATE_PREDICATE] = [ $this, 'finishPredicate' ]; $this->transitionTable[self::STATE_OBJECT][self::STATE_SUBJECT] = [ $this, 'finishSubject' ]; $this->transitionTable[self::STATE_OBJECT][self::STATE_DOCUMENT] = [ $this, 'finishDocument' ]; } /** * Emit $val as JSON, with $indent extra indentations on each line. * @param array $val * @param int $indent * @return string the JSON string for $val */ public function encode( $val, $indent ) { $str = json_encode( $val, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); // Strip outermost open/close braces/brackets $str = preg_replace( '/^[[{]\n?|\n?[}\]]$/', '', $str ); if ( $indent > 0 ) { // add extra indentation $str = preg_replace( '/^/m', str_repeat( ' ', $indent ), $str ); } return $str; } /** * Return a "compact IRI" corresponding to the given base/local pair. * This adds entries to the "@context" key when needed to allow use * of a given prefix. * @see https://www.w3.org/TR/json-ld/#dfn-compact-iri * * @param string $base A QName prefix if $local is given, or an IRI if $local is null. * @param string|null $local A QName suffix, or null if $base is an IRI. * * @return string A compact IRI. */ private function compactify( $base, $local = null ) { $this->expandShorthand( $base, $local ); if ( $local === null ) { return $base; } else { if ( $base !== '_' && isset( $this->prefixes[ $base ] ) ) { if ( $base === '' ) { // Empty prefix not supported; use full IRI return $this->prefixes[ $base ] . $local; } if ( !isset( $this->context[ $base ] ) ) { $this->context[ $base ] = $this->prefixes[ $base ]; } if ( $this->context[ $base ] !== $this->prefixes[ $base ] ) { // Context name conflict; use full IRI return $this->prefixes[ $base ] . $local; } } return $base . ':' . $local; } } /** * Return an absolute IRI from the given base/local pair. * @see https://www.w3.org/TR/json-ld/#dfn-absolute-iri * * @param string $base A QName prefix if $local is given, or an IRI if $local is null. * @param string|null $local A QName suffix, or null if $base is an IRI. * * @return string|null An absolute IRI, or null if it cannot be constructed. */ private function toIRI( $base, $local ) { $this->expandShorthand( $base, $local ); $this->expandQName( $base, $local ); if ( $local !== null ) { throw new LogicException( 'Unknown prefix: ' . $base ); } return $base; } /** * Return a appropriate term for the current predicate value. * * @return string */ private function getCurrentTerm() { list( $base, $local ) = $this->currentPredicate; $predIRI = $this->toIRI( $base, $local ); if ( $predIRI === self::RDF_TYPE_IRI ) { return $predIRI; } $this->expandShorthand( $base, $local ); if ( $local === null ) { + // @phan-suppress-next-line PhanTypeMismatchReturnNullable return $base; } elseif ( $base !== '_' && !isset( $this->prefixes[ $local ] ) ) { // Prefixes get priority over field names in @context $pred = $this->compactify( $base, $local ); if ( !isset( $this->context[ $local ] ) ) { $this->context[ $local ] = [ '@id' => $pred ]; } if ( $this->context[ $local ][ '@id' ] === $pred ) { return $local; } return $pred; } return $this->compactify( $base, $local ); } /** * Write document header. */ protected function beginJson() { if ( $this->role === self::DOCUMENT_ROLE ) { $this->write( "{\n" ); $this->write( function () { // If this buffer is drained early, disable @graph optimization $this->disableGraphOpt = true; return ''; } ); } } /** * Write document footer. */ protected function finishJson() { // If we haven't drained yet, and @graph has only 1 element, then we // can optimize our output and hoist the single node to top level. if ( $this->role === self::DOCUMENT_ROLE ) { if ( ( !$this->disableGraphOpt ) && count( $this->graph ) === 1 ) { + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $this->write( $this->encode( $this->graph[0], 0 ) ); $this->graph = null; // We're done with @graph. } else { $this->disableGraphOpt = true; $this->write( "\n ]" ); } } if ( count( $this->context ) ) { // Write @context field. $this->write( ",\n" ); $this->write( $this->encode( [ '@context' => $this->context ], 0 ) ); } $this->write( "\n}" ); } protected function finishDocument() { $this->finishSubject(); $this->write( function () { // if this is drained before finishJson(), then disable // the graph optimization and dump what we've got so far. $str = ''; if ( $this->graph !== null && count( $this->graph ) > 0 ) { $this->disableGraphOpt = true; if ( $this->role === self::DOCUMENT_ROLE && !$this->wroteGraph ) { $str .= " \"@graph\": [\n"; $this->wroteGraph = true; } else { $str .= ",\n"; } $str .= $this->encode( $this->graph, 1 ); $this->graph = []; return $str; } // Delay; maybe we'll be able to optimize this later. return $str; } ); } /** * @param string $base * @param string|null $local */ protected function writeSubject( $base, $local = null ) { $this->predicates = [ '@id' => $this->compactify( $base, $local ) ]; } protected function finishSubject() { $this->finishPredicate(); $this->graph[] = $this->predicates; } /** * @param string $base * @param string|null $local */ protected function writePredicate( $base, $local = null ) { // no op } /** * @param string $base * @param string|null $local */ protected function writeResource( $base, $local = null ) { $pred = $this->getCurrentTerm(); $value = $this->compactify( $base, $local ); $this->addTypedValue( '@id', $value, [ '@id' => $value ], ( $pred === self::RDF_TYPE_IRI ) ); } /** * @param string $text * @param string|null $language */ protected function writeText( $text, $language = null ) { if ( !$this->isValidLanguageCode( $language ) ) { $this->addTypedValue( self::DEFAULT_TYPE, $text ); } else { $expanded = [ '@language' => $language, '@value' => $text ]; $this->addTypedValue( self::DEFAULT_TYPE, $expanded, $expanded ); } } /** * @param string $literal * @param string|null $typeBase * @param string|null $typeLocal */ public function writeValue( $literal, $typeBase, $typeLocal = null ) { if ( $typeBase === null && $typeLocal === null ) { $this->addTypedValue( self::DEFAULT_TYPE, $literal ); return; } switch ( $this->toIRI( $typeBase, $typeLocal ) ) { case 'http://www.w3.org/2001/XMLSchema#string': $this->addTypedValue( self::DEFAULT_TYPE, strval( $literal ) ); return; case 'http://www.w3.org/2001/XMLSchema#integer': $this->addTypedValue( self::DEFAULT_TYPE, intval( $literal ) ); return; case 'http://www.w3.org/2001/XMLSchema#boolean': $this->addTypedValue( self::DEFAULT_TYPE, ( $literal === 'true' ) ); return; case 'http://www.w3.org/2001/XMLSchema#double': $v = floatval( $literal ); // Only "numbers with fractions" are xsd:double. We need // to verify that the JSON string will contain a decimal // point, otherwise the value would be interpreted as an // xsd:integer. // TODO: consider instead using JSON_PRESERVE_ZERO_FRACTION // in $this->encode() once our required PHP >= 5.6.6. // OTOH, the spec language is ambiguous about whether "5." // would be considered an integer or a double. if ( strpos( json_encode( $v ), '.' ) !== false ) { $this->addTypedValue( self::DEFAULT_TYPE, $v ); return; } } $type = $this->compactify( $typeBase, $typeLocal ); $literal = strval( $literal ); $this->addTypedValue( $type, $literal, [ '@type' => $type, '@value' => $literal ] ); } /** * Add a typed value for the given predicate. If possible, adds a * default type to the context to avoid having to repeat type information * in each value for this predicate. If there is already a default * type which conflicts with this one, or if $forceExpand is true, * then use the "expanded" value which will explicitly override any * default type. * * @param string $type The compactified JSON-LD @type for this value, or * self::DEFAULT_TYPE to indicate the default JSON-LD type coercion rules * should be used. * @param string|int|float|bool $simpleVal The "simple" representation * for this value, used if the type can be hoisted into the context. * @param array|null $expandedVal The "expanded" representation for this * value, used if the context @type conflicts with this value; or null * to use "@value" for the expanded representation. * @param bool $forceExpand If true, don't try to add this type to the * context. Defaults to false. */ protected function addTypedValue( $type, $simpleVal, $expandedVal = null, $forceExpand = false ) { if ( !$forceExpand ) { $pred = $this->getCurrentTerm(); if ( $type === self::DEFAULT_TYPE ) { + // @phan-suppress-next-line PhanTypeMismatchDimFetch if ( !isset( $this->context[ $pred ][ '@type' ] ) ) { $this->defaulted[ $pred ] = true; } if ( isset( $this->defaulted[ $pred ] ) ) { $this->values[] = $simpleVal; return; } } elseif ( !isset( $this->defaulted[ $pred ] ) ) { if ( !isset( $this->context[ $pred ] ) ) { $this->context[ $pred ] = []; } if ( !isset( $this->context[ $pred ][ '@type' ] ) ) { $this->context[ $pred ][ '@type' ] = $type; } if ( $this->context[ $pred ][ '@type' ] === $type ) { $this->values[] = $simpleVal; return; } } } if ( $expandedVal === null ) { $this->values[] = [ '@value' => $simpleVal ]; } else { $this->values[] = $expandedVal; } } protected function finishPredicate() { $name = $this->getCurrentTerm(); if ( $name === self::RDF_TYPE_IRI ) { $name = '@type'; $this->values = array_map( static function ( array $val ) { return $val[ '@id' ]; }, $this->values ); } if ( isset( $this->predicates[$name] ) ) { $was = $this->predicates[$name]; // Wrap $was into a numeric indexed array if it isn't already. // Note that $was could have non-numeric indices, eg // [ "@id" => "foo" ], in which was it still needs to be wrapped. if ( !( is_array( $was ) && isset( $was[0] ) ) ) { $was = [ $was ]; } $this->values = array_merge( $was, $this->values ); } $cnt = count( $this->values ); if ( $cnt === 0 ) { throw new LogicException( 'finishPredicate can\'t be called without at least one value' ); } elseif ( $cnt === 1 ) { $this->predicates[$name] = $this->values[0]; } else { $this->predicates[$name] = $this->values; } $this->values = []; } /** * @param string $role * @param BNodeLabeler $labeler * * @return RdfWriterBase */ protected function newSubWriter( $role, BNodeLabeler $labeler ) { $writer = new self( $role, $labeler ); // Have subwriter share context with this parent. $writer->context = &$this->context; $writer->defaulted = &$this->defaulted; // We can't use the @graph optimization. $this->disableGraphOpt = true; return $writer; } /** * @return string a MIME type */ public function getMimeType() { return 'application/ld+json; charset=UTF-8'; } } diff --git a/src/RdfWriterBase.php b/src/RdfWriterBase.php index cca8a9b..cad0f8a 100644 --- a/src/RdfWriterBase.php +++ b/src/RdfWriterBase.php @@ -1,633 +1,634 @@ role = $role; $this->labeler = $labeler ?: new BNodeLabeler(); $this->registerShorthand( 'a', 'rdf', 'type' ); $this->prefix( 'rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' ); $this->prefix( 'xsd', 'http://www.w3.org/2001/XMLSchema#' ); } /** * @param string $role * @param BNodeLabeler $labeler * * @return RdfWriterBase */ abstract protected function newSubWriter( $role, BNodeLabeler $labeler ); /** * Registers a shorthand that can be used instead of a qname, * like 'a' can be used instead of 'rdf:type'. * * @param string $shorthand * @param string $prefix * @param string $local */ protected function registerShorthand( $shorthand, $prefix, $local ) { $this->shorthands[$shorthand] = [ $prefix, $local ]; } /** * Registers a prefix * * @param string $prefix * @param string $iri The base IRI * * @throws LogicException */ public function prefix( $prefix, $iri ) { if ( $this->prefixesLocked ) { throw new LogicException( 'Prefixes can not be added after start()' ); } $this->prefixes[$prefix] = $iri; } /** * Determines whether $shorthand can be used as a shorthand. * * @param string $shorthand * * @return bool */ protected function isShorthand( $shorthand ) { return isset( $this->shorthands[$shorthand] ); } /** * Determines whether $shorthand can legally be used as a prefix. * * @param string $prefix * * @return bool */ protected function isPrefix( $prefix ) { return isset( $this->prefixes[$prefix] ); } /** * Returns the prefix map. * * @return string[] An associative array mapping prefixes to base IRIs. */ public function getPrefixes() { return $this->prefixes; } /** * @param string|null $languageCode * * @return bool */ protected function isValidLanguageCode( $languageCode ) { // preg_match is somewhat (12%) slower than strspn but more readable return $languageCode !== null && preg_match( '/^[\da-z-]{2,}$/i', $languageCode ); } /** * @return RdfWriter */ final public function sub() { $writer = $this->newSubWriter( self::SUBDOCUMENT_ROLE, $this->labeler ); $writer->state = self::STATE_DOCUMENT; // share registered prefixes $writer->prefixes =& $this->prefixes; $this->subs[] = $writer; return $writer; } /** * @return string A string corresponding to one of the the XXX_ROLE constants. */ final public function getRole() { return $this->role; } /** * Appends string to the output buffer. * @param string $w */ final protected function write( $w ) { $this->buffer[] = $w; } /** * If $base is a shorthand, $base and $local are updated to hold whatever qname * the shorthand was associated with. * * Otherwise, $base and $local remain unchanged. * * @param string &$base * @param string|null &$local */ protected function expandShorthand( &$base, &$local ) { if ( $local === null && isset( $this->shorthands[$base] ) ) { + // @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring list( $base, $local ) = $this->shorthands[$base]; } } /** * If $base is a registered prefix, $base will be replaced by the base IRI associated with * that prefix, with $local appended. $local will be set to null. * * Otherwise, $base and $local remain unchanged. * * @param string &$base * @param string|null &$local * * @throws LogicException */ protected function expandQName( &$base, &$local ) { if ( $local !== null && $base !== '_' ) { if ( isset( $this->prefixes[$base] ) ) { $base = $this->prefixes[$base] . $local; // XXX: can we avoid this concat? $local = null; } else { throw new LogicException( 'Unknown prefix: ' . $base ); } } } /** * @see RdfWriter::blank() * * @param string|null $label node label, will be generated if not given. * * @return string */ final public function blank( $label = null ) { return $this->labeler->getLabel( $label ); } /** * @see RdfWriter::start() */ final public function start() { $this->state( self::STATE_DOCUMENT ); $this->prefixesLocked = true; } /** * @see RdfWriter::finish() */ final public function finish() { // close all unclosed states $this->state( self::STATE_DOCUMENT ); // ...then insert output of sub-writers into the buffer, // so it gets placed before the footer... $this->drainSubs(); // and then finalize $this->state( self::STATE_FINISH ); // Detaches all subs. $this->subs = []; } /** * @see RdfWriter::drain() * * @return string RDF */ final public function drain() { // we can drain after finish, but finish state is sticky if ( $this->state !== self::STATE_FINISH ) { $this->state( self::STATE_DOCUMENT ); } $this->drainSubs(); $this->flattenBuffer(); $rdf = implode( '', $this->buffer ); $this->buffer = []; return $rdf; } /** * Calls drain() an any RdfWriter instances in $this->buffer, and replaces them * in $this->buffer with the string returned by the drain() call. Any closures * present in the $this->buffer will be called, and replaced by their return value. */ private function flattenBuffer() { foreach ( $this->buffer as &$b ) { if ( $b instanceof Closure ) { $b = $b(); } if ( $b instanceof RdfWriter ) { $b = $b->drain(); } } } /** * Drains all subwriters, and appends their output to this writer's buffer. * Subwriters remain usable. */ private function drainSubs() { foreach ( $this->subs as $sub ) { $rdf = $sub->drain(); $this->write( $rdf ); } } /** * @see RdfWriter::about() * * @param string $base A QName prefix if $local is given, or an IRI if $local is null. * @param string|null $local A QName suffix, or null if $base is an IRI. * * @return RdfWriter $this */ final public function about( $base, $local = null ) { $this->expandSubject( $base, $local ); if ( $this->state === self::STATE_OBJECT && $base === $this->currentSubject[0] && $local === $this->currentSubject[1] ) { return $this; // redundant about() call } $this->state( self::STATE_SUBJECT ); $this->currentSubject[0] = $base; $this->currentSubject[1] = $local; $this->currentPredicate[0] = null; $this->currentPredicate[1] = null; $this->writeSubject( $base, $local ); return $this; } /** * @see RdfWriter::a() * Shorthand for say( 'a' )->is( $type ). * * @param string $typeBase The data type's QName prefix if $typeLocal is given, * or an IRI or shorthand if $typeLocal is null. * @param string|null $typeLocal The data type's QName suffix, * or null if $typeBase is an IRI or shorthand. * * @return RdfWriter $this */ final public function a( $typeBase, $typeLocal = null ) { return $this->say( 'a' )->is( $typeBase, $typeLocal ); } /** * @see RdfWriter::say() * * @param string $base A QName prefix. * @param string|null $local A QName suffix. * * @return RdfWriter $this */ final public function say( $base, $local = null ) { $this->expandPredicate( $base, $local ); if ( $this->state === self::STATE_OBJECT && $base === $this->currentPredicate[0] && $local === $this->currentPredicate[1] ) { return $this; // redundant about() call } $this->state( self::STATE_PREDICATE ); $this->currentPredicate[0] = $base; $this->currentPredicate[1] = $local; $this->writePredicate( $base, $local ); return $this; } /** * @see RdfWriter::is() * * @param string $base A QName prefix if $local is given, or an IRI if $local is null. * @param string|null $local A QName suffix, or null if $base is an IRI. * * @return RdfWriter $this */ final public function is( $base, $local = null ) { $this->state( self::STATE_OBJECT ); $this->expandResource( $base, $local ); $this->writeResource( $base, $local ); return $this; } /** * @see RdfWriter::text() * * @param string $text the text to be placed in the output * @param string|null $language the language the text is in * * @return $this */ final public function text( $text, $language = null ) { $this->state( self::STATE_OBJECT ); $this->writeText( $text, $language ); return $this; } /** * @see RdfWriter::value() * * @param string $value the value encoded as a string * @param string|null $typeBase The data type's QName prefix if $typeLocal is given, * or an IRI or shorthand if $typeLocal is null. * @param string|null $typeLocal The data type's QName suffix, * or null if $typeBase is an IRI or shorthand. * * @return $this */ final public function value( $value, $typeBase = null, $typeLocal = null ) { $this->state( self::STATE_OBJECT ); if ( $typeBase === null && !is_string( $value ) ) { $vtype = gettype( $value ); switch ( $vtype ) { case 'integer': $typeBase = 'xsd'; $typeLocal = 'integer'; $value = "$value"; break; case 'double': $typeBase = 'xsd'; $typeLocal = 'double'; $value = "$value"; break; case 'boolean': $typeBase = 'xsd'; $typeLocal = 'boolean'; $value = $value ? 'true' : 'false'; break; } } $this->expandType( $typeBase, $typeLocal ); $this->writeValue( $value, $typeBase, $typeLocal ); return $this; } /** * State transition table * First state is "from", second is "to" * @var array */ protected $transitionTable = [ self::STATE_START => [ self::STATE_DOCUMENT => true, ], self::STATE_DOCUMENT => [ self::STATE_DOCUMENT => true, self::STATE_SUBJECT => true, self::STATE_FINISH => true, ], self::STATE_SUBJECT => [ self::STATE_PREDICATE => true, ], self::STATE_PREDICATE => [ self::STATE_OBJECT => true, ], self::STATE_OBJECT => [ self::STATE_DOCUMENT => true, self::STATE_SUBJECT => true, self::STATE_PREDICATE => true, self::STATE_OBJECT => true, ], ]; /** * Perform a state transition. Writer states roughly correspond to states in a naive * regular parser for the respective syntax. State transitions may generate output, * particularly of structural elements which correspond to terminals in a respective * parser. * * @param int $newState one of the self::STATE_... constants * * @throws LogicException */ final protected function state( $newState ) { if ( !isset( $this->transitionTable[$this->state][$newState] ) ) { throw new LogicException( 'Bad transition: ' . $this->state . ' -> ' . $newState ); } $action = $this->transitionTable[$this->state][$newState]; if ( $action !== true ) { if ( is_string( $action ) ) { $this->write( $action ); } else { $action(); } } $this->state = $newState; } /** * Must be implemented to generate output that starts a statement (or set of statements) * about a subject. Depending on the requirements of the output format, the implementation * may be empty. * * @note $base and $local are given as passed to about() and processed by expandSubject(). * * @param string $base * @param string|null $local */ abstract protected function writeSubject( $base, $local = null ); /** * Must be implemented to generate output that represents the association of a predicate * with a subject that was previously defined by a call to writeSubject(). * * @note $base and $local are given as passed to say() and processed by expandPredicate(). * * @param string $base * @param string|null $local */ abstract protected function writePredicate( $base, $local = null ); /** * Must be implemented to generate output that represents a resource used as the object * of a statement. * * @note $base and $local are given as passed to is() and processed by expandObject(). * * @param string $base * @param string|null $local */ abstract protected function writeResource( $base, $local = null ); /** * Must be implemented to generate output that represents a text used as the object * of a statement. * * @param string $text the text to be placed in the output * @param string|null $language the language the text is in */ abstract protected function writeText( $text, $language ); /** * Must be implemented to generate output that represents a (typed) literal used as the object * of a statement. * * @note $typeBase and $typeLocal are given as passed to value() and processed by expandType(). * * @param string $value the value encoded as a string * @param string|null $typeBase * @param string|null $typeLocal */ abstract protected function writeValue( $value, $typeBase, $typeLocal = null ); /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for subject identifiers. * * @param string &$base * @param string|null &$local */ protected function expandSubject( &$base, &$local ) { } /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for predicate identifiers. * * @param string &$base * @param string|null &$local */ protected function expandPredicate( &$base, &$local ) { } /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for resource identifiers. * * @param string &$base * @param string|null &$local */ protected function expandResource( &$base, &$local ) { } /** * Perform any expansion (shorthand to qname, qname to IRI) desired * for type identifiers. * * @param string|null &$base * @param string|null &$local */ protected function expandType( &$base, &$local ) { } } diff --git a/src/XmlRdfWriter.php b/src/XmlRdfWriter.php index 121d966..7419849 100644 --- a/src/XmlRdfWriter.php +++ b/src/XmlRdfWriter.php @@ -1,275 +1,276 @@ transitionTable[self::STATE_START][self::STATE_DOCUMENT] = function () { $this->beginDocument(); }; $this->transitionTable[self::STATE_DOCUMENT][self::STATE_FINISH] = function () { $this->finishDocument(); }; $this->transitionTable[self::STATE_OBJECT][self::STATE_DOCUMENT] = function () { $this->finishSubject(); }; $this->transitionTable[self::STATE_OBJECT][self::STATE_SUBJECT] = function () { $this->finishSubject(); }; } /** * @param string $text * * @return string */ private function escape( $text ) { return htmlspecialchars( $text, ENT_QUOTES ); } /** * @inheritDoc */ protected function expandSubject( &$base, &$local ) { $this->expandQName( $base, $local ); } /** * @inheritDoc */ protected function expandPredicate( &$base, &$local ) { $this->expandShorthand( $base, $local ); } /** * @inheritDoc */ protected function expandResource( &$base, &$local ) { $this->expandQName( $base, $local ); } /** * @inheritDoc */ protected function expandType( &$base, &$local ) { $this->expandQName( $base, $local ); } /** * @param string $ns * @param string $name * @param string[] $attributes * @param string|null $content */ private function tag( $ns, $name, $attributes = [], $content = null ) { $sep = $ns === '' ? '' : ':'; $this->write( '<' . $ns . $sep . $name ); foreach ( $attributes as $attr => $value ) { if ( is_int( $attr ) ) { // positional array entries are passed verbatim, may be callbacks. $this->write( $value ); continue; } $this->write( " $attr=\"" . $this->escape( $value ) . '"' ); } if ( $content === null ) { $this->write( '>' ); } elseif ( $content === '' ) { $this->write( '/>' ); } else { $this->write( '>' . $content ); $this->close( $ns, $name ); } } /** * @param string $ns * @param string $name */ private function close( $ns, $name ) { $sep = $ns === '' ? '' : ':'; $this->write( '' ); } /** * Generates an attribute list, containing the attribute given by $name, or rdf:nodeID * if $target is a blank node id (starting with "_:"). If $target is a qname, an attempt * is made to resolve it into a full IRI based on the namespaces registered by calling * prefix(). * * @param string $name the attribute name (without the 'rdf:' prefix) * @param string|null $base * @param string|null $local * * @throws InvalidArgumentException * @return string[] */ private function getTargetAttributes( $name, $base, $local ) { if ( $base === null && $local === null ) { return []; } // handle blank if ( $base === '_' ) { $name = 'nodeID'; $value = $local; } elseif ( $local !== null ) { throw new InvalidArgumentException( "Expected IRI, got QName: $base:$local" ); } else { $value = $base; } return [ + // @phan-suppress-next-line PhanTypeMismatchReturn "rdf:$name" => $value ]; } /** * Emit a document header. */ private function beginDocument() { $this->write( "\n" ); // define a callback for generating namespace attributes $namespaceAttrCallback = function () { $attr = ''; $namespaces = $this->getPrefixes(); foreach ( $namespaces as $ns => $uri ) { $escapedUri = htmlspecialchars( $uri, ENT_QUOTES ); $nss = $ns === '' ? '' : ":$ns"; $attr .= " xmlns$nss=\"$escapedUri\""; } return $attr; }; $this->tag( 'rdf', 'RDF', [ $namespaceAttrCallback ] ); $this->write( "\n" ); } /** * @param string $base * @param string|null $local */ protected function writeSubject( $base, $local = null ) { $attr = $this->getTargetAttributes( 'about', $base, $local ); $this->write( "\t" ); $this->tag( 'rdf', 'Description', $attr ); $this->write( "\n" ); } /** * Emit the root element */ private function finishSubject() { $this->write( "\t" ); $this->close( 'rdf', 'Description' ); $this->write( "\n" ); } /** * Write document footer */ private function finishDocument() { // close document element $this->close( 'rdf', 'RDF' ); $this->write( "\n" ); } /** * @param string $base * @param string|null $local */ protected function writePredicate( $base, $local = null ) { // noop } /** * @param string $base * @param string|null $local */ protected function writeResource( $base, $local = null ) { $attr = $this->getTargetAttributes( 'resource', $base, $local ); $this->write( "\t\t" ); $this->tag( $this->currentPredicate[0], $this->currentPredicate[1], $attr, '' ); $this->write( "\n" ); } /** * @param string $text * @param string|null $language */ protected function writeText( $text, $language = null ) { $attr = $this->isValidLanguageCode( $language ) ? [ 'xml:lang' => $language ] : []; $this->write( "\t\t" ); $this->tag( $this->currentPredicate[0], $this->currentPredicate[1], $attr, $this->escape( $text ) ); $this->write( "\n" ); } /** * @param string $literal * @param string|null $typeBase * @param string|null $typeLocal */ public function writeValue( $literal, $typeBase, $typeLocal = null ) { $attr = $this->getTargetAttributes( 'datatype', $typeBase, $typeLocal ); $this->write( "\t\t" ); $this->tag( $this->currentPredicate[0], $this->currentPredicate[1], $attr, $this->escape( $literal ) ); $this->write( "\n" ); } /** * @param string $role * @param BNodeLabeler $labeler * * @return RdfWriterBase */ protected function newSubWriter( $role, BNodeLabeler $labeler ) { $writer = new self( $role, $labeler ); return $writer; } /** * @return string a MIME type */ public function getMimeType() { return 'application/rdf+xml; charset=UTF-8'; } }