1from collections import namedtuple
2import logging
3import os
4import os.path
5import re
6import textwrap
7
8from c_common.tables import build_table, resolve_columns
9from c_parser.parser._regexes import _ind
10from ._files import iter_header_files, resolve_filename
11from . import REPO_ROOT
12
13
14logger = logging.getLogger(__name__)
15
16
17INCLUDE_ROOT = os.path.join(REPO_ROOT, 'Include')
18INCLUDE_CPYTHON = os.path.join(INCLUDE_ROOT, 'cpython')
19INCLUDE_INTERNAL = os.path.join(INCLUDE_ROOT, 'internal')
20
21_MAYBE_NESTED_PARENS = textwrap.dedent(r'''
22    (?:
23        (?: [^(]* [(] [^()]* [)] )* [^(]*
24    )
25''')
26
27CAPI_FUNC = textwrap.dedent(rf'''
28    (?:
29        ^
30        \s*
31        PyAPI_FUNC \s*
32        [(]
33        {_ind(_MAYBE_NESTED_PARENS, 2)}
34        [)] \s*
35        (\w+)  # <func>
36        \s* [(]
37    )
38''')
39CAPI_DATA = textwrap.dedent(rf'''
40    (?:
41        ^
42        \s*
43        PyAPI_DATA \s*
44        [(]
45        {_ind(_MAYBE_NESTED_PARENS, 2)}
46        [)] \s*
47        (\w+)  # <data>
48        \b [^(]
49    )
50''')
51CAPI_INLINE = textwrap.dedent(r'''
52    (?:
53        ^
54        \s*
55        static \s+ inline \s+
56        .*?
57        \s+
58        ( \w+ )  # <inline>
59        \s* [(]
60    )
61''')
62CAPI_MACRO = textwrap.dedent(r'''
63    (?:
64        (\w+)  # <macro>
65        [(]
66    )
67''')
68CAPI_CONSTANT = textwrap.dedent(r'''
69    (?:
70        (\w+)  # <constant>
71        \s+ [^(]
72    )
73''')
74CAPI_DEFINE = textwrap.dedent(rf'''
75    (?:
76        ^
77        \s* [#] \s* define \s+
78        (?:
79            {_ind(CAPI_MACRO, 3)}
80            |
81            {_ind(CAPI_CONSTANT, 3)}
82            |
83            (?:
84                # ignored
85                \w+   # <defined_name>
86                \s*
87                $
88            )
89        )
90    )
91''')
92CAPI_RE = re.compile(textwrap.dedent(rf'''
93    (?:
94        {_ind(CAPI_FUNC, 2)}
95        |
96        {_ind(CAPI_DATA, 2)}
97        |
98        {_ind(CAPI_INLINE, 2)}
99        |
100        {_ind(CAPI_DEFINE, 2)}
101    )
102'''), re.VERBOSE)
103
104KINDS = [
105    'func',
106    'data',
107    'inline',
108    'macro',
109    'constant',
110]
111
112
113def _parse_line(line, prev=None):
114    last = line
115    if prev:
116        if not prev.endswith(os.linesep):
117            prev += os.linesep
118        line = prev + line
119    m = CAPI_RE.match(line)
120    if not m:
121        if not prev and line.startswith('static inline '):
122            return line  # the new "prev"
123        #if 'PyAPI_' in line or '#define ' in line or ' define ' in line:
124        #    print(line)
125        return None
126    results = zip(KINDS, m.groups())
127    for kind, name in results:
128        if name:
129            clean = last.split('//')[0].rstrip()
130            if clean.endswith('*/'):
131                clean = clean.split('/*')[0].rstrip()
132
133            if kind == 'macro' or kind == 'constant':
134                if not clean.endswith('\\'):
135                    return name, kind
136            elif kind == 'inline':
137                if clean.endswith('}'):
138                    if not prev or clean == '}':
139                        return name, kind
140            elif kind == 'func' or kind == 'data':
141                if clean.endswith(';'):
142                    return name, kind
143            else:
144                # This should not be reached.
145                raise NotImplementedError
146            return line  # the new "prev"
147    # It was a plain #define.
148    return None
149
150
151LEVELS = [
152    'stable',
153    'cpython',
154    'private',
155    'internal',
156]
157
158def _get_level(filename, name, *,
159               _cpython=INCLUDE_CPYTHON + os.path.sep,
160               _internal=INCLUDE_INTERNAL + os.path.sep,
161               ):
162    if filename.startswith(_internal):
163        return 'internal'
164    elif name.startswith('_'):
165        return 'private'
166    elif os.path.dirname(filename) == INCLUDE_ROOT:
167        return 'stable'
168    elif filename.startswith(_cpython):
169        return 'cpython'
170    else:
171        raise NotImplementedError
172    #return '???'
173
174
175GROUPINGS = {
176    'kind': KINDS,
177    'level': LEVELS,
178}
179
180
181class CAPIItem(namedtuple('CAPIItem', 'file lno name kind level')):
182
183    @classmethod
184    def from_line(cls, line, filename, lno, prev=None):
185        parsed = _parse_line(line, prev)
186        if not parsed:
187            return None, None
188        if isinstance(parsed, str):
189            # incomplete
190            return None, parsed
191        name, kind = parsed
192        level = _get_level(filename, name)
193        self = cls(filename, lno, name, kind, level)
194        if prev:
195            self._text = (prev + line).rstrip().splitlines()
196        else:
197            self._text = [line.rstrip()]
198        return self, None
199
200    @property
201    def relfile(self):
202        return self.file[len(REPO_ROOT) + 1:]
203
204    @property
205    def text(self):
206        try:
207            return self._text
208        except AttributeError:
209            # XXX Actually ready the text from disk?.
210            self._text = []
211            if self.kind == 'data':
212                self._text = [
213                    f'PyAPI_DATA(...) {self.name}',
214                ]
215            elif self.kind == 'func':
216                self._text = [
217                    f'PyAPI_FUNC(...) {self.name}(...);',
218                ]
219            elif self.kind == 'inline':
220                self._text = [
221                    f'static inline {self.name}(...);',
222                ]
223            elif self.kind == 'macro':
224                self._text = [
225                    f'#define {self.name}(...) \\',
226                    f'    ...',
227                ]
228            elif self.kind == 'constant':
229                self._text = [
230                    f'#define {self.name} ...',
231                ]
232            else:
233                raise NotImplementedError
234
235            return self._text
236
237
238def _parse_groupby(raw):
239    if not raw:
240        raw = 'kind'
241
242    if isinstance(raw, str):
243        groupby = raw.replace(',', ' ').strip().split()
244    else:
245        raise NotImplementedError
246
247    if not all(v in GROUPINGS for v in groupby):
248        raise ValueError(f'invalid groupby value {raw!r}')
249    return groupby
250
251
252def _resolve_full_groupby(groupby):
253    if isinstance(groupby, str):
254        groupby = [groupby]
255    groupings = []
256    for grouping in groupby + list(GROUPINGS):
257        if grouping not in groupings:
258            groupings.append(grouping)
259    return groupings
260
261
262def summarize(items, *, groupby='kind', includeempty=True, minimize=None):
263    if minimize is None:
264        if includeempty is None:
265            minimize = True
266            includeempty = False
267        else:
268            minimize = includeempty
269    elif includeempty is None:
270        includeempty = minimize
271    elif minimize and includeempty:
272        raise ValueError(f'cannot minimize and includeempty at the same time')
273
274    groupby = _parse_groupby(groupby)[0]
275    _outer, _inner = _resolve_full_groupby(groupby)
276    outers = GROUPINGS[_outer]
277    inners = GROUPINGS[_inner]
278
279    summary = {
280        'totals': {
281            'all': 0,
282            'subs': {o: 0 for o in outers},
283            'bygroup': {o: {i: 0 for i in inners}
284                        for o in outers},
285        },
286    }
287
288    for item in items:
289        outer = getattr(item, _outer)
290        inner = getattr(item, _inner)
291        # Update totals.
292        summary['totals']['all'] += 1
293        summary['totals']['subs'][outer] += 1
294        summary['totals']['bygroup'][outer][inner] += 1
295
296    if not includeempty:
297        subtotals = summary['totals']['subs']
298        bygroup = summary['totals']['bygroup']
299        for outer in outers:
300            if subtotals[outer] == 0:
301                del subtotals[outer]
302                del bygroup[outer]
303                continue
304
305            for inner in inners:
306                if bygroup[outer][inner] == 0:
307                    del bygroup[outer][inner]
308            if minimize:
309                if len(bygroup[outer]) == 1:
310                    del bygroup[outer]
311
312    return summary
313
314
315def _parse_capi(lines, filename):
316    if isinstance(lines, str):
317        lines = lines.splitlines()
318    prev = None
319    for lno, line in enumerate(lines, 1):
320        parsed, prev = CAPIItem.from_line(line, filename, lno, prev)
321        if parsed:
322            yield parsed
323    if prev:
324        parsed, prev = CAPIItem.from_line('', filename, lno, prev)
325        if parsed:
326            yield parsed
327        if prev:
328            print('incomplete match:')
329            print(filename)
330            print(prev)
331            raise Exception
332
333
334def iter_capi(filenames=None):
335    for filename in iter_header_files(filenames):
336        with open(filename) as infile:
337            for item in _parse_capi(infile, filename):
338                yield item
339
340
341def resolve_filter(ignored):
342    if not ignored:
343        return None
344    ignored = set(_resolve_ignored(ignored))
345    def filter(item, *, log=None):
346        if item.name not in ignored:
347            return True
348        if log is not None:
349            log(f'ignored {item.name!r}')
350        return False
351    return filter
352
353
354def _resolve_ignored(ignored):
355    if isinstance(ignored, str):
356        ignored = [ignored]
357    for raw in ignored:
358        if isinstance(raw, str):
359            if raw.startswith('|'):
360                yield raw[1:]
361            elif raw.startswith('<') and raw.endswith('>'):
362                filename = raw[1:-1]
363                try:
364                    infile = open(filename)
365                except Exception as exc:
366                    logger.error(f'ignore file failed: {exc}')
367                    continue
368                logger.log(1, f'reading ignored names from {filename!r}')
369                with infile:
370                    for line in infile:
371                        if not line:
372                            continue
373                        if line[0].isspace():
374                            continue
375                        line = line.partition('#')[0].rstrip()
376                        if line:
377                            # XXX Recurse?
378                            yield line
379            else:
380                raw = raw.strip()
381                if raw:
382                    yield raw
383        else:
384            raise NotImplementedError
385
386
387def _collate(items, groupby, includeempty):
388    groupby = _parse_groupby(groupby)[0]
389    maxfilename = maxname = maxkind = maxlevel = 0
390
391    collated = {}
392    groups = GROUPINGS[groupby]
393    for group in groups:
394        collated[group] = []
395
396    for item in items:
397        key = getattr(item, groupby)
398        collated[key].append(item)
399        maxfilename = max(len(item.relfile), maxfilename)
400        maxname = max(len(item.name), maxname)
401        maxkind = max(len(item.kind), maxkind)
402        maxlevel = max(len(item.level), maxlevel)
403    if not includeempty:
404        for group in groups:
405            if not collated[group]:
406                del collated[group]
407    maxextra = {
408        'kind': maxkind,
409        'level': maxlevel,
410    }
411    return collated, groupby, maxfilename, maxname, maxextra
412
413
414def _get_sortkey(sort, _groupby, _columns):
415    if sort is True or sort is None:
416        # For now:
417        def sortkey(item):
418            return (
419                item.level == 'private',
420                LEVELS.index(item.level),
421                KINDS.index(item.kind),
422                os.path.dirname(item.file),
423                os.path.basename(item.file),
424                item.name,
425            )
426        return sortkey
427
428        sortfields = 'not-private level kind dirname basename name'.split()
429    elif isinstance(sort, str):
430        sortfields = sort.replace(',', ' ').strip().split()
431    elif callable(sort):
432        return sort
433    else:
434        raise NotImplementedError
435
436    # XXX Build a sortkey func from sortfields.
437    raise NotImplementedError
438
439
440##################################
441# CLI rendering
442
443_MARKERS = {
444    'level': {
445        'S': 'stable',
446        'C': 'cpython',
447        'P': 'private',
448        'I': 'internal',
449    },
450    'kind': {
451        'F': 'func',
452        'D': 'data',
453        'I': 'inline',
454        'M': 'macro',
455        'C': 'constant',
456    },
457}
458
459
460def resolve_format(format):
461    if not format:
462        return 'table'
463    elif isinstance(format, str) and format in _FORMATS:
464        return format
465    else:
466        return resolve_columns(format)
467
468
469def get_renderer(format):
470    format = resolve_format(format)
471    if isinstance(format, str):
472        try:
473            return _FORMATS[format]
474        except KeyError:
475            raise ValueError(f'unsupported format {format!r}')
476    else:
477        def render(items, **kwargs):
478            return render_table(items, columns=format, **kwargs)
479        return render
480
481
482def render_table(items, *,
483                 columns=None,
484                 groupby='kind',
485                 sort=True,
486                 showempty=False,
487                 verbose=False,
488                 ):
489    if groupby is None:
490        groupby = 'kind'
491    if showempty is None:
492        showempty = False
493
494    if groupby:
495        (collated, groupby, maxfilename, maxname, maxextra,
496         ) = _collate(items, groupby, showempty)
497        for grouping in GROUPINGS:
498            maxextra[grouping] = max(len(g) for g in GROUPINGS[grouping])
499
500        _, extra = _resolve_full_groupby(groupby)
501        extras = [extra]
502        markers = {extra: _MARKERS[extra]}
503
504        groups = GROUPINGS[groupby]
505    else:
506        # XXX Support no grouping?
507        raise NotImplementedError
508
509    if columns:
510        def get_extra(item):
511            return {extra: getattr(item, extra)
512                    for extra in ('kind', 'level')}
513    else:
514        if verbose:
515            extracols = [f'{extra}:{maxextra[extra]}'
516                         for extra in extras]
517            def get_extra(item):
518                return {extra: getattr(item, extra)
519                        for extra in extras}
520        elif len(extras) == 1:
521            extra, = extras
522            extracols = [f'{m}:1' for m in markers[extra]]
523            def get_extra(item):
524                return {m: m if getattr(item, extra) == markers[extra][m] else ''
525                        for m in markers[extra]}
526        else:
527            raise NotImplementedError
528            #extracols = [[f'{m}:1' for m in markers[extra]]
529            #             for extra in extras]
530            #def get_extra(item):
531            #    values = {}
532            #    for extra in extras:
533            #        cur = markers[extra]
534            #        for m in cur:
535            #            values[m] = m if getattr(item, m) == cur[m] else ''
536            #    return values
537        columns = [
538            f'filename:{maxfilename}',
539            f'name:{maxname}',
540            *extracols,
541        ]
542    header, div, fmt = build_table(columns)
543
544    if sort:
545        sortkey = _get_sortkey(sort, groupby, columns)
546
547    total = 0
548    for group, grouped in collated.items():
549        if not showempty and group not in collated:
550            continue
551        yield ''
552        yield f' === {group} ==='
553        yield ''
554        yield header
555        yield div
556        if grouped:
557            if sort:
558                grouped = sorted(grouped, key=sortkey)
559            for item in grouped:
560                yield fmt.format(
561                    filename=item.relfile,
562                    name=item.name,
563                    **get_extra(item),
564                )
565        yield div
566        subtotal = len(grouped)
567        yield f'  sub-total: {subtotal}'
568        total += subtotal
569    yield ''
570    yield f'total: {total}'
571
572
573def render_full(items, *,
574                groupby='kind',
575                sort=None,
576                showempty=None,
577                verbose=False,
578                ):
579    if groupby is None:
580        groupby = 'kind'
581    if showempty is None:
582        showempty = False
583
584    if sort:
585        sortkey = _get_sortkey(sort, groupby, None)
586
587    if groupby:
588        collated, groupby, _, _, _ = _collate(items, groupby, showempty)
589        for group, grouped in collated.items():
590            yield '#' * 25
591            yield f'# {group} ({len(grouped)})'
592            yield '#' * 25
593            yield ''
594            if not grouped:
595                continue
596            if sort:
597                grouped = sorted(grouped, key=sortkey)
598            for item in grouped:
599                yield from _render_item_full(item, groupby, verbose)
600                yield ''
601    else:
602        if sort:
603            items = sorted(items, key=sortkey)
604        for item in items:
605            yield from _render_item_full(item, None, verbose)
606            yield ''
607
608
609def _render_item_full(item, groupby, verbose):
610    yield item.name
611    yield f'  {"filename:":10} {item.relfile}'
612    for extra in ('kind', 'level'):
613        #if groupby != extra:
614            yield f'  {extra+":":10} {getattr(item, extra)}'
615    if verbose:
616        print('  ---------------------------------------')
617        for lno, line in enumerate(item.text, item.lno):
618            print(f'  | {lno:3} {line}')
619        print('  ---------------------------------------')
620
621
622def render_summary(items, *,
623                   groupby='kind',
624                   sort=None,
625                   showempty=None,
626                   verbose=False,
627                   ):
628    if groupby is None:
629        groupby = 'kind'
630    summary = summarize(
631        items,
632        groupby=groupby,
633        includeempty=showempty,
634        minimize=None if showempty else not verbose,
635    )
636
637    subtotals = summary['totals']['subs']
638    bygroup = summary['totals']['bygroup']
639    lastempty = False
640    for outer, subtotal in subtotals.items():
641        if bygroup:
642            subtotal = f'({subtotal})'
643            yield f'{outer + ":":20} {subtotal:>8}'
644        else:
645            yield f'{outer + ":":10} {subtotal:>8}'
646        if outer in bygroup:
647            for inner, count in bygroup[outer].items():
648                yield f'   {inner + ":":9} {count}'
649            lastempty = False
650        else:
651            lastempty = True
652
653    total = f'*{summary["totals"]["all"]}*'
654    label = '*total*:'
655    if bygroup:
656        yield f'{label:20} {total:>8}'
657    else:
658        yield f'{label:10} {total:>9}'
659
660
661_FORMATS = {
662    'table': render_table,
663    'full': render_full,
664    'summary': render_summary,
665}
666