Path: blob/master/src/infrastructure/markup/remarkup/PhutilRemarkupEngine.php
12241 views
<?php12final class PhutilRemarkupEngine extends PhutilMarkupEngine {34const MODE_DEFAULT = 0;5const MODE_TEXT = 1;6const MODE_HTML_MAIL = 2;78const MAX_CHILD_DEPTH = 32;910private $blockRules = array();11private $config = array();12private $mode;13private $metadata = array();14private $states = array();15private $postprocessRules = array();16private $storage;1718public function setConfig($key, $value) {19$this->config[$key] = $value;20return $this;21}2223public function getConfig($key, $default = null) {24return idx($this->config, $key, $default);25}2627public function setMode($mode) {28$this->mode = $mode;29return $this;30}3132public function isTextMode() {33return $this->mode & self::MODE_TEXT;34}3536public function isAnchorMode() {37return $this->getState('toc');38}3940public function isHTMLMailMode() {41return $this->mode & self::MODE_HTML_MAIL;42}4344public function getQuoteDepth() {45return $this->getConfig('runtime.quote.depth', 0);46}4748public function setQuoteDepth($depth) {49return $this->setConfig('runtime.quote.depth', $depth);50}5152public function setBlockRules(array $rules) {53assert_instances_of($rules, 'PhutilRemarkupBlockRule');5455$rules = msortv($rules, 'getPriorityVector');5657$this->blockRules = $rules;58foreach ($this->blockRules as $rule) {59$rule->setEngine($this);60}6162$post_rules = array();63foreach ($this->blockRules as $block_rule) {64foreach ($block_rule->getMarkupRules() as $rule) {65$key = $rule->getPostprocessKey();66if ($key !== null) {67$post_rules[$key] = $rule;68}69}70}7172$this->postprocessRules = $post_rules;7374return $this;75}7677public function getTextMetadata($key, $default = null) {78if (isset($this->metadata[$key])) {79return $this->metadata[$key];80}81return idx($this->metadata, $key, $default);82}8384public function setTextMetadata($key, $value) {85$this->metadata[$key] = $value;86return $this;87}8889public function storeText($text) {90if ($this->isTextMode()) {91$text = phutil_safe_html($text);92}93return $this->storage->store($text);94}9596public function overwriteStoredText($token, $new_text) {97if ($this->isTextMode()) {98$new_text = phutil_safe_html($new_text);99}100$this->storage->overwrite($token, $new_text);101return $this;102}103104public function markupText($text) {105return $this->postprocessText($this->preprocessText($text));106}107108public function pushState($state) {109if (empty($this->states[$state])) {110$this->states[$state] = 0;111}112$this->states[$state]++;113return $this;114}115116public function popState($state) {117if (empty($this->states[$state])) {118throw new Exception(pht("State '%s' pushed more than popped!", $state));119}120$this->states[$state]--;121if (!$this->states[$state]) {122unset($this->states[$state]);123}124return $this;125}126127public function getState($state) {128return !empty($this->states[$state]);129}130131public function preprocessText($text) {132$this->metadata = array();133$this->storage = new PhutilRemarkupBlockStorage();134135$blocks = $this->splitTextIntoBlocks($text);136137$output = array();138foreach ($blocks as $block) {139$output[] = $this->markupBlock($block);140}141$output = $this->flattenOutput($output);142143$map = $this->storage->getMap();144$this->storage = null;145$metadata = $this->metadata;146147148return array(149'output' => $output,150'storage' => $map,151'metadata' => $metadata,152);153}154155private function splitTextIntoBlocks($text, $depth = 0) {156// Apply basic block and paragraph normalization to the text. NOTE: We don't157// strip trailing whitespace because it is semantic in some contexts,158// notably inlined diffs that the author intends to show as a code block.159$text = phutil_split_lines($text, true);160$block_rules = $this->blockRules;161$blocks = array();162$cursor = 0;163164$can_merge = array();165foreach ($block_rules as $key => $block_rule) {166if ($block_rule instanceof PhutilRemarkupDefaultBlockRule) {167$can_merge[$key] = true;168}169}170171$last_block = null;172$last_block_key = -1;173174// See T13487. For very large inputs, block separation can dominate175// runtime. This is written somewhat clumsily to attempt to handle176// very large inputs as gracefully as is practical.177178while (isset($text[$cursor])) {179$starting_cursor = $cursor;180foreach ($block_rules as $block_key => $block_rule) {181$num_lines = $block_rule->getMatchingLineCount($text, $cursor);182183if ($num_lines) {184$current_block = array(185'start' => $cursor,186'num_lines' => $num_lines,187'rule' => $block_rule,188'empty' => self::isEmptyBlock($text, $cursor, $num_lines),189'children' => array(),190'merge' => isset($can_merge[$block_key]),191);192193$should_merge = self::shouldMergeParagraphBlocks(194$text,195$last_block,196$current_block);197198if ($should_merge) {199$last_block['num_lines'] =200($last_block['num_lines'] + $current_block['num_lines']);201202$last_block['empty'] =203($last_block['empty'] && $current_block['empty']);204205$blocks[$last_block_key] = $last_block;206} else {207$blocks[] = $current_block;208209$last_block = $current_block;210$last_block_key++;211}212213$cursor += $num_lines;214215break;216}217}218219if ($starting_cursor === $cursor) {220throw new Exception(pht('Block in text did not match any block rule.'));221}222}223224// See T13487. It's common for blocks to be small, and this loop seems to225// measure as faster if we manually concatenate blocks than if we226// "array_slice()" and "implode()" blocks. This is a bit muddy.227228foreach ($blocks as $key => $block) {229$min = $block['start'];230$max = $min + $block['num_lines'];231232$lines = '';233for ($ii = $min; $ii < $max; $ii++) {234if (isset($text[$ii])) {235$lines .= $text[$ii];236}237}238239$blocks[$key]['text'] = $lines;240}241242// Stop splitting child blocks apart if we get too deep. This arrests243// any blocks which have looping child rules, and stops the stack from244// exploding if someone writes a hilarious comment with 5,000 levels of245// quoted text.246247if ($depth < self::MAX_CHILD_DEPTH) {248foreach ($blocks as $key => $block) {249$rule = $block['rule'];250if (!$rule->supportsChildBlocks()) {251continue;252}253254list($parent_text, $child_text) = $rule->extractChildText(255$block['text']);256$blocks[$key]['text'] = $parent_text;257$blocks[$key]['children'] = $this->splitTextIntoBlocks(258$child_text,259$depth + 1);260}261}262263return $blocks;264}265266private function markupBlock(array $block) {267$rule = $block['rule'];268269$rule->willMarkupChildBlocks();270271$children = array();272foreach ($block['children'] as $child) {273$children[] = $this->markupBlock($child);274}275276$rule->didMarkupChildBlocks();277278if ($children) {279$children = $this->flattenOutput($children);280} else {281$children = null;282}283284return $rule->markupText($block['text'], $children);285}286287private function flattenOutput(array $output) {288if ($this->isTextMode()) {289$output = implode("\n\n", $output)."\n";290} else {291$output = phutil_implode_html("\n\n", $output);292}293294return $output;295}296297private static function shouldMergeParagraphBlocks(298$text,299$last_block,300$current_block) {301302// If we're at the beginning of the input, we can't merge.303if ($last_block === null) {304return false;305}306307// If the previous block wasn't a default block, we can't merge.308if (!$last_block['merge']) {309return false;310}311312// If the current block isn't a default block, we can't merge.313if (!$current_block['merge']) {314return false;315}316317// If the last block was empty, we definitely want to merge.318if ($last_block['empty']) {319return true;320}321322// If this block is empty, we definitely want to merge.323if ($current_block['empty']) {324return true;325}326327// Check if the last line of the previous block or the first line of this328// block have any non-whitespace text. If they both do, we're going to329// merge.330331// If either of them are a blank line or a line with only whitespace, we332// do not merge: this means we've found a paragraph break.333334$tail = $text[$current_block['start'] - 1];335$head = $text[$current_block['start']];336if (strlen(trim($tail)) && strlen(trim($head))) {337return true;338}339340return false;341}342343private static function isEmptyBlock($text, $start, $num_lines) {344for ($cursor = $start; $cursor < $start + $num_lines; $cursor++) {345if (strlen(trim($text[$cursor]))) {346return false;347}348}349return true;350}351352public function postprocessText(array $dict) {353$this->metadata = idx($dict, 'metadata', array());354355$this->storage = new PhutilRemarkupBlockStorage();356$this->storage->setMap(idx($dict, 'storage', array()));357358foreach ($this->blockRules as $block_rule) {359$block_rule->postprocess();360}361362foreach ($this->postprocessRules as $rule) {363$rule->didMarkupText();364}365366return $this->restoreText(idx($dict, 'output'));367}368369public function restoreText($text) {370return $this->storage->restore($text, $this->isTextMode());371}372}373374375