diff -r 96d817f41c4c Lib/idlelib/FormatParagraph.py --- a/Lib/idlelib/FormatParagraph.py Wed Jul 24 21:02:50 2013 +0200 +++ b/Lib/idlelib/FormatParagraph.py Wed Jul 24 23:52:01 2013 -0400 @@ -1,18 +1,19 @@ -# Extension to format a paragraph +"""Extension to format a paragraph or selection to a max width. -# Does basic, standard text formatting, and also understands Python -# comment blocks. Thus, for editing Python source code, this -# extension is really only suitable for reformatting these comment -# blocks or triple-quoted strings. +Does basic, standard text formatting, and also understands Python +comment blocks. Thus, for editing Python source code, this +extension is really only suitable for reformatting these comment +blocks or triple-quoted strings. -# Known problems with comment reformatting: -# * If there is a selection marked, and the first line of the -# selection is not complete, the block will probably not be detected -# as comments, and will have the normal "text formatting" rules -# applied. -# * If a comment block has leading whitespace that mixes tabs and -# spaces, they will not be considered part of the same block. -# * Fancy comments, like this bulleted list, arent handled :-) +Known problems with comment reformatting: +* If there is a selection marked, and the first line of the + selection is not complete, the block will probably not be detected + as comments, and will have the normal "text formatting" rules + applied. +* If a comment block has leading whitespace that mixes tabs and + spaces, they will not be considered part of the same block. +* Fancy comments, like this bulleted list, aren't handled :-) +""" import re from idlelib.configHandler import idleConf @@ -32,38 +33,27 @@ self.editwin = None def format_paragraph_event(self, event): + """Formats paragraph to a max width specified in idleConf. + + If text is selected, format_paragraph_event will start breaking lines + at the max width, starting from the beginning selection. + + If no text is selected, format_paragraph_event uses the current + cursor location to determine the paragraph (lines of text surrounded + by blank lines) and formats it. + """ maxformatwidth = int(idleConf.GetOption('main', 'FormatParagraph', 'paragraph', type='int')) text = self.editwin.text first, last = self.editwin.get_selection_indices() if first and last: data = text.get(first, last) - comment_header = '' + comment_header = get_comment_header(data) else: first, last, comment_header, data = \ find_paragraph(text, text.index("insert")) if comment_header: - # Reformat the comment lines - convert to text sans header. - lines = data.split("\n") - lines = map(lambda st, l=len(comment_header): st[l:], lines) - data = "\n".join(lines) - # Reformat to maxformatwidth chars or a 20 char width, - # whichever is greater. - format_width = max(maxformatwidth - len(comment_header), 20) - newdata = reformat_paragraph(data, format_width) - # re-split and re-insert the comment header. - newdata = newdata.split("\n") - # If the block ends in a \n, we dont want the comment - # prefix inserted after it. (Im not sure it makes sense to - # reformat a comment block that isnt made of complete - # lines, but whatever!) Can't think of a clean solution, - # so we hack away - block_suffix = "" - if not newdata[-1]: - block_suffix = "\n" - newdata = newdata[:-1] - builder = lambda item, prefix=comment_header: prefix+item - newdata = '\n'.join(map(builder, newdata)) + block_suffix + newdata = reformat_comment(data, maxformatwidth, comment_header) else: # Just a normal text format newdata = reformat_paragraph(data, maxformatwidth) @@ -80,20 +70,30 @@ return "break" def find_paragraph(text, mark): + """Returns the start/stop indices enclosing the paragraph that mark is in. + + Also returns the comment format string and paragraph of text between the + start/stop indices. + """ lineno, col = map(int, mark.split(".")) line = text.get("%d.0" % lineno, "%d.0 lineend" % lineno) + + # Look for start of next paragraph if the index passed in is a blank line while text.compare("%d.0" % lineno, "<", "end") and is_all_white(line): lineno = lineno + 1 line = text.get("%d.0" % lineno, "%d.0 lineend" % lineno) first_lineno = lineno comment_header = get_comment_header(line) comment_header_len = len(comment_header) + + # Once start line found, search for end of paragraph (a blank line) while get_comment_header(line)==comment_header and \ not is_all_white(line[comment_header_len:]): lineno = lineno + 1 line = text.get("%d.0" % lineno, "%d.0 lineend" % lineno) last = "%d.0" % lineno - # Search back to beginning of paragraph + + # Search back to beginning of paragraph (first blank line before) lineno = first_lineno - 1 line = text.get("%d.0" % lineno, "%d.0 lineend" % lineno) while lineno > 0 and \ @@ -102,9 +102,11 @@ lineno = lineno - 1 line = text.get("%d.0" % lineno, "%d.0 lineend" % lineno) first = "%d.0" % (lineno+1) + return first, last, comment_header, text.get(first, last) def reformat_paragraph(data, limit): + """Returns data string reformatted to specified width (limit).""" lines = data.split("\n") i = 0 n = len(lines) @@ -139,13 +141,52 @@ new.extend(lines[i:]) return "\n".join(new) +def reformat_comment(data, limit, comment_header): + + """Returns string reformatted to specified width with comment header.""" + + # Reformat the comment lines - convert to text sans header. + lines = data.split("\n") + lines = map(lambda st, l=len(comment_header): st[l:], lines) + data = "\n".join(lines) + # Reformat to maxformatwidth chars or a 20 char width, + # whichever is greater. + format_width = max(limit - len(comment_header), 20) + newdata = reformat_paragraph(data, format_width) + # re-split and re-insert the comment header. + newdata = newdata.split("\n") + # If the block ends in a \n, we dont want the comment + # prefix inserted after it. (Im not sure it makes sense to + # reformat a comment block that isnt made of complete + # lines, but whatever!) Can't think of a clean solution, + # so we hack away + block_suffix = "" + if not newdata[-1]: + block_suffix = "\n" + newdata = newdata[:-1] + builder = lambda item, prefix=comment_header: prefix+item + return '\n'.join(map(builder, newdata)) + block_suffix + def is_all_white(line): + """Return True if line consists entirely of whitespace.""" return re.match(r"^\s*$", line) is not None def get_indent(line): - return re.match(r"^(\s*)", line).group() + """Return the initial whitespace substring of line.""" + return re.match(r"^([ \t]*)", line).group() def get_comment_header(line): - m = re.match(r"^(\s*#*)", line) + """Attempts to return string with leading whitespace and '#' from line. + + For example, the string " #" could be returned, then used to format + the rest of the lines in the paragraph with the same indent. + """ + m = re.match(r"^([ \t]*#*)", line) if m is None: return "" return m.group(1) + +if __name__ == "__main__": + from test import support; support.use_resources = ['gui'] + import unittest + unittest.main('idlelib.idle_test.test_formatparagraph', + verbosity=2, exit=False) diff -r 96d817f41c4c Lib/idlelib/idle_test/test_formatparagraph.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Lib/idlelib/idle_test/test_formatparagraph.py Wed Jul 24 23:52:01 2013 -0400 @@ -0,0 +1,437 @@ +import unittest +from idlelib import FormatParagraph as fp +from idlelib.EditorWindow import EditorWindow as Editor +from tkinter import Tk, Text +from test.support import requires + + +class Is_Get_Test(unittest.TestCase): + + """Test the is_ and get_ functions in FormatParagraph.""" + + @classmethod + def setUpClass(cls): + cls.test_comment = '# This is a comment' + cls.test_nocomment = 'This is not a comment' + cls.trailingws_comment = '# This is a comment ' + cls.leadingws_comment = ' # This is a comment' + cls.leadingws_nocomment = ' This is not a comment' + + def test_is_all_white(self): + Equal = self.assertEqual + Equal(fp.is_all_white('\t\n\r\f\v'), True) + Equal(fp.is_all_white(self.test_comment), False) + + def test_get_indent(self): + Equal = self.assertEqual + Equal(fp.get_indent(self.test_comment), '') + Equal(fp.get_indent(self.trailingws_comment), '') + Equal(fp.get_indent(self.leadingws_comment), ' ') + Equal(fp.get_indent(self.leadingws_nocomment), ' ') + + def test_get_comment_header(self): + Equal = self.assertEqual + + # Test comment strings + Equal(fp.get_comment_header(self.test_comment), '#') + Equal(fp.get_comment_header(self.trailingws_comment), '#') + Equal(fp.get_comment_header(self.leadingws_comment), ' #') + + # Test non-comment strings + Equal(fp.get_comment_header(self.leadingws_nocomment), ' ') + Equal(fp.get_comment_header(self.test_nocomment), '') + + +class FindTest(unittest.TestCase): + + """Test the find_paragraph function in FormatParagraph. + + Using the runcase() function, find_paragraph() is called with 'mark' set at + every possible index before and inside the test paragraph. + + It appears that code with the same indentation as a quted string is grouped + as part of the same paragraph, which is probably incorrect behavior. + """ + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.editor = Editor(root=cls.root) + cls.text = cls.editor.text + + def runcase(self, inserttext, stopline, expected): + # Check that find_paragraph returns the expected paragraph when + # the mark index is set to every possible position up to but not + # including the stop line + text = self.text + text.insert('1.0', inserttext) + for line in range(1, stopline): + linelength = len(text.get("%d.0" % line, "%d.0 lineend" % line)) + for col in range(0, linelength): + tempindex = "%d.%d" % (line, col) + self.assertEqual(fp.find_paragraph(text, tempindex), expected) + text.delete('1.0', 'end') + + def test_find_paragraph(self): + comment = ( + "# Comment block with no blank lines before\n" + "# Comment line\n" + "# Comment line\n" + "\n") + self.runcase(comment, 4, ('1.0', '4.0', '#', comment[0:73])) + + comment = ( + "\n" + "# Comment block with whitespace line before and after\n" + "# Comment line\n" + "\n") + self.runcase(comment, 4, ('2.0', '4.0', '#', comment[1:70])) + + comment = ( + "\n" + " # Indented comment block with whitespace before and after\n" + " # Comment line\n" + "\n") + self.runcase(comment, 4, ('2.0', '4.0', ' #', comment[1:82])) + + comment = ( + "\n" + "# Single line comment\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:23])) + + comment = ( + "\n" + " # Single line comment with leading whitespace\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', ' #', comment[1:51])) + + comment = ( + "\n" + "# Comment immediately followed by code\n" + "x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:40])) + + comment = ( + "\n" + " # Indented comment immediately followed by code\n" + "x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', ' #', comment[1:53])) + + comment = ( + "\n" + "# Comment immediately followed by indented code\n" + " x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:49])) + + comment = ( + "\n" + "# Comment block with whitespace line before and not after\n" + "# Comment line") + # Text widget adds newline at the end + self.runcase(comment, 4, ('2.0', '4.0', '#', comment[1:]+'\n')) + + teststring = ( + '"""String with no blank lines before\n' + 'String line\n' + 'String line\n' + '"""\n' + '\n') + self.runcase(teststring, 5, ('1.0', '5.0', '', teststring[0:65])) + + teststring = ( + "\n" + '"""String with whitespace line before and after\n' + 'String line.\n' + '"""\n' + '\n') + self.runcase(teststring, 5, ('2.0', '5.0', '', teststring[1:66])) + + teststring = ( + '\n' + ' """Indented string with whitespace before and after\n' + ' Comment string.\n' + ' """\n' + '\n') + self.runcase(teststring, 5, ('2.0', '5.0', ' ', teststring[1:85])) + + teststring = ( + '\n' + '"""Single line string."""\n' + '\n') + self.runcase(teststring, 3, ('2.0', '3.0', '', teststring[1:27])) + + teststring = ( + '\n' + ' """Single line string with leading whitespace."""\n' + '\n') + self.runcase(teststring, 3, ('2.0', '3.0', ' ', teststring[1:55])) + + # I suspect the following test case represents incorrect or at least + # undesired behavior by find_paragraph. The string and code lines have + # the same comment header, so they are treated as the same paragraph. + # When FormatParagraph is run in IDLE, 'x = 42' is pulled up onto the + # same line as the string. + teststring = ( + '\n' + '"""String immediately followed by code."""\n' + 'x = 42\n' + '\n') + self.runcase(teststring, 3, ('2.0', '4.0', '', teststring[1:51])) + # desired result: + # self.runcase(teststring, 3, ('2.0', '3.0', '', teststring[1:44])) + + teststring = ( + '\n' + ' """Indented string immediately followed by code."""\n' + ' x = 42\n' + '\n') + self.runcase(teststring, 3, ('2.0', '4.0', ' ', teststring[1:68])) + # Same issue as previous test, desired result: + # self.runcase(teststring, 3, ('2.0', '3.0', ' ', teststring[1:57])) + + teststring = ( + '\n' + '"""String with whitespace line before and not after\n' + 'String line\n' + '"""') + # Text widget adds a newline at the end + self.runcase(teststring, 4, ('2.0', '5.0', '', teststring[1:]+'\n')) + + +class ReformatFunctionTest(unittest.TestCase): + """Tests the reformat_paragraph function without the editor window.""" + + def test_reformat_paragrah(self): + Equal = self.assertEqual + + for linewidth in range(1, 7): + result = fp.reformat_paragraph("# hello world", linewidth) + expected = ("#\nhello\nworld") + Equal(result, expected) + + for linewidth in range(7, 13): + result = fp.reformat_paragraph("# hello world", linewidth) + expected = ("# hello\nworld") + Equal(result, expected) + + # Test with leading newline + for linewidth in range(1, 7): + result = fp.reformat_paragraph("\n# hello world", linewidth) + expected = ("\n#\nhello\nworld") + Equal(result, expected) + + for linewidth in range(7, 13): + result = fp.reformat_paragraph("\n# hello world", linewidth) + expected = ("\n# hello\nworld") + Equal(result, expected) + + for linewidth in range(7, 13): + result = fp.reformat_paragraph("\n# hello world", linewidth) + expected = ("\n# hello\nworld") + Equal(result, expected) + + # Test with line width longer than string + for linewidth in range(13, 15): + result = fp.reformat_paragraph("# hello world", linewidth) + expected = ("# hello world") + Equal(result, expected) + + +class ReformatCommentTest(unittest.TestCase): + """Tests the reformat_paragraph function without the editor window.""" + + def test_reformat_comment(self): + Equal = self.assertEqual + + # reformat_comment formats to a minimum of 20 characters + test_string = ( + " \"\"\"this is a test of a reformat for a triple quoted string" + " will it reformat to less than 70 characters for me?\"\"\"") + result = fp.reformat_comment(test_string, 70, " ") + expected = ( + " \"\"\"this is a test of a reformat for a triple quoted string" + " will it\n" + " reformat to less than 70 characters for me?\"\"\"") + Equal(result, expected) + + test_comment = ( + "# this is a test of a reformat for a triple quoted string will " + "it reformat to less than 70 characters for me?") + result = fp.reformat_comment(test_comment, 70, "#") + expected = ( + "# this is a test of a reformat for a triple quoted string will " + "it\n" + "# reformat to less than 70 characters for me?") + Equal(result, expected) + +class ReformatClassTest(unittest.TestCase): + + """Test the reformatting of text inside a text widget by FormatParagraph""" + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.editor = Editor(root=cls.root) + cls.text = cls.editor.text + cls.formatter = fp.FormatParagraph(cls.editor) + + cls.test_string = ( + " \"\"\"this is a test of a reformat for a triple " + "quoted string will it reformat to less than 70 " + "characters for me?\"\"\"") + cls.multiline_test_string = ( + " \"\"\"The first line is under the max width.\n" + " The second line's length is way over the max width. It goes " + "on and on until it is over 100 characters long.\n" + " Same thing with the third line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + " The fourth line is short like the first line.\n" + " \"\"\"") + cls.multiline_test_comment = ( + "# The first line is under the max width.\n" + "# The second line's length is way over the max width. It goes on " + "and on until it is over 100 characters long.\n" + "# Same thing with the third line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + "# The fourth line is short like the first line.") + cls.multiparagraph_test_comment = ( + "# The first line is under the max width.\n" + "# The second line's length is way over the max width. It goes on " + "and on until it is over 100 characters long.\n" + "\n" + "# Same thing with the fourth line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + "# The fifth line is short like the first line.") + + def test_format_paragraph_event(self): + # Test formatting a long triple quoted string with no selection + # and cursor ('insert') at '1.0' + self.text.mark_set('insert', '1.0') + self.text.insert('1.0', self.test_string) + self.formatter.format_paragraph_event('ParameterDoesNothing') + result = self.text.get('1.0', 'end') + # FormatParagraph adds a newline after formatting and the text + # widget also adds a newline character, hence the two newlines + expected = ( + " \"\"\"this is a test of a reformat for a triple quoted " + "string will it\n" + " reformat to less than 70 characters for me?\"\"\"\n\n") + self.assertEqual(result, expected) + self.text.delete('1.0', 'end') + + # Test formatting a long triple quoted string with selection from + # 1.15 to 'end' + self.text.insert('1.0', self.test_string) + self.text.tag_add('sel', '1.15', '1.117') + self.formatter.format_paragraph_event('ParameterDoesNothing') + result = self.text.get('1.0', 'end') + expected = ( + " \"\"\"this is a test of a reformat for a triple " + "quoted string will it reformat to\n" + "less than 70 characters for me?\"\"\"\n") + self.assertEqual(result, expected) + self.text.delete('1.0', 'end') + + # Test formatting a long triple quoted string when multiple lines + # inside a paragraph are selected + self.text.insert('1.0', self.multiline_test_string) + self.text.tag_add('sel', '2.0', '4.0') + self.formatter.format_paragraph_event('ParameterDoesNothing') + result = self.text.get('1.0', 'end') + # The expected result moves the three quotation marks to the last line + # It would be a good idea to modify FormatParagraph to leave on their + # own line. + expected = ( + " \"\"\"The first line is under the max width.\n" + " The second line's length is way over the max width. It goes " + "on and\n" + " on until it is over 100 characters long. Same thing with the " + "third\n" + " line. It is also way over the max width, but FormatParagraph " + "will\n" + " fix it.\n" + " The fourth line is short like the first line.\n" + " \"\"\"\n") + self.assertEqual(result, expected) + self.text.delete('1.0', 'end') + + # Test formatting a comment block when nothing is selected + # and cursor ('insert') at '1.0' + self.text.insert('1.0', self.multiline_test_comment) + self.formatter.format_paragraph_event('ParameterDoesNothing') + result = self.text.get('1.0', 'end') + expected = ( + "# The first line is under the max width. The second line's " + "length is\n" + "# way over the max width. It goes on and on until it is over " + "100\n" + "# characters long. Same thing with the third line. It is also " + "way over\n" + "# the max width, but FormatParagraph will fix it. The fourth " + "line is\n" + "# short like the first line.\n\n") + self.assertEqual(result, expected) + self.text.delete('1.0', 'end') + + # Test formatting a comment block when lines 3 and 4 are selected + self.text.insert('1.0', self.multiline_test_comment) + self.text.tag_add('sel', '3.0', '5.0') + self.formatter.format_paragraph_event('ParameterDoesNothing') + result = self.text.get('1.0', 'end') + expected = ( + "# The first line is under the max width.\n" + "# The second line's length is way over the max width. It goes on " + "and on until it is over 100 characters long.\n" + "# Same thing with the third line. It is also way over the max " + "width,\n" + "# but FormatParagraph will fix it. The fourth line is short like " + "the\n" + "# first line.\n\n") + self.assertEqual(result, expected) + self.text.delete('1.0', 'end') + + # Test formatting a single long line in a larger comment block when + # using line 2 + self.text.insert('1.0', self.multiline_test_comment) + self.text.tag_add('sel', '2.0', '3.0') + self.formatter.format_paragraph_event('ParameterDoesNothing') + result = self.text.get('1.0', 'end') + expected = ( + "# The first line is under the max width.\n" + "# The second line's length is way over the max width. It goes on " + "and\n" + "# on until it is over 100 characters long.\n" + "# Same thing with the third line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + "# The fourth line is short like the first line.\n") + self.assertEqual(result, expected) + self.text.delete('1.0', 'end') + + # Test formatting a comment block with whitespace after to ensure + # FormatParagraph stops formatting when it sees whitespace + self.text.insert('1.0', self.multiparagraph_test_comment) + self.text.mark_set('insert', '1.0') + self.formatter.format_paragraph_event('ParameterDoesNothing') + result = self.text.get('1.0', 'end') + expected = ( + "# The first line is under the max width. The second line's " + "length is\n" + "# way over the max width. It goes on and on until it is over " + "100\n" + "# characters long.\n" + "\n" + "# Same thing with the fourth line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + "# The fifth line is short like the first line.\n") + self.assertEqual(result, expected) + self.text.delete('1.0', 'end') + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2)