diff --git a/.github/workflows/docs_preview.yml b/.github/workflows/docs_preview.yml index a3e593ac..488f1a89 100644 --- a/.github/workflows/docs_preview.yml +++ b/.github/workflows/docs_preview.yml @@ -19,6 +19,9 @@ on: concurrency: preview-${{github.ref}} jobs: + job-name: + permissions: + contents: write deploy-preview: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 790d8f9e..aadd659b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] fail-fast: false runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 75e4514a..1af61bf6 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ An example for a Configuration file is given below "lowercase_intrinsics": true, "hover_signature": true, "use_signature_help": true, + "folding_range": true, "excl_paths": ["tests/**", "tools/**"], "excl_suffixes": ["_skip.f90"], "include_dirs": ["include/**"], diff --git a/docs/options.rst b/docs/options.rst index e68c4a0d..18efbf35 100644 --- a/docs/options.rst +++ b/docs/options.rst @@ -66,6 +66,8 @@ All the ``fortls`` settings with their default arguments can be found below "hover_signature": false, "hover_language": "fortran90", + "folding_range": false, + "max_line_length": -1, "max_comment_line_length": -1, "disable_diagnostics": false, diff --git a/fortls/fortls.schema.json b/fortls/fortls.schema.json index 62b434d7..78c6cca9 100644 --- a/fortls/fortls.schema.json +++ b/fortls/fortls.schema.json @@ -135,6 +135,12 @@ "title": "Hover Language", "type": "string" }, + "folding_range": { + "default": false, + "description": "Fold editor based on language keywords", + "title": "Folding Range", + "type": "boolean" + }, "max_line_length": { "default": -1, "description": "Maximum line length (default: -1)", diff --git a/fortls/interface.py b/fortls/interface.py index e16d0641..1d214ceb 100644 --- a/fortls/interface.py +++ b/fortls/interface.py @@ -210,6 +210,13 @@ def cli(name: str = "fortls") -> argparse.ArgumentParser: ), ) + # Folding range ------------------------------------------------------------ + group.add_argument( + "--folding_range", + action="store_true", + help="Fold editor based on language keywords", + ) + # Diagnostic options ------------------------------------------------------- group = parser.add_argument_group("Diagnostic options (error swigles)") group.add_argument( diff --git a/fortls/langserver.py b/fortls/langserver.py index b6a1982e..54ee3778 100644 --- a/fortls/langserver.py +++ b/fortls/langserver.py @@ -151,6 +151,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, @@ -226,6 +227,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"] = { @@ -1250,6 +1252,56 @@ def serve_codeActions(self, request: dict): action["diagnostics"] = new_diags return action_list + def serve_folding_range(self, request: dict): + # Get parameters from request + params: dict = request["params"] + uri: str = params["textDocument"]["uri"] + path = path_from_uri(uri) + # Find object + file_obj = self.workspace.get(path) + if file_obj is None: + return None + if file_obj.ast is None: + return None + else: + folding_start = file_obj.ast.folding_start + folding_end = file_obj.ast.folding_end + if ( + folding_start is None + or folding_end is None + or len(folding_start) != len(folding_end) + ): + return None + # Construct folding_rage list: + folding_ranges = [] + # first treat scope objects ... + for scope in file_obj.ast.scope_list: + n_mlines = len(scope.mlines) + # ...with intermediate folding lines (if, select case) ... + if n_mlines > 0: + self.add_range(folding_ranges, scope.sline - 1, scope.mlines[0] - 2) + for i in range(1, n_mlines): + self.add_range( + folding_ranges, scope.mlines[i - 1] - 1, scope.mlines[i] - 2 + ) + self.add_range(folding_ranges, scope.mlines[-1] - 1, scope.eline - 2) + # ...and without, ... + else: + self.add_range(folding_ranges, scope.sline - 1, scope.eline - 2) + # ...and finally treat comment blocks + folds = len(folding_start) + for i in range(0, folds): + self.add_range(folding_ranges, folding_start[i] - 1, folding_end[i] - 1) + + return folding_ranges + + def add_range(self, folding_ranges: list, start: int, end: int): + folding_range = { + "startLine": start, + "endLine": end, + } + folding_ranges.append(folding_range) + def send_diagnostics(self, uri: str): diag_results, diag_exp = self.get_diagnostics(uri) if diag_results is not None: @@ -1621,6 +1673,9 @@ def _load_config_file_general(self, config_dict: dict) -> None: self.hover_signature = config_dict.get("hover_signature", self.hover_signature) self.hover_language = config_dict.get("hover_language", self.hover_language) + # Folding range -------------------------------------------------------- + self.folding_range = config_dict.get("folding_range", self.folding_range) + # Diagnostic options --------------------------------------------------- self.max_line_length = config_dict.get("max_line_length", self.max_line_length) self.max_comment_line_length = config_dict.get( diff --git a/fortls/parsers/internal/ast.py b/fortls/parsers/internal/ast.py index 94bf448f..61484bc4 100644 --- a/fortls/parsers/internal/ast.py +++ b/fortls/parsers/internal/ast.py @@ -34,6 +34,10 @@ def __init__(self, file_obj=None): self.inherit_objs: list = [] self.linkable_objs: list = [] self.external_objs: list = [] + self.folding_start: list = [] + self.folding_end: list = [] + self.comment_block_start = 0 + self.comment_block_end = 0 self.none_scope = None self.inc_scope = None self.current_scope = None diff --git a/fortls/parsers/internal/parser.py b/fortls/parsers/internal/parser.py index 5095a6d4..4a87571b 100644 --- a/fortls/parsers/internal/parser.py +++ b/fortls/parsers/internal/parser.py @@ -1305,6 +1305,20 @@ def parse( line = multi_lines.pop() get_full = False + # Add comment blocks to folding patterns + if FRegex.FREE_COMMENT.match(line) is not None: + if file_ast.comment_block_start == 0: + file_ast.comment_block_start = line_no + else: + file_ast.comment_block_end = line_no + elif file_ast.comment_block_start != 0: + # Only fold consecutive comment lines + if file_ast.comment_block_end > file_ast.comment_block_start + 1: + file_ast.folding_start.append(file_ast.comment_block_start) + file_ast.folding_end.append(line_no - 1) + file_ast.comment_block_end = 0 + file_ast.comment_block_start = 0 + if line == "": continue # Skip empty lines @@ -1335,6 +1349,10 @@ def parse( # Need to keep the line number for registering start of Scopes line_no_end += len(post_lines) line = "".join([line] + post_lines) + # Add multilines to folding blocks + if line_no != line_no_end: + file_ast.folding_start.append(line_no) + file_ast.folding_end.append(line_no_end) line, line_label = strip_line_label(line) line_stripped = strip_strings(line, maintain_len=True) # Find trailing comments @@ -1353,6 +1371,22 @@ def parse( line_no_comment = line # Test for scope end if file_ast.end_scope_regex is not None: + # treat intermediate folding lines in scopes if they exist + if ( + file_ast.end_scope_regex == FRegex.END_IF + and FRegex.ELSE_IF.match(line_no_comment) is not None + ): + self.update_scope_mlist(file_ast, "#IF", line_no) + elif ( + file_ast.end_scope_regex == FRegex.END_SELECT + and ( + FRegex.SELECT_CASE.match(line_no_comment) + or FRegex.CASE_DEFAULT.match(line_no_comment) + ) + is not None + ): + self.update_scope_mlist(file_ast, "#SELECT", line_no) + match = FRegex.END_WORD.match(line_no_comment) # Handle end statement if self.parse_end_scope_word(line_no_comment, line_no, file_ast, match): @@ -1488,6 +1522,8 @@ def parse( keywords=keywords, ) file_ast.add_scope(new_sub, FRegex.END_SUB) + if line_no != line_no_end: + file_ast.scope_list[-1].mlines.append(line_no_end) log.debug("%s !!! SUBROUTINE - Ln:%d", line, line_no) elif obj_type == "fun": @@ -1684,6 +1720,20 @@ def parse( log.debug("%s: %s", error["range"], error["message"]) return file_ast + def update_scope_mlist( + self, file_ast: FortranAST, scope_name_prefix: str, line_no: int + ): + """Find the last unclosed scope (eline == sline) containing the + scope_name_prefix and add update its nb of intermediate lines (mlines)""" + + i = 1 + while True: + scope = file_ast.scope_list[-i] + if (scope_name_prefix in scope.name) and (scope.eline == scope.sline): + scope.mlines.append(line_no) + return + i += 1 + def parse_imp_dim(self, line: str): """Parse the implicit dimension of an array e.g. var(3,4), var_name(size(val,1)*10) diff --git a/fortls/parsers/internal/scope.py b/fortls/parsers/internal/scope.py index 7592729a..fc96746b 100644 --- a/fortls/parsers/internal/scope.py +++ b/fortls/parsers/internal/scope.py @@ -38,6 +38,7 @@ def __init__( self.file_ast: FortranAST = file_ast self.sline: int = line_number self.eline: int = line_number + self.mlines: list = [] self.name: str = name self.children: list[T[Scope]] = [] self.members: list = [] diff --git a/fortls/regex_patterns.py b/fortls/regex_patterns.py index 46cc5d28..8697df90 100644 --- a/fortls/regex_patterns.py +++ b/fortls/regex_patterns.py @@ -146,6 +146,9 @@ class FortranRegularExpressions: r" |MODULE|PROGRAM|SUBROUTINE|FUNCTION|PROCEDURE|TYPE|DO|IF|SELECT)?", I, ) + ELSE_IF: Pattern = compile(r"(^|.*\s)(ELSE$|ELSE(\s)|ELSEIF(\s*\())", I) + SELECT_CASE: Pattern = compile(r"((^|\s*\s)(CASE)(\s*\())", I) + CASE_DEFAULT: Pattern = compile(r"[ ]*CASE[ ]+DEFAULT", I) # Object regex patterns CLASS_VAR: Pattern = compile(r"(TYPE|CLASS)[ ]*\(", I) DEF_KIND: Pattern = compile(r"(\w*)[ ]*\((?:KIND|LEN)?[ =]*(\w*)", I) diff --git a/setup.py b/setup.py index 30656e08..a7750973 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -"""Builds the fortls Language Server -""" +"""Builds the fortls Language Server""" + import setuptools setuptools.setup() diff --git a/test/test_server.py b/test/test_server.py index 639ef427..f19bb73c 100644 --- a/test/test_server.py +++ b/test/test_server.py @@ -197,6 +197,7 @@ def check_return(result_array): ["test_str2", 13, 5], ["test_sub", 6, 8], ["test_vis_mod", 2, 0], + ["to_test", 13, 2], ) assert len(result_array) == len(objs) for i, obj in enumerate(objs): diff --git a/test/test_server_folding.py b/test/test_server_folding.py new file mode 100644 index 00000000..a95e3d73 --- /dev/null +++ b/test/test_server_folding.py @@ -0,0 +1,76 @@ +from setup_tests import Path, run_request, test_dir, write_rpc_request + + +def folding_req(file_path: Path) -> str: + return write_rpc_request( + 1, + "textDocument/foldingRange", + {"textDocument": {"uri": str(file_path)}}, + ) + + +def validate_folding(results: list, ref: list): + assert len(results) == len(ref) + for i in range(0, len(results)): + assert results[i] == ref[i] + + +def test_folding_if(): + """Test the ranges for several blocks are correct""" + string = write_rpc_request(1, "initialize", {"rootPath": str(test_dir)}) + file_path = test_dir / "subdir" / "test_folding_if.f90" + string += folding_req(file_path) + errcode, results = run_request(string) + assert errcode == 0 + ref = [ + {"startLine": 0, "endLine": 43}, + {"startLine": 11, "endLine": 21}, + {"startLine": 22, "endLine": 23}, + {"startLine": 12, "endLine": 20}, + {"startLine": 14, "endLine": 15}, + {"startLine": 16, "endLine": 17}, + {"startLine": 18, "endLine": 19}, + {"startLine": 31, "endLine": 35}, + {"startLine": 36, "endLine": 39}, + {"startLine": 40, "endLine": 41}, + {"startLine": 2, "endLine": 6}, + {"startLine": 26, "endLine": 28}, + {"startLine": 32, "endLine": 34}, + ] + validate_folding(results[1], ref) + + +def test_folding_select_case(): + """Test the ranges for several blocks are correct""" + string = write_rpc_request(1, "initialize", {"rootPath": str(test_dir)}) + file_path = test_dir / "subdir" / "test_folding_select_case.f90" + string += folding_req(file_path) + errcode, results = run_request(string) + assert errcode == 0 + ref = [ + {"startLine": 0, "endLine": 14}, + {"startLine": 4, "endLine": 4}, + {"startLine": 5, "endLine": 6}, + {"startLine": 7, "endLine": 8}, + {"startLine": 9, "endLine": 10}, + {"startLine": 11, "endLine": 12}, + ] + validate_folding(results[1], ref) + + +def test_folding_subroutine(): + """Test the ranges for several blocks are correct""" + string = write_rpc_request(1, "initialize", {"rootPath": str(test_dir)}) + file_path = test_dir / "subdir" / "test_folding_subroutine.f90" + string += folding_req(file_path) + errcode, results = run_request(string) + assert errcode == 0 + print(results[1]) + ref = [ + {"startLine": 1, "endLine": 4}, + {"startLine": 5, "endLine": 17}, + {"startLine": 1, "endLine": 5}, + {"startLine": 8, "endLine": 11}, + {"startLine": 15, "endLine": 16}, + ] + validate_folding(results[1], ref) diff --git a/test/test_source/f90_config.json b/test/test_source/f90_config.json index e7f9e0e9..00d8b7d1 100644 --- a/test/test_source/f90_config.json +++ b/test/test_source/f90_config.json @@ -20,6 +20,7 @@ "variable_hover": true, "hover_signature": true, "hover_language": "FortranFreeForm", + "folding_range": true, "max_line_length": 80, "max_comment_line_length": 80, diff --git a/test/test_source/pp/.pp_conf.json b/test/test_source/pp/.pp_conf.json index 0cf75a8a..30dd2844 100644 --- a/test/test_source/pp/.pp_conf.json +++ b/test/test_source/pp/.pp_conf.json @@ -4,6 +4,7 @@ "variable_hover": true, "hover_signature": true, "enable_code_actions": true, + "folding_range": true, "pp_suffixes": [".h", ".F90"], "incl_suffixes": [".h"], "include_dirs": ["include"], diff --git a/test/test_source/subdir/test_folding_if.f90 b/test/test_source/subdir/test_folding_if.f90 new file mode 100644 index 00000000..4059de17 --- /dev/null +++ b/test/test_source/subdir/test_folding_if.f90 @@ -0,0 +1,45 @@ +program if + +!test comment folding + ! adding some comment lines + ! to check is comment folding + ! works + ! as expected + +implicit none +integer :: i, j + + if (i > 0 .and. j > 0) then + if (i > j) then + j = j + 1 + if (mod(j,100) == 0) then + print*, "j = ", j + else if (mod(j,100) < 50) then + print*, "j = ", j + else + print*, "j = ", j + end if + end if + else + print*, i-j + end if + + if (i==0) & + i = 1; & + j = 2 + + + if (j == i) then + ! testing some + ! comment lines + ! right here + print*, "well done" + else if(.true.) then + print*, "missed something..." + print*, "something more" + ! random comment here + else + print*, "something else" + end if + + end program if diff --git a/test/test_source/subdir/test_folding_select_case.f90 b/test/test_source/subdir/test_folding_select_case.f90 new file mode 100644 index 00000000..190680af --- /dev/null +++ b/test/test_source/subdir/test_folding_select_case.f90 @@ -0,0 +1,16 @@ +program select_case + +integer :: to_test, six + +select case (to_test) +case (1) + six = 6*to_test +case (2) + six = 3*to_test +case (3) + six = 2*to_test +case default + six = 6 +end select + +end program select_case diff --git a/test/test_source/subdir/test_folding_subroutine.f90 b/test/test_source/subdir/test_folding_subroutine.f90 new file mode 100644 index 00000000..e2d07372 --- /dev/null +++ b/test/test_source/subdir/test_folding_subroutine.f90 @@ -0,0 +1,19 @@ +!test folding of subroutine arguments and of subroutine body +subroutine too_many_args(one, two, & + three, & + four, & + five, & + six ) + +!test multiline folding +integer, intent(in) :: one, two,& + three, & + four, & + five +integer, intent(out) :: six + +!test_multiline_folding +six = five + one + four - two + & +2*three + +end subroutine too_many_args