From 1565e59dc830c5cd533b8f841b817363bff1dcc0 Mon Sep 17 00:00:00 2001 From: Andreas Zach Date: Wed, 1 Oct 2025 11:29:00 +0200 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 From 9cb735159f9cd29e9999e488303ce44de195fb71 Mon Sep 17 00:00:00 2001 From: Andreas Zach Date: Wed, 1 Oct 2025 11:29:00 +0200 Subject: [PATCH 06/11] Add comment_spacing parameter to control spaces before inline comments This commit adds a new --comment-spacing parameter that allows users to specify the number of spaces between code and inline comments when using --strip-comments. The default value is 1 space. Changes: - Added comment_spacing parameter to format_comments function - Added --comment-spacing argument to the argument parser - Added non_negative_int helper function for argument validation - Updated all function calls to pass the new parameter - Added test case for the new functionality This corresponds to commit 1565e59 from the original branch. --- fprettify/__init__.py | 27 ++++++++++++++++++++------- fprettify/tests/unittests.py | 4 ++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/fprettify/__init__.py b/fprettify/__init__.py index 247c0a6..8fb1efe 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -1445,7 +1445,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 @@ -1468,7 +1468,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 @@ -1481,7 +1481,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()) @@ -1490,7 +1490,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 @@ -1548,7 +1548,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) @@ -1636,13 +1636,13 @@ def reformat_ffile_combined(infile, outfile, impose_indent=True, indent_size=3, else: indent_special = 1 -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()) @@ -1976,6 +1976,7 @@ def build_ws_dict(args): args_out['whitespace'] = args.whitespace args_out['llength'] = 1024 if args.line_length == 0 else args.line_length args_out['strip_comments'] = args.strip_comments + args_out['comment_spacing'] = args.comment_spacing args_out['format_decl'] = args.enable_decl args_out['indent_fypp'] = not args.disable_fypp args_out['indent_mod'] = not args.disable_indent_mod @@ -1995,6 +1996,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 + parser = argparse.ArgumentParser(**args) parser.add_argument("-i", "--indent", type=int, default=3, @@ -2041,6 +2052,8 @@ def str2bool(str): " | 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, diff --git a/fprettify/tests/unittests.py b/fprettify/tests/unittests.py index 911bcd8..5f6d755 100644 --- a/fprettify/tests/unittests.py +++ b/fprettify/tests/unittests.py @@ -144,9 +144,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 fa0b69822e96c107d82e10120efbb204fecd8305 Mon Sep 17 00:00:00 2001 From: Andreas Zach Date: Wed, 1 Oct 2025 11:29:20 +0200 Subject: [PATCH 07/11] Update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a4f130f..33e0fe0 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 80528ec7141d1ed9b4b71302271d5113aa6837fa Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 10:51:01 +0100 Subject: [PATCH 08/11] feat: add whitespace option for string concatenation This commit adds support for formatting whitespace around the string concatenation operator (//). The feature includes: 1. Added 'concat' mapping (index 10) to the whitespace formatting system 2. Extended all whitespace level arrays to include concat formatting 3. Added string concatenation operator formatting logic in add_whitespace_charwise() 4. Added --whitespace-concat argument to enable/disable concat formatting 5. Added comprehensive test coverage Key features: - Whitespace level 4 enables concat formatting by default - Context-aware: only formats // outside of strings and comments - Safe: includes level and delimiter checks to avoid formatting inside strings - Configurable: can be enabled/disabled with --whitespace-concat flag This corresponds to commit ac77f26 from the original branch. --- fprettify/__init__.py | 27 +++++++++++++++++++++------ fprettify/tests/unittests.py | 17 +++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/fprettify/__init__.py b/fprettify/__init__.py index 8fb1efe..b9f50de 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -1054,19 +1054,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") @@ -1220,6 +1221,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] @@ -1954,6 +1966,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 args_out = {} @@ -2039,6 +2052,8 @@ def non_negative_int(value): 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") diff --git a/fprettify/tests/unittests.py b/fprettify/tests/unittests.py index 5f6d755..e1b99c0 100644 --- a/fprettify/tests/unittests.py +++ b/fprettify/tests/unittests.py @@ -74,6 +74,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 100567ca5f48ff2d7c10db3f416a48c73653f870 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 10:53:59 +0100 Subject: [PATCH 09/11] fix(indent): realign mis-indented nested blocks This commit fixes an issue where nested blocks (like do loops inside do loops) were not properly indented when they were misaligned with the expected indentation level. The fix adds a check for misaligned indentation in blocked do/if constructs: - Added indent_misaligned check: indent_size > 0 and offset % indent_size != 0 - This ensures that blocks are properly indented even when the original code has inconsistent indentation This corresponds to the core code change from commit 8487922. Note: The test files from the original commit are not included in this commit because the fortran_tests/ directory is gitignored in this repository. --- fprettify/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fprettify/__init__.py b/fprettify/__init__.py index b9f50de..12c081e 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -888,7 +888,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 1159aedd1d2c25362ffed72db42c2bce6a1f2a1c Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 11:14:00 +0100 Subject: [PATCH 10/11] Add line continuation helper functions --- fprettify/__init__.py | 133 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/fprettify/__init__.py b/fprettify/__init__.py index 12c081e..c405114 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -1873,6 +1873,139 @@ def get_manual_alignment(lines): return manual_lines_indent +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 len(line.rstrip('\n')) <= llength: + return None + + stripped_line = line.lstrip(' ') + if not stripped_line: + return None + + split_pos = _find_split_position(stripped_line, llength - ind_use) + if split_pos is None: + return None + + first_chunk = ' ' * ind_use + stripped_line[:split_pos] + ' &' + remaining = stripped_line[split_pos:] + + new_lines = [first_chunk] + follow_indent = ind_use + indent_size + + while len(remaining) > (llength - follow_indent): + split_pos = _find_split_position(remaining, llength - follow_indent) + if split_pos is None: + break + new_lines.append(' ' * follow_indent + remaining[:split_pos] + ' &') + remaining = remaining[split_pos:] + + if remaining: + new_lines.append(' ' * follow_indent + remaining) + + 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, llength, use_same_line, is_omp_conditional, label, filename, line_nr): """Write reformatted line to file""" From b2505ec5dd5654777ab42e2657d8fc1d333d4983 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 11:26:57 +0100 Subject: [PATCH 11/11] Restore auto line-splitting functionality lost during rebase - Replace incomplete _auto_split_line with robust original implementation - Update write_formatted_line signature with indent_size and allow_split params - Integrate auto-split and inline comment detach logic into write_formatted_line - Add tests for line splitting, comment detachment, and edge cases This restores functionality from PR #4 that was lost when the rebased branches were merged back into master. --- fprettify/__init__.py | 135 +++++++++++++++++------ fprettify/tests/unittests.py | 206 +++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 34 deletions(-) diff --git a/fprettify/__init__.py b/fprettify/__init__.py index c405114..c530017 100644 --- a/fprettify/__init__.py +++ b/fprettify/__init__.py @@ -1634,8 +1634,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) # rm subsequent blank lines skip_blank = EMPTY_RE.search( @@ -1912,33 +1914,71 @@ def _auto_split_line(line, ind_use, llength, indent_size): respect the configured line-length limit. Returns a list of new line fragments when successful, otherwise None. """ - if len(line.rstrip('\n')) <= llength: + if llength < 40: return None - - stripped_line = line.lstrip(' ') - if not stripped_line: + + stripped = line.lstrip(' ') + if not stripped: return None - - split_pos = _find_split_position(stripped_line, llength - ind_use) - if split_pos is None: + if stripped.startswith('&'): return None - - first_chunk = ' ' * ind_use + stripped_line[:split_pos] + ' &' - remaining = stripped_line[split_pos:] - - new_lines = [first_chunk] - follow_indent = ind_use + indent_size - - while len(remaining) > (llength - follow_indent): - split_pos = _find_split_position(remaining, llength - follow_indent) - if split_pos is 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 - new_lines.append(' ' * follow_indent + remaining[:split_pos] + ' &') - remaining = remaining[split_pos:] - - if remaining: - new_lines.append(' ' * follow_indent + remaining) - + 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 @@ -2006,10 +2046,14 @@ def _detach_inline_comment(idx, indent, lines, orig_lines): return True -def write_formatted_line(outfile, indent, lines, orig_lines, indent_special, llength, use_same_line, is_omp_conditional, label, filename, line_nr): +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 @@ -2034,15 +2078,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) @@ -2051,6 +2113,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/unittests.py b/fprettify/tests/unittests.py index e1b99c0..f084b50 100644 --- a/fprettify/tests/unittests.py +++ b/fprettify/tests/unittests.py @@ -849,6 +849,212 @@ def test_first_line_non_code(self): outstr = " ! a comment\n function fun()\n ! a comment\n end" self.assert_fprettify_result([], instr, outstr) + 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) +