From f0de226257de3ad62dff42a306aa14a95d413471 Mon Sep 17 00:00:00 2001 From: Tomoto Shimizu Washio Date: Sun, 29 Jun 2025 14:56:53 +0900 Subject: [PATCH 1/3] feat: FoldingRangeProvider support --- fortls/folding_ranges.py | 241 ++++++++++++++++++ fortls/langserver.py | 22 ++ test/test_server.py | 2 + test/test_server_folding_range.py | 64 +++++ .../folding_range/test_folding_range.f | 25 ++ .../folding_range/test_folding_range.f90 | 37 +++ 6 files changed, 391 insertions(+) create mode 100644 fortls/folding_ranges.py create mode 100644 test/test_server_folding_range.py create mode 100644 test/test_source/folding_range/test_folding_range.f create mode 100644 test/test_source/folding_range/test_folding_range.f90 diff --git a/fortls/folding_ranges.py b/fortls/folding_ranges.py new file mode 100644 index 00000000..a8873991 --- /dev/null +++ b/fortls/folding_ranges.py @@ -0,0 +1,241 @@ +import re +from typing import Literal, TypedDict +from fortls.constants import ( + FUNCTION_TYPE_ID, + IF_TYPE_ID, + MODULE_TYPE_ID, + SELECT_TYPE_ID, + SUBMODULE_TYPE_ID, + SUBROUTINE_TYPE_ID, + WHERE_TYPE_ID, + FRegex, +) +from fortls.parsers.internal.parser import FortranFile +from fortls.parsers.internal.scope import Scope + + +class FoldingRange(TypedDict, total=False): + startLine: int + endLine: int + kind: Literal["comment", "imports", "region"] + + +def get_folding_ranges_by_block_comment(file_obj: FortranFile, min_block_size: int): + """ + Get the folding ranges in this file based on the block comment + + Returns + ------- + list[FoldingRange] + List of folding ranges as defined in LSP including `startLine` and `endLine`, + with `kind` of `comment`. + """ + tracker = BlockCommentTracker(min_block_size) + comment_regex = file_obj.get_comment_regexs()[0] + + for index, line in enumerate(file_obj.contents_split): + if comment_regex.match(line): + tracker.process(index) + + tracker.finalize(file_obj.nLines) + + return [ + { + "startLine": r[0], + "endLine": r[1], + "kind": "comment", + } + for r in tracker.result + ] + + +class BlockCommentTracker: + """Track the comment lines and generates ranges for block comments in `result`""" + + def __init__(self, min_block_size: int): + self.min_block_size = min_block_size + """Minimum number of consecutive comment lines to make a block""" + self.result: list[tuple[int, int]] = [] + """List or ranges for comment blocks""" + + self._start_index = -99 + self._last_index = -99 + + def process(self, index: int): + """Process comment lines one by one""" + assert index > self._last_index + if index - self._last_index == 1: + # Consecutive, just keep tracking + self._last_index = index + else: + # Sequence just broken, add to the result if the size is large enough + if self._last_index - self._start_index + 1 >= self.min_block_size: + self.result.append((self._start_index, self._last_index)) + # Start a new block + self._start_index = index + self._last_index = index + + def finalize(self, max_line_index: int): + """Process all unfinished blocks""" + self.process(max_line_index + 99) + + +def get_folding_ranges_by_indent(file_obj: FortranFile) -> list[FoldingRange]: + """ + Get the folding ranges in this file based on the indent + + Returns + ------- + list[FoldingRange] + List of folding ranges as defined in LSP including `startLine` and `endLine`. + """ + + tracker = IndentTracker() + + index = 0 + while index < file_obj.nLines: + # Extract the code from the line (and following concatenated lines) + [_, curr_line, post_lines] = file_obj.get_code_line( + index, backward=False, strip_comment=True + ) + if file_obj.fixed: + curr_line = curr_line[6:] + code_line = curr_line + "".join(post_lines) + + # Process the indent if the line is not empty + indent = IndentTracker.count_indent(code_line) + if indent < len(code_line): + tracker.process(index, indent) + + # Increment the line index skipping the concatenated lines + index += 1 + len(post_lines) + + tracker.finalize(file_obj.nLines) + + return [ + { + "startLine": r[0], + "endLine": r[1], + } + for r in tracker.result + ] + + +class IndentTracker: + """Track the indent changes and generates ranges in `result`.""" + + INDENT_PATTERN = re.compile(r"[ ]*") + + @classmethod + def count_indent(self, s: str) -> int: + return len(self.INDENT_PATTERN.match(s).group(0)) + + def __init__(self): + self.result: list[tuple[int, int]] = [] + """List or ranges based on indent changes""" + + self._indent_stack: list[tuple[int, int]] = [] # start index and indent + self._last_indent = -1 + self._last_index = -1 + + def process(self, index: int, indent: int): + """Process indented lines one by one""" + assert index > self._last_index + + if indent > self._last_indent: + # At indent in, push the start index and indent to the stack + self._indent_stack.append((self._last_index, indent)) + elif indent < self._last_indent: + # At indent out, create ranges for the preceding deeper blocks + while self._indent_stack and self._indent_stack[-1][1] > indent: + start_index = self._indent_stack.pop()[0] + # Add to the result only if the range is valid + if start_index >= 0 and index - start_index > 1: + self.result.append((start_index, index - 1)) + + self._last_indent = indent + self._last_index = index + + def finalize(self, num_lines: int): + """Process all unfinished blocks""" + self.process(num_lines, -1) + + +RANGE_CLOSE_PATTENS: dict[int, re.Pattern] = { + IF_TYPE_ID: re.compile(r"ELSE(\s*IF)?\b", re.I), + SELECT_TYPE_ID: re.compile(r"(CASE|TYPE|CLASS)\b", re.I), + WHERE_TYPE_ID: re.compile(r"ELSEWHERE\b", re.I), + MODULE_TYPE_ID: FRegex.CONTAINS, + SUBMODULE_TYPE_ID: FRegex.CONTAINS, + FUNCTION_TYPE_ID: FRegex.CONTAINS, + SUBROUTINE_TYPE_ID: FRegex.CONTAINS, +} + + +def get_folding_ranges_by_syntax(file_obj: FortranFile) -> list[FoldingRange]: + """ + Get the folding ranges in this file based on the syntax + + Returns + ------- + list[FoldingRange] + List of folding ranges as defined in LSP including `startLine` and `endLine`. + """ + + range_by_sline: dict[int, int] = dict() + scope_by_sline: dict[int, Scope] = dict() + + scopes: list[Scope] = file_obj.ast.scope_list + for scope in scopes: + # We assume different scopes should have different slines, but just in case... + conflict_range = range_by_sline.get(scope.sline) + if conflict_range is not None and scope.eline - 1 < conflict_range: + continue + # Create default ranges based on each scope, + # which may be split later in this process + range_by_sline[scope.sline] = scope.eline - 1 + scope_by_sline[scope.sline] = scope + + # Split the scope if necessary + for scope in scopes: + range_close_pattern = RANGE_CLOSE_PATTENS.get(scope.get_type()) + if range_close_pattern is None: + continue + + range_sline = None if scope.get_type() in [SELECT_TYPE_ID] else scope.sline + + line_no = scope.sline + 1 + while line_no < scope.eline: + # Skip child scopes + child_scope = scope_by_sline.get(line_no) + if child_scope: + line_no = child_scope.eline + 1 + continue + + # Extract the code from the line (and following concatenated lines) + [_, curr_line, post_lines] = file_obj.get_code_line( + line_no - 1, backward=False, strip_comment=True + ) + if file_obj.fixed: + curr_line = curr_line[6:] + code_line = (curr_line + "".join(post_lines)).strip() + + # If the code matches to the pattern, split the range + if range_close_pattern.match(code_line): + if range_sline is not None: + range_by_sline[range_sline] = line_no - 1 + range_sline = line_no + + line_no += 1 + len(post_lines) + + if range_sline is not None: + range_by_sline[range_sline] = line_no - 1 + + return [ + { + "startLine": r[0] - 1, + "endLine": r[1] - 1, + } + for r in range_by_sline.items() + if r[0] < r[1] + ] diff --git a/fortls/langserver.py b/fortls/langserver.py index b53850d6..ff06418b 100644 --- a/fortls/langserver.py +++ b/fortls/langserver.py @@ -13,6 +13,11 @@ from typing import Pattern from urllib.error import URLError +from fortls.folding_ranges import ( + get_folding_ranges_by_block_comment, + get_folding_ranges_by_indent, + get_folding_ranges_by_syntax, +) import json5 from packaging import version @@ -151,6 +156,7 @@ def noop(request: dict): "textDocument/didClose": self.serve_onClose, "textDocument/didChange": self.serve_onChange, "textDocument/codeAction": self.serve_codeActions, + "textDocument/foldingRange": self.serve_folding_range, "initialized": noop, "workspace/didChangeWatchedFiles": noop, "workspace/didChangeConfiguration": noop, @@ -227,6 +233,7 @@ def serve_initialize(self, request: dict): "renameProvider": True, "workspaceSymbolProvider": True, "textDocumentSync": self.sync_type, + "foldingRangeProvider": True, } if self.use_signature_help: server_capabilities["signatureHelpProvider"] = { @@ -1540,6 +1547,21 @@ def serve_default(self, request: dict): code=-32601, message=f"method {request['method']} not found" ) + def serve_folding_range(self, request: dict): + uri = request["params"]["textDocument"]["uri"] + path = path_from_uri(uri) + file_obj = self.workspace[path] + if file_obj is None: + return None + + result = get_folding_ranges_by_block_comment(file_obj, min_block_size=3) + use_indent = True # If False, use syntax + if use_indent: + result += get_folding_ranges_by_indent(file_obj) + else: + result += get_folding_ranges_by_syntax(file_obj) + return result + def _load_config_file(self) -> None: """Loads the configuration file for the Language Server""" diff --git a/test/test_server.py b/test/test_server.py index 639ef427..24609730 100644 --- a/test/test_server.py +++ b/test/test_server.py @@ -178,6 +178,8 @@ def check_return(result_array): ["test", 6, 7], ["test_abstract", 2, 0], ["test_associate_block", 2, 0], + ["test_folding_range_fixed_form", 2, 1], + ["test_folding_range_free_form", 2, 0], ["test_free", 2, 0], ["test_gen_type", 5, 1], ["test_generic", 2, 0], diff --git a/test/test_server_folding_range.py b/test/test_server_folding_range.py new file mode 100644 index 00000000..1d590589 --- /dev/null +++ b/test/test_server_folding_range.py @@ -0,0 +1,64 @@ +from pathlib import Path + +from setup_tests import path_to_uri, run_request, test_dir, write_rpc_request + + +def validate_ranges(result_ranges, ref_ranges): + actual_ranges = [ + (r["startLine"], r["endLine"], r.get("kind")) for r in result_ranges + ] + actual_ranges.sort() + assert actual_ranges == ref_ranges + + +def folding_range_request(uri: Path): + return write_rpc_request( + 1, + "textDocument/foldingRange", + { + "textDocument": {"uri": str(uri)}, + }, + ) + + +def test_folding_range_fixed_form(): + """Test several patterns of folding range in fixed form""" + string = write_rpc_request(1, "initialize", {"rootPath": str(test_dir)}) + file_path = test_dir / "folding_range" / "test_folding_range.f" + string += folding_range_request(file_path) + errcode, results = run_request(string) + assert errcode == 0 + ref_ranges = [ + (1, 23, None), + (2, 22, None), + (3, 7, None), + (5, 6, None), + (10, 11, None), + (12, 19, None), + (16, 18, "comment"), + (20, 21, None), + ] + validate_ranges(results[1], ref_ranges) + + +def test_folding_range_free_form(): + """Test several patterns of folding range in free form""" + string = write_rpc_request(1, "initialize", {"rootPath": str(test_dir)}) + file_path = test_dir / "folding_range" / "test_folding_range.f90" + string += folding_range_request(file_path) + errcode, results = run_request(string) + assert errcode == 0 + ref_ranges = [ + (0, 29, None), + (2, 14, None), + (5, 9, None), + (10, 13, None), + (17, 19, "comment"), + (20, 27, None), + (21, 23, None), + (24, 25, None), + (26, 27, None), + (30, 35, None), + (32, 34, None), + ] + validate_ranges(results[1], ref_ranges) diff --git a/test/test_source/folding_range/test_folding_range.f b/test/test_source/folding_range/test_folding_range.f new file mode 100644 index 00000000..94c4fdb8 --- /dev/null +++ b/test/test_source/folding_range/test_folding_range.f @@ -0,0 +1,25 @@ +C SHORT COMMENT + PROGRAM test_folding_range_fixed_form + DO I=1,5 + DO 100 J=1,5 + PRINT *, N + IF (N == M) THEN + PRINT *, N + END IF + 100 CONTINUE + + IF (N == M) THEN + PRINT *, N + ELSE + * IF (N == M) + * THEN + +C BLOCK COMMENT +C BLOCK COMMENT +C BLOCK COMMENT + PRINT *, N + ELSE + PRINT *, N + END IF + END DO + END PROGRAM diff --git a/test/test_source/folding_range/test_folding_range.f90 b/test/test_source/folding_range/test_folding_range.f90 new file mode 100644 index 00000000..23f9a9d1 --- /dev/null +++ b/test/test_source/folding_range/test_folding_range.f90 @@ -0,0 +1,37 @@ +program test_folding_range_free_form + dimension k(3) + do i = 1, 5 + n = 1 + m = 2 + where (k > 2) + ! hoge + k = & ! hoge + n * & ! should be ignored + 3 ! should be ignored + elsewhere + ! piyo + k = n * 2 + k = m * 2 + end where + end do + + ! block comment + ! block comment + ! block comment + select case (int(sum(k))) + case (:5) + print *, & + "sum is small." + case (6:15) + print *, "sum is moderate." + case default + print *, "sum is large." + end select + +contains + + subroutine dosomething() + print *, n + print *, m + end subroutine dosomething +end program test_folding_range_free_form From 28ec6f8c3150d96cef8870b5af3f272c7824aa3d Mon Sep 17 00:00:00 2001 From: Tomoto Shimizu Washio Date: Mon, 30 Jun 2025 01:36:06 +0900 Subject: [PATCH 2/3] feat: add command line options --- fortls/interface.py | 16 ++++++++++++++++ fortls/langserver.py | 21 +++++++++++++++++---- test/test_interface.py | 14 ++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/fortls/interface.py b/fortls/interface.py index e16d0641..c291fed1 100644 --- a/fortls/interface.py +++ b/fortls/interface.py @@ -280,6 +280,22 @@ def cli(name: str = "fortls") -> argparse.ArgumentParser: help="Enable experimental code actions (default: false)", ) + # Folding Range options ---------------------------------------------------- + group = parser.add_argument_group("FoldingRange options") + group.add_argument( + "--folding-range-mode", + choices=["indent", "syntax"], + default="indent", + help="How to detect folding ranges, by indent or syntax (default: indent)", + ) + group.add_argument( + "--folding-range-comment-lines", + type=int, + default=3, + metavar="INTEGER", + help="Number of comment lines to consider for folding (default: 3)", + ) + # Debug # By default debug arguments are hidden _debug_commandline_args(parser) diff --git a/fortls/langserver.py b/fortls/langserver.py index ff06418b..3894dc35 100644 --- a/fortls/langserver.py +++ b/fortls/langserver.py @@ -1554,12 +1554,18 @@ def serve_folding_range(self, request: dict): if file_obj is None: return None - result = get_folding_ranges_by_block_comment(file_obj, min_block_size=3) - use_indent = True # If False, use syntax - if use_indent: + result = [] + + result += get_folding_ranges_by_block_comment( + file_obj, + min_block_size=self.folding_range_comment_lines, + ) + + if self.folding_range_mode == "indent": result += get_folding_ranges_by_indent(file_obj) - else: + elif self.folding_range_mode == "syntax": result += get_folding_ranges_by_syntax(file_obj) + return result def _load_config_file(self) -> None: @@ -1667,6 +1673,13 @@ def _load_config_file_general(self, config_dict: dict) -> None: self.enable_code_actions = config_dict.get( "enable_code_actions", self.enable_code_actions ) + # Folding Range Options ------------------------------------------------ + self.folding_range_mode = config_dict.get( + "folding_range_mode", self.folding_range_mode + ) + self.folding_range_comment_lines = config_dict.get( + "folding_range_comment_lines", self.folding_range_comment_lines + ) def _load_config_file_preproc(self, config_dict: dict) -> None: self.pp_suffixes = config_dict.get("pp_suffixes", None) diff --git a/test/test_interface.py b/test/test_interface.py index 764ac56a..287a1adc 100644 --- a/test/test_interface.py +++ b/test/test_interface.py @@ -83,6 +83,20 @@ def test_command_line_code_actions_options(): assert args.enable_code_actions +def test_command_line_folding_range_options(): + args = parser.parse_args( + "--folding-range-mode syntax --folding-range-comment-lines 5".split() + ) + assert args.folding_range_mode == "syntax" + assert args.folding_range_comment_lines == 5 + + +def test_command_line_folding_range_options_default(): + args = parser.parse_args("".split()) + assert args.folding_range_mode == "indent" + assert args.folding_range_comment_lines == 3 + + def unittest_server_init(conn=None): from fortls.langserver import LangServer From 596677e9fc3cb0a7016324eb28d39b6087acf64e Mon Sep 17 00:00:00 2001 From: Tomoto Shimizu Washio Date: Mon, 30 Jun 2025 01:43:23 +0900 Subject: [PATCH 3/3] doc: add document about folding range feature --- README.md | 4 ++++ docs/options.rst | 5 ++++- fortls/folding_ranges.py | 6 +++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 75e4514a..b02ad413 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,9 @@ and [Emacs](https://fortls.fortran-lang.org/editor_integration.html#emacs). - Code actions - Generate type-bound procedures and implementation templates for deferred procedures +- Folding ranges + - Detect folding ranges based on Fortran syntax or indent as well as + consecutive comment lines ### Notes/Limitations @@ -150,6 +153,7 @@ An example for a Configuration file is given below | `textDocument/didClose` | Document synchronisation upon closing | | `textDocument/didChange` | Document synchronisation upon changes to the document | | `textDocument/codeAction` | **Experimental** Generate code | +| `textDocument/foldingRange | Get folding ranges based on Fortran syntax and indent | ## Future plans diff --git a/docs/options.rst b/docs/options.rst index e68c4a0d..34cf4ac0 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -76,7 +76,10 @@ All the ``fortls`` settings with their default arguments can be found below "symbol_skip_mem": false, - "enable_code_actions": false + "enable_code_actions": false, + + "folding_range_mode": "indent", + "folding_range_comment_lines": 3 } Sources file parsing diff --git a/fortls/folding_ranges.py b/fortls/folding_ranges.py index a8873991..be3fe2a9 100644 --- a/fortls/folding_ranges.py +++ b/fortls/folding_ranges.py @@ -22,7 +22,7 @@ class FoldingRange(TypedDict, total=False): def get_folding_ranges_by_block_comment(file_obj: FortranFile, min_block_size: int): """ - Get the folding ranges in this file based on the block comment + Get the folding ranges in the given file based on the block comment Returns ------- @@ -82,7 +82,7 @@ def finalize(self, max_line_index: int): def get_folding_ranges_by_indent(file_obj: FortranFile) -> list[FoldingRange]: """ - Get the folding ranges in this file based on the indent + Get the folding ranges in the given file based on the indent Returns ------- @@ -174,7 +174,7 @@ def finalize(self, num_lines: int): def get_folding_ranges_by_syntax(file_obj: FortranFile) -> list[FoldingRange]: """ - Get the folding ranges in this file based on the syntax + Get the folding ranges in the given file based on the syntax Returns -------