diff --git a/src/filesystem/PhutilDeferredLog.php b/src/filesystem/PhutilDeferredLog.php index bc47ca5..386fa8c 100644 --- a/src/filesystem/PhutilDeferredLog.php +++ b/src/filesystem/PhutilDeferredLog.php @@ -1,240 +1,246 @@ setData( * array( * 'T' => date('c'), * 'u' => $username, * )); * * The log will be appended when the object's destructor is called, or when you * invoke @{method:write}. Note that programs can exit without invoking object * destructors (e.g., in the case of an unhandled exception, memory exhaustion, * or SIGKILL) so writes are not guaranteed. You can call @{method:write} to * force an explicit write to disk before the destructor is called. * * Log variables will be written with bytes 0x00-0x1F, 0x7F-0xFF, and backslash * escaped using C-style escaping. Since this range includes tab, you can use * tabs as field separators to ensure the file format is easily parsable. In * PHP, you can decode this encoding with `stripcslashes`. * * If a variable is included in the log format but a value is never provided * with @{method:setData}, it will be written as "-". * * @task log Logging * @task write Writing the Log * @task internal Internals */ final class PhutilDeferredLog extends Phobject { private $file; private $format; private $data; private $didWrite; private $failQuietly; /* -( Logging )------------------------------------------------------------ */ /** * Create a new log entry, which will be written later. The format string * should use "%x"-style placeholders to represent data which will be added * later: * * $log = new PhutilDeferredLog('/some/file.log', '[%T] %u'); * * @param string|null The file the entry should be written to, or null to * create a log object which does not write anywhere. * @param string The log entry format. * @task log */ public function __construct($file, $format) { $this->file = $file; $this->format = $format; $this->data = array(); $this->didWrite = false; } /** * Add data to the log. Provide a map of variables to replace in the format * string. For example, if you use a format string like: * * "[%T]\t%u" * * ...you might add data like this: * * $log->setData( * array( * 'T' => date('c'), * 'u' => $username, * )); * * When the log is written, the "%T" and "%u" variables will be replaced with * the values you provide. * * @param dict Map of variables to values. * @return this * @task log */ public function setData(array $map) { $this->data = $map + $this->data; return $this; } /** * Get existing log data. * * @param string Log data key. * @param wild Default to return if data does not exist. * @return wild Data, or default if data does not exist. * @task log */ public function getData($key, $default = null) { return idx($this->data, $key, $default); } /** * Set the path where the log will be written. You can pass `null` to prevent * the log from writing. * * NOTE: You can not change the file after the log writes. * * @param string|null File where the entry should be written to, or null to * prevent writes. * @return this * @task log */ public function setFile($file) { if ($this->didWrite) { throw new Exception( pht('You can not change the logfile after a write has occurred!')); } $this->file = $file; return $this; } public function getFile() { return $this->file; } /** * Set quiet (logged) failure, instead of the default loud (exception) * failure. Throwing exceptions from destructors which exit at the end of a * request can result in difficult-to-debug behavior. */ public function setFailQuietly($fail_quietly) { $this->failQuietly = $fail_quietly; return $this; } /* -( Writing the Log )---------------------------------------------------- */ /** * When the log object is destroyed, it writes if it hasn't written yet. * @task write */ public function __destruct() { $this->write(); } /** * Write the log explicitly, if it hasn't been written yet. Normally you do * not need to call this method; it will be called when the log object is * destroyed. However, you can explicitly force the write earlier by calling * this method. * * A log object will never write more than once, so it is safe to call this * method even if the object's destructor later runs. * * @return this * @task write */ public function write() { if ($this->didWrite) { return $this; } // Even if we aren't going to write, format the line to catch any errors // and invoke possible __toString() calls. $line = $this->format(); - if ($this->file !== null) { - $dir = dirname($this->file); - if (!Filesystem::pathExists($dir)) { - Filesystem::createDirectory($dir, 0755, true); - } + try { + if ($this->file !== null) { + $dir = dirname($this->file); + if (!Filesystem::pathExists($dir)) { + Filesystem::createDirectory($dir, 0755, true); + } - $ok = @file_put_contents( - $this->file, - $line, - FILE_APPEND | LOCK_EX); + $ok = @file_put_contents( + $this->file, + $line, + FILE_APPEND | LOCK_EX); - if ($ok === false) { - $message = pht("Unable to write to logfile '%s'!", $this->file); - if ($this->failQuietly) { - phlog($message); - } else { - throw new Exception($message); + if ($ok === false) { + throw new Exception( + pht( + 'Unable to write to logfile "%s"!', + $this->file)); } } + } catch (Exception $ex) { + if ($this->failQuietly) { + phlog($ex); + } else { + throw $ex; + } } $this->didWrite = true; return $this; } /* -( Internals )---------------------------------------------------------- */ /** * Format the log string, replacing "%x" variables with values. * * @return string Finalized, log string for writing to disk. * @task internals */ private function format() { // Always convert '%%' to literal '%'. $map = array('%' => '%') + $this->data; $result = ''; $saw_percent = false; foreach (phutil_utf8v($this->format) as $c) { if ($saw_percent) { $saw_percent = false; if (array_key_exists($c, $map)) { $result .= addcslashes($map[$c], "\0..\37\\\177..\377"); } else { $result .= '-'; } } else if ($c == '%') { $saw_percent = true; } else { $result .= $c; } } return rtrim($result)."\n"; } } diff --git a/src/markup/engine/PhutilRemarkupEngine.php b/src/markup/engine/PhutilRemarkupEngine.php index 336c186..1c5ff78 100644 --- a/src/markup/engine/PhutilRemarkupEngine.php +++ b/src/markup/engine/PhutilRemarkupEngine.php @@ -1,302 +1,302 @@ config[$key] = $value; return $this; } public function getConfig($key, $default = null) { return idx($this->config, $key, $default); } public function setMode($mode) { $this->mode = $mode; return $this; } public function isTextMode() { return $this->mode & self::MODE_TEXT; } public function isHTMLMailMode() { return $this->mode & self::MODE_HTML_MAIL; } public function setBlockRules(array $rules) { assert_instances_of($rules, 'PhutilRemarkupBlockRule'); - $rules = msort($rules, 'getPriority'); + $rules = msortv($rules, 'getPriorityVector'); $this->blockRules = $rules; foreach ($this->blockRules as $rule) { $rule->setEngine($this); } $post_rules = array(); foreach ($this->blockRules as $block_rule) { foreach ($block_rule->getMarkupRules() as $rule) { $key = $rule->getPostprocessKey(); if ($key !== null) { $post_rules[$key] = $rule; } } } $this->postprocessRules = $post_rules; return $this; } public function getTextMetadata($key, $default = null) { if (isset($this->metadata[$key])) { return $this->metadata[$key]; } return idx($this->metadata, $key, $default); } public function setTextMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function storeText($text) { if ($this->isTextMode()) { $text = phutil_safe_html($text); } return $this->storage->store($text); } public function overwriteStoredText($token, $new_text) { if ($this->isTextMode()) { $new_text = phutil_safe_html($new_text); } $this->storage->overwrite($token, $new_text); return $this; } public function markupText($text) { return $this->postprocessText($this->preprocessText($text)); } public function pushState($state) { if (empty($this->states[$state])) { $this->states[$state] = 0; } $this->states[$state]++; return $this; } public function popState($state) { if (empty($this->states[$state])) { throw new Exception(pht("State '%s' pushed more than popped!", $state)); } $this->states[$state]--; if (!$this->states[$state]) { unset($this->states[$state]); } return $this; } public function getState($state) { return !empty($this->states[$state]); } public function preprocessText($text) { $this->metadata = array(); $this->storage = new PhutilRemarkupBlockStorage(); $blocks = $this->splitTextIntoBlocks($text); $output = array(); foreach ($blocks as $block) { $output[] = $this->markupBlock($block); } $output = $this->flattenOutput($output); $map = $this->storage->getMap(); $this->storage = null; $metadata = $this->metadata; return array( 'output' => $output, 'storage' => $map, 'metadata' => $metadata, ); } private function splitTextIntoBlocks($text, $depth = 0) { // Apply basic block and paragraph normalization to the text. NOTE: We don't // strip trailing whitespace because it is semantic in some contexts, // notably inlined diffs that the author intends to show as a code block. $text = phutil_split_lines($text, true); $block_rules = $this->blockRules; $blocks = array(); $cursor = 0; $prev_block = array(); while (isset($text[$cursor])) { $starting_cursor = $cursor; foreach ($block_rules as $block_rule) { $num_lines = $block_rule->getMatchingLineCount($text, $cursor); if ($num_lines) { if ($blocks) { $prev_block = last($blocks); } $curr_block = array( 'start' => $cursor, 'num_lines' => $num_lines, 'rule' => $block_rule, 'is_empty' => self::isEmptyBlock($text, $cursor, $num_lines), 'children' => array(), ); if ($prev_block && self::shouldMergeBlocks($text, $prev_block, $curr_block)) { $blocks[last_key($blocks)]['num_lines'] += $curr_block['num_lines']; $blocks[last_key($blocks)]['is_empty'] = $blocks[last_key($blocks)]['is_empty'] && $curr_block['is_empty']; } else { $blocks[] = $curr_block; } $cursor += $num_lines; break; } } if ($starting_cursor === $cursor) { throw new Exception(pht('Block in text did not match any block rule.')); } } foreach ($blocks as $key => $block) { $lines = array_slice($text, $block['start'], $block['num_lines']); $blocks[$key]['text'] = implode('', $lines); } // Stop splitting child blocks apart if we get too deep. This arrests // any blocks which have looping child rules, and stops the stack from // exploding if someone writes a hilarious comment with 5,000 levels of // quoted text. if ($depth < self::MAX_CHILD_DEPTH) { foreach ($blocks as $key => $block) { $rule = $block['rule']; if (!$rule->supportsChildBlocks()) { continue; } list($parent_text, $child_text) = $rule->extractChildText( $block['text']); $blocks[$key]['text'] = $parent_text; $blocks[$key]['children'] = $this->splitTextIntoBlocks( $child_text, $depth + 1); } } return $blocks; } private function markupBlock(array $block) { $children = array(); foreach ($block['children'] as $child) { $children[] = $this->markupBlock($child); } if ($children) { $children = $this->flattenOutput($children); } else { $children = null; } return $block['rule']->markupText($block['text'], $children); } private function flattenOutput(array $output) { if ($this->isTextMode()) { $output = implode("\n\n", $output)."\n"; } else { $output = phutil_implode_html("\n\n", $output); } return $output; } private static function shouldMergeBlocks($text, $prev_block, $curr_block) { $block_rules = ipull(array($prev_block, $curr_block), 'rule'); $default_rule = 'PhutilRemarkupDefaultBlockRule'; try { assert_instances_of($block_rules, $default_rule); // If the last block was empty keep merging if ($prev_block['is_empty']) { return true; } // If this line is blank keep merging if ($curr_block['is_empty']) { return true; } // If the current line and the last line have content, keep merging if (strlen(trim($text[$curr_block['start'] - 1]))) { if (strlen(trim($text[$curr_block['start']]))) { return true; } } } catch (Exception $e) {} return false; } private static function isEmptyBlock($text, $start, $num_lines) { for ($cursor = $start; $cursor < $start + $num_lines; $cursor++) { if (strlen(trim($text[$cursor]))) { return false; } } return true; } public function postprocessText(array $dict) { $this->metadata = idx($dict, 'metadata', array()); $this->storage = new PhutilRemarkupBlockStorage(); $this->storage->setMap(idx($dict, 'storage', array())); foreach ($this->blockRules as $block_rule) { $block_rule->postprocess(); } foreach ($this->postprocessRules as $rule) { $rule->didMarkupText(); } return $this->restoreText(idx($dict, 'output')); } public function restoreText($text) { return $this->storage->restore($text, $this->isTextMode()); } } diff --git a/src/markup/engine/__tests__/remarkup/percent-block-solo.txt b/src/markup/engine/__tests__/remarkup/percent-block-solo.txt new file mode 100644 index 0000000..34ddbd3 --- /dev/null +++ b/src/markup/engine/__tests__/remarkup/percent-block-solo.txt @@ -0,0 +1,8 @@ +%%% +**x**%%% +~~~~~~~~~~ +

+
**x**

+~~~~~~~~~~ + +**x** diff --git a/src/markup/engine/remarkup/blockrule/PhutilRemarkupBlockRule.php b/src/markup/engine/remarkup/blockrule/PhutilRemarkupBlockRule.php index 5210811..feac6cf 100644 --- a/src/markup/engine/remarkup/blockrule/PhutilRemarkupBlockRule.php +++ b/src/markup/engine/remarkup/blockrule/PhutilRemarkupBlockRule.php @@ -1,164 +1,170 @@ addInt($this->getPriority()) + ->addString(get_class($this)); } abstract public function markupText($text, $children); /** * This will get an array of unparsed lines and return the number of lines * from the first array value that it can parse. * * @param array $lines * @param int $cursor * * @return int */ abstract public function getMatchingLineCount(array $lines, $cursor); protected function didMarkupText() { return; } final public function setEngine(PhutilRemarkupEngine $engine) { $this->engine = $engine; $this->updateRules(); return $this; } final protected function getEngine() { return $this->engine; } public function setMarkupRules(array $rules) { assert_instances_of($rules, 'PhutilRemarkupRule'); $this->rules = $rules; $this->updateRules(); return $this; } private function updateRules() { $engine = $this->getEngine(); if ($engine) { $this->rules = msort($this->rules, 'getPriority'); foreach ($this->rules as $rule) { $rule->setEngine($engine); } } return $this; } final public function getMarkupRules() { return $this->rules; } final public function postprocess() { $this->didMarkupText(); } final protected function applyRules($text) { foreach ($this->getMarkupRules() as $rule) { $text = $rule->apply($text); } return $text; } public function supportsChildBlocks() { return false; } public function extractChildText($text) { throw new PhutilMethodNotImplementedException(); } protected function renderRemarkupTable(array $out_rows) { assert_instances_of($out_rows, 'array'); if ($this->getEngine()->isTextMode()) { $lengths = array(); foreach ($out_rows as $r => $row) { foreach ($row['content'] as $c => $cell) { $text = $this->getEngine()->restoreText($cell['content']); $lengths[$c][$r] = phutil_utf8_strlen($text); } } $max_lengths = array_map('max', $lengths); $out = array(); foreach ($out_rows as $r => $row) { $headings = false; foreach ($row['content'] as $c => $cell) { $length = $max_lengths[$c] - $lengths[$c][$r]; $out[] = '| '.$cell['content'].str_repeat(' ', $length).' '; if ($cell['type'] == 'th') { $headings = true; } } $out[] = "|\n"; if ($headings) { foreach ($row['content'] as $c => $cell) { $char = ($cell['type'] == 'th' ? '-' : ' '); $out[] = '| '.str_repeat($char, $max_lengths[$c]).' '; } $out[] = "|\n"; } } return rtrim(implode('', $out), "\n"); } if ($this->getEngine()->isHTMLMailMode()) { $table_attributes = array( 'style' => 'border-collapse: separate; border-spacing: 1px; background: #d3d3d3; margin: 12px 0;', ); $cell_attributes = array( 'style' => 'background: #ffffff; padding: 3px 6px;', ); } else { $table_attributes = array( 'class' => 'remarkup-table', ); $cell_attributes = array(); } $out = array(); $out[] = "\n"; foreach ($out_rows as $row) { $cells = array(); foreach ($row['content'] as $cell) { $cells[] = phutil_tag( $cell['type'], $cell_attributes, $cell['content']); } $out[] = phutil_tag($row['type'], array(), $cells); $out[] = "\n"; } $table = phutil_tag('table', $table_attributes, $out); return phutil_tag_div('remarkup-table-wrap', $table); } } diff --git a/src/markup/engine/remarkup/blockrule/PhutilRemarkupLiteralBlockRule.php b/src/markup/engine/remarkup/blockrule/PhutilRemarkupLiteralBlockRule.php index c1d4a0a..527db4f 100644 --- a/src/markup/engine/remarkup/blockrule/PhutilRemarkupLiteralBlockRule.php +++ b/src/markup/engine/remarkup/blockrule/PhutilRemarkupLiteralBlockRule.php @@ -1,82 +1,93 @@ $line) { $line = preg_replace('/^\s*%%%/', '', $line); $line = preg_replace('/%%%(\s*)\z/', '\1', $line); $text[$key] = $line; } if ($this->getEngine()->isTextMode()) { return implode('', $text); } return phutil_tag( 'p', array( 'class' => 'remarkup-literal', ), phutil_implode_html(phutil_tag('br', array()), $text)); } } diff --git a/src/markup/engine/remarkup/blockrule/PhutilRemarkupReplyBlockRule.php b/src/markup/engine/remarkup/blockrule/PhutilRemarkupReplyBlockRule.php index bf2620f..37d9fcc 100644 --- a/src/markup/engine/remarkup/blockrule/PhutilRemarkupReplyBlockRule.php +++ b/src/markup/engine/remarkup/blockrule/PhutilRemarkupReplyBlockRule.php @@ -1,117 +1,117 @@ >!/', $lines[$pos])) { do { ++$pos; } while (isset($lines[$pos]) && preg_match('/^>/', $lines[$pos])); } return ($pos - $cursor); } public function supportsChildBlocks() { return true; } public function extractChildText($text) { $text = phutil_split_lines($text, true); $head = array(); $body = array(); $head = substr(reset($text), 3); $body = array_slice($text, 1); // Remove the carets. foreach ($body as $key => $line) { $body[$key] = substr($line, 1); } // Strip leading empty lines. foreach ($body as $key => $line) { if (strlen(trim($line))) { break; } unset($body[$key]); } return array(trim($head), implode('', $body)); } public function markupText($text, $children) { $text = $this->applyRules($text); if ($this->getEngine()->isTextMode()) { $children = phutil_split_lines($children, true); foreach ($children as $key => $child) { if (strlen(trim($child))) { $children[$key] = '> '.$child; } else { $children[$key] = '>'.$child; } } $children = implode('', $children); return $text."\n\n".$children; } if ($this->getEngine()->isHTMLMailMode()) { $block_attributes = array( 'style' => 'border-left: 3px solid #8C98B8; color: #6B748C; font-style: italic; margin: 4px 0 12px 0; padding: 8px 12px; background-color: #F8F9FC;', ); $head_attributes = array( 'style' => 'font-style: normal; padding-bottom: 4px;', ); $reply_attributes = array( 'style' => 'margin: 0; padding: 0; border: 0; color: rgb(107, 116, 140);', ); } else { $block_attributes = array( 'class' => 'remarkup-reply-block', ); $head_attributes = array( 'class' => 'remarkup-reply-head', ); $reply_attributes = array( 'class' => 'remarkup-reply-body', ); } return phutil_tag( 'blockquote', $block_attributes, array( "\n", phutil_tag( 'div', $head_attributes, $text), "\n", phutil_tag( 'div', $reply_attributes, $children), "\n", )); } }