Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/infrastructure/markup/remarkup/PhutilRemarkupEngine.php
12241 views
1
<?php
2
3
final class PhutilRemarkupEngine extends PhutilMarkupEngine {
4
5
const MODE_DEFAULT = 0;
6
const MODE_TEXT = 1;
7
const MODE_HTML_MAIL = 2;
8
9
const MAX_CHILD_DEPTH = 32;
10
11
private $blockRules = array();
12
private $config = array();
13
private $mode;
14
private $metadata = array();
15
private $states = array();
16
private $postprocessRules = array();
17
private $storage;
18
19
public function setConfig($key, $value) {
20
$this->config[$key] = $value;
21
return $this;
22
}
23
24
public function getConfig($key, $default = null) {
25
return idx($this->config, $key, $default);
26
}
27
28
public function setMode($mode) {
29
$this->mode = $mode;
30
return $this;
31
}
32
33
public function isTextMode() {
34
return $this->mode & self::MODE_TEXT;
35
}
36
37
public function isAnchorMode() {
38
return $this->getState('toc');
39
}
40
41
public function isHTMLMailMode() {
42
return $this->mode & self::MODE_HTML_MAIL;
43
}
44
45
public function getQuoteDepth() {
46
return $this->getConfig('runtime.quote.depth', 0);
47
}
48
49
public function setQuoteDepth($depth) {
50
return $this->setConfig('runtime.quote.depth', $depth);
51
}
52
53
public function setBlockRules(array $rules) {
54
assert_instances_of($rules, 'PhutilRemarkupBlockRule');
55
56
$rules = msortv($rules, 'getPriorityVector');
57
58
$this->blockRules = $rules;
59
foreach ($this->blockRules as $rule) {
60
$rule->setEngine($this);
61
}
62
63
$post_rules = array();
64
foreach ($this->blockRules as $block_rule) {
65
foreach ($block_rule->getMarkupRules() as $rule) {
66
$key = $rule->getPostprocessKey();
67
if ($key !== null) {
68
$post_rules[$key] = $rule;
69
}
70
}
71
}
72
73
$this->postprocessRules = $post_rules;
74
75
return $this;
76
}
77
78
public function getTextMetadata($key, $default = null) {
79
if (isset($this->metadata[$key])) {
80
return $this->metadata[$key];
81
}
82
return idx($this->metadata, $key, $default);
83
}
84
85
public function setTextMetadata($key, $value) {
86
$this->metadata[$key] = $value;
87
return $this;
88
}
89
90
public function storeText($text) {
91
if ($this->isTextMode()) {
92
$text = phutil_safe_html($text);
93
}
94
return $this->storage->store($text);
95
}
96
97
public function overwriteStoredText($token, $new_text) {
98
if ($this->isTextMode()) {
99
$new_text = phutil_safe_html($new_text);
100
}
101
$this->storage->overwrite($token, $new_text);
102
return $this;
103
}
104
105
public function markupText($text) {
106
return $this->postprocessText($this->preprocessText($text));
107
}
108
109
public function pushState($state) {
110
if (empty($this->states[$state])) {
111
$this->states[$state] = 0;
112
}
113
$this->states[$state]++;
114
return $this;
115
}
116
117
public function popState($state) {
118
if (empty($this->states[$state])) {
119
throw new Exception(pht("State '%s' pushed more than popped!", $state));
120
}
121
$this->states[$state]--;
122
if (!$this->states[$state]) {
123
unset($this->states[$state]);
124
}
125
return $this;
126
}
127
128
public function getState($state) {
129
return !empty($this->states[$state]);
130
}
131
132
public function preprocessText($text) {
133
$this->metadata = array();
134
$this->storage = new PhutilRemarkupBlockStorage();
135
136
$blocks = $this->splitTextIntoBlocks($text);
137
138
$output = array();
139
foreach ($blocks as $block) {
140
$output[] = $this->markupBlock($block);
141
}
142
$output = $this->flattenOutput($output);
143
144
$map = $this->storage->getMap();
145
$this->storage = null;
146
$metadata = $this->metadata;
147
148
149
return array(
150
'output' => $output,
151
'storage' => $map,
152
'metadata' => $metadata,
153
);
154
}
155
156
private function splitTextIntoBlocks($text, $depth = 0) {
157
// Apply basic block and paragraph normalization to the text. NOTE: We don't
158
// strip trailing whitespace because it is semantic in some contexts,
159
// notably inlined diffs that the author intends to show as a code block.
160
$text = phutil_split_lines($text, true);
161
$block_rules = $this->blockRules;
162
$blocks = array();
163
$cursor = 0;
164
165
$can_merge = array();
166
foreach ($block_rules as $key => $block_rule) {
167
if ($block_rule instanceof PhutilRemarkupDefaultBlockRule) {
168
$can_merge[$key] = true;
169
}
170
}
171
172
$last_block = null;
173
$last_block_key = -1;
174
175
// See T13487. For very large inputs, block separation can dominate
176
// runtime. This is written somewhat clumsily to attempt to handle
177
// very large inputs as gracefully as is practical.
178
179
while (isset($text[$cursor])) {
180
$starting_cursor = $cursor;
181
foreach ($block_rules as $block_key => $block_rule) {
182
$num_lines = $block_rule->getMatchingLineCount($text, $cursor);
183
184
if ($num_lines) {
185
$current_block = array(
186
'start' => $cursor,
187
'num_lines' => $num_lines,
188
'rule' => $block_rule,
189
'empty' => self::isEmptyBlock($text, $cursor, $num_lines),
190
'children' => array(),
191
'merge' => isset($can_merge[$block_key]),
192
);
193
194
$should_merge = self::shouldMergeParagraphBlocks(
195
$text,
196
$last_block,
197
$current_block);
198
199
if ($should_merge) {
200
$last_block['num_lines'] =
201
($last_block['num_lines'] + $current_block['num_lines']);
202
203
$last_block['empty'] =
204
($last_block['empty'] && $current_block['empty']);
205
206
$blocks[$last_block_key] = $last_block;
207
} else {
208
$blocks[] = $current_block;
209
210
$last_block = $current_block;
211
$last_block_key++;
212
}
213
214
$cursor += $num_lines;
215
216
break;
217
}
218
}
219
220
if ($starting_cursor === $cursor) {
221
throw new Exception(pht('Block in text did not match any block rule.'));
222
}
223
}
224
225
// See T13487. It's common for blocks to be small, and this loop seems to
226
// measure as faster if we manually concatenate blocks than if we
227
// "array_slice()" and "implode()" blocks. This is a bit muddy.
228
229
foreach ($blocks as $key => $block) {
230
$min = $block['start'];
231
$max = $min + $block['num_lines'];
232
233
$lines = '';
234
for ($ii = $min; $ii < $max; $ii++) {
235
if (isset($text[$ii])) {
236
$lines .= $text[$ii];
237
}
238
}
239
240
$blocks[$key]['text'] = $lines;
241
}
242
243
// Stop splitting child blocks apart if we get too deep. This arrests
244
// any blocks which have looping child rules, and stops the stack from
245
// exploding if someone writes a hilarious comment with 5,000 levels of
246
// quoted text.
247
248
if ($depth < self::MAX_CHILD_DEPTH) {
249
foreach ($blocks as $key => $block) {
250
$rule = $block['rule'];
251
if (!$rule->supportsChildBlocks()) {
252
continue;
253
}
254
255
list($parent_text, $child_text) = $rule->extractChildText(
256
$block['text']);
257
$blocks[$key]['text'] = $parent_text;
258
$blocks[$key]['children'] = $this->splitTextIntoBlocks(
259
$child_text,
260
$depth + 1);
261
}
262
}
263
264
return $blocks;
265
}
266
267
private function markupBlock(array $block) {
268
$rule = $block['rule'];
269
270
$rule->willMarkupChildBlocks();
271
272
$children = array();
273
foreach ($block['children'] as $child) {
274
$children[] = $this->markupBlock($child);
275
}
276
277
$rule->didMarkupChildBlocks();
278
279
if ($children) {
280
$children = $this->flattenOutput($children);
281
} else {
282
$children = null;
283
}
284
285
return $rule->markupText($block['text'], $children);
286
}
287
288
private function flattenOutput(array $output) {
289
if ($this->isTextMode()) {
290
$output = implode("\n\n", $output)."\n";
291
} else {
292
$output = phutil_implode_html("\n\n", $output);
293
}
294
295
return $output;
296
}
297
298
private static function shouldMergeParagraphBlocks(
299
$text,
300
$last_block,
301
$current_block) {
302
303
// If we're at the beginning of the input, we can't merge.
304
if ($last_block === null) {
305
return false;
306
}
307
308
// If the previous block wasn't a default block, we can't merge.
309
if (!$last_block['merge']) {
310
return false;
311
}
312
313
// If the current block isn't a default block, we can't merge.
314
if (!$current_block['merge']) {
315
return false;
316
}
317
318
// If the last block was empty, we definitely want to merge.
319
if ($last_block['empty']) {
320
return true;
321
}
322
323
// If this block is empty, we definitely want to merge.
324
if ($current_block['empty']) {
325
return true;
326
}
327
328
// Check if the last line of the previous block or the first line of this
329
// block have any non-whitespace text. If they both do, we're going to
330
// merge.
331
332
// If either of them are a blank line or a line with only whitespace, we
333
// do not merge: this means we've found a paragraph break.
334
335
$tail = $text[$current_block['start'] - 1];
336
$head = $text[$current_block['start']];
337
if (strlen(trim($tail)) && strlen(trim($head))) {
338
return true;
339
}
340
341
return false;
342
}
343
344
private static function isEmptyBlock($text, $start, $num_lines) {
345
for ($cursor = $start; $cursor < $start + $num_lines; $cursor++) {
346
if (strlen(trim($text[$cursor]))) {
347
return false;
348
}
349
}
350
return true;
351
}
352
353
public function postprocessText(array $dict) {
354
$this->metadata = idx($dict, 'metadata', array());
355
356
$this->storage = new PhutilRemarkupBlockStorage();
357
$this->storage->setMap(idx($dict, 'storage', array()));
358
359
foreach ($this->blockRules as $block_rule) {
360
$block_rule->postprocess();
361
}
362
363
foreach ($this->postprocessRules as $rule) {
364
$rule->didMarkupText();
365
}
366
367
return $this->restoreText(idx($dict, 'output'));
368
}
369
370
public function restoreText($text) {
371
return $this->storage->restore($text, $this->isTextMode());
372
}
373
}
374
375