diff --git a/README.md b/README.md index eaebb47..96e4d3d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/tests/unit/parser_functions_test.php b/tests/unit/parser_functions_test.php index 1cc3568..5057205 100644 --- a/tests/unit/parser_functions_test.php +++ b/tests/unit/parser_functions_test.php @@ -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', ); @@ -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}}')); diff --git a/tests/unit/text_tools_test.php b/tests/unit/text_tools_test.php index 7c4aa90..e23d1a0 100644 --- a/tests/unit/text_tools_test.php +++ b/tests/unit/text_tools_test.php @@ -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)); } } diff --git a/wiki/text_tools.php b/wiki/text_tools.php index 15ed5b3..d38ccf5 100644 --- a/wiki/text_tools.php +++ b/wiki/text_tools.php @@ -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; @@ -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; } @@ -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; } @@ -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) {