Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ An article
| `{{#time: j F Y, H:i \| date}}` | a date in a format, in UTC: `Y y n m M F d j D l N w z W t L H G h g i s a A U c r e` as in PHP's `date()`, `"quoted text"` and `\c` as written; `date` is anything PHP reads (`2026-10-12`, `@1791500000`, `yesterday`, `+1 week`), and the time now when left out |
| `{{CURRENTYEAR}}`, `{{CURRENTMONTH}}`, `{{CURRENTMONTHNAME}}`, `{{CURRENTMONTHABBREV}}`, `{{CURRENTDAY}}`, `{{CURRENTDAY2}}`, `{{CURRENTDAYNAME}}`, `{{CURRENTDOW}}`, `{{CURRENTWEEK}}`, `{{CURRENTHOUR}}`, `{{CURRENTTIME}}`, `{{CURRENTTIMESTAMP}}` | the date and time now, in UTC, with English month and day names |
| `{{#len: text}}` | the number of characters |
| `{{#pos: text \| find \| from}}`, `{{#rpos: text \| find}}` | where `find` first or last occurs, counted from 0; `#pos` gives nothing and `#rpos` gives `-1` when it is not there |
| `{{#pos: text \| find \| from}}`, `{{#rpos: text \| find}}` | where `find` first or last occurs, counted from 0; `#pos` gives nothing and `#rpos` gives `-1` when it is not there; an empty `find` is a space |
| `{{#sub: text \| start \| length}}` | part of `text`; a negative `start` counts from the end and a negative `length` leaves that many off the end |
| `{{#replace: text \| find \| with}}` | every `find` swapped for `with` |
| `{{#explode: text \| separator \| position \| limit}}` | one piece of `text` cut at `separator`, counted from 0 (negative from the end) |
| `{{#replace: text \| find \| with}}` | every `find` swapped for `with`; an empty `find` is a space, so `{{#replace: text \|\|  }}` keeps `text` on one line |
| `{{#explode: text \| separator \| position \| limit}}` | one piece of `text` cut at `separator`, counted from 0 (negative from the end); an empty `separator` is a space |
| `{{padleft: text \| length \| fill}}`, `{{padright: text \| length \| fill}}` | `text` filled up to `length` with `fill` (default `0`) |
| `{{urlencode: text}}`, `{{anchorencode: text}}` | `text` as a web address part, and as a heading anchor |

Expand Down
38 changes: 38 additions & 0 deletions tests/unit/parser_functions_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ protected function setUp(): void
'Template:Mid' => '{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}',
'Template:Wide' => '{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}',
'Template:Deep' => '{{#if: {{{1|}}} | {{Deep|{{{1}}}}} | end}}',
'Template:Edge' => '[{{{1}}}][{{{n|}}}]',
'Main Page' => 'x',
);

Expand Down Expand Up @@ -243,6 +244,43 @@ public function test_string_functions()
$this->assertSame('b,c', $this->run_text('{{#explode: a,b,c | , | 1 | 2}}'));
}

public function test_an_empty_search_text_is_a_space()
{
$this->assertSame('a b c', $this->run_text('{{#replace: a b c ||  }}'));
$this->assertSame('a_b_c', $this->run_text('{{#replace: a b c | | _}}'), 'a space written there is trimmed away, which leaves the space too');
$this->assertSame('abc', $this->run_text('{{#replace: a b c }}'));
$this->assertSame('1', $this->run_text('{{#pos: a b c | }}'));
$this->assertSame('3', $this->run_text('{{#rpos: a b c | }}'));
$this->assertSame('b', $this->run_text('{{#explode: a b c | | 1}}'));
$this->assertSame('a b c', $this->run_text('{{#replace: a b c |   | _}}'), 'as in MediaWiki, the search text is matched as written, not as an entity');
}

