blob: e11ca3a9d26faaacebb78d2bb2777a0fd8f8aef7 [file] [log] [blame]
Cheryl Sabella82494aa2019-07-17 09:44:44 -04001"""Format all or a selected region (line slice) of text.
2
3Region formatting options: paragraph, comment block, indent, deindent,
4comment, uncomment, tabify, and untabify.
5
6File renamed from paragraph.py with functions added from editor.py.
7"""
8import re
9from tkinter.simpledialog import askinteger
10from idlelib.config import idleConf
11
12
13class FormatParagraph:
14 """Format a paragraph, comment block, or selection to a max width.
15
16 Does basic, standard text formatting, and also understands Python
17 comment blocks. Thus, for editing Python source code, this
18 extension is really only suitable for reformatting these comment
19 blocks or triple-quoted strings.
20
21 Known problems with comment reformatting:
22 * If there is a selection marked, and the first line of the
23 selection is not complete, the block will probably not be detected
24 as comments, and will have the normal "text formatting" rules
25 applied.
26 * If a comment block has leading whitespace that mixes tabs and
27 spaces, they will not be considered part of the same block.
28 * Fancy comments, like this bulleted list, aren't handled :-)
29 """
30 def __init__(self, editwin):
31 self.editwin = editwin
32
33 @classmethod
34 def reload(cls):
35 cls.max_width = idleConf.GetOption('extensions', 'FormatParagraph',
36 'max-width', type='int', default=72)
37
38 def close(self):
39 self.editwin = None
40
41 def format_paragraph_event(self, event, limit=None):
42 """Formats paragraph to a max width specified in idleConf.
43
44 If text is selected, format_paragraph_event will start breaking lines
45 at the max width, starting from the beginning selection.
46
47 If no text is selected, format_paragraph_event uses the current
48 cursor location to determine the paragraph (lines of text surrounded
49 by blank lines) and formats it.
50
51 The length limit parameter is for testing with a known value.
52 """
53 limit = self.max_width if limit is None else limit
54 text = self.editwin.text
55 first, last = self.editwin.get_selection_indices()
56 if first and last:
57 data = text.get(first, last)
58 comment_header = get_comment_header(data)
59 else:
60 first, last, comment_header, data = \
61 find_paragraph(text, text.index("insert"))
62 if comment_header:
63 newdata = reformat_comment(data, limit, comment_header)
64 else:
65 newdata = reformat_paragraph(data, limit)
66 text.tag_remove("sel", "1.0", "end")
67
68 if newdata != data:
69 text.mark_set("insert", first)
70 text.undo_block_start()
71 text.delete(first, last)
72 text.insert(first, newdata)
73 text.undo_block_stop()
74 else:
75 text.mark_set("insert", last)
76 text.see("insert")
77 return "break"
78
79
80FormatParagraph.reload()
81
82def find_paragraph(text, mark):
83 """Returns the start/stop indices enclosing the paragraph that mark is in.
84
85 Also returns the comment format string, if any, and paragraph of text
86 between the start/stop indices.
87 """
88 lineno, col = map(int, mark.split("."))
89 line = text.get("%d.0" % lineno, "%d.end" % lineno)
90
91 # Look for start of next paragraph if the index passed in is a blank line
92 while text.compare("%d.0" % lineno, "<", "end") and is_all_white(line):
93 lineno = lineno + 1
94 line = text.get("%d.0" % lineno, "%d.end" % lineno)
95 first_lineno = lineno
96 comment_header = get_comment_header(line)
97 comment_header_len = len(comment_header)
98
99 # Once start line found, search for end of paragraph (a blank line)
100 while get_comment_header(line)==comment_header and \
101 not is_all_white(line[comment_header_len:]):
102 lineno = lineno + 1
103 line = text.get("%d.0" % lineno, "%d.end" % lineno)
104 last = "%d.0" % lineno
105
106 # Search back to beginning of paragraph (first blank line before)
107 lineno = first_lineno - 1
108 line = text.get("%d.0" % lineno, "%d.end" % lineno)
109 while lineno > 0 and \
110 get_comment_header(line)==comment_header and \
111 not is_all_white(line[comment_header_len:]):
112 lineno = lineno - 1
113 line = text.get("%d.0" % lineno, "%d.end" % lineno)
114 first = "%d.0" % (lineno+1)
115
116 return first, last, comment_header, text.get(first, last)
117
118# This should perhaps be replaced with textwrap.wrap
119def reformat_paragraph(data, limit):
120 """Return data reformatted to specified width (limit)."""
121 lines = data.split("\n")
122 i = 0
123 n = len(lines)
124 while i < n and is_all_white(lines[i]):
125 i = i+1
126 if i >= n:
127 return data
128 indent1 = get_indent(lines[i])
129 if i+1 < n and not is_all_white(lines[i+1]):
130 indent2 = get_indent(lines[i+1])
131 else:
132 indent2 = indent1
133 new = lines[:i]
134 partial = indent1
135 while i < n and not is_all_white(lines[i]):
136 # XXX Should take double space after period (etc.) into account
137 words = re.split(r"(\s+)", lines[i])
138 for j in range(0, len(words), 2):
139 word = words[j]
140 if not word:
141 continue # Can happen when line ends in whitespace
142 if len((partial + word).expandtabs()) > limit and \
143 partial != indent1:
144 new.append(partial.rstrip())
145 partial = indent2
146 partial = partial + word + " "
147 if j+1 < len(words) and words[j+1] != " ":
148 partial = partial + " "
149 i = i+1
150 new.append(partial.rstrip())
151 # XXX Should reformat remaining paragraphs as well
152 new.extend(lines[i:])
153 return "\n".join(new)
154
155def reformat_comment(data, limit, comment_header):
156 """Return data reformatted to specified width with comment header."""
157
158 # Remove header from the comment lines
159 lc = len(comment_header)
160 data = "\n".join(line[lc:] for line in data.split("\n"))
161 # Reformat to maxformatwidth chars or a 20 char width,
162 # whichever is greater.
163 format_width = max(limit - len(comment_header), 20)
164 newdata = reformat_paragraph(data, format_width)
165 # re-split and re-insert the comment header.
166 newdata = newdata.split("\n")
167 # If the block ends in a \n, we don't want the comment prefix
168 # inserted after it. (Im not sure it makes sense to reformat a
169 # comment block that is not made of complete lines, but whatever!)
170 # Can't think of a clean solution, so we hack away
171 block_suffix = ""
172 if not newdata[-1]:
173 block_suffix = "\n"
174 newdata = newdata[:-1]
175 return '\n'.join(comment_header+line for line in newdata) + block_suffix
176
177def is_all_white(line):
178 """Return True if line is empty or all whitespace."""
179
180 return re.match(r"^\s*$", line) is not None
181
182def get_indent(line):
183 """Return the initial space or tab indent of line."""
184 return re.match(r"^([ \t]*)", line).group()
185
186def get_comment_header(line):
187 """Return string with leading whitespace and '#' from line or ''.
188
189 A null return indicates that the line is not a comment line. A non-
190 null return, such as ' #', will be used to find the other lines of
191 a comment block with the same indent.
192 """
193 m = re.match(r"^([ \t]*#*)", line)
194 if m is None: return ""
195 return m.group(1)
196
197
198# Copy from editor.py; importing it would cause an import cycle.
199_line_indent_re = re.compile(r'[ \t]*')
200
201def get_line_indent(line, tabwidth):
202 """Return a line's indentation as (# chars, effective # of spaces).
203
204 The effective # of spaces is the length after properly "expanding"
205 the tabs into spaces, as done by str.expandtabs(tabwidth).
206 """
207 m = _line_indent_re.match(line)
208 return m.end(), len(m.group().expandtabs(tabwidth))
209
210
211class FormatRegion:
212 "Format selected text."
213
214 def __init__(self, editwin):
215 self.editwin = editwin
216
217 def get_region(self):
218 """Return line information about the selected text region.
219
220 If text is selected, the first and last indices will be
221 for the selection. If there is no text selected, the
222 indices will be the current cursor location.
223
224 Return a tuple containing (first index, last index,
225 string representation of text, list of text lines).
226 """
227 text = self.editwin.text
228 first, last = self.editwin.get_selection_indices()
229 if first and last:
230 head = text.index(first + " linestart")
231 tail = text.index(last + "-1c lineend +1c")
232 else:
233 head = text.index("insert linestart")
234 tail = text.index("insert lineend +1c")
235 chars = text.get(head, tail)
236 lines = chars.split("\n")
237 return head, tail, chars, lines
238
239 def set_region(self, head, tail, chars, lines):
240 """Replace the text between the given indices.
241
242 Args:
243 head: Starting index of text to replace.
244 tail: Ending index of text to replace.
245 chars: Expected to be string of current text
246 between head and tail.
247 lines: List of new lines to insert between head
248 and tail.
249 """
250 text = self.editwin.text
251 newchars = "\n".join(lines)
252 if newchars == chars:
253 text.bell()
254 return
255 text.tag_remove("sel", "1.0", "end")
256 text.mark_set("insert", head)
257 text.undo_block_start()
258 text.delete(head, tail)
259 text.insert(head, newchars)
260 text.undo_block_stop()
261 text.tag_add("sel", head, "insert")
262
263 def indent_region_event(self, event=None):
264 "Indent region by indentwidth spaces."
265 head, tail, chars, lines = self.get_region()
266 for pos in range(len(lines)):
267 line = lines[pos]
268 if line:
269 raw, effective = get_line_indent(line, self.editwin.tabwidth)
270 effective = effective + self.editwin.indentwidth
271 lines[pos] = self.editwin._make_blanks(effective) + line[raw:]
272 self.set_region(head, tail, chars, lines)
273 return "break"
274
275 def dedent_region_event(self, event=None):
276 "Dedent region by indentwidth spaces."
277 head, tail, chars, lines = self.get_region()
278 for pos in range(len(lines)):
279 line = lines[pos]
280 if line:
281 raw, effective = get_line_indent(line, self.editwin.tabwidth)
282 effective = max(effective - self.editwin.indentwidth, 0)
283 lines[pos] = self.editwin._make_blanks(effective) + line[raw:]
284 self.set_region(head, tail, chars, lines)
285 return "break"
286
287 def comment_region_event(self, event=None):
288 """Comment out each line in region.
289
290 ## is appended to the beginning of each line to comment it out.
291 """
292 head, tail, chars, lines = self.get_region()
293 for pos in range(len(lines) - 1):
294 line = lines[pos]
295 lines[pos] = '##' + line
296 self.set_region(head, tail, chars, lines)
297 return "break"
298
299 def uncomment_region_event(self, event=None):
300 """Uncomment each line in region.
301
302 Remove ## or # in the first positions of a line. If the comment
303 is not in the beginning position, this command will have no effect.
304 """
305 head, tail, chars, lines = self.get_region()
306 for pos in range(len(lines)):
307 line = lines[pos]
308 if not line:
309 continue
310 if line[:2] == '##':
311 line = line[2:]
312 elif line[:1] == '#':
313 line = line[1:]
314 lines[pos] = line
315 self.set_region(head, tail, chars, lines)
316 return "break"
317
318 def tabify_region_event(self, event=None):
319 "Convert leading spaces to tabs for each line in selected region."
320 head, tail, chars, lines = self.get_region()
321 tabwidth = self._asktabwidth()
322 if tabwidth is None:
323 return
324 for pos in range(len(lines)):
325 line = lines[pos]
326 if line:
327 raw, effective = get_line_indent(line, tabwidth)
328 ntabs, nspaces = divmod(effective, tabwidth)
329 lines[pos] = '\t' * ntabs + ' ' * nspaces + line[raw:]
330 self.set_region(head, tail, chars, lines)
331 return "break"
332
333 def untabify_region_event(self, event=None):
334 "Expand tabs to spaces for each line in region."
335 head, tail, chars, lines = self.get_region()
336 tabwidth = self._asktabwidth()
337 if tabwidth is None:
338 return
339 for pos in range(len(lines)):
340 lines[pos] = lines[pos].expandtabs(tabwidth)
341 self.set_region(head, tail, chars, lines)
342 return "break"
343
344 def _asktabwidth(self):
345 "Return value for tab width."
346 return askinteger(
347 "Tab width",
348 "Columns per tab? (2-16)",
349 parent=self.editwin.text,
350 initialvalue=self.editwin.indentwidth,
351 minvalue=2,
352 maxvalue=16)
353
354
355if __name__ == "__main__":
356 from unittest import main
357 main('idlelib.idle_test.test_format', verbosity=2, exit=False)