Skip to content

Feat/folding ranges #487

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
241 changes: 241 additions & 0 deletions fortls/folding_ranges.py
Original file line number Diff line number Diff line change
@@ -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 the given 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 the given 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 the given 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]
]
16 changes: 16 additions & 0 deletions fortls/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions fortls/langserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"] = {
Expand Down Expand Up @@ -1540,6 +1547,27 @@ 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 = []

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)
elif self.folding_range_mode == "syntax":
result += get_folding_ranges_by_syntax(file_obj)

return result

def _load_config_file(self) -> None:
"""Loads the configuration file for the Language Server"""

Expand Down Expand Up @@ -1645,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)
Expand Down
14 changes: 14 additions & 0 deletions test/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions test/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading
Loading