Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: josemmo/uxml
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.1.0
Choose a base ref
...
head repository: josemmo/uxml
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: main
Choose a head ref

Commits on Feb 7, 2021

  1. Replaced PHPUnit with phpunit-bridge

    - Removed PHPUnit 9 dependency
    - Added symfony/phpunit-bridge dependency
    josemmo committed Feb 7, 2021
    Copy the full SHA
    f288769 View commit details
  2. Migrated from Travis CI to GA Actions

    - Created CI workflow
    - Removed Travis CI configuration
    - Updated CI badge in README.md
    josemmo committed Feb 7, 2021
    Copy the full SHA
    bee202a View commit details
  3. Fixed heredoc in tests

    - Fixed heredoc for PHP 7.1 and 7.2
    josemmo committed Feb 7, 2021
    Copy the full SHA
    38af389 View commit details

Commits on May 25, 2021

  1. Fixed unescaped entities

    - Added node value as text content to fix "unterminated entity reference"
    - Updated unit tests
    
    > Ref josemmo/einvoicing#3
    josemmo committed May 25, 2021
    Copy the full SHA
    55cfa5e View commit details
  2. Disabled xdebug in CI

    - Update CI workflow
    josemmo committed May 25, 2021
    Copy the full SHA
    d2c8a58 View commit details
  3. Removed deprecated methods

    - Removed UXML::load() method, renamed to UXML::fromString()
    - Made UXML constructor private
    josemmo committed May 25, 2021
    Copy the full SHA
    a891f4d View commit details
  4. Fixed type mismatch in phan

    - Added type hint to UXML->element
    
    > PHP 8.1 (nightly) threw PhanTypeMismatchArgumentNullableInternal
    josemmo committed May 25, 2021
    Copy the full SHA
    980eff9 View commit details
  5. Suppressed phan type mismatch

    - Updated UXML::getAll() method
    josemmo committed May 25, 2021
    Copy the full SHA
    ff1e44c View commit details
  6. Added support for PHP 8.1

    - Fixed "Passing null to parameter 1 ($version) of type string is deprecated"
    - Updated unit tests
    josemmo committed May 25, 2021
    Copy the full SHA
    60b5996 View commit details

Commits on Aug 7, 2021

  1. Hardened static analyzer

    - Upgraded phan/phan to v5.0.0
    - Updated phan configuration file
    josemmo committed Aug 7, 2021
    Copy the full SHA
    49a9431 View commit details
  2. Fixed "Cannot assign null" bug

    - Do not set DOMElement::$textContent if value is NULL (PHP 8.1-beta throws exception)
    josemmo committed Aug 7, 2021
    Copy the full SHA
    2ca9799 View commit details
  3. Handled DOMElement failures

    - Updated UXML::newInstance() to throw exception if failed to create new DOMElement or import nodes
    - Fixed typo in PHPDoc block
    josemmo committed Aug 7, 2021
    Copy the full SHA
    7658d42 View commit details
  4. Fixed new instance method signature

    - Updated UXML::newInstance() signature
    josemmo committed Aug 7, 2021
    Copy the full SHA
    b33d5d6 View commit details
  5. Updated CI workflow

    - Forced AST extension to be downloaded from PECL (instead of using "php-ast" Ubuntu package)
    - Updated phan script
    josemmo committed Aug 7, 2021
    Copy the full SHA
    6dc0038 View commit details
  6. Fixed "Call to method on non-object"

    - Updated UXML::remove() method (was giving static analysis error on PHP 8.1)
    josemmo committed Aug 7, 2021
    Copy the full SHA
    be11039 View commit details

