• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Module for size report ASCII tables from DataSourceMaps."""
15
16import enum
17from typing import (
18    Iterable,
19    Tuple,
20    Union,
21    Type,
22    List,
23    Optional,
24    NamedTuple,
25    cast,
26)
27
28from pw_bloat.label import DataSourceMap, DiffDataSourceMap, Label
29
30
31class AsciiCharset(enum.Enum):
32    """Set of ASCII characters for drawing tables."""
33
34    TL = '+'
35    TM = '+'
36    TR = '+'
37    ML = '+'
38    MM = '+'
39    MR = '+'
40    BL = '+'
41    BM = '+'
42    BR = '+'
43    V = '|'
44    H = '-'
45    HH = '='
46
47
48class LineCharset(enum.Enum):
49    """Set of line-drawing characters for tables."""
50
51    TL = '┌'
52    TM = '┬'
53    TR = '┐'
54    ML = '├'
55    MM = '┼'
56    MR = '┤'
57    BL = '└'
58    BM = '┴'
59    BR = '┘'
60    V = '│'
61    H = '─'
62    HH = '═'
63
64
65class _Align(enum.Enum):
66    CENTER = 0
67    LEFT = 1
68    RIGHT = 2
69
70
71def get_label_status(curr_label: Label) -> str:
72    if curr_label.is_new():
73        return 'NEW'
74    if curr_label.is_del():
75        return 'DEL'
76    return ''
77
78
79def diff_sign_sizes(size: int, diff_mode: bool) -> str:
80    if diff_mode:
81        size_sign = '+' if size > 0 else ''
82        return f"{size_sign}{size:,}"
83    return f"{size:,}"
84
85
86class BloatTableOutput:
87    """ASCII Table generator from DataSourceMap."""
88
89    _RST_PADDING_WIDTH = 6
90    _DEFAULT_MAX_WIDTH = 80
91
92    class _LabelContent(NamedTuple):
93        name: str
94        size: int
95        label_status: str
96
97    def __init__(
98        self,
99        ds_map: Union[DiffDataSourceMap, DataSourceMap],
100        col_max_width: int = _DEFAULT_MAX_WIDTH,
101        charset: Union[Type[AsciiCharset], Type[LineCharset]] = AsciiCharset,
102        rst_output: bool = False,
103        diff_label: Optional[str] = None,
104    ):
105        self._data_source_map = ds_map
106        self._cs = charset
107        self._total_size = 0
108        col_names = [*self._data_source_map.get_ds_names(), 'sizes']
109        self._diff_mode = False
110        self._diff_label = diff_label
111        if isinstance(self._data_source_map, DiffDataSourceMap):
112            col_names = ['diff', *col_names]
113            self._diff_mode = True
114        self._col_names = col_names
115        self._additional_padding = 0
116        self._ascii_table_rows: List[str] = []
117        self._rst_output = rst_output
118        self._total_divider = self._cs.HH.value
119        if self._rst_output:
120            self._total_divider = self._cs.H.value
121            self._additional_padding = self._RST_PADDING_WIDTH
122
123        self._col_widths = self._generate_col_width(col_max_width)
124
125    def _generate_col_width(self, col_max_width: int) -> List[int]:
126        """Find column width for all data sources and sizes."""
127        max_len_size = 0
128        diff_len_col_width = 0
129
130        col_list = [
131            len(ds_name) for ds_name in self._data_source_map.get_ds_names()
132        ]
133        for curr_label in self._data_source_map.labels():
134            self._total_size += curr_label.size
135            max_len_size = max(
136                len(diff_sign_sizes(self._total_size, self._diff_mode)),
137                len(diff_sign_sizes(curr_label.size, self._diff_mode)),
138                max_len_size,
139            )
140            for index, parent_label in enumerate(
141                [*curr_label.parents, curr_label.name]
142            ):
143                if len(parent_label) > col_max_width:
144                    col_list[index] = col_max_width
145                elif len(parent_label) > col_list[index]:
146                    col_list[index] = len(parent_label)
147
148        diff_same = 0
149        if self._diff_mode:
150            col_list = [len('Total'), *col_list]
151            diff_same = len('(SAME)')
152        col_list.append(max(max_len_size, len('sizes'), diff_same))
153
154        if self._diff_label is not None:
155            sum_all_col_names = sum(col_list)
156            if sum_all_col_names < len(self._diff_label):
157                diff_len_col_width = (
158                    len(self._diff_label) - sum_all_col_names
159                ) // len(self._col_names)
160
161        return [
162            (x + self._additional_padding + diff_len_col_width)
163            for x in col_list
164        ]
165
166    def _diff_label_names(
167        self,
168        old_labels: Optional[Tuple[_LabelContent, ...]],
169        new_labels: Tuple[_LabelContent, ...],
170    ) -> Tuple[_LabelContent, ...]:
171        """Return difference between arrays of labels."""
172
173        if old_labels is None:
174            return new_labels
175        diff_list = []
176        for new_lb, old_lb in zip(new_labels, old_labels):
177            if (new_lb.name == old_lb.name) and (new_lb.size == old_lb.size):
178                diff_list.append(self._LabelContent('', 0, ''))
179            else:
180                diff_list.append(new_lb)
181
182        return tuple(diff_list)
183
184    def _label_title_row(self) -> List[str]:
185        label_rows = []
186        label_cells = ''
187        divider_cells = ''
188        for width in self._col_widths:
189            label_cells += ' ' * width + ' '
190            divider_cells += (self._cs.H.value * width) + self._cs.H.value
191        if self._diff_label is not None:
192            label_cells = self._diff_label.center(len(label_cells[:-1]), ' ')
193        label_rows.extend(
194            [
195                f"{self._cs.TL.value}{divider_cells[:-1]}{self._cs.TR.value}",
196                f"{self._cs.V.value}{label_cells}{self._cs.V.value}",
197                f"{self._cs.ML.value}{divider_cells[:-1]}{self._cs.MR.value}",
198            ]
199        )
200        return label_rows
201
202    def create_table(self) -> str:
203        """Parse DataSourceMap to create ASCII table."""
204        curr_lb_hierachy = None
205        last_diff_name = ''
206        if self._diff_mode:
207            self._ascii_table_rows.extend([*self._label_title_row()])
208        else:
209            self._ascii_table_rows.extend(
210                [self._create_border(True, self._cs.H.value)]
211            )
212        self._ascii_table_rows.extend([*self._create_title_row()])
213
214        has_entries = False
215
216        for curr_label in self._data_source_map.labels():
217            if curr_label.size == 0:
218                continue
219
220            has_entries = True
221
222            new_lb_hierachy = tuple(
223                [
224                    *self._get_ds_label_size(curr_label.parents),
225                    self._LabelContent(
226                        curr_label.name,
227                        curr_label.size,
228                        get_label_status(curr_label),
229                    ),
230                ]
231            )
232            diff_list = self._diff_label_names(
233                curr_lb_hierachy, new_lb_hierachy
234            )
235            curr_lb_hierachy = new_lb_hierachy
236
237            if curr_label.parents and curr_label.parents[0] == last_diff_name:
238                continue
239            if (
240                self._diff_mode
241                and diff_list[0].name
242                and (
243                    not cast(
244                        DiffDataSourceMap, self._data_source_map
245                    ).has_diff_sublabels(diff_list[0].name)
246                )
247            ):
248                if (len(self._ascii_table_rows) > 5) and (
249                    self._ascii_table_rows[-1][0] != '+'
250                ):
251                    self._ascii_table_rows.append(
252                        self._row_divider(
253                            len(self._col_names), self._cs.H.value
254                        )
255                    )
256                self._ascii_table_rows.append(
257                    self._create_same_label_row(1, diff_list[0].name)
258                )
259
260                last_diff_name = curr_label.parents[0]
261            else:
262                self._ascii_table_rows += self._create_diff_rows(diff_list)
263
264        if self._rst_output and self._ascii_table_rows[-1][0] == '+':
265            self._ascii_table_rows.pop()
266
267        self._ascii_table_rows.extend(
268            [*self._create_total_row(is_empty=not has_entries)]
269        )
270
271        return '\n'.join(self._ascii_table_rows) + '\n'
272
273    def _create_same_label_row(self, col_index: int, label: str) -> str:
274        label_row = ''
275        for col in range(len(self._col_names) - 1):
276            if col == col_index:
277                curr_cell = self._create_cell(label, False, col, _Align.LEFT)
278            else:
279                curr_cell = self._create_cell('', False, col)
280            label_row += curr_cell
281        label_row += self._create_cell(
282            "(SAME)", True, len(self._col_widths) - 1, _Align.RIGHT
283        )
284        return label_row
285
286    def _get_ds_label_size(
287        self, parent_labels: Tuple[str, ...]
288    ) -> Iterable[_LabelContent]:
289        """Produce label, size pairs from parent label names."""
290        parent_label_sizes = []
291        for index, target_label in enumerate(parent_labels):
292            for curr_label in self._data_source_map.labels(index):
293                if curr_label.name == target_label:
294                    diff_label = get_label_status(curr_label)
295                    parent_label_sizes.append(
296                        self._LabelContent(
297                            curr_label.name, curr_label.size, diff_label
298                        )
299                    )
300                    break
301        return parent_label_sizes
302
303    def _create_total_row(self, is_empty: bool = False) -> Iterable[str]:
304        complete_total_rows = []
305
306        if self._diff_mode and is_empty:
307            # When diffing two identical binaries, output a row indicating that
308            # the two are the same.
309            no_diff_row = ''
310            for i in range(len(self._col_names)):
311                if i == 0:
312                    no_diff_row += self._create_cell(
313                        'N/A', False, i, _Align.CENTER
314                    )
315                elif i == len(self._col_names) - 1:
316                    no_diff_row += self._create_cell('0', True, i)
317                else:
318                    no_diff_row += self._create_cell(
319                        '(same)', False, i, _Align.CENTER
320                    )
321            complete_total_rows.append(no_diff_row)
322
323        complete_total_rows.append(
324            self._row_divider(len(self._col_names), self._total_divider)
325        )
326        total_row = ''
327
328        for i in range(len(self._col_names)):
329            if i == 0:
330                total_row += self._create_cell('Total', False, i, _Align.LEFT)
331            elif i == len(self._col_names) - 1:
332                total_size_str = diff_sign_sizes(
333                    self._total_size, self._diff_mode
334                )
335                total_row += self._create_cell(total_size_str, True, i)
336            else:
337                total_row += self._create_cell('', False, i, _Align.CENTER)
338
339        complete_total_rows.extend(
340            [total_row, self._create_border(False, self._cs.H.value)]
341        )
342        return complete_total_rows
343
344    def _create_diff_rows(
345        self, diff_list: Tuple[_LabelContent, ...]
346    ) -> Iterable[str]:
347        """Create rows for each label according to its index in diff_list."""
348        curr_row = ''
349        diff_index = 0
350        diff_rows = []
351        for index, label_content in enumerate(diff_list):
352            if label_content.name:
353                if self._diff_mode:
354                    curr_row += self._create_cell(
355                        label_content.label_status, False, 0
356                    )
357                    diff_index = 1
358                for cell_index in range(
359                    diff_index, len(diff_list) + diff_index
360                ):
361                    if cell_index == index + diff_index:
362                        if (
363                            cell_index == diff_index
364                            and len(self._ascii_table_rows) > 5
365                            and not self._rst_output
366                        ):
367                            diff_rows.append(
368                                self._row_divider(
369                                    len(self._col_names), self._cs.H.value
370                                )
371                            )
372                        if (
373                            len(label_content.name) + self._additional_padding
374                        ) > self._col_widths[cell_index]:
375                            curr_row = self._multi_row_label(
376                                label_content.name, cell_index
377                            )
378                            break
379                        curr_row += self._create_cell(
380                            label_content.name, False, cell_index, _Align.LEFT
381                        )
382                    else:
383                        curr_row += self._create_cell('', False, cell_index)
384
385                # Add size end of current row.
386                curr_size = diff_sign_sizes(label_content.size, self._diff_mode)
387                curr_row += self._create_cell(
388                    curr_size, True, len(self._col_widths) - 1, _Align.RIGHT
389                )
390                diff_rows.append(curr_row)
391                if self._rst_output:
392                    diff_rows.append(
393                        self._row_divider(
394                            len(self._col_names), self._cs.H.value
395                        )
396                    )
397                curr_row = ''
398
399        return diff_rows
400
401    def _create_cell(
402        self,
403        content: str,
404        last_cell: bool,
405        col_index: int,
406        align: Optional[_Align] = _Align.RIGHT,
407    ) -> str:
408        v_border = self._cs.V.value
409        if self._rst_output and content:
410            content = content.replace('_', '\\_')
411        pad_diff = self._col_widths[col_index] - len(content)
412        padding = (pad_diff // 2) * ' '
413        odd_pad = ' ' if pad_diff % 2 == 1 else ''
414        string_cell = ''
415
416        if align == _Align.CENTER:
417            string_cell = f'{v_border}{odd_pad}{padding}{content}{padding}'
418        elif align == _Align.LEFT:
419            string_cell = f'{v_border}{content}{padding*2}{odd_pad}'
420        elif align == _Align.RIGHT:
421            string_cell = f'{v_border}{padding*2}{odd_pad}{content}'
422
423        if last_cell:
424            string_cell += self._cs.V.value
425        return string_cell
426
427    def _multi_row_label(self, content: str, target_col_index: int) -> str:
428        """Split content name into multiple rows within correct column."""
429        max_len = self._col_widths[target_col_index] - self._additional_padding
430        split_content = '...'.join(
431            content[max_len:][i : i + max_len - 3]
432            for i in range(0, len(content[max_len:]), max_len - 3)
433        )
434        split_content = f"{content[:max_len]}...{split_content}"
435        split_tab_content = [
436            split_content[i : i + max_len]
437            for i in range(0, len(split_content), max_len)
438        ]
439        multi_label = []
440        curr_row = ''
441        for index, cut_content in enumerate(split_tab_content):
442            last_cell = False
443            for blank_cell_index in range(len(self._col_names)):
444                if blank_cell_index == target_col_index:
445                    curr_row += self._create_cell(
446                        cut_content, False, target_col_index, _Align.LEFT
447                    )
448                else:
449                    if blank_cell_index == len(self._col_names) - 1:
450                        if index == len(split_tab_content) - 1:
451                            break
452                        last_cell = True
453                    curr_row += self._create_cell(
454                        '', last_cell, blank_cell_index
455                    )
456            multi_label.append(curr_row)
457            curr_row = ''
458
459        return '\n'.join(multi_label)
460
461    def _row_divider(self, col_num: int, h_div: str) -> str:
462        l_border = ''
463        r_border = ''
464        row_div = ''
465        for col in range(col_num):
466            if col == 0:
467                l_border = self._cs.ML.value
468                r_border = ''
469            elif col == (col_num - 1):
470                l_border = self._cs.MM.value
471                r_border = self._cs.MR.value
472            else:
473                l_border = self._cs.MM.value
474                r_border = ''
475
476            row_div += f"{l_border}{self._col_widths[col] * h_div}{r_border}"
477        return row_div
478
479    def _create_title_row(self) -> Iterable[str]:
480        title_rows = []
481        title_cells = ''
482        last_cell = False
483        for index, curr_name in enumerate(self._col_names):
484            if index == len(self._col_names) - 1:
485                last_cell = True
486            title_cells += self._create_cell(
487                curr_name, last_cell, index, _Align.CENTER
488            )
489        title_rows.extend(
490            [
491                title_cells,
492                self._row_divider(len(self._col_names), self._cs.HH.value),
493            ]
494        )
495        return title_rows
496
497    def _create_border(self, top: bool, h_div: str):
498        """Top or bottom borders of ASCII table."""
499        row_div = ''
500        for col in range(len(self._col_names)):
501            if top:
502                if col == 0:
503                    l_div = self._cs.TL.value
504                    r_div = ''
505                elif col == (len(self._col_names) - 1):
506                    l_div = self._cs.TM.value
507                    r_div = self._cs.TR.value
508                else:
509                    l_div = self._cs.TM.value
510                    r_div = ''
511            else:
512                if col == 0:
513                    l_div = self._cs.BL.value
514                    r_div = ''
515                elif col == (len(self._col_names) - 1):
516                    l_div = self._cs.BM.value
517                    r_div = self._cs.BR.value
518                else:
519                    l_div = self._cs.BM.value
520                    r_div = ''
521
522            row_div += f"{l_div}{self._col_widths[col] * h_div}{r_div}"
523        return row_div
524
525
526class RstOutput:
527    """Tabular output in ASCII format, which is also valid RST."""
528
529    def __init__(
530        self, ds_map: DataSourceMap, table_label: Optional[str] = None
531    ):
532        self._data_source_map = ds_map
533        self._table_label = table_label
534        self._diff_mode = False
535        if isinstance(self._data_source_map, DiffDataSourceMap):
536            self._diff_mode = True
537
538    def create_table(self) -> str:
539        """Initializes RST table and builds first row."""
540        table_builder = [
541            '\n.. list-table::',
542            '   :widths: auto',
543            '   :header-rows: 1\n',
544        ]
545        header_cols = ['Label', 'Segment', 'Delta']
546        for i, col_name in enumerate(header_cols):
547            list_space = '*' if i == 0 else ' '
548            table_builder.append(f"   {list_space} - {col_name}")
549
550        return '\n'.join(table_builder) + f'\n{self.add_report_row()}\n'
551
552    def _label_status_unchanged(self, parent_lb_name: str) -> bool:
553        """Determines if parent label has no status change in diff mode."""
554        for curr_lb in self._data_source_map.labels():
555            if curr_lb.size != 0:
556                if (
557                    curr_lb.parents and (parent_lb_name == curr_lb.parents[0])
558                ) or (curr_lb.name == parent_lb_name):
559                    if get_label_status(curr_lb) != '':
560                        return False
561        return True
562
563    def add_report_row(self) -> str:
564        """Add in new size report row with Label, Segment, and Delta.
565
566        Returns:
567            RST string that is the current row with a full symbols
568            table breakdown of the corresponding segment.
569        """
570        table_rows = []
571        curr_row = []
572        curr_label_name = ''
573        for parent_lb in self._data_source_map.labels(0):
574            if parent_lb.size != 0:
575                if (self._table_label is not None) and (
576                    curr_label_name != self._table_label
577                ):
578                    curr_row.append(f'   * - {self._table_label} ')
579                    curr_label_name = self._table_label
580                else:
581                    curr_row.append('   * -')
582                curr_row.extend(
583                    [
584                        f'     - .. dropdown:: {parent_lb.name}',
585                        '            :animate: fade-in\n',
586                        '            .. list-table::',
587                        '               :widths: auto\n',
588                    ]
589                )
590                if self._label_status_unchanged(parent_lb.name):
591                    skip_status = 1, '*'
592                else:
593                    skip_status = 0, ' '
594                for curr_lb in self._data_source_map.labels():
595                    if (curr_lb.size != 0) and (
596                        (
597                            curr_lb.parents
598                            and (parent_lb.name == curr_lb.parents[0])
599                        )
600                        or (curr_lb.name == parent_lb.name)
601                    ):
602                        sign_size = diff_sign_sizes(
603                            curr_lb.size, self._diff_mode
604                        )
605                        curr_status = get_label_status(curr_lb)
606                        curr_name = curr_lb.name.replace('_', '\\_')
607                        to_extend = [
608                            f'               * - {curr_status}',
609                            f'               {skip_status[1]} - {sign_size}',
610                            f'                 - {curr_name}\n',
611                        ][skip_status[0] :]
612                        curr_row.extend(to_extend)
613                curr_row.append(
614                    f'     - {diff_sign_sizes(parent_lb.size, self._diff_mode)}'
615                )
616            table_rows.extend(curr_row)
617            curr_row = []
618
619        # No size difference.
620        if len(table_rows) == 0:
621            table_rows.extend(
622                [f'\n   * - {self._table_label}', '     - (ALL)', '     - 0']
623            )
624
625        return '\n'.join(table_rows)
626