Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
   1  <?php
   2  
   3  /**

   4   * Takes tokens makes them well-formed (balance end tags, etc.)

   5   *

   6   * Specification of the armor attributes this strategy uses:

   7   *

   8   *      - MakeWellFormed_TagClosedError: This armor field is used to

   9   *        suppress tag closed errors for certain tokens [TagClosedSuppress],

  10   *        in particular, if a tag was generated automatically by HTML

  11   *        Purifier, we may rely on our infrastructure to close it for us

  12   *        and shouldn't report an error to the user [TagClosedAuto].

  13   */
  14  class HTMLPurifier_Strategy_MakeWellFormed extends HTMLPurifier_Strategy
  15  {
  16  
  17      /**

  18       * Array stream of tokens being processed.

  19       * @type HTMLPurifier_Token[]

  20       */
  21      protected $tokens;
  22  
  23      /**

  24       * Current token.

  25       * @type HTMLPurifier_Token

  26       */
  27      protected $token;
  28  
  29      /**

  30       * Zipper managing the true state.

  31       * @type HTMLPurifier_Zipper

  32       */
  33      protected $zipper;
  34  
  35      /**

  36       * Current nesting of elements.

  37       * @type array

  38       */
  39      protected $stack;
  40  
  41      /**

  42       * Injectors active in this stream processing.

  43       * @type HTMLPurifier_Injector[]

  44       */
  45      protected $injectors;
  46  
  47      /**

  48       * Current instance of HTMLPurifier_Config.

  49       * @type HTMLPurifier_Config

  50       */
  51      protected $config;
  52  
  53      /**

  54       * Current instance of HTMLPurifier_Context.

  55       * @type HTMLPurifier_Context

  56       */
  57      protected $context;
  58  
  59      /**

  60       * @param HTMLPurifier_Token[] $tokens

  61       * @param HTMLPurifier_Config $config

  62       * @param HTMLPurifier_Context $context

  63       * @return HTMLPurifier_Token[]

  64       * @throws HTMLPurifier_Exception

  65       */
  66      public function execute($tokens, $config, $context)
  67      {
  68          $definition = $config->getHTMLDefinition();
  69  
  70          // local variables

  71          $generator = new HTMLPurifier_Generator($config, $context);
  72          $escape_invalid_tags = $config->get('Core.EscapeInvalidTags');
  73          // used for autoclose early abortion

  74          $global_parent_allowed_elements = $definition->info_parent_def->child->getAllowedElements($config);
  75          $e = $context->get('ErrorCollector', true);
  76          $i = false; // injector index

  77          list($zipper, $token) = HTMLPurifier_Zipper::fromArray($tokens);
  78          if ($token === NULL) {
  79              return array();
  80          }
  81          $reprocess = false; // whether or not to reprocess the same token

  82          $stack = array();
  83  
  84          // member variables

  85          $this->stack =& $stack;
  86          $this->tokens =& $tokens;
  87          $this->token =& $token;
  88          $this->zipper =& $zipper;
  89          $this->config = $config;
  90          $this->context = $context;
  91  
  92          // context variables

  93          $context->register('CurrentNesting', $stack);
  94          $context->register('InputZipper', $zipper);
  95          $context->register('CurrentToken', $token);
  96  
  97          // -- begin INJECTOR --

  98  
  99          $this->injectors = array();
 100  
 101          $injectors = $config->getBatch('AutoFormat');
 102          $def_injectors = $definition->info_injector;
 103          $custom_injectors = $injectors['Custom'];
 104          unset($injectors['Custom']); // special case

 105          foreach ($injectors as $injector => $b) {
 106              // XXX: Fix with a legitimate lookup table of enabled filters

 107              if (strpos($injector, '.') !== false) {
 108                  continue;
 109              }
 110              $injector = "HTMLPurifier_Injector_$injector";
 111              if (!$b) {
 112                  continue;
 113              }
 114              $this->injectors[] = new $injector;
 115          }
 116          foreach ($def_injectors as $injector) {
 117              // assumed to be objects

 118              $this->injectors[] = $injector;
 119          }
 120          foreach ($custom_injectors as $injector) {
 121              if (!$injector) {
 122                  continue;
 123              }
 124              if (is_string($injector)) {
 125                  $injector = "HTMLPurifier_Injector_$injector";
 126                  $injector = new $injector;
 127              }
 128              $this->injectors[] = $injector;
 129          }
 130  
 131          // give the injectors references to the definition and context

 132          // variables for performance reasons

 133          foreach ($this->injectors as $ix => $injector) {
 134              $error = $injector->prepare($config, $context);
 135              if (!$error) {
 136                  continue;
 137              }
 138              array_splice($this->injectors, $ix, 1); // rm the injector

 139              trigger_error("Cannot enable {$injector->name} injector because $error is not allowed", E_USER_WARNING);
 140          }
 141  
 142          // -- end INJECTOR --

 143  
 144          // a note on reprocessing:

 145          //      In order to reduce code duplication, whenever some code needs

 146          //      to make HTML changes in order to make things "correct", the

 147          //      new HTML gets sent through the purifier, regardless of its

 148          //      status. This means that if we add a start token, because it

 149          //      was totally necessary, we don't have to update nesting; we just

 150          //      punt ($reprocess = true; continue;) and it does that for us.

 151  
 152          // isset is in loop because $tokens size changes during loop exec

 153          for (;;
 154               // only increment if we don't need to reprocess

 155               $reprocess ? $reprocess = false : $token = $zipper->next($token)) {
 156  
 157              // check for a rewind

 158              if (is_int($i)) {
 159                  // possibility: disable rewinding if the current token has a

 160                  // rewind set on it already. This would offer protection from

 161                  // infinite loop, but might hinder some advanced rewinding.

 162                  $rewind_offset = $this->injectors[$i]->getRewindOffset();
 163                  if (is_int($rewind_offset)) {
 164                      for ($j = 0; $j < $rewind_offset; $j++) {
 165                          if (empty($zipper->front)) break;
 166                          $token = $zipper->prev($token);
 167                          // indicate that other injectors should not process this token,

 168                          // but we need to reprocess it.  See Note [Injector skips]

 169                          unset($token->skip[$i]);
 170                          $token->rewind = $i;
 171                          if ($token instanceof HTMLPurifier_Token_Start) {
 172                              array_pop($this->stack);
 173                          } elseif ($token instanceof HTMLPurifier_Token_End) {
 174                              $this->stack[] = $token->start;
 175                          }
 176                      }
 177                  }
 178                  $i = false;
 179              }
 180  
 181              // handle case of document end

 182              if ($token === NULL) {
 183                  // kill processing if stack is empty

 184                  if (empty($this->stack)) {
 185                      break;
 186                  }
 187  
 188                  // peek

 189                  $top_nesting = array_pop($this->stack);
 190                  $this->stack[] = $top_nesting;
 191  
 192                  // send error [TagClosedSuppress]

 193                  if ($e && !isset($top_nesting->armor['MakeWellFormed_TagClosedError'])) {
 194                      $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag closed by document end', $top_nesting);
 195                  }
 196  
 197                  // append, don't splice, since this is the end

 198                  $token = new HTMLPurifier_Token_End($top_nesting->name);
 199  
 200                  // punt!

 201                  $reprocess = true;
 202                  continue;
 203              }
 204  
 205              //echo '<br>'; printZipper($zipper, $token);//printTokens($this->stack);

 206              //flush();

 207  
 208              // quick-check: if it's not a tag, no need to process

 209              if (empty($token->is_tag)) {
 210                  if ($token instanceof HTMLPurifier_Token_Text) {
 211                      foreach ($this->injectors as $i => $injector) {
 212                          if (isset($token->skip[$i])) {
 213                              // See Note [Injector skips]

 214                              continue;
 215                          }
 216                          if ($token->rewind !== null && $token->rewind !== $i) {
 217                              continue;
 218                          }
 219                          // XXX fuckup

 220                          $r = $token;
 221                          $injector->handleText($r);
 222                          $token = $this->processToken($r, $i);
 223                          $reprocess = true;
 224                          break;
 225                      }
 226                  }
 227                  // another possibility is a comment

 228                  continue;
 229              }
 230  
 231              if (isset($definition->info[$token->name])) {
 232                  $type = $definition->info[$token->name]->child->type;
 233              } else {
 234                  $type = false; // Type is unknown, treat accordingly

 235              }
 236  
 237              // quick tag checks: anything that's *not* an end tag

 238              $ok = false;
 239              if ($type === 'empty' && $token instanceof HTMLPurifier_Token_Start) {
 240                  // claims to be a start tag but is empty

 241                  $token = new HTMLPurifier_Token_Empty(
 242                      $token->name,
 243                      $token->attr,
 244                      $token->line,
 245                      $token->col,
 246                      $token->armor
 247                  );
 248                  $ok = true;
 249              } elseif ($type && $type !== 'empty' && $token instanceof HTMLPurifier_Token_Empty) {
 250                  // claims to be empty but really is a start tag

 251                  // NB: this assignment is required

 252                  $old_token = $token;
 253                  $token = new HTMLPurifier_Token_End($token->name);
 254                  $token = $this->insertBefore(
 255                      new HTMLPurifier_Token_Start($old_token->name, $old_token->attr, $old_token->line, $old_token->col, $old_token->armor)
 256                  );
 257                  // punt (since we had to modify the input stream in a non-trivial way)

 258                  $reprocess = true;
 259                  continue;
 260              } elseif ($token instanceof HTMLPurifier_Token_Empty) {
 261                  // real empty token

 262                  $ok = true;
 263              } elseif ($token instanceof HTMLPurifier_Token_Start) {
 264                  // start tag

 265  
 266                  // ...unless they also have to close their parent

 267                  if (!empty($this->stack)) {
 268  
 269                      // Performance note: you might think that it's rather

 270                      // inefficient, recalculating the autoclose information

 271                      // for every tag that a token closes (since when we

 272                      // do an autoclose, we push a new token into the

 273                      // stream and then /process/ that, before

 274                      // re-processing this token.)  But this is

 275                      // necessary, because an injector can make an

 276                      // arbitrary transformations to the autoclosing

 277                      // tokens we introduce, so things may have changed

 278                      // in the meantime.  Also, doing the inefficient thing is

 279                      // "easy" to reason about (for certain perverse definitions

 280                      // of "easy")

 281  
 282                      $parent = array_pop($this->stack);
 283                      $this->stack[] = $parent;
 284  
 285                      $parent_def = null;
 286                      $parent_elements = null;
 287                      $autoclose = false;
 288                      if (isset($definition->info[$parent->name])) {
 289                          $parent_def = $definition->info[$parent->name];
 290                          $parent_elements = $parent_def->child->getAllowedElements($config);
 291                          $autoclose = !isset($parent_elements[$token->name]);
 292                      }
 293  
 294                      if ($autoclose && $definition->info[$token->name]->wrap) {
 295                          // Check if an element can be wrapped by another

 296                          // element to make it valid in a context (for

 297                          // example, <ul><ul> needs a <li> in between)

 298                          $wrapname = $definition->info[$token->name]->wrap;
 299                          $wrapdef = $definition->info[$wrapname];
 300                          $elements = $wrapdef->child->getAllowedElements($config);
 301                          if (isset($elements[$token->name]) && isset($parent_elements[$wrapname])) {
 302                              $newtoken = new HTMLPurifier_Token_Start($wrapname);
 303                              $token = $this->insertBefore($newtoken);
 304                              $reprocess = true;
 305                              continue;
 306                          }
 307                      }
 308  
 309                      $carryover = false;
 310                      if ($autoclose && $parent_def->formatting) {
 311                          $carryover = true;
 312                      }
 313  
 314                      if ($autoclose) {
 315                          // check if this autoclose is doomed to fail

 316                          // (this rechecks $parent, which his harmless)

 317                          $autoclose_ok = isset($global_parent_allowed_elements[$token->name]);
 318                          if (!$autoclose_ok) {
 319                              foreach ($this->stack as $ancestor) {
 320                                  $elements = $definition->info[$ancestor->name]->child->getAllowedElements($config);
 321                                  if (isset($elements[$token->name])) {
 322                                      $autoclose_ok = true;
 323                                      break;
 324                                  }
 325                                  if ($definition->info[$token->name]->wrap) {
 326                                      $wrapname = $definition->info[$token->name]->wrap;
 327                                      $wrapdef = $definition->info[$wrapname];
 328                                      $wrap_elements = $wrapdef->child->getAllowedElements($config);
 329                                      if (isset($wrap_elements[$token->name]) && isset($elements[$wrapname])) {
 330                                          $autoclose_ok = true;
 331                                          break;
 332                                      }
 333                                  }
 334                              }
 335                          }
 336                          if ($autoclose_ok) {
 337                              // errors need to be updated

 338                              $new_token = new HTMLPurifier_Token_End($parent->name);
 339                              $new_token->start = $parent;
 340                              // [TagClosedSuppress]

 341                              if ($e && !isset($parent->armor['MakeWellFormed_TagClosedError'])) {
 342                                  if (!$carryover) {
 343                                      $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag auto closed', $parent);
 344                                  } else {
 345                                      $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag carryover', $parent);
 346                                  }
 347                              }
 348                              if ($carryover) {
 349                                  $element = clone $parent;
 350                                  // [TagClosedAuto]

 351                                  $element->armor['MakeWellFormed_TagClosedError'] = true;
 352                                  $element->carryover = true;
 353                                  $token = $this->processToken(array($new_token, $token, $element));
 354                              } else {
 355                                  $token = $this->insertBefore($new_token);
 356                              }
 357                          } else {
 358                              $token = $this->remove();
 359                          }
 360                          $reprocess = true;
 361                          continue;
 362                      }
 363  
 364                  }
 365                  $ok = true;
 366              }
 367  
 368              if ($ok) {
 369                  foreach ($this->injectors as $i => $injector) {
 370                      if (isset($token->skip[$i])) {
 371                          // See Note [Injector skips]

 372                          continue;
 373                      }
 374                      if ($token->rewind !== null && $token->rewind !== $i) {
 375                          continue;
 376                      }
 377                      $r = $token;
 378                      $injector->handleElement($r);
 379                      $token = $this->processToken($r, $i);
 380                      $reprocess = true;
 381                      break;
 382                  }
 383                  if (!$reprocess) {
 384                      // ah, nothing interesting happened; do normal processing

 385                      if ($token instanceof HTMLPurifier_Token_Start) {
 386                          $this->stack[] = $token;
 387                      } elseif ($token instanceof HTMLPurifier_Token_End) {
 388                          throw new HTMLPurifier_Exception(
 389                              'Improper handling of end tag in start code; possible error in MakeWellFormed'
 390                          );
 391                      }
 392                  }
 393                  continue;
 394              }
 395  
 396              // sanity check: we should be dealing with a closing tag

 397              if (!$token instanceof HTMLPurifier_Token_End) {
 398                  throw new HTMLPurifier_Exception('Unaccounted for tag token in input stream, bug in HTML Purifier');
 399              }
 400  
 401              // make sure that we have something open

 402              if (empty($this->stack)) {
 403                  if ($escape_invalid_tags) {
 404                      if ($e) {
 405                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Unnecessary end tag to text');
 406                      }
 407                      $token = new HTMLPurifier_Token_Text($generator->generateFromToken($token));
 408                  } else {
 409                      if ($e) {
 410                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Unnecessary end tag removed');
 411                      }
 412                      $token = $this->remove();
 413                  }
 414                  $reprocess = true;
 415                  continue;
 416              }
 417  
 418              // first, check for the simplest case: everything closes neatly.

 419              // Eventually, everything passes through here; if there are problems

 420              // we modify the input stream accordingly and then punt, so that

 421              // the tokens get processed again.

 422              $current_parent = array_pop($this->stack);
 423              if ($current_parent->name == $token->name) {
 424                  $token->start = $current_parent;
 425                  foreach ($this->injectors as $i => $injector) {
 426                      if (isset($token->skip[$i])) {
 427                          // See Note [Injector skips]

 428                          continue;
 429                      }
 430                      if ($token->rewind !== null && $token->rewind !== $i) {
 431                          continue;
 432                      }
 433                      $r = $token;
 434                      $injector->handleEnd($r);
 435                      $token = $this->processToken($r, $i);
 436                      $this->stack[] = $current_parent;
 437                      $reprocess = true;
 438                      break;
 439                  }
 440                  continue;
 441              }
 442  
 443              // okay, so we're trying to close the wrong tag

 444  
 445              // undo the pop previous pop

 446              $this->stack[] = $current_parent;
 447  
 448              // scroll back the entire nest, trying to find our tag.

 449              // (feature could be to specify how far you'd like to go)

 450              $size = count($this->stack);
 451              // -2 because -1 is the last element, but we already checked that

 452              $skipped_tags = false;
 453              for ($j = $size - 2; $j >= 0; $j--) {
 454                  if ($this->stack[$j]->name == $token->name) {
 455                      $skipped_tags = array_slice($this->stack, $j);
 456                      break;
 457                  }
 458              }
 459  
 460              // we didn't find the tag, so remove

 461              if ($skipped_tags === false) {
 462                  if ($escape_invalid_tags) {
 463                      if ($e) {
 464                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Stray end tag to text');
 465                      }
 466                      $token = new HTMLPurifier_Token_Text($generator->generateFromToken($token));
 467                  } else {
 468                      if ($e) {
 469                          $e->send(E_WARNING, 'Strategy_MakeWellFormed: Stray end tag removed');
 470                      }
 471                      $token = $this->remove();
 472                  }
 473                  $reprocess = true;
 474                  continue;
 475              }
 476  
 477              // do errors, in REVERSE $j order: a,b,c with </a></b></c>

 478              $c = count($skipped_tags);
 479              if ($e) {
 480                  for ($j = $c - 1; $j > 0; $j--) {
 481                      // notice we exclude $j == 0, i.e. the current ending tag, from

 482                      // the errors... [TagClosedSuppress]

 483                      if (!isset($skipped_tags[$j]->armor['MakeWellFormed_TagClosedError'])) {
 484                          $e->send(E_NOTICE, 'Strategy_MakeWellFormed: Tag closed by element end', $skipped_tags[$j]);
 485                      }
 486                  }
 487              }
 488  
 489              // insert tags, in FORWARD $j order: c,b,a with </a></b></c>

 490              $replace = array($token);
 491              for ($j = 1; $j < $c; $j++) {
 492                  // ...as well as from the insertions

 493                  $new_token = new HTMLPurifier_Token_End($skipped_tags[$j]->name);
 494                  $new_token->start = $skipped_tags[$j];
 495                  array_unshift($replace, $new_token);
 496                  if (isset($definition->info[$new_token->name]) && $definition->info[$new_token->name]->formatting) {
 497                      // [TagClosedAuto]

 498                      $element = clone $skipped_tags[$j];
 499                      $element->carryover = true;
 500                      $element->armor['MakeWellFormed_TagClosedError'] = true;
 501                      $replace[] = $element;
 502                  }
 503              }
 504              $token = $this->processToken($replace);
 505              $reprocess = true;
 506              continue;
 507          }
 508  
 509          $context->destroy('CurrentToken');
 510          $context->destroy('CurrentNesting');
 511          $context->destroy('InputZipper');
 512  
 513          unset($this->injectors, $this->stack, $this->tokens);
 514          return $zipper->toArray($token);
 515      }
 516  
 517      /**

 518       * Processes arbitrary token values for complicated substitution patterns.

 519       * In general:

 520       *

 521       * If $token is an array, it is a list of tokens to substitute for the

 522       * current token. These tokens then get individually processed. If there

 523       * is a leading integer in the list, that integer determines how many

 524       * tokens from the stream should be removed.

 525       *

 526       * If $token is a regular token, it is swapped with the current token.

 527       *

 528       * If $token is false, the current token is deleted.

 529       *

 530       * If $token is an integer, that number of tokens (with the first token

 531       * being the current one) will be deleted.

 532       *

 533       * @param HTMLPurifier_Token|array|int|bool $token Token substitution value

 534       * @param HTMLPurifier_Injector|int $injector Injector that performed the substitution; default is if

 535       *        this is not an injector related operation.

 536       * @throws HTMLPurifier_Exception

 537       */
 538      protected function processToken($token, $injector = -1)
 539      {
 540          // Zend OpCache miscompiles $token = array($token), so

 541          // avoid this pattern.  See: https://github.com/ezyang/htmlpurifier/issues/108

 542  
 543          // normalize forms of token

 544          if (is_object($token)) {
 545              $tmp = $token;
 546              $token = array(1, $tmp);
 547          }
 548          if (is_int($token)) {
 549              $tmp = $token;
 550              $token = array($tmp);
 551          }
 552          if ($token === false) {
 553              $token = array(1);
 554          }
 555          if (!is_array($token)) {
 556              throw new HTMLPurifier_Exception('Invalid token type from injector');
 557          }
 558          if (!is_int($token[0])) {
 559              array_unshift($token, 1);
 560          }
 561          if ($token[0] === 0) {
 562              throw new HTMLPurifier_Exception('Deleting zero tokens is not valid');
 563          }
 564  
 565          // $token is now an array with the following form:

 566          // array(number nodes to delete, new node 1, new node 2, ...)

 567  
 568          $delete = array_shift($token);
 569          list($old, $r) = $this->zipper->splice($this->token, $delete, $token);
 570  
 571          if ($injector > -1) {
 572              // See Note [Injector skips]

 573              // Determine appropriate skips.  Here's what the code does:

 574              //  *If* we deleted one or more tokens, copy the skips

 575              //  of those tokens into the skips of the new tokens (in $token).

 576              //  Also, mark the newly inserted tokens as having come from

 577              //  $injector.

 578              $oldskip = isset($old[0]) ? $old[0]->skip : array();
 579              foreach ($token as $object) {
 580                  $object->skip = $oldskip;
 581                  $object->skip[$injector] = true;
 582              }
 583          }
 584  
 585          return $r;
 586  
 587      }
 588  
 589      /**

 590       * Inserts a token before the current token. Cursor now points to

 591       * this token.  You must reprocess after this.

 592       * @param HTMLPurifier_Token $token

 593       */
 594      private function insertBefore($token)
 595      {
 596          // NB not $this->zipper->insertBefore(), due to positioning

 597          // differences

 598          $splice = $this->zipper->splice($this->token, 0, array($token));
 599  
 600          return $splice[1];
 601      }
 602  
 603      /**

 604       * Removes current token. Cursor now points to new token occupying previously

 605       * occupied space.  You must reprocess after this.

 606       */
 607      private function remove()
 608      {
 609          return $this->zipper->delete();
 610      }
 611  }
 612  
 613  // Note [Injector skips]

 614  // ~~~~~~~~~~~~~~~~~~~~~

 615  // When I originally designed this class, the idea behind the 'skip'

 616  // property of HTMLPurifier_Token was to help avoid infinite loops

 617  // in injector processing.  For example, suppose you wrote an injector

 618  // that bolded swear words.  Naively, you might write it so that

 619  // whenever you saw ****, you replaced it with <strong>****</strong>.

 620  //

 621  // When this happens, we will reprocess all of the tokens with the

 622  // other injectors.  Now there is an opportunity for infinite loop:

 623  // if we rerun the swear-word injector on these tokens, we might

 624  // see **** and then reprocess again to get

 625  // <strong><strong>****</strong></strong> ad infinitum.

 626  //

 627  // Thus, the idea of a skip is that once we process a token with

 628  // an injector, we mark all of those tokens as having "come from"

 629  // the injector, and we never run the injector again on these

 630  // tokens.

 631  //

 632  // There were two more complications, however:

 633  //

 634  //  - With HTMLPurifier_Injector_RemoveEmpty, we noticed that if

 635  //    you had <b><i></i></b>, after you removed the <i></i>, you

 636  //    really would like this injector to go back and reprocess

 637  //    the <b> tag, discovering that it is now empty and can be

 638  //    removed.  So we reintroduced the possibility of infinite looping

 639  //    by adding a "rewind" function, which let you go back to an

 640  //    earlier point in the token stream and reprocess it with injectors.

 641  //    Needless to say, we need to UN-skip the token so it gets

 642  //    reprocessed.

 643  //

 644  //  - Suppose that you successfuly process a token, replace it with

 645  //    one with your skip mark, but now another injector wants to

 646  //    process the skipped token with another token.  Should you continue

 647  //    to skip that new token, or reprocess it?  If you reprocess,

 648  //    you can end up with an infinite loop where one injector converts

 649  //    <a> to <b>, and then another injector converts it back.  So

 650  //    we inherit the skips, but for some reason, I thought that we

 651  //    should inherit the skip from the first token of the token

 652  //    that we deleted.  Why?  Well, it seems to work OK.

 653  //

 654  // If I were to redesign this functionality, I would absolutely not

 655  // go about doing it this way: the semantics are just not very well

 656  // defined, and in any case you probably wanted to operate on trees,

 657  // not token streams.

 658  
 659  // vim: et sw=4 sts=4