/**
* On a board the text of a call reaches the template service with entities as written
* (see templates::expand_all()), so   is not whitespace to the trim and is shown as
* a space once the result is rendered, as on MediaWiki.
*/
public function test_an_entity_space_survives_the_trim_of_a_result()
{
$this->assertSame('AandB', $this->run_text('A{{#if: x | and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#if: x | and}}B'));
$this->assertSame('Aand B', $this->run_text('A{{#if: | x | and }}B'));
$this->assertSame('A
andB', $this->run_text('A{{#if: x |
and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#ifeq: 1 | 1 | and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#switch: a | a = and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#switch: z | a = no | and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#switch: z | a = no | #default = and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#ifexpr: 1 > 0 | and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#iferror: x | no | and}}B'));
$this->assertSame('A andB', $this->run_text('A{{#iferror: and}}B'));
}

public function test_named_parameters_are_trimmed_and_positional_ones_are_not()
{
$this->assertSame('[ a ][b]', $this->run_text('{{Edge| a | n = b }}'));
$this->assertSame('[ a ][ b]', $this->run_text('{{Edge| a | n =  b }}'));
}

public function test_padding_and_encoding()
{
$this->assertSame('00042', $this->run_text('{{padleft: 42 | 5}}'));
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/text_tools_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public function test_positions_count_characters_not_bytes()
$this->assertSame('2', text_tools::pos('ééx', 'x'));
$this->assertSame('', text_tools::pos('abc', ''));
$this->assertSame('-1', text_tools::rpos('abc', ''));
$this->assertSame('1', text_tools::pos('é b', ''));
$this->assertSame('3', text_tools::rpos('é b c', ''));
$this->assertSame('x', text_tools::sub('éxé', 1, 1));
}
}
41 changes: 21 additions & 20 deletions wiki/text_tools.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,30 @@ class text_tools
/** Format letters {{#time:}} passes on to PHP's date(). */
const TIME_LETTERS = 'dDjlNwzWFmMntLoYyaAgGhHisTPOcrUeI';

/**
* The text {{#pos:}}, {{#rpos:}}, {{#replace:}} and {{#explode:}} look for. Left
* empty, it is a single space, as in MediaWiki: arguments are trimmed, so a space
* cannot be written there.
*
* @param string $search The search text as given
* @return string
*/
protected static function search_text($search)
{
return ($search === '') ? ' ' : $search;
}

/**
* {{#pos:}}: where a text first occurs in another.
*
* @param string $haystack
* @param string $needle
* @param string $needle Empty for a space
* @param int $offset Characters to skip
* @return string The position counted from 0, or "" when it is not there
*/
public static function pos($haystack, $needle, $offset = 0)
{
if ($needle === '')
{
return '';
}
$needle = self::search_text($needle);
$position = mb_strpos($haystack, $needle, max(0, min($offset, mb_strlen($haystack))));

return ($position === false) ? '' : (string) $position;
Expand All @@ -45,12 +55,12 @@ public static function pos($haystack, $needle, $offset = 0)
* {{#rpos:}}: where a text last occurs in another.
*
* @param string $haystack
* @param string $needle
* @param string $needle Empty for a space
* @return string The position counted from 0, or "-1" when it is not there
*/
public static function rpos($haystack, $needle)
{
$position = ($needle === '') ? false : mb_strrpos($haystack, $needle);
$position = mb_strrpos($haystack, self::search_text($needle));

return ($position === false) ? '-1' : (string) $position;
}
Expand All @@ -72,18 +82,13 @@ public static function sub($text, $start, $length = null)
* {{#replace:}}: every occurrence of a text swapped for another.
*
* @param string $text
* @param string $search
* @param string $search Empty for a space
* @param string $replacement
* @return string
*/
public static function replace($text, $search, $replacement)
{
if ($search === '')
{
return $text;
}

$result = str_replace($search, $replacement, $text);
$result = str_replace(self::search_text($search), $replacement, $text);

return (mb_strlen($result) > self::MAX_LENGTH) ? mb_substr($result, 0, self::MAX_LENGTH) : $result;
}
Expand All @@ -92,18 +97,14 @@ public static function replace($text, $search, $replacement)
* {{#explode:}}: one piece of a text cut at a delimiter.
*
* @param string $text
* @param string $delimiter
* @param string $delimiter Empty for a space
* @param int $position The piece, counted from 0; negative counts from the end
* @param int|null $limit Cut into at most this many pieces first
* @return string "" when there is no such piece
*/
public static function explode($text, $delimiter, $position = 0, $limit = null)
{
if ($delimiter === '')
{
return '';
}

$delimiter = self::search_text($delimiter);
$pieces = ($limit !== null && $limit > 0) ? explode($delimiter, $text, $limit) : explode($delimiter, $text);
if ($position < 0)
{
Expand Down
Loading