From 1565e59dc830c5cd533b8f841b817363bff1dcc0 Mon Sep 17 00:00:00 2001 From: Andreas Zach Date: Wed, 1 Oct 2025 11:29:00 +0200 Subject: [PATCH 1/5] Specify amount of spaces before inline comment starts --- fprettify/__init__.py | 27 ++++++++++++++++++++------- fprettify/tests/__init__.py | 4 ++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/fprettify/__init__.py b/fprettify/__init__.py index d6450a3..2feda73 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -1419,7 +1419,7 @@ def reformat_inplace(filename, stdout=False, diffonly=False, **kwargs): # pragm def reformat_ffile(infile, outfile, impose_indent=True, indent_size=3, strict_indent=False, impose_whitespace=True, case_dict={}, impose_replacements=False, cstyle=False, whitespace=2, whitespace_dict={}, llength=132, - strip_comments=False, format_decl=False, orig_filename=None, indent_fypp=True, indent_mod=True): + strip_comments=False, comment_spacing=1, format_decl=False, orig_filename=None, indent_fypp=True, indent_mod=True): """main method to be invoked for formatting a Fortran file.""" # note: whitespace formatting and indentation may require different parsing rules @@ -1442,7 +1442,7 @@ def reformat_ffile(infile, outfile, impose_indent=True, indent_size=3, strict_in reformat_ffile_combined(oldfile, newfile, _impose_indent, indent_size, strict_indent, impose_whitespace, case_dict, impose_replacements, cstyle, whitespace, whitespace_dict, llength, - strip_comments, format_decl, orig_filename, indent_fypp, indent_mod) + strip_comments, comment_spacing, format_decl, orig_filename, indent_fypp, indent_mod) oldfile = newfile # 2) indentation @@ -1455,7 +1455,7 @@ def reformat_ffile(infile, outfile, impose_indent=True, indent_size=3, strict_in reformat_ffile_combined(oldfile, newfile, impose_indent, indent_size, strict_indent, _impose_whitespace, case_dict, _impose_replacements, cstyle, whitespace, whitespace_dict, llength, - strip_comments, format_decl, orig_filename, indent_fypp, indent_mod) + strip_comments, comment_spacing, format_decl, orig_filename, indent_fypp, indent_mod) outfile.write(newfile.getvalue()) @@ -1464,7 +1464,7 @@ def reformat_ffile(infile, outfile, impose_indent=True, indent_size=3, strict_in def reformat_ffile_combined(infile, outfile, impose_indent=True, indent_size=3, strict_indent=False, impose_whitespace=True, case_dict={}, impose_replacements=False, cstyle=False, whitespace=2, whitespace_dict={}, llength=132, - strip_comments=False, format_decl=False, orig_filename=None, indent_fypp=True, indent_mod=True): + strip_comments=False, comment_spacing=1, format_decl=False, orig_filename=None, indent_fypp=True, indent_mod=True): if not orig_filename: orig_filename = infile.name @@ -1522,7 +1522,7 @@ def reformat_ffile_combined(infile, outfile, impose_indent=True, indent_size=3, else: indent = [len(l) - len((l.lstrip(' ')).lstrip('&')) for l in lines] - comment_lines = format_comments(lines, comments, strip_comments) + comment_lines = format_comments(lines, comments, strip_comments, comment_spacing) auto_align, auto_format, in_format_off_block = parse_fprettify_directives( lines, comment_lines, in_format_off_block, orig_filename, stream.line_nr) @@ -1611,13 +1611,13 @@ def reformat_ffile_combined(infile, outfile, impose_indent=True, indent_size=3, f_line) and not any(comments) and not is_omp_conditional and not label -def format_comments(lines, comments, strip_comments): +def format_comments(lines, comments, strip_comments, comment_spacing=1): comments_ftd = [] for line, comment in zip(lines, comments): has_comment = bool(comment.strip()) if has_comment: if strip_comments: - sep = not comment.strip() == line.strip() + sep = 0 if comment.strip() == line.strip() else comment_spacing else: line_minus_comment = line.replace(comment,"") sep = len(line_minus_comment.rstrip('\n')) - len(line_minus_comment.rstrip()) @@ -1929,6 +1929,16 @@ def str2bool(str): else: return None + def non_negative_int(value): + """helper function to ensure a non-negative integer""" + try: + int_value = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) + if int_value < 0: + raise argparse.ArgumentTypeError("expected a non-negative integer") + return int_value + def get_config_file_list(filename): """helper function to create list of config files found in parent directories""" config_file_list = [] @@ -1999,6 +2009,8 @@ def get_arg_parser(args): " | 2: uppercase") parser.add_argument("--strip-comments", action='store_true', default=False, help="strip whitespaces before comments") + parser.add_argument("--comment-spacing", type=non_negative_int, default=1, + help="number of spaces between code and inline comments when '--strip-comments' is used") parser.add_argument('--disable-fypp', action='store_true', default=False, help="Disables the indentation of fypp preprocessor blocks.") parser.add_argument('--disable-indent-mod', action='store_true', default=False, @@ -2134,6 +2146,7 @@ def build_ws_dict(args): whitespace_dict=ws_dict, llength=1024 if file_args.line_length == 0 else file_args.line_length, strip_comments=file_args.strip_comments, + comment_spacing=file_args.comment_spacing, format_decl=file_args.enable_decl, indent_fypp=not file_args.disable_fypp, indent_mod=not file_args.disable_indent_mod) diff --git a/fprettify/tests/__init__.py b/fprettify/tests/__init__.py index 5980930..72bbb97 100644 --- a/fprettify/tests/__init__.py +++ b/fprettify/tests/__init__.py @@ -213,9 +213,13 @@ def test_comments(self): outstring_exp_strip = ("TYPE mytype\n! c1\n !c2\n INTEGER :: a ! c3\n" " REAL :: b, & ! c4\n ! c5\n ! c6\n" " d ! c7\nEND TYPE ! c8") + outstring_exp_strip_spacing3 = ("TYPE mytype\n! c1\n !c2\n INTEGER :: a ! c3\n" + " REAL :: b, & ! c4\n ! c5\n ! c6\n" + " d ! c7\nEND TYPE ! c8") self.assert_fprettify_result([], instring, outstring_exp_default) self.assert_fprettify_result(['--strip-comments'], instring, outstring_exp_strip) + self.assert_fprettify_result(['--strip-comments', '--comment-spacing', '3'], instring, outstring_exp_strip_spacing3) def test_directive(self): """ From dac0b5e3da95b6c67313c61263c143a83502c9d3 Mon Sep 17 00:00:00 2001 From: Andreas Zach Date: Wed, 1 Oct 2025 11:29:20 +0200 Subject: [PATCH 2/5] Update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a1ea7f8..6da69ad 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ For more options, read fprettify -h ``` +When cleaning up inline comments, `--strip-comments` removes superfluous whitespace in front of comment markers. Combine it with `--comment-spacing N` to specify how many spaces should remain between code and the trailing comment (default: 1). + ## Editor integration For editor integration, use From 49dbd73f472af0718be59345908096104fe44298 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Wed, 15 Oct 2025 23:00:54 +0200 Subject: [PATCH 3/5] Add configurable spacing around string concatenation (#1) --- fprettify/__init__.py | 27 +++++++++++++++++++++------ fprettify/tests/__init__.py | 17 +++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/fprettify/__init__.py b/fprettify/__init__.py index 2feda73..08620e6 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -1043,19 +1043,20 @@ def format_single_fline(f_line, whitespace, whitespace_dict, linebreak_pos, 'print': 6, # 6: print / read statements 'type': 7, # 7: select type components 'intrinsics': 8, # 8: intrinsics - 'decl': 9 # 9: declarations + 'decl': 9, # 9: declarations + 'concat': 10 # 10: string concatenation } if whitespace == 0: - spacey = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + spacey = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] elif whitespace == 1: - spacey = [1, 1, 1, 1, 0, 0, 1, 0, 1, 1] + spacey = [1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0] elif whitespace == 2: - spacey = [1, 1, 1, 1, 1, 0, 1, 0, 1, 1] + spacey = [1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 0] elif whitespace == 3: - spacey = [1, 1, 1, 1, 1, 1, 1, 0, 1, 1] + spacey = [1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0] elif whitespace == 4: - spacey = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + spacey = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] else: raise NotImplementedError("unknown value for whitespace") @@ -1209,6 +1210,17 @@ def add_whitespace_charwise(line, spacey, scope_parser, format_decl, filename, l + rhs.lstrip(' ') line_ftd = line_ftd.rstrip(' ') + # format string concatenation operator '//' + if (char == '/' and line[pos:pos + 2] == "//" and (pos == 0 or line[pos - 1] != '/') + and level == 0 and pos > end_of_delim): + lhs = line_ftd[:pos + offset] + rhs = line_ftd[pos + 2 + offset:] + line_ftd = lhs.rstrip(' ') \ + + ' ' * spacey[10] \ + + "//" \ + + ' ' * spacey[10] \ + + rhs.lstrip(' ') + # format '::' if format_decl and line[pos:pos+2] == "::": lhs = line_ftd[:pos + offset] @@ -1996,6 +2008,8 @@ def get_arg_parser(args): help="boolean, en-/disable whitespace for select type components") parser.add_argument("--whitespace-intrinsics", type=str2bool, nargs="?", default="None", const=True, help="boolean, en-/disable whitespace for intrinsics like if/write/close") + parser.add_argument("--whitespace-concat", type=str2bool, nargs="?", default="None", const=True, + help="boolean, en-/disable whitespace for string concatenation operator '//'") parser.add_argument("--strict-indent", action='store_true', default=False, help="strictly impose indentation even for nested loops") parser.add_argument("--enable-decl", action="store_true", default=False, help="enable whitespace formatting of declarations ('::' operator).") parser.add_argument("--disable-indent", action='store_true', default=False, help="don't impose indentation") @@ -2056,6 +2070,7 @@ def build_ws_dict(args): ws_dict['print'] = args.whitespace_print ws_dict['type'] = args.whitespace_type ws_dict['intrinsics'] = args.whitespace_intrinsics + ws_dict['concat'] = args.whitespace_concat return ws_dict # support legacy input: diff --git a/fprettify/tests/__init__.py b/fprettify/tests/__init__.py index 72bbb97..d82c365 100644 --- a/fprettify/tests/__init__.py +++ b/fprettify/tests/__init__.py @@ -145,6 +145,23 @@ def test_type_selector(self): self.assert_fprettify_result(['-w 4'], instring, outstring_exp) + def test_concat(self): + """test for concat operator whitespace formatting""" + instring = "str=a//b//c" + outstring_w0 = "str=a//b//c" + outstring_w2 = "str = a//b//c" + outstring_w4 = "str = a // b // c" + outstring_explicit = "str = a // b // c" + instring_in_string = 'msg = "URL: http://example.com"' + instring_in_comment = 'a = b ! http://example.com' + + self.assert_fprettify_result(['-w', '0'], instring, outstring_w0) + self.assert_fprettify_result(['-w', '2'], instring, outstring_w2) + self.assert_fprettify_result(['-w', '4'], instring, outstring_w4) + self.assert_fprettify_result(['--whitespace-concat'], instring, outstring_explicit) + self.assert_fprettify_result([], instring_in_string, instring_in_string) + self.assert_fprettify_result([], instring_in_comment, instring_in_comment) + def test_indent(self): """simple test for indent options -i in [0, 3, 4]""" From 2b248781f6c7996847163a5a77373f38c7f9a8b5 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 16 Oct 2025 10:05:21 +0200 Subject: [PATCH 4/5] Fix indentation drift after single-line IFs (#2) * feat: add whitespace option for string concatenation * Fix concat operator formatting and add context checks Address Qodo review comments: - Add level and end_of_delim checks to avoid formatting // inside strings/comments - Remove unsafe trailing rstrip that could strip string literal whitespace - Enable concat spacing at whitespace level 4 - Add comprehensive test coverage for concat operator formatting * Restore README.md from master * fix(indent): realign mis-indented nested blocks * Update example.f90 * Update example.f90 * Fix indentation in nested DO loops * Update expected_results * Update expected_results --- fortran_tests/after/indent_single_line_if.f90 | 15 +++++++++++++++ fortran_tests/before/indent_single_line_if.f90 | 15 +++++++++++++++ fortran_tests/test_results/expected_results | 1 + fprettify/__init__.py | 3 ++- 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 fortran_tests/after/indent_single_line_if.f90 create mode 100644 fortran_tests/before/indent_single_line_if.f90 diff --git a/fortran_tests/after/indent_single_line_if.f90 b/fortran_tests/after/indent_single_line_if.f90 new file mode 100644 index 0000000..949764b --- /dev/null +++ b/fortran_tests/after/indent_single_line_if.f90 @@ -0,0 +1,15 @@ +subroutine format_params(param_indices, code) + integer, allocatable :: param_indices(:) + character(len=:), allocatable :: code + integer :: i + if (allocated(param_indices) .and. size(param_indices) > 0) then + code = code//"(" + do i = 1, size(param_indices) + if (i > 1) code = code//", " + if (param_indices(i) > 0) then + code = code//"value" + end if + end do + code = code//")" + end if +end subroutine format_params diff --git a/fortran_tests/before/indent_single_line_if.f90 b/fortran_tests/before/indent_single_line_if.f90 new file mode 100644 index 0000000..5337420 --- /dev/null +++ b/fortran_tests/before/indent_single_line_if.f90 @@ -0,0 +1,15 @@ +subroutine format_params(param_indices, code) + integer, allocatable :: param_indices(:) + character(len=:), allocatable :: code + integer :: i + if (allocated(param_indices) .and. size(param_indices) > 0) then + code = code // "(" + do i = 1, size(param_indices) + if (i > 1) code = code // ", " + if (param_indices(i) > 0) then + code = code // "value" + end if + end do + code = code // ")" + end if +end subroutine format_params diff --git a/fortran_tests/test_results/expected_results b/fortran_tests/test_results/expected_results index e109307..8b0c4b4 100644 --- a/fortran_tests/test_results/expected_results +++ b/fortran_tests/test_results/expected_results @@ -2265,3 +2265,4 @@ cp2k/src/xas_tdp_types.F : 728f382598e79fa0e7b3be6d88a3218fea45e19744bbe7bdaaa96 cp2k/src/xas_tdp_utils.F : 002dfdc6e9d5979516458b6f890950bd94df49e947633a7253b46be5f3fd7d61 cp2k/src/xc/xc_sr_lda.F : 094099ac92a6749028c004d37b7646e2af7de402ee5804de27192b56588cc7fe cp2k/src/xtb_ehess.F : 45fe2c022760195affb0fd5155d865b6deac896cf6e6714e772bef04afad4be2 +indent_single_line_if.f90 : 4f4c540d9783d9dbca9c5ef8819f34c5414ef73e61f76547a6c68c00fab21151 diff --git a/fprettify/__init__.py b/fprettify/__init__.py index 08620e6..841edd5 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -877,7 +877,8 @@ def inspect_ffile_format(infile, indent_size, strict_indent, indent_fypp=False, # don't impose indentation for blocked do/if constructs: if (IF_RE.search(f_line) or DO_RE.search(f_line)): - if (prev_offset != offset or strict_indent): + indent_misaligned = indent_size > 0 and offset % indent_size != 0 + if (prev_offset != offset or strict_indent or indent_misaligned): indents[-1] = indent_size else: indents[-1] = indent_size From 2f1157521deb9244b64b4b6caa1d4a75cbfb8d0f Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sun, 9 Nov 2025 23:30:43 +0100 Subject: [PATCH 5/5] Improve line splitting for long logical lines (#4) * fix(indent): keep indent when at line limit * Fix padding calculation for line length overflow Address Qodo review: recompute padding in elif block to correctly right-align lines at the line length limit when indent would overflow. Update test expectations to match corrected behavior. * Improve line splitting for long logical lines * Guard line splitting for syntax-safe breakpoints * Improve auto line splitting for strings and continuation layout * Update expected results for nested-loop indentation * Ensure indent pass splits long lines * Test indent-only line splitting * Refactor auto split insertion * Split inline comments before enforcing line limits --- fortran_tests/after/example.f90 | 6 +- fortran_tests/after/example_swapcase.f90 | 6 +- fortran_tests/test_results/expected_results | 4 +- fprettify/__init__.py | 222 +++++++++++++++++++- fprettify/tests/__init__.py | 212 ++++++++++++++++++- 5 files changed, 429 insertions(+), 21 deletions(-) diff --git a/fortran_tests/after/example.f90 b/fortran_tests/after/example.f90 index ed6c30b..f8ba5ca 100644 --- a/fortran_tests/after/example.f90 +++ b/fortran_tests/after/example.f90 @@ -133,9 +133,9 @@ program example_prog do l = 1, 3 do i = 4, 5 do my_integer = 1, 1 - do j = 1, 2 - write (*, *) test_function(m, r, k, l) + i - end do + do j = 1, 2 + write (*, *) test_function(m, r, k, l) + i + end do end do end do end do diff --git a/fortran_tests/after/example_swapcase.f90 b/fortran_tests/after/example_swapcase.f90 index 53a8f2e..babc643 100644 --- a/fortran_tests/after/example_swapcase.f90 +++ b/fortran_tests/after/example_swapcase.f90 @@ -180,9 +180,9 @@ PROGRAM example_prog DO l = 1, 3 DO i = 4, 5 DO my_integer = 1, 1 - DO j = 1, 2 - WRITE (*, *) test_function(m, r, k, l) + i - END DO + DO j = 1, 2 + WRITE (*, *) test_function(m, r, k, l) + i + END DO END DO END DO END DO diff --git a/fortran_tests/test_results/expected_results b/fortran_tests/test_results/expected_results index 8b0c4b4..9dd763f 100644 --- a/fortran_tests/test_results/expected_results +++ b/fortran_tests/test_results/expected_results @@ -1,4 +1,4 @@ -example.f90 : f5b449553856f8e62b253402ed2189044554f53c9954aad045db44ff3c2d49b7 +example.f90 : 39c6dc1e8111c867721ec3ab45a83ea0e7ef6c5b9bef8ff325bbb7816ba36228 RosettaCodeData/Task/100-doors/Fortran/100-doors-1.f : b44289edb55a75ca29407be3ca0d997119253d4c7adb5b3dfc1119944036ab0f RosettaCodeData/Task/100-doors/Fortran/100-doors-2.f : 263122b2af3e3637a7dab0bc0216dec27d76068b7352e9ab85e420de625408be RosettaCodeData/Task/24-game-Solve/Fortran/24-game-solve-1.f : 8927cfcfe15685f1513ed923b7ac38058358ec6586de83920679b537aa5b2d03 @@ -2177,7 +2177,7 @@ cp2k/src/xtb_coulomb.F : 0f3a97d48e2aa9883e052afaa9c0c834fcc1c00eeeab81df508e040 cp2k/src/xtb_matrices.F : e9b617ac1ec85b8bfb04c4030e67ac6a18d16586ad7252d112e4aa7bf0a10936 cp2k/src/xtb_parameters.F : 30320b3ecb2187e4a0f81bff8f27c5474e77b3ce7fe1c16a1de61c5eb69e7889 cp2k/src/xtb_types.F : a34cc5d2cd61bfa2c6194e0413e7772391a59e92add52e50d01e199897662b13 -example_swapcase.f90 : 8dfac266553a438deb71e3faf5aeb97cd067a004c5cf61cda341237cd6328d55 +example_swapcase.f90 : a674964b61e60ce4e11064880fe05555cb101d7e44079aecc1e02cf4d70899d0 where_forall.f90 : 11062d8d766cce036a0c2ed25ce3d8fe75fee47ab1e6566ec24c6b0043f6ffea cp2k/src/almo_scf_lbfgs_types.F : 4dea88ca22891e587a0b0dc84b2067f23f453cdcb9afebf953b5b0237c4391db cp2k/src/common/callgraph.F : 5a54e42c7a616eae001f8eb9dc1efe73d9eb92b353a763b1adfbec1e20c7179d diff --git a/fprettify/__init__.py b/fprettify/__init__.py index 841edd5..89bde24 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -1608,8 +1608,10 @@ def reformat_ffile_combined(infile, outfile, impose_indent=True, indent_size=3, if indent[0] < len(label): indent = [ind + len(label) - indent[0] for ind in indent] - write_formatted_line(outfile, indent, lines, orig_lines, indent_special, llength, - use_same_line, is_omp_conditional, label, orig_filename, stream.line_nr) + allow_auto_split = auto_format and (impose_whitespace or impose_indent) + write_formatted_line(outfile, indent, lines, orig_lines, indent_special, indent_size, llength, + use_same_line, is_omp_conditional, label, orig_filename, stream.line_nr, + allow_split=allow_auto_split) do_indent, use_same_line = pass_defaults_to_next_line(f_line) @@ -1846,10 +1848,185 @@ def get_manual_alignment(lines): return manual_lines_indent -def write_formatted_line(outfile, indent, lines, orig_lines, indent_special, llength, use_same_line, is_omp_conditional, label, filename, line_nr): +def _find_split_position(text, max_width): + """ + Locate a suitable breakpoint (prefer whitespace or comma) within max_width. + Returns None if no such breakpoint exists. + """ + if max_width < 1: + return None + search_limit = min(len(text) - 1, max_width) + if search_limit < 0: + return None + + spaces = [] + commas = [] + + for pos, char in CharFilter(text): + if pos > search_limit: + break + if char == ' ': + spaces.append(pos) + elif char == ',': + commas.append(pos) + + for candidate in reversed(spaces): + if len(text) - candidate >= 12: + return candidate + + for candidate in reversed(commas): + if len(text) - candidate > 4: + return candidate + 1 + + return None + + +def _auto_split_line(line, ind_use, llength, indent_size): + """ + Attempt to split a long logical line into continuation lines that + respect the configured line-length limit. Returns a list of new line + fragments when successful, otherwise None. + """ + if llength < 40: + return None + + stripped = line.lstrip(' ') + if not stripped: + return None + if stripped.startswith('&'): + return None + line_has_newline = stripped.endswith('\n') + if line_has_newline: + stripped = stripped[:-1] + + has_comment = False + for _, char in CharFilter(stripped, filter_comments=False): + if char == '!': + has_comment = True + break + if has_comment: + return None + + max_first = llength - ind_use - 2 # reserve for trailing ampersand + if max_first <= 0: + return None + + break_pos = _find_split_position(stripped, max_first) + if break_pos is None or break_pos >= len(stripped): + return None + + remainder = stripped[break_pos:].lstrip() + if not remainder: + return None + + first_chunk = stripped[:break_pos].rstrip() + new_lines = [first_chunk + ' &'] + + current_indent = ind_use + indent_size + current = remainder + + while current: + available = llength - current_indent + if available <= 0: + return None + + # final chunk (fits without ampersand) + if len(current) + 2 <= available: + new_lines.append(current) + break + + split_limit = available - 2 # account for ' &' suffix + if split_limit <= 0: + return None + + cont_break = _find_split_position(current, split_limit) + if cont_break is None or cont_break >= len(current): + return None + + chunk = current[:cont_break].rstrip() + if not chunk: + return None + new_lines.append(chunk + ' &') + current = current[cont_break:].lstrip() + + if line_has_newline: + new_lines = [chunk.rstrip('\n') + '\n' for chunk in new_lines] + + return new_lines + + +def _insert_split_chunks(idx, split_lines, indent, indent_size, lines, orig_lines): + """Replace the original line at `idx` with its split chunks and matching indents.""" + base_indent = indent[idx] + indent.pop(idx) + lines.pop(idx) + orig_lines.pop(idx) + + follow_indent = base_indent + indent_size + new_indents = [base_indent] + [follow_indent] * (len(split_lines) - 1) + + for new_line, new_indent in reversed(list(zip(split_lines, new_indents))): + lines.insert(idx, new_line) + indent.insert(idx, new_indent) + orig_lines.insert(idx, new_line) + + +def _split_inline_comment(line): + """Return (code, comment) strings if line contains a detachable inline comment.""" + if '!' not in line: + return None + + has_newline = line.endswith('\n') + body = line[:-1] if has_newline else line + + comment_pos = None + for pos, _ in CharFilter(body, filter_comments=False): + if body[pos] == '!': + comment_pos = pos + break + if comment_pos is None: + return None + + code = body[:comment_pos].rstrip() + comment = body[comment_pos:].lstrip() + + if not code or not comment: + return None + + if has_newline: + code += '\n' + comment += '\n' + + return code, comment + + +def _detach_inline_comment(idx, indent, lines, orig_lines): + """Split an inline comment into its own line keeping indentation metadata.""" + splitted = _split_inline_comment(lines[idx]) + if not splitted: + return False + + code_line, comment_line = splitted + base_indent = indent[idx] + + lines[idx] = code_line + orig_lines[idx] = code_line + + indent.insert(idx + 1, base_indent) + lines.insert(idx + 1, comment_line) + orig_lines.insert(idx + 1, comment_line) + + return True + + +def write_formatted_line(outfile, indent, lines, orig_lines, indent_special, indent_size, llength, use_same_line, is_omp_conditional, label, filename, line_nr, allow_split): """Write reformatted line to file""" - for ind, line, orig_line in zip(indent, lines, orig_lines): + idx = 0 + while idx < len(lines): + ind = indent[idx] + line = lines[idx] + orig_line = orig_lines[idx] # get actual line length excluding comment: line_length = 0 @@ -1874,15 +2051,33 @@ def write_formatted_line(outfile, indent, lines, orig_lines, indent_special, lle else: label_use = '' - if ind_use + line_length <= (llength+1): # llength (default 132) plus 1 newline char + padding = ind_use - 3 * is_omp_conditional - len(label_use) + \ + len(line) - len(line.lstrip(' ')) + padding = max(0, padding) + + stripped_line = line.lstrip(' ') + rendered_length = len('!$ ' * is_omp_conditional + label_use + ' ' * padding + + stripped_line.rstrip('\n')) + + needs_split = allow_split and rendered_length > llength + + if needs_split: + split_lines = _auto_split_line(line, ind_use, llength, indent_size) + if split_lines: + _insert_split_chunks(idx, split_lines, indent, indent_size, lines, orig_lines) + continue + if _detach_inline_comment(idx, indent, lines, orig_lines): + continue + + if rendered_length <= llength: outfile.write('!$ ' * is_omp_conditional + label_use + - ' ' * (ind_use - 3 * is_omp_conditional - len(label_use) + - len(line) - len(line.lstrip(' '))) + - line.lstrip(' ')) + ' ' * padding + stripped_line) elif line_length <= (llength+1): - outfile.write('!$ ' * is_omp_conditional + label_use + ' ' * - ((llength+1) - 3 * is_omp_conditional - len(label_use) - - len(line.lstrip(' '))) + line.lstrip(' ')) + # Recompute padding to right-align at the line length limit + padding_overflow = (llength + 1) - 3 * is_omp_conditional - len(label_use) - len(line.lstrip(' ')) + padding_overflow = max(0, padding_overflow) + outfile.write('!$ ' * is_omp_conditional + label_use + + ' ' * padding_overflow + line.lstrip(' ')) log_message(LINESPLIT_MESSAGE+" (limit: "+str(llength)+")", "warning", filename, line_nr) @@ -1891,6 +2086,11 @@ def write_formatted_line(outfile, indent, lines, orig_lines, indent_special, lle log_message(LINESPLIT_MESSAGE+" (limit: "+str(llength)+")", "warning", filename, line_nr) + if label: + label = '' + + idx += 1 + def get_curr_delim(line, pos): """get delimiter token in line starting at pos, if it exists""" diff --git a/fprettify/tests/__init__.py b/fprettify/tests/__init__.py index d82c365..1e5d709 100644 --- a/fprettify/tests/__init__.py +++ b/fprettify/tests/__init__.py @@ -219,6 +219,214 @@ def test_disable(self): self.assert_fprettify_result(['--disable-indent'], instring, outstring_exp_noindent) self.assert_fprettify_result(['--disable-indent', '--disable-whitespace'], instring, instring) + + def test_indent_preserves_line_length_limit(self): + """indentation should remain stable when exceeding line length""" + in_lines = [ + 'subroutine demo(tokens, stmt_start)', + ' type(dummy), intent(in) :: tokens(:)', + ' integer, intent(in) :: stmt_start', + ' integer :: i, nesting_level', + '', + ' if (tokens(stmt_start)%text == "if") then', + ' if (tokens(i)%text == "endif") then', + ' nesting_level = nesting_level - 1', + ' else if (tokens(i)%text == "end" .and. i + 1 <= size(tokens) .and. &', + ' tokens(i + 1)%kind == TK_KEYWORD .and. tokens(i + 1)%text == "if") then', + ' nesting_level = nesting_level - 1', + ' end if', + ' end if', + '', + ' if (tokens(i)%text == "end") then', + ' if (i + 1 <= size(tokens) .and. tokens(i + 1)%kind == TK_KEYWORD) then', + ' if (tokens(i + 1)%text == "do" .and. tokens(stmt_start)%text == "do") then', + ' nesting_level = nesting_level - 1', + ' else if (tokens(i + 1)%text == "select" .and. tokens(stmt_start)%text == "select") then', + ' nesting_level = nesting_level - 1', + ' else if (tokens(i + 1)%text == "where" .and. tokens(stmt_start)%text == "where") then', + ' nesting_level = nesting_level - 1', + ' end if', + ' end if', + ' end if', + 'end subroutine demo', + '' + ] + + out_lines = [ + 'subroutine demo(tokens, stmt_start)', + ' type(dummy), intent(in) :: tokens(:)', + ' integer, intent(in) :: stmt_start', + ' integer :: i, nesting_level', + '', + ' if (tokens(stmt_start)%text == "if") then', + ' if (tokens(i)%text == "endif") then', + ' nesting_level = nesting_level - 1', + ' else if (tokens(i)%text == "end" .and. i + 1 <= size(tokens) .and. &', + ' tokens(i + 1)%kind == TK_KEYWORD .and. tokens(i + 1)%text == "if") then', + ' nesting_level = nesting_level - 1', + ' end if', + ' end if', + '', + ' if (tokens(i)%text == "end") then', + ' if (i + 1 <= size(tokens) .and. tokens(i + 1)%kind == TK_KEYWORD) then', + ' if (tokens(i + 1)%text == "do" .and. tokens(stmt_start)%text == "do") then', + ' nesting_level = nesting_level - 1', + ' else if (tokens(i + 1)%text == "select" .and. tokens(stmt_start)%text == &', + ' "select") then', + ' nesting_level = nesting_level - 1', + ' else if (tokens(i + 1)%text == "where" .and. tokens(stmt_start)%text == &', + ' "where") then', + ' nesting_level = nesting_level - 1', + ' end if', + ' end if', + ' end if', + 'end subroutine demo', + '' + ] + + instring = '\n'.join(in_lines) + outstring_exp = '\n'.join(out_lines) + + self.assert_fprettify_result(['-l', '90'], instring, outstring_exp) + + def test_auto_split_long_logical_line(self): + """automatically split long logical lines that exceed the limit after indentation""" + instring = ( + "subroutine demo()\n" + " integer :: a\n" + " if (this_condition_is_lengthy .or. second_lengthy_condition) cycle\n" + "end subroutine demo" + ) + + outstring_exp = ( + "subroutine demo()\n" + " integer :: a\n" + " if (this_condition_is_lengthy .or. &\n" + " second_lengthy_condition) cycle\n" + "end subroutine demo" + ) + + self.assert_fprettify_result(['-i', '4', '-l', '68'], instring, outstring_exp) + + def test_auto_split_handles_bang_in_string(self): + """ensure split logic ignores exclamation marks inside string literals""" + instring = ( + "subroutine demo(str)\n" + " character(len=*), intent(in) :: str\n" + " if (str .eq. \"This string has a ! bang inside\") print *, str//\", wow!\"\n" + "end subroutine demo" + ) + + outstring_exp = ( + "subroutine demo(str)\n" + " character(len=*), intent(in) :: str\n" + " if (str .eq. \"This string has a ! bang inside\") print *, &\n" + " str//\", wow!\"\n" + "end subroutine demo" + ) + + self.assert_fprettify_result(['-i', '4', '-l', '72'], instring, outstring_exp) + + def test_auto_split_after_indent_adjustment(self): + """splitting must also run during the indentation pass to stay idempotent""" + instring = ( + "program demo\n" + " integer :: i\n" + " if (.true.) then\n" + " if (.true.) then\n" + " if (i > 1 .and. this_is_a_pretty_freaking_long_parameter_name .eq. 42) print *, \"too long\"\n" + " end if\n" + " end if\n" + "end program demo\n" + ) + + outstring_exp = ( + "program demo\n" + " integer :: i\n" + " if (.true.) then\n" + " if (.true.) then\n" + " if (i > 1 .and. this_is_a_pretty_freaking_long_parameter_name .eq. 42) print &\n" + " *, \"too long\"\n" + " end if\n" + " end if\n" + "end program demo\n" + ) + + self.assert_fprettify_result(['-i', '4', '-l', '100'], instring, outstring_exp) + + def test_auto_split_when_whitespace_disabled(self): + """indent-only runs must still split long logical lines""" + instring = ( + "program demo\n" + " if (.true.) then\n" + " if (.true.) then\n" + " if (i > 1 .and. identifier_that_is_far_too_long .eq. 42) print *, \"oops\"\n" + " end if\n" + " end if\n" + "end program demo\n" + ) + + outstring_exp = ( + "program demo\n" + " if (.true.) then\n" + " if (.true.) then\n" + " if (i > 1 .and. identifier_that_is_far_too_long .eq. 42) &\n" + " print *, \"oops\"\n" + " end if\n" + " end if\n" + "end program demo\n" + ) + + self.assert_fprettify_result(['-i', '4', '-l', '70', '--disable-whitespace'], instring, outstring_exp) + + def test_line_length_detaches_inline_comment(self): + """inline comments should move to their own line when they exceed the limit""" + instring = ( + "program demo\n" + " if (.true.) then\n" + " print *, 'prefix '//'and '//'suffix' ! trailing comment\n" + " end if\n" + "end program demo\n" + ) + + outstring_exp = ( + "program demo\n" + " if (.true.) then\n" + " print *, 'prefix '//'and '//'suffix'\n" + " ! trailing comment\n" + " end if\n" + "end program demo\n" + ) + + self.assert_fprettify_result(['-i', '4', '-l', '60'], instring, outstring_exp) + + def test_line_length_comment_then_split(self): + """detaching the comment must still allow the code line to split further""" + instring = ( + "program demo\n" + " if (.true.) then\n" + " if (.true.) then\n" + " if (foo_bar_identifier .and. bar_baz_identifier) print *, long_identifier, another_long_identifier ! note\n" + " end if\n" + " end if\n" + "end program demo\n" + ) + + outstring_exp = ( + "program demo\n" + " if (.true.) then\n" + " if (.true.) then\n" + " if (foo_bar_identifier .and. bar_baz_identifier) print *, &\n" + " long_identifier, another_long_identifier\n" + " ! note\n" + " end if\n" + " end if\n" + "end program demo\n" + ) + + self.assert_fprettify_result(['-i', '4', '-l', '72'], instring, outstring_exp) + + def test_comments(self): """test options related to comments""" instring = ("TYPE mytype\n! c1\n !c2\n INTEGER :: a ! c3\n" @@ -375,12 +583,12 @@ def test_line_length(self): "INQUIRE(14)"] instring_ = "if( min == max.and.min .eq. thres ) one_really_long_function_call_to_hit_the_line_limit(parameter1, parameter2,parameter3,parameter4,parameter5,err) ! this line would be too long" outstring = ["REAL(KIND=4) :: r, f ! some reals", - "REAL(KIND=4) :: r,f ! some reals", + "REAL(KIND=4) :: r, f\n! some reals", "if (min == max .and. min .eq. thres)", "if( min == max.and.min .eq. thres )", "INQUIRE (14)", "INQUIRE (14)"] - outstring_ = ["if( min == max.and.min .eq. thres ) one_really_long_function_call_to_hit_the_line_limit(parameter1, parameter2,parameter3,parameter4,parameter5,err) ! this line would be too long", + outstring_ = ["if (min == max .and. min .eq. thres) one_really_long_function_call_to_hit_the_line_limit(parameter1, parameter2, parameter3, &\n parameter4, parameter5, err)\n! this line would be too long", "if (min == max .and. min .eq. thres) one_really_long_function_call_to_hit_the_line_limit(parameter1, parameter2, parameter3, parameter4, parameter5, err) ! this line would be too long"] # test shorter lines first, after all the actual length doesn't matter