Commits on Feb 19, 2022

  1. Upgraded static analyzer

    - Upgraded phan/phan
    - Updated CI workflow
    josemmo committed Feb 19, 2022
    Copy the full SHA
    a4a60c9 View commit details
  2. Improved linting

    - Updated UXML
    josemmo committed Feb 19, 2022
    Copy the full SHA
    fb34a2a View commit details
  3. Fixed issue with static analyzer

    - Removed "ignore-platform-reqs" argument from CI workflow
    - Updated composer.json
    josemmo committed Feb 19, 2022
    Copy the full SHA
    d702ba9 View commit details
  4. Fixed type mismatch

    - Updated UXML::getAll() method
    josemmo committed Feb 19, 2022
    Copy the full SHA
    c23cd49 View commit details

Commits on Feb 20, 2022

  1. Added support for PHP 8.2

    - Implemented WeakMap usage in UXML class
    - Added unit tests
    josemmo committed Feb 20, 2022
    Copy the full SHA
    edd8e58 View commit details
  2. Added WeakMap stub

    - Created WeakMap stub class
    - Updated phan configuration
    josemmo committed Feb 20, 2022
    Copy the full SHA
    3283eea View commit details

Commits on May 28, 2022

  1. Fixed handling of namespaces

    - Updated UXML::newInstance()
    - Updated unit tests
    josemmo committed May 28, 2022
    Copy the full SHA
    bcd7d8e View commit details

Commits on Jul 5, 2022

  1. Fixed typo in documentation

    - Updated README.md
    josemmo authored Jul 5, 2022
    Copy the full SHA
    5d47482 View commit details

Commits on Dec 8, 2022

  1. Added PHP 8.3 to tests

    - Updated CI workflow
    josemmo committed Dec 8, 2022
    Copy the full SHA
    a2bcbf8 View commit details

Commits on Feb 8, 2024

  1. Added PHP 8.4 to tests

    - Updated CI workflow
    josemmo committed Feb 8, 2024
    Copy the full SHA
    cfa5ac3 View commit details
Showing with 450 additions and 132 deletions.
  1. +41 −0 .github/workflows/ci.yml
  2. +144 −25 .phan/config.php
  3. +63 −0 .phan/stubs/WeakMap.php
  4. +0 −21 .travis.yml
  5. +2 −2 README.md
  6. +2 −2 composer.json
  7. +107 −46 src/UXML.php
  8. +91 −36 tests/UXMLTest.php
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on: [push, pull_request]

jobs:
tests:
name: Run tests
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental || false }}
strategy:
fail-fast: false
matrix:
php-version: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3']
include:
- php-version: '8.4'
experimental: true
steps:
# Download code from repository
- name: Checkout code
uses: actions/checkout@v4

# Setup PHP
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: none
extensions: ast-stable

# Setup Composer
- name: Setup Composer
run: composer validate --strict && composer install

# Run static analyzer
- name: Run static analyzer
if: ${{ success() && matrix.php-version != '7.1' }}
run: vendor/bin/phan --allow-polyfill-parser --color --no-progress-bar

# Run tests
- name: Run tests
run: vendor/bin/simple-phpunit --testdox
169 changes: 144 additions & 25 deletions .phan/config.php
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
<?php
use Phan\Issue;

