Skip to content

Commit aae1933

Browse files
committed
add some utility functions to CLI class for outputting on a terminal #5226 #6563
1 parent c4b06ab commit aae1933

File tree

1 file changed

+278
-0
lines changed

1 file changed

+278
-0
lines changed

server/lib/classes/cli.inc.php

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,282 @@ public function error($msg) {
172172
die();
173173
}
174174

175+
/**
176+
* @return bool true when STDOUT is not redirected to a file
177+
*/
178+
public function isStdOutATty() {
179+
return defined('STDOUT') && function_exists('posix_isatty') && posix_isatty(constant('STDOUT'));
180+
}
181+
182+
/**
183+
* Return the width of the string $text (not counting ANSI escape codes).
184+
*
185+
* @param string $text
186+
* @return int
187+
*/
188+
public function stringWidth($text) {
189+
return mb_strwidth(preg_replace("/\033\[.*?m/", '', $text), 'utf8');
190+
}
191+
192+
/**
193+
* Formats text so that it is not longer than $width.
194+
*
195+
* ANSI escape codes do not count to the line length and when a text needs to be wrapped, the currently active
196+
* ANSI escape codes get reset before inserting the newline character. This is needed to wrap text in multiple columns.
197+
*
198+
* @param string $text unwrapped text. May already contain newlines that get preserved.
199+
* @param int $width maximum line width
200+
* @return string
201+
*/
202+
public function wrapText($text, $width = 80) {
203+
$characters = [];
204+
$cur_width = 0;
205+
$in_ansi = false;
206+
$prev_ansi = '';
207+
$current_ansi = '';
208+
$reset_ansi = "\033[0m";
209+
$input = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY);
210+
foreach($input as $index => $char) {
211+
if($char == "\n") {
212+
$prev_ansi = '';
213+
$cur_width = 0;
214+
if($current_ansi) {
215+
$characters[] = $reset_ansi . "\n" . $current_ansi;
216+
} else {
217+
$characters[] = "\n";
218+
}
219+
} elseif($in_ansi || $char == "\033") {
220+
$characters[] = $char;
221+
if($char == "\033") {
222+
$prev_ansi = $prev_ansi . $current_ansi;
223+
$current_ansi = '';
224+
}
225+
$current_ansi .= $char;
226+
$in_ansi = $char != 'm';
227+
if(!$in_ansi) {
228+
if($current_ansi == $reset_ansi) {
229+
$current_ansi = '';
230+
$prev_ansi = '';
231+
} else {
232+
$current_ansi = $prev_ansi . $current_ansi;
233+
}
234+
}
235+
} elseif($char == ' ') {
236+
// look ahead $width characters and search for a newline or another space
237+
$prev_ansi = '';
238+
$lookahead_length = 0;
239+
$has_better_break = false;
240+
$lookup_reached_end = false;
241+
$lookahead_in_ansi = false;
242+
for($i = $index + 1; $i < count($input); $i++) {
243+
$next_char = $input[$i];
244+
$lookup_reached_end = $i == count($input) - 1;
245+
if($next_char == "\n") {
246+
$has_better_break = $cur_width + $lookahead_length < $width;
247+
break;
248+
} elseif($lookahead_in_ansi || $next_char == "\033") {
249+
$lookahead_in_ansi = $char != 'm';
250+
} elseif($next_char == ' ' && $cur_width + $lookahead_length < $width) {
251+
$has_better_break = true;
252+
break;
253+
} else {
254+
$lookahead_length += mb_strwidth($next_char, 'utf8');
255+
}
256+
if($cur_width + $lookahead_length > $width) {
257+
break;
258+
}
259+
}
260+
if(!$lookup_reached_end && !$has_better_break) {
261+
$cur_width = 0;
262+
if($current_ansi) {
263+
$characters[] = $reset_ansi . "\n" . $current_ansi;
264+
} else {
265+
$characters[] = "\n";
266+
}
267+
} else {
268+
$characters[] = $char;
269+
$cur_width += 1;
270+
}
271+
} else {
272+
$prev_ansi = '';
273+
$char_width = mb_strwidth($char, 'utf8');
274+
if($cur_width + $char_width > $width) {
275+
$cur_width = 0;
276+
if($current_ansi) {
277+
$characters[] = $reset_ansi . "\n" . $current_ansi;
278+
} else {
279+
$characters[] = "\n";
280+
}
281+
}
282+
$characters[] = $char;
283+
$cur_width += $char_width;
284+
}
285+
}
286+
return join('', $characters);
287+
}
288+
289+
/**
290+
* Divides $value up into $count parts. Returns an array with $count integers.
291+
* The sum of all returned integers is always $value (Even when rounding happens).
292+
*
293+
* @param int $value
294+
* @param int $count
295+
* @return int[]
296+
*/
297+
private function getDiscreetDistribution($value, $count) {
298+
$per_item = intval(floor($value / $count));
299+
$distribution = array_fill(0, $count, $per_item);
300+
// add 1 to the columns until the column sum is equal to $value
301+
$sum = $per_item * $count;
302+
for($i = 0; $i < $count && $sum < $value; $i++) {
303+
$distribution[$i] += 1;
304+
$sum += 1;
305+
}
306+
return $distribution;
307+
}
308+
309+
/**
310+
* Outputs an array of arrays as table to STDOUT. All cells should already be strings. You can use ANSI escape code sequences in the cells.
311+
*
312+
* Simple table:
313+
* <code>
314+
* $this->outputTable([['one', 'two'], ['1', '2']]);
315+
* </code>
316+
*
317+
* You can define minimum column lengths:
318+
* <code>
319+
* $this->outputTable([['one', 'two'], ['1', '2']], ['min_lengths' => [10, 10]]);
320+
* </code>
321+
*
322+
* The table tries to size itself to fit in the terminal window width. As default all column width 5 or wider will shrink,
323+
* but you can also specify which column(s) should shrink:
324+
* <code>
325+
* $this->outputTable([
326+
* ['variable', 'fixed', 'variable'],
327+
* [str_repeat('this is a long column ', 40), '2', str_repeat('another long column ', 60)]
328+
* ], ['variable_columns' => [0,2]]);
329+
* </code>
330+
*
331+
* Small tables do not fill the available terminal window width by default.
332+
* You can change this with the `expand` $option:
333+
* <code>
334+
* $this->outputTable([['one', 'two'], ['1', '2']], ['expand' => true]);
335+
* </code>
336+
*
337+
* @param array $table array of arrays (rows -> columns) to display as table.
338+
* @param array{min_lengths: array|null, variable_columns: array|string|int|null, expand: bool}|null $options optional formatting options
339+
* @return void
340+
*/
341+
public function outputTable($table, $options = null) {
342+
if(empty($table)) {
343+
$this->swriteln('No data to display');
344+
return;
345+
}
346+
if(!is_array($options)) {
347+
$options = [];
348+
}
349+
if(!is_array($options['min_lengths'])) {
350+
$options['min_lengths'] = [];
351+
}
352+
353+
// process input $table
354+
$columns = [];
355+
$rows = [];
356+
$num_columns = false;
357+
foreach($table as $row) {
358+
$c = count($row);
359+
if(!$c || $num_columns !== false && $c != $num_columns) {
360+
$this->error("every input row of outputTable input needs to the same size (not null)");
361+
}
362+
$num_columns = $c;
363+
$r = [];
364+
$index = 0;
365+
foreach($row as $value) {
366+
$value_as_string = (string)$value;
367+
$max = 0;
368+
foreach(explode("\n", $value_as_string) as $line) {
369+
$len = $this->stringWidth($line);
370+
$max = max($max, max($options['min_lengths'][$index] ?: 0, $len));
371+
}
372+
if(!isset($columns[$index])) {
373+
$columns[$index] = ['length' => 0, 'first' => $index == 0, 'last' => $index == $num_columns - 1];
374+
}
375+
if($columns[$index]['length'] < $max) {
376+
$columns[$index]['length'] = $max;
377+
}
378+
$r[$index] = $value_as_string;
379+
$index += 1;
380+
}
381+
$rows[] = $r;
382+
}
383+
384+
// fit table to terminal
385+
if($this->isStdOutATty()) {
386+
if(!isset($options['variable_columns']) || $options['variable_columns'] == 'all') {
387+
$options['variable_columns'] = range(0, $num_columns - 1);
388+
} elseif(!is_array($options['variable_columns'])) {
389+
$options['variable_columns'] = explode(',', (string)$options['variable_columns']);
390+
}
391+
$minimum_variable_column_width = 5;
392+
$shrink_variable_columns = array_values(array_filter($options['variable_columns'], function($index) use ($minimum_variable_column_width, $columns, $num_columns) {
393+
return $index < $num_columns && $columns[$index]['length'] >= $minimum_variable_column_width;
394+
}));
395+
$expand_variable_columns = array_values(array_filter($options['variable_columns'], function($index) use ($num_columns) {
396+
return $index < $num_columns;
397+
}));
398+
$terminal_width = intval(trim(exec("tput cols") ?: '')) ?: 80;
399+
$table_width = array_reduce($columns, function($sum, $column) {
400+
return $sum + $column['length'] + 3;
401+
}, 1);
402+
if(count($shrink_variable_columns) > 0 && $table_width > $terminal_width) {
403+
$diff = $table_width - $terminal_width;
404+
$diff_per_column = $this->getDiscreetDistribution($diff, count($shrink_variable_columns));
405+
foreach($shrink_variable_columns as $i => $index) {
406+
$new_length = $columns[$index]['length'] - $diff_per_column[$i];
407+
$columns[$index]['length'] = max($minimum_variable_column_width, $new_length);
408+
}
409+
} elseif(count($expand_variable_columns) > 0 && $options['expand'] && $table_width < $terminal_width) {
410+
$diff = $terminal_width - $table_width;
411+
$diff_per_column = $this->getDiscreetDistribution($diff, count($expand_variable_columns));
412+
foreach($expand_variable_columns as $i => $index) {
413+
$new_length = $columns[$index]['length'] + $diff_per_column[$i];
414+
$columns[$index]['length'] = $new_length;
415+
}
416+
}
417+
}
418+
419+
// output table
420+
$separator = function($first_start, $mid_start, $mid, $mid_end, $last_end) use ($columns) {
421+
$this->swriteln(array_reduce($columns, function($line, $column) use ($first_start, $mid_start, $mid, $mid_end, $last_end) {
422+
return $line . ($column['first'] ? $first_start : $mid_start) . str_repeat($mid, $column['length']) . ($column['last'] ? $last_end : $mid_end);
423+
}, ''));
424+
};
425+
foreach($rows as $row_index => $row) {
426+
if($row_index == 0) {
427+
$separator('╔═', '╤═', '', '', '═╗');
428+
}
429+
// first pass -> re-wrap lines and get max number of lines of this row
430+
$height = 1;
431+
$lines = [];
432+
foreach($columns as $index => $column) {
433+
$lines[$index] = explode("\n", $this->wrapText($row[$index], $column['length']));
434+
$height = max($height, count($lines[$index]));
435+
}
436+
// second pass -> output row, line by line
437+
for($inner = 0; $inner < $height; $inner++) {
438+
$line = "";
439+
foreach($columns as $index => $column) {
440+
$value = $lines[$index][$inner] ?: '';
441+
$value .= str_repeat(' ', $column['length'] - $this->stringWidth($value));
442+
$line .= ($column['first'] ? '' : '') . $value . ($column['last'] ? '' : ' ');
443+
}
444+
$this->swriteln($line);
445+
}
446+
if($row_index < count($rows) - 1) {
447+
$separator('╟─', '┼─', '', '', '─╢');
448+
} else {
449+
$separator('╚═', '╧═', '', '', '═╝');
450+
}
451+
}
452+
}
175453
}

0 commit comments

Comments
 (0)