1e5c31af7Sopenharmony_ci#!/usr/bin/env python3
2e5c31af7Sopenharmony_ci#
3e5c31af7Sopenharmony_ci# Copyright (c) 2019 Collabora, Ltd.
4e5c31af7Sopenharmony_ci#
5e5c31af7Sopenharmony_ci# SPDX-License-Identifier: Apache-2.0
6e5c31af7Sopenharmony_ci#
7e5c31af7Sopenharmony_ci# Author(s):    Ryan Pavlik <ryan.pavlik@collabora.com>
8e5c31af7Sopenharmony_ci#
9e5c31af7Sopenharmony_ci# Purpose:      This script converts leading comments on some Python
10e5c31af7Sopenharmony_ci#               classes and functions into docstrings.
11e5c31af7Sopenharmony_ci#               It doesn't attempt to deal with line continuations, etc.
12e5c31af7Sopenharmony_ci#               so you may want to "join line" on your def statements
13e5c31af7Sopenharmony_ci#               temporarily before running.
14e5c31af7Sopenharmony_ci
15e5c31af7Sopenharmony_ciimport re
16e5c31af7Sopenharmony_ci
17e5c31af7Sopenharmony_cifrom spec_tools.file_process import LinewiseFileProcessor
18e5c31af7Sopenharmony_ci
19e5c31af7Sopenharmony_ciCOMMENT_RE = re.compile(r" *#(!.*| (?P<content>.*))?")
20e5c31af7Sopenharmony_ciCONVERTIBLE_DEF_RE = re.compile(r"(?P<indentation> *)(def|class) .*:")
21e5c31af7Sopenharmony_ci
22e5c31af7Sopenharmony_ci
23e5c31af7Sopenharmony_ciclass CommentConverter(LinewiseFileProcessor):
24e5c31af7Sopenharmony_ci    def __init__(self, single_line_quotes=False, allow_blank_lines=False):
25e5c31af7Sopenharmony_ci        super().__init__()
26e5c31af7Sopenharmony_ci        self.comment_lines = []
27e5c31af7Sopenharmony_ci        "Temporary storage for contiguous comment lines."
28e5c31af7Sopenharmony_ci
29e5c31af7Sopenharmony_ci        self.trailing_empty_lines = []
30e5c31af7Sopenharmony_ci        "Temporary storage for empty lines following a comment."
31e5c31af7Sopenharmony_ci
32e5c31af7Sopenharmony_ci        self.output_lines = []
33e5c31af7Sopenharmony_ci        "Fully-processed output lines."
34e5c31af7Sopenharmony_ci
35e5c31af7Sopenharmony_ci        self.single_line_quotes = single_line_quotes
36e5c31af7Sopenharmony_ci        "Whether we generate simple, single-line quotes for single line comments."
37e5c31af7Sopenharmony_ci
38e5c31af7Sopenharmony_ci        self.allow_blank_lines = allow_blank_lines
39e5c31af7Sopenharmony_ci        "Whether we allow blank lines between a comment and the thing it's considered to document."
40e5c31af7Sopenharmony_ci
41e5c31af7Sopenharmony_ci        self.done_with_initial_comment = False
42e5c31af7Sopenharmony_ci        "Have we read our first non-comment line yet?"
43e5c31af7Sopenharmony_ci
44e5c31af7Sopenharmony_ci    def output_line(self, line=None):
45e5c31af7Sopenharmony_ci        if line:
46e5c31af7Sopenharmony_ci            self.output_lines.append(line)
47e5c31af7Sopenharmony_ci        else:
48e5c31af7Sopenharmony_ci            self.output_lines.append("")
49e5c31af7Sopenharmony_ci
50e5c31af7Sopenharmony_ci    def output_normal_line(self, line):
51e5c31af7Sopenharmony_ci        # flush any comment lines we had stored and output this line.
52e5c31af7Sopenharmony_ci        self.dump_comment_lines()
53e5c31af7Sopenharmony_ci        self.output_line(line)
54e5c31af7Sopenharmony_ci
55e5c31af7Sopenharmony_ci    def dump_comment_lines(self):
56e5c31af7Sopenharmony_ci        # Early out for empty
57e5c31af7Sopenharmony_ci        if not self.comment_lines:
58e5c31af7Sopenharmony_ci            return
59e5c31af7Sopenharmony_ci
60e5c31af7Sopenharmony_ci        for line in self.comment_lines:
61e5c31af7Sopenharmony_ci            self.output_line(line)
62e5c31af7Sopenharmony_ci        self.comment_lines = []
63e5c31af7Sopenharmony_ci
64e5c31af7Sopenharmony_ci        for line in self.trailing_empty_lines:
65e5c31af7Sopenharmony_ci            self.output_line(line)
66e5c31af7Sopenharmony_ci        self.trailing_empty_lines = []
67e5c31af7Sopenharmony_ci
68e5c31af7Sopenharmony_ci    def dump_converted_comment_lines(self, indent):
69e5c31af7Sopenharmony_ci        # Early out for empty
70e5c31af7Sopenharmony_ci        if not self.comment_lines:
71e5c31af7Sopenharmony_ci            return
72e5c31af7Sopenharmony_ci
73e5c31af7Sopenharmony_ci        for line in self.trailing_empty_lines:
74e5c31af7Sopenharmony_ci            self.output_line(line)
75e5c31af7Sopenharmony_ci        self.trailing_empty_lines = []
76e5c31af7Sopenharmony_ci
77e5c31af7Sopenharmony_ci        indent = indent + '    '
78e5c31af7Sopenharmony_ci
79e5c31af7Sopenharmony_ci        def extract(line):
80e5c31af7Sopenharmony_ci            match = COMMENT_RE.match(line)
81e5c31af7Sopenharmony_ci            content = match.group('content')
82e5c31af7Sopenharmony_ci            if content:
83e5c31af7Sopenharmony_ci                return content
84e5c31af7Sopenharmony_ci            return ""
85e5c31af7Sopenharmony_ci
86e5c31af7Sopenharmony_ci        # Extract comment content
87e5c31af7Sopenharmony_ci        lines = [extract(line) for line in self.comment_lines]
88e5c31af7Sopenharmony_ci
89e5c31af7Sopenharmony_ci        # Drop leading empty comments.
90e5c31af7Sopenharmony_ci        while lines and not lines[0].strip():
91e5c31af7Sopenharmony_ci            lines.pop(0)
92e5c31af7Sopenharmony_ci
93e5c31af7Sopenharmony_ci        # Drop trailing empty comments.
94e5c31af7Sopenharmony_ci        while lines and not lines[-1].strip():
95e5c31af7Sopenharmony_ci            lines.pop()
96e5c31af7Sopenharmony_ci
97e5c31af7Sopenharmony_ci        # Add single- or multi-line-string quote
98e5c31af7Sopenharmony_ci        if self.single_line_quotes \
99e5c31af7Sopenharmony_ci            and len(lines) == 1 \
100e5c31af7Sopenharmony_ci                and '"' not in lines[0]:
101e5c31af7Sopenharmony_ci            quote = '"'
102e5c31af7Sopenharmony_ci        else:
103e5c31af7Sopenharmony_ci            quote = '"""'
104e5c31af7Sopenharmony_ci        lines[0] = quote + lines[0]
105e5c31af7Sopenharmony_ci        lines[-1] = lines[-1] + quote
106e5c31af7Sopenharmony_ci
107e5c31af7Sopenharmony_ci        # Output lines, indenting content as required.
108e5c31af7Sopenharmony_ci        for line in lines:
109e5c31af7Sopenharmony_ci            if line:
110e5c31af7Sopenharmony_ci                self.output_line(indent + line)
111e5c31af7Sopenharmony_ci            else:
112e5c31af7Sopenharmony_ci                # Don't indent empty comment lines
113e5c31af7Sopenharmony_ci                self.output_line()
114e5c31af7Sopenharmony_ci
115e5c31af7Sopenharmony_ci        # Clear stored comment lines since we processed them
116e5c31af7Sopenharmony_ci        self.comment_lines = []
117e5c31af7Sopenharmony_ci
118e5c31af7Sopenharmony_ci    def queue_comment_line(self, line):
119e5c31af7Sopenharmony_ci        if self.trailing_empty_lines:
120e5c31af7Sopenharmony_ci            # If we had blank lines between comment lines, they are separate blocks
121e5c31af7Sopenharmony_ci            self.dump_comment_lines()
122e5c31af7Sopenharmony_ci        self.comment_lines.append(line)
123e5c31af7Sopenharmony_ci
124e5c31af7Sopenharmony_ci    def handle_empty_line(self, line):
125e5c31af7Sopenharmony_ci        """Handle an empty line.
126e5c31af7Sopenharmony_ci
127e5c31af7Sopenharmony_ci        Contiguous empty lines between a comment and something documentable do not
128e5c31af7Sopenharmony_ci        disassociate the comment from the documentable thing.
129e5c31af7Sopenharmony_ci        We have someplace else to store these lines in case there isn't something
130e5c31af7Sopenharmony_ci        documentable coming up."""
131e5c31af7Sopenharmony_ci        if self.comment_lines and self.allow_blank_lines:
132e5c31af7Sopenharmony_ci            self.trailing_empty_lines.append(line)
133e5c31af7Sopenharmony_ci        else:
134e5c31af7Sopenharmony_ci            self.output_normal_line(line)
135e5c31af7Sopenharmony_ci
136e5c31af7Sopenharmony_ci    def is_next_line_doc_comment(self):
137e5c31af7Sopenharmony_ci        next_line = self.next_line_rstripped
138e5c31af7Sopenharmony_ci        if next_line is None:
139e5c31af7Sopenharmony_ci            return False
140e5c31af7Sopenharmony_ci
141e5c31af7Sopenharmony_ci        return next_line.strip().startswith('"')
142e5c31af7Sopenharmony_ci
143e5c31af7Sopenharmony_ci    def process_line(self, line_num, line):
144e5c31af7Sopenharmony_ci        line = line.rstrip()
145e5c31af7Sopenharmony_ci        comment_match = COMMENT_RE.match(line)
146e5c31af7Sopenharmony_ci        def_match = CONVERTIBLE_DEF_RE.match(line)
147e5c31af7Sopenharmony_ci
148e5c31af7Sopenharmony_ci        # First check if this is a comment line.
149e5c31af7Sopenharmony_ci        if comment_match:
150e5c31af7Sopenharmony_ci            if self.done_with_initial_comment:
151e5c31af7Sopenharmony_ci                self.queue_comment_line(line)
152e5c31af7Sopenharmony_ci            else:
153e5c31af7Sopenharmony_ci                self.output_line(line)
154e5c31af7Sopenharmony_ci        else:
155e5c31af7Sopenharmony_ci            # If not a comment line, then by definition we're done with the comment header.
156e5c31af7Sopenharmony_ci            self.done_with_initial_comment = True
157e5c31af7Sopenharmony_ci            if not line.strip():
158e5c31af7Sopenharmony_ci                self.handle_empty_line(line)
159e5c31af7Sopenharmony_ci            elif def_match and not self.is_next_line_doc_comment():
160e5c31af7Sopenharmony_ci                # We got something we can make a docstring for:
161e5c31af7Sopenharmony_ci                # print the thing the docstring is for first,
162e5c31af7Sopenharmony_ci                # then the converted comment.
163e5c31af7Sopenharmony_ci
164e5c31af7Sopenharmony_ci                indent = def_match.group('indentation')
165e5c31af7Sopenharmony_ci                self.output_line(line)
166e5c31af7Sopenharmony_ci                self.dump_converted_comment_lines(indent)
167e5c31af7Sopenharmony_ci            else:
168e5c31af7Sopenharmony_ci                # Can't make a docstring for this line:
169e5c31af7Sopenharmony_ci                self.output_normal_line(line)
170e5c31af7Sopenharmony_ci
171e5c31af7Sopenharmony_ci    def process(self, fn, write=False):
172e5c31af7Sopenharmony_ci        self.process_file(fn)
173e5c31af7Sopenharmony_ci
174e5c31af7Sopenharmony_ci        if write:
175e5c31af7Sopenharmony_ci            with open(fn, 'w', encoding='utf-8') as fp:
176e5c31af7Sopenharmony_ci                for line in self.output_lines:
177e5c31af7Sopenharmony_ci                    fp.write(line)
178e5c31af7Sopenharmony_ci                    fp.write('\n')
179e5c31af7Sopenharmony_ci
180e5c31af7Sopenharmony_ci        # Reset state
181e5c31af7Sopenharmony_ci        self.__init__(self.single_line_quotes, self.allow_blank_lines)
182e5c31af7Sopenharmony_ci
183e5c31af7Sopenharmony_ci
184e5c31af7Sopenharmony_cidef main():
185e5c31af7Sopenharmony_ci    import argparse
186e5c31af7Sopenharmony_ci
187e5c31af7Sopenharmony_ci    parser = argparse.ArgumentParser()
188e5c31af7Sopenharmony_ci    parser.add_argument('filenames', metavar='filename',
189e5c31af7Sopenharmony_ci                        type=str, nargs='+',
190e5c31af7Sopenharmony_ci                        help='A Python file to transform.')
191e5c31af7Sopenharmony_ci    parser.add_argument('-b', '--blanklines', action='store_true',
192e5c31af7Sopenharmony_ci                        help='Allow blank lines between a comment and a define and still convert that comment.')
193e5c31af7Sopenharmony_ci
194e5c31af7Sopenharmony_ci    args = parser.parse_args()
195e5c31af7Sopenharmony_ci
196e5c31af7Sopenharmony_ci    converter = CommentConverter(allow_blank_lines=args.blanklines)
197e5c31af7Sopenharmony_ci    for fn in args.filenames:
198e5c31af7Sopenharmony_ci        print("Processing", fn)
199e5c31af7Sopenharmony_ci        converter.process(fn, write=True)
200e5c31af7Sopenharmony_ci
201e5c31af7Sopenharmony_ci
202e5c31af7Sopenharmony_ciif __name__ == "__main__":
203e5c31af7Sopenharmony_ci    main()
204