/**
* This configuration will be read and overlaid on top of the
* default configuration. Command-line arguments will be applied
* default configuration. Command line arguments will be applied
* after this file is read.
*
* @see https://github.com/phan/phan/wiki/Phan-Config-Settings for all configurable options
* @see src/Phan/Config.php for the configurable options in this version of Phan
*
* A Note About Paths
* ==================
*
* Files referenced from this file should be defined as
*
* ```
* Config::projectPath('relative_path/to/file')
* ```
*
* where the relative path is relative to the root of the
* project which is defined as either the working directory
* of the phan executable or a path passed in via the CLI
* '-d' flag.
*/
return [
// Supported values: `'5.6'`, `'7.0'`, `'7.1'`, `'7.2'`, `'7.3'`,
// `'7.4'`, `null`.
// The PHP version that the codebase will be checked for compatibility against.
// For best results, the PHP binary used to run Phan should have the same PHP version.
// (Phan relies on Reflection for some types, param counts,
// and checks for undefined classes/methods/functions)
//
// Supported values: `'5.6'`, `'7.0'`, `'7.1'`, `'7.2'`, `'7.3'`, `'7.4'`,
// `'8.0'`, `'8.1'`, `null`.
// If this is set to `null`,
// then Phan assumes the PHP version which is closest to the minor version
// of the php executable used to execute Phan.
//
// Note that the **only** effect of choosing `'5.6'` is to infer
// that functions removed in php 7.0 exist.
// Note that the **only** effect of choosing `'5.6'` is to infer that functions removed in php 7.0 exist.
// (See `backward_compatibility_checks` for additional options)
// TODO: Set this.
'target_php_version' => null,

// A list of directories that should be parsed for class and
@@ -25,15 +47,11 @@
// Thus, both first-party and third-party code being used by
// your application should be included in this list.
'directory_list' => [
'src'
'src',
'vendor',
'.phan/stubs',
],

// A regex used to match every file name that you want to
// exclude from parsing. Actual value will exclude every
// "test", "tests", "Test" and "Tests" folders found in
// "vendor/" directory.
'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@',

// A directory list that defines files that will be excluded
// from static analysis, but whose class and method
// information should be included.
@@ -46,28 +64,129 @@
// should be added to both the `directory_list`
// and `exclude_analysis_directory_list` arrays.
'exclude_analysis_directory_list' => [
'vendor/'
'vendor',
'.phan/stubs',
],

// Set to true in order to attempt to detect unused variables.
// `dead_code_detection` will also enable unused variable detection.
// If enabled, Phan will warn if **any** type in a method invocation's object
// is definitely not an object,
// or if **any** type in an invoked expression is not a callable.
// Setting this to true will introduce numerous false positives
// (and reveal some bugs).
'strict_method_checking' => true,

// If enabled, Phan will warn if **any** type in the argument's union type
// cannot be cast to a type in the parameter's expected union type.
// Setting this to true will introduce numerous false positives
// (and reveal some bugs).
'strict_param_checking' => true,

// If enabled, Phan will warn if **any** type in a property assignment's union type
// cannot be cast to a type in the property's declared union type.
// Setting this to true will introduce numerous false positives
// (and reveal some bugs).
// (For self-analysis, Phan has a large number of suppressions and file-level suppressions, due to \ast\Node being difficult to type check)
'strict_property_checking' => true,

// If enabled, Phan will warn if **any** type in a returned value's union type
// cannot be cast to the declared return type.
// Setting this to true will introduce numerous false positives
// (and reveal some bugs).
// (For self-analysis, Phan has a large number of suppressions and file-level suppressions, due to \ast\Node being difficult to type check)
'strict_return_checking' => true,

// If enabled, Phan will warn if **any** type of the object expression for a property access
// does not contain that property.
'strict_object_checking' => true,

// If enabled, check all methods that override a
// parent method to make sure its signature is
// compatible with the parent's. This check
// can add quite a bit of time to the analysis.
// This will also check if final methods are overridden, etc.
'analyze_signature_compatibility' => true,

// If true, check to make sure the return type declared
// in the doc-block (if any) matches the return type
// declared in the method signature.
'check_docblock_signature_return_type_match' => true,

// If true, check to make sure the param types declared
// in the doc-block (if any) matches the param types
// declared in the method signature.
'check_docblock_signature_param_type_match' => true,

// Set to true in order to attempt to detect dead
// (unreferenced) code. Keep in mind that the
// results will only be a guess given that classes,
// properties, constants and methods can be referenced
// as variables (like `$class->$property` or
// `$class->$method()`) in ways that we're unable
// to make sense of.
//
// This has a few known false positives, e.g. for loops or branches.
'unused_variable_detection' => true,
// To more aggressively detect dead code,
// you may want to set `dead_code_detection_prefer_false_negative` to `false`.
'dead_code_detection' => true,

// Set to true in order to attempt to detect redundant and impossible conditions.
//
// This has some false positives involving loops,
// variables set in branches of loops, and global variables.
'redundant_condition_detection' => true,

// Set to true in order to attempt to detect error-prone truthiness/falsiness checks.
//
// This is not suitable for all codebases.
'error_prone_truthy_condition_detection' => true,

// Enable or disable support for generic templated
// class types.
'generic_types_enabled' => true,

// If enabled, warn about throw statement where the exception types
// are not documented in the PHPDoc of functions, methods, and closures.
'warn_about_undocumented_throw_statements' => true,

// If enabled (and warn_about_undocumented_throw_statements is enabled),
// warn about function/closure/method calls that have (at)throws
// without the invoking method documenting that exception.
// If enabled (and `warn_about_undocumented_throw_statements` is enabled),
// Phan will warn about function/closure/method invocations that have `@throws`
// that aren't caught or documented in the invoking method.
'warn_about_undocumented_exceptions_thrown_by_invoked_functions' => true,

// A list of plugin files to execute
// NOTE: values can be the base name without the extension for plugins bundled with Phan (E.g. 'AlwaysReturnPlugin')
// or relative/absolute paths to the plugin (Relative to the project root).
// The minimum severity level to report on. This can be
// set to Issue::SEVERITY_LOW, Issue::SEVERITY_NORMAL or
// Issue::SEVERITY_CRITICAL.
'minimum_severity' => Issue::SEVERITY_LOW,

// Add any issue types (such as `'PhanUndeclaredMethod'`)
// to this list to inhibit them from being reported.
'suppress_issue_types' => [
'PhanUnreferencedClass',
'PhanUnreferencedPublicMethod',
],

// A list of plugin files to execute.
// Plugins which are bundled with Phan can be added here by providing their name
// (e.g. 'AlwaysReturnPlugin')
//
// Documentation about available bundled plugins can be found
// at https://github.com/phan/phan/tree/v4/.phan/plugins
//
// Alternately, you can pass in the full path to a PHP file
// with the plugin's implementation.
// (e.g. 'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php')
'plugins' => [
'AlwaysReturnPlugin', // Checks if a function, closure or method unconditionally returns.
'DollarDollarPlugin',
'DuplicateArrayKeyPlugin',
'DuplicateExpressionPlugin',
'EmptyStatementListPlugin',
'InlineHTMLPlugin',
'LoopVariableReusePlugin',
'PreferNamespaceUsePlugin',
]
'PregRegexCheckerPlugin',
'PrintfCheckerPlugin',
'SleepCheckerPlugin',
'UnreachableCodePlugin', // Checks for syntactically unreachable statements in the global scope or function bodies.
'UseReturnValuePlugin',
],
];
63 changes: 63 additions & 0 deletions .phan/stubs/WeakMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
/**
* Weak maps allow creating a map from objects to arbitrary values
* (similar to SplObjectStorage) without preventing the objects that are used
* as keys from being garbage collected. If an object key is garbage collected,
* it will simply be removed from the map.
*
* @since 8.0
* @source https://github.com/JetBrains/phpstorm-stubs/blob/master/Core/Core_c.php
*
* @template TKey of object
* @template TValue
* @template-implements IteratorAggregate<TKey, TValue>
*/
final class WeakMap implements ArrayAccess, Countable, IteratorAggregate {
/**
* Returns {@see true} if the value for the object is contained in
* the {@see WeakMap} and {@see false} instead.
*
* @param TKey $object Any object
* @return bool
*/
public function offsetExists($object): bool {}

/**
* Returns the existsing value by an object.
*
* @param TKey $object Any object
* @return TValue Value associated with the key object
*/
public function offsetGet($object): mixed {}

/**
* Sets a new value for an object.
*
* @param TKey $object Any object
* @param TValue $value Any value
* @return void
*/
public function offsetSet($object, mixed $value): void {}

/**
* Force removes an object value from the {@see WeakMap} instance.
*
* @param TKey $object Any object
* @return void
*/
public function offsetUnset($object): void {}

/**
* Returns an iterator in the "[object => mixed]" format.
*
* @return Traversable<TKey, TValue>
*/
public function getIterator(): Iterator {}

/**
* Returns the number of items in the {@see WeakMap} instance.
*
* @return int
*/
public function count(): int {}
}
21 changes: 0 additions & 21 deletions .travis.yml

This file was deleted.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Uncomplicated XML
[![Build Status](https://travis-ci.com/josemmo/uxml.svg?branch=main)](https://travis-ci.com/josemmo/uxml)
[![Build Status](https://github.com/josemmo/uxml/workflows/CI/badge.svg)](https://github.com/josemmo/uxml/actions)
[![Latest Version](https://img.shields.io/packagist/v/josemmo/uxml)](https://packagist.org/packages/josemmo/uxml)
[![Minimum PHP Version](https://img.shields.io/packagist/php-v/josemmo/uxml)](#installation)
[![License](https://img.shields.io/github/license/josemmo/uxml)](LICENSE)
@@ -12,7 +12,7 @@ It consist of just a single class which uses the PHP built-in `DOMElement` and `

### Using Composer
```
composer install josemmo/uxml
composer require josemmo/uxml
```

### Without Composer
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@
"lib-libxml": "*"
},
"require-dev": {
"phpunit/phpunit": "^9",
"phan/phan": "^4"
"phan/phan": "*",
"symfony/phpunit-bridge": "*"
}
}
153 changes: 107 additions & 46 deletions src/UXML.php
Original file line number Diff line number Diff line change
@@ -3,31 +3,36 @@

use DOMDocument;
use DOMElement;
use DOMException;
use DOMXPath;
use InvalidArgumentException;
use WeakMap;

use function class_exists;
use function count;
use function preg_replace_callback;
use function strpos;
use function strstr;

/** @phan-file-suppress PhanDeprecatedFunction */
class UXML {
const NS_PREFIX = "__uxml_ns_";
protected $element;
const NS_PREFIX = '__uxml_ns_';

/**
* Load XML document
* @param string $xmlString XML string
* @return self Root XML element
* @throws InvalidArgumentException if failed to parse XML
* @deprecated 0.1.0 Will be removed in next release, renamed to UXML::fromString()
* DOMElement instances
*
* Map of DOMElement references used from PHP 8.0 to avoid "Creation of dynamic property" deprecation warning.
* In previous versions, a custom DOMElement::$uxml property is used to keep a reference to the UXML instance.
*
* @var WeakMap<DOMElement,self>|null|false
*/
public static function load(string $xmlString): self {
return self::fromString($xmlString);
}
private static $elements = null;

/** @var DOMElement */
protected $element;

/**
* Create instance from XML string
*
* @param string $xmlString XML string
* @return self Root XML element
* @throws InvalidArgumentException if failed to parse XML
@@ -41,33 +46,63 @@ public static function fromString(string $xmlString): self {
return new self($doc->documentElement);
}


/**
* Create instance from DOM element
*
* @param DOMElement $element DOM element
* @return self Wrapped element as a UXML instance
* @suppress PhanUndeclaredProperty
* @suppress PhanUndeclaredProperty,PhanPossiblyNonClassMethodCall
*/
public static function fromElement(DOMElement $element): self {
// For PHP versions supporting WeakMap
if (self::$elements) {
return self::$elements->offsetExists($element) ?
self::$elements->offsetGet($element) :
new self($element);
}

// Fallback to dynamic properties
return $element->uxml ?? new self($element);
}


/**
* Create new instance
* @param string $name Element tag name
* @param string|null $value Element value or NULL for empty
* @param array $attrs Element attributes
* @param DOMDocument|null $doc Document instance
* @return self New instamce
*
* @param string $name Element tag name
* @param string|null $value Element value or `null` for empty
* @param array<string,string> $attrs Element attributes
* @param DOMDocument|null $doc Document instance
* @return self New instance
* @throws DOMException if failed to create new instance
*/
public static function newInstance(string $name, ?string $value=null, array $attrs=[], DOMDocument $doc=null): self {
public static function newInstance(string $name, ?string $value=null, array $attrs=[], ?DOMDocument $doc=null): self {
$targetDoc = ($doc === null) ? new DOMDocument() : $doc;
$domElement = $targetDoc->createElement($name, $value);

// Get namespace
$prefix = strstr($name, ':', true) ?: '';
$namespace = $attrs[empty($prefix) ? 'xmlns' : "xmlns:$prefix"] ?? $targetDoc->lookupNamespaceUri($prefix);

// Create element
$domElement = ($namespace === null) ?
$targetDoc->createElement($name) :
$targetDoc->createElementNS($namespace, $name);
if ($domElement === false) {
throw new DOMException('Failed to create DOMElement');
}

// Append element to document (in case of new document)
if ($doc === null) {
$targetDoc->appendChild($domElement);
}

// Set content
if ($value !== null) {
$domElement->textContent = $value;
}

// Set attributes
foreach ($attrs as $attrName=>$attrValue) {
if ($attrName === "xmlns" || strpos($attrName, 'xmlns:') === 0) {
if ($attrName === 'xmlns' || strpos($attrName, 'xmlns:') === 0) {
$domElement->setAttributeNS('http://www.w3.org/2000/xmlns/', $attrName, $attrValue);
} else {
$domElement->setAttribute($attrName, $attrValue);
@@ -78,63 +113,73 @@ public static function newInstance(string $name, ?string $value=null, array $att
return new self($domElement);
}


/**
* Class constructor
*
* @param DOMElement $element DOM Element instance
* @deprecated 0.1.0 Will become private in next release, use UXML::fromElement() instead
* @suppress PhanUndeclaredProperty
*/
public function __construct(DOMElement $element) {
private function __construct(DOMElement $element) {
// Initialize map of elements (if needed)
if (self::$elements === null) {
self::$elements = class_exists(WeakMap::class) ? new WeakMap() : false;
}

// Setup new instance
$this->element = $element;
$this->element->uxml = $this;
if (self::$elements) {
self::$elements->offsetSet($this->element, $this); // @phan-suppress-current-line PhanPossiblyNonClassMethodCall
} else {
$this->element->uxml = $this;
}
}


/**
* Get DOM element instance
*
* @return DOMElement DOM element instance
*/
public function element(): DOMElement {
return $this->element;
}


/**
* Get parent element
*
* @return self Parent element instance or this instance if it has no parent
*/
public function parent(): self {
$parentNode = $this->element->parentNode;
return ($parentNode !== null && $parentNode instanceof DOMElement) ? self::fromElement($parentNode) : $this;
}


/**
* Is empty
* @return boolean TRUE if the element has no inner content, FALSE otherwise
*
* @return boolean `true` if the element has no inner content, `false` otherwise
*/
public function isEmpty(): bool {
return ($this->element->childNodes->length === 0);
}


/**
* Add child element
*
* @param string $name New element tag name
* @param string|null $value New element value or NULL for empty
* @param string|null $value New element value or `null` for empty
* @param array $attrs New element attributes
* @return self New element instance
* @throws DOMException if failed to create child element
*/
public function add(string $name, ?string $value=null, array $attrs=[]): self {
$child = self::newInstance($name, $value, $attrs, $this->element->ownerDocument);
$this->element->appendChild($child->element);
return $child;
}


/**
* Find elements
*
* @param string $xpath XPath query relative to this element
* @param int|null $limit Maximum number of results to return
* @return self[] Matched elements
@@ -146,10 +191,11 @@ public function getAll(string $xpath, ?int $limit=null): array {
if (!isset($namespaces[$ns])) {
$namespaces[$ns] = self::NS_PREFIX . count($namespaces);
}
return $namespaces[$ns] . ":";
return $namespaces[$ns] . ':';
}, $xpath);

// Create instance
// @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal
$xpathInstance = new DOMXPath($this->element->ownerDocument);
foreach ($namespaces as $ns=>$prefix) {
$xpathInstance->registerNamespace($prefix, $ns);
@@ -161,15 +207,15 @@ public function getAll(string $xpath, ?int $limit=null): array {
foreach ($domNodes as $domNode) {
if (!$domNode instanceof DOMElement) continue;
$res[] = self::fromElement($domNode);
if (($limit !== null) && (--$limit <= 0)) break;
if ($limit !== null && --$limit <= 0) break;
}

return $res;
}


/**
* Find one element
*
* @param string $xpath XPath query relative to this element
* @return self|null First matched element or NULL if not found
*/
@@ -178,48 +224,63 @@ public function get(string $xpath): ?self {
return $res[0] ?? null;
}


/**
* Remove this element
*
* After calling this method on an instance it will become unusable.
* Calling it on a root element will have no effect.
*/
public function remove(): void {
$this->element->parentNode->removeChild($this->element);
$parent = $this->element->parentNode;
if ($parent !== null) {
$parent->removeChild($this->element);
}
}


/**
* Export element and children as text
*
* @return string Text representation
*/
public function asText(): string {
return $this->element->textContent;
}


/**
* Export as XML string
* @param string|null $version Document version, NULL for no declaration
*
* @param string|null $version Document version, `null` for no declaration
* @param string $encoding Document encoding
* @param boolean $format Format output
* @return string XML string
*/
public function asXML(?string $version="1.0", string $encoding="UTF-8", bool $format=true): string {
$doc = new DOMDocument($version, $encoding);
public function asXML(?string $version='1.0', string $encoding='UTF-8', bool $format=true): string {
$doc = new DOMDocument();

// Define document properties
if ($version === null) {
$doc->xmlStandalone = true;
} else {
$doc->xmlVersion = $version;
}
$doc->encoding = $encoding;
$doc->formatOutput = $format;
$doc->appendChild($doc->importNode($this->element, true));

// Export XML string
$rootNode = $doc->importNode($this->element, true);
if ($rootNode !== false) {
$doc->appendChild($rootNode);
}
$res = ($version === null) ? $doc->saveXML($doc->documentElement) : $doc->saveXML();
unset($doc);

return $res;
}


/**
* @inheritdoc
*/
public function __toString(): string {
return $this->asXML(null, '', false);
return $this->asXML(null, 'UTF-8', false);
}
}
127 changes: 91 additions & 36 deletions tests/UXMLTest.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<?php
namespace Tests;

use DOMDocument;
use DOMElement;
use DOMException;
use UXML\UXML;
use PHPUnit\Framework\TestCase;

@@ -25,6 +27,27 @@ public function testCanCreateElements(): void {
$this->assertEquals('<TagName/>', $xml);
}

public function testCanHandleSpecialCharacters(): void {
$xml = UXML::newInstance('Test', 'A&a Co. > B&b Ltd.');
$this->assertEquals('<Test>A&amp;a Co. &gt; B&amp;b Ltd.</Test>', $xml);

$this->expectException(DOMException::class);
UXML::newInstance('Test&Fail', 'Not a valid tag name');
}

public function testCanExportXml(): void {
$xml = UXML::newInstance('Root');
$this->assertEquals('<Root/>', (string) $xml);
$this->assertEquals('<Root/>', $xml->asXML(null));
$this->assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Root/>\n", $xml->asXML());
$this->assertEquals("<?xml version=\"1.1\" encoding=\"ISO-8859-1\"?>\n<Root/>\n", $xml->asXML('1.1', 'ISO-8859-1'));

$xml = UXML::fromString('<a><b>1</b><b>2</b></a>');
$this->assertEquals("<a>\n <b>1</b>\n <b>2</b>\n</a>", $xml->asXML(null));
$this->assertEquals('<a><b>1</b><b>2</b></a>', (string) $xml);
$this->assertEquals('<a><b>1</b><b>2</b></a>', $xml->asXML(null, 'UTF-8', false));
}

public function testCanLoadXml(): void {
$source = "<fruits>";
$source .= "<fruit>Banana</fruit>";
@@ -58,16 +81,16 @@ public function testCanHandleAttributes(): void {

public function testCanGetSingleElement(): void {
$source = <<<XML
<movie>
<name lang="en-US">Inception</name>
<year>2010</year>
<director>
<name>Christopher</name>
<surname>Nolan</surname>
<year>1970</year>
</director>
</movie>
XML;
<movie>
<name lang="en-US">Inception</name>
<year>2010</year>
<director>
<name>Christopher</name>
<surname>Nolan</surname>
<year>1970</year>
</director>
</movie>
XML;
$xml = UXML::fromString($source);

$this->assertEquals('<year>1970</year>', $xml->get('director/year'));
@@ -80,19 +103,19 @@ public function testCanGetSingleElement(): void {

public function testCanGetAllElements(): void {
$source = <<<XML
<root>
<a>
<b>1</b>
<b>2</b>
<c>-1</c>
<b>3</b>
<c>-2</c>
<d>Inf</d>
</a>
<b>4</b>
<c>-3</c>
</root>
XML;
<root>
<a>
<b>1</b>
<b>2</b>
<c>-1</c>
<b>3</b>
<c>-2</c>
<d>Inf</d>
</a>
<b>4</b>
<c>-3</c>
</root>
XML;
$xml = UXML::fromString($source);

$this->assertEquals('1,2,3', $this->listToText($xml->getAll('a/b')));
@@ -108,6 +131,21 @@ public function testCanHandleClarkNotation(): void {
$this->assertSame($xml->get('{urn:abc}b'), $xml->get('ns:b'));
}

public function testCanHandleNamespaces(): void {
$xml = UXML::fromString('<root xmlns="urn:root" xmlns:ns="urn:child"><ns:child /><ns:child /></root>');
$this->assertEquals(2, count($xml->getAll('ns:child')));
$xml->add('ns:child', 'Another child');
$this->assertEquals(3, count($xml->getAll('ns:child')));

$xml = UXML::newInstance('root', null, [
'xmlns:ns' => 'urn:child'
]);
$xml->add('ns:child', 'A1')->add('child', 'A2', ['xmlns' => 'urn:child']);
$xml->add('ns:child', 'B1')->add('ns:child', 'B2');
$this->assertEquals(2, count($xml->getAll('ns:child')));
$this->assertEquals(4, count($xml->getAll('//ns:child')));
}

public function testCanGetParent(): void {
$root = UXML::newInstance('Root');
$level1 = $root->add('Level1');
@@ -122,24 +160,41 @@ public function testCanGetParent(): void {

public function testCanRemoveElements(): void {
$source = <<<XML
<root>
<a>1</a>
<a>2</a>
<a>3</a>
<b>4</b>
<b>5</b>
<b>6</b>
<a>7</a>
<a>8</a>
<b>9</b>
<a>10</a>
</root>
XML;
<root>
<a>1</a>
<a>2</a>
<a>3</a>
<b>4</b>
<b>5</b>
<b>6</b>
<a>7</a>
<a>8</a>
<b>9</b>
<a>10</a>
</root>
XML;
$xml = UXML::fromString($source);
foreach ($xml->getAll('a') as $item) {
$item->remove();
}
$this->assertEmpty($xml->getAll('a'));
$this->assertEquals('<root><b>4</b><b>5</b><b>6</b><b>9</b></root>', $xml);
}

public function testCanHandleExistingInstances(): void {
$doc = new DOMDocument();

$rootNode = $doc->createElement('root');
$rootA = UXML::fromElement($rootNode);
$rootB = UXML::fromElement($rootNode);
$this->assertSame($rootA, $rootB);
$this->assertSame($rootA->element(), $rootNode);

$child = UXML::newInstance('Child', null, [], $doc);
$childNode = $child->element();
$rootNode->appendChild($childNode);
$this->assertSame($rootA->get('Child'), $child);
$this->assertSame($child->parent(), $rootA);
$this->assertNotSame($child, $rootA);
}
}