diff --git a/.gitignore b/.gitignore index b4ef0c0a..08cc07b5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .vscode *.egg-info venv/ +.venv/ dist/ build/ docs/_build/ diff --git a/fortls/debug.py b/fortls/debug.py index 4a3c8c97..242585be 100644 --- a/fortls/debug.py +++ b/fortls/debug.py @@ -7,20 +7,13 @@ import json5 +from .exceptions import DebugError, ParameterError, ParserError from .helper_functions import only_dirs, resolve_globs from .jsonrpc import JSONRPC2Connection, ReadWriter, path_from_uri from .langserver import LangServer from .parsers.internal.parser import FortranFile, preprocess_file -class DebugError(Exception): - """Base class for debug CLI.""" - - -class ParameterError(DebugError): - """Exception raised for errors in the parameters.""" - - def is_debug_mode(args): debug_flags = [ "debug_diagnostics", @@ -425,9 +418,12 @@ def debug_parser(args): print(f' File = "{args.debug_filepath}"') file_obj = FortranFile(args.debug_filepath, pp_suffixes) - err_str, _ = file_obj.load_from_disk() - if err_str: - raise DebugError(f"Reading file failed: {err_str}") + try: + file_obj.load_from_disk() + except ParserError as exc: + msg = f"Reading file {args.debug_filepath} failed: {str(exc)}" + raise DebugError(msg) from exc + print(f' File = "{args.debug_filepath}"') print(f" Detected format: {'fixed' if file_obj.fixed else 'free'}") print("\n" + "=" * 80 + "\nParser Output\n" + "=" * 80 + "\n") file_ast = file_obj.parse(debug=True, pp_defs=pp_defs, include_dirs=include_dirs) diff --git a/fortls/exceptions.py b/fortls/exceptions.py new file mode 100644 index 00000000..654788b2 --- /dev/null +++ b/fortls/exceptions.py @@ -0,0 +1,17 @@ +from __future__ import annotations + + +class DebugError(Exception): + """Base class for debug CLI.""" + + +class ParameterError(DebugError): + """Exception raised for errors in the parameters.""" + + +class ParserError(Exception): + """Parser base class exception""" + + +class FortranFileNotFoundError(ParserError, FileNotFoundError): + """File not found""" diff --git a/fortls/langserver.py b/fortls/langserver.py index 8ecbce1c..d388ce3e 100644 --- a/fortls/langserver.py +++ b/fortls/langserver.py @@ -51,7 +51,11 @@ load_intrinsics, set_lowercase_intrinsics, ) -from fortls.parsers.internal.parser import FortranFile, get_line_context +from fortls.parsers.internal.parser import ( + FortranFile, + FortranFileNotFoundError, + get_line_context, +) from fortls.parsers.internal.scope import Scope from fortls.parsers.internal.use import Use from fortls.parsers.internal.utilities import ( @@ -1313,9 +1317,10 @@ def serve_onChange(self, request: dict): return # Parse newly updated file if reparse_req: - _, err_str = self.update_workspace_file(path, update_links=True) - if err_str is not None: - self.post_message(f"Change request failed for file '{path}': {err_str}") + try: + self.update_workspace_file(path, update_links=True) + except LSPError as e: + self.post_message(f"Change request failed for file '{path}': {str(e)}") return # Update include statements linking to this file for _, tmp_file in self.workspace.items(): @@ -1350,11 +1355,12 @@ def serve_onSave( for key in ast_old.global_dict: self.obj_tree.pop(key, None) return - did_change, err_str = self.update_workspace_file( - filepath, read_file=True, allow_empty=did_open - ) - if err_str is not None: - self.post_message(f"Save request failed for file '{filepath}': {err_str}") + try: + did_change = self.update_workspace_file( + filepath, read_file=True, allow_empty=did_open + ) + except LSPError as e: + self.post_message(f"Save request failed for file '{filepath}': {str(e)}") return if did_change: # Update include statements linking to this file @@ -1390,12 +1396,14 @@ def update_workspace_file( return False, None else: return False, "File does not exist" # Error during load - err_string, file_changed = file_obj.load_from_disk() - if err_string: - log.error("%s : %s", err_string, filepath) - return False, err_string # Error during file read - if not file_changed: - return False, None + try: + file_changed = file_obj.load_from_disk() + if not file_changed: + return False, None + except FortranFileNotFoundError as exc: + log.error("%s : %s", str(exc), filepath) + raise LSPError from exc + ast_new = file_obj.parse( pp_defs=self.pp_defs, include_dirs=self.include_dirs ) @@ -1452,9 +1460,11 @@ def file_init( A Fortran file object or a string containing the error message """ file_obj = FortranFile(filepath, pp_suffixes) - err_str, _ = file_obj.load_from_disk() - if err_str: - return err_str + # TODO: allow to bubble up the error message + try: + file_obj.load_from_disk() + except FortranFileNotFoundError as e: + return str(e) try: # On Windows multiprocess does not propagate global variables in a shell. # Windows uses 'spawn' while Unix uses 'fork' which propagates globals. @@ -1844,6 +1854,10 @@ def update_recursion_limit(limit: int) -> None: sys.setrecursionlimit(limit) +class LSPError(Exception): + """Base class for Language Server Protocol errors""" + + class JSONRPC2Error(Exception): def __init__(self, code, message, data=None): self.code = code diff --git a/fortls/parsers/internal/parser.py b/fortls/parsers/internal/parser.py index 1cab8d11..56c54146 100644 --- a/fortls/parsers/internal/parser.py +++ b/fortls/parsers/internal/parser.py @@ -9,9 +9,9 @@ # Python < 3.8 does not have typing.Literals try: - from typing import Literal + from typing import Iterable, Literal except ImportError: - from typing_extensions import Literal + from typing_extensions import Iterable, Literal from re import Match, Pattern @@ -24,6 +24,7 @@ Severity, log, ) +from fortls.exceptions import FortranFileNotFoundError from fortls.ftypes import ( ClassInfo, FunSig, @@ -870,41 +871,45 @@ def copy(self) -> FortranFile: copy_obj.set_contents(self.contents_split) return copy_obj - def load_from_disk(self) -> tuple[str | None, bool | None]: + def load_from_disk(self) -> bool: """Read file from disk or update file contents only if they have changed A MD5 hash is used to determine that Returns ------- - tuple[str|None, bool|None] - ``str`` : string containing IO error message else None - ``bool``: boolean indicating if the file has changed + bool + boolean indicating if the file has changed + + Raises + ------ + FortranFileNotFoundError + If the file could not be found """ contents: str try: + # errors="replace" prevents UnicodeDecodeError being raised with open(self.path, encoding="utf-8", errors="replace") as f: contents = re.sub(r"\t", r" ", f.read()) - except OSError: - return "Could not read/decode file", None - else: - # Check if files are the same - try: - hash = hashlib.md5( - contents.encode("utf-8"), usedforsecurity=False - ).hexdigest() - # Python <=3.8 does not have the `usedforsecurity` option - except TypeError: - hash = hashlib.md5(contents.encode("utf-8")).hexdigest() - - if hash == self.hash: - return None, False - - self.hash = hash - self.contents_split = contents.splitlines() - self.fixed = detect_fixed_format(self.contents_split) - self.contents_pp = self.contents_split - self.nLines = len(self.contents_split) - return None, True + except FileNotFoundError as exc: + raise FortranFileNotFoundError(exc) from exc + # Check if files are the same + try: + hash = hashlib.md5( + contents.encode("utf-8"), usedforsecurity=False + ).hexdigest() + # Python <=3.8 does not have the `usedforsecurity` option + except TypeError: + hash = hashlib.md5(contents.encode("utf-8")).hexdigest() + + if hash == self.hash: + return False + + self.hash = hash + self.contents_split = contents.splitlines() + self.fixed = detect_fixed_format(self.contents_split) + self.contents_pp = self.contents_split + self.nLines = len(self.contents_split) + return True def apply_change(self, change: dict) -> bool: """Apply a change to the file.""" @@ -2070,14 +2075,11 @@ def replace_vars(line: str): if defs is None: defs = {} - out_line = replace_defined(text) - out_line = replace_vars(out_line) try: - line_res = eval(replace_ops(out_line)) - except: + return eval(replace_ops(replace_vars(replace_defined(text)))) + # This needs to catch all possible exceptions thrown by eval() + except Exception: return False - else: - return line_res def expand_func_macro(def_name: str, def_value: tuple[str, str]): def_args, sub = def_value @@ -2096,6 +2098,14 @@ def append_multiline_macro(def_value: str | tuple, line: str): return (def_args, def_value) return def_value + line + def find_file_in_directories(directories: Iterable[str], filename: str) -> str: + for include_dir in directories: + file = os.path.join(include_dir, filename) + if os.path.isfile(file): + return file + msg = f"Could not locate include file: {filename} in {directories}" + raise FortranFileNotFoundError(msg) + if pp_defs is None: pp_defs = {} if include_dirs is None: @@ -2249,40 +2259,21 @@ def append_multiline_macro(def_value: str | tuple, line: str): if (match is not None) and ((len(pp_stack) == 0) or (pp_stack[-1][0] < 0)): log.debug("%s !!! Include statement(%d)", line.strip(), i + 1) include_filename = match.group(1).replace('"', "") - include_path = None - # Intentionally keep this as a list and not a set. There are cases - # where projects play tricks with the include order of their headers - # to get their codes to compile. Using a set would not permit that. - for include_dir in include_dirs: - include_path_tmp = os.path.join(include_dir, include_filename) - if os.path.isfile(include_path_tmp): - include_path = os.path.abspath(include_path_tmp) - break - if include_path is not None: - try: - include_file = FortranFile(include_path) - err_string, _ = include_file.load_from_disk() - if err_string is None: - log.debug("\n!!! Parsing include file '%s'", include_path) - _, _, _, defs_tmp = preprocess_file( - include_file.contents_split, - file_path=include_path, - pp_defs=defs_tmp, - include_dirs=include_dirs, - debug=debug, - ) - log.debug("!!! Completed parsing include file\n") - - else: - log.debug("!!! Failed to parse include file: %s", err_string) - - except: - log.debug("!!! Failed to parse include file: exception") - - else: - log.debug( - "%s !!! Could not locate include file (%d)", line.strip(), i + 1 + try: + include_path = find_file_in_directories(include_dirs, include_filename) + include_file = FortranFile(include_path) + include_file.load_from_disk() + log.debug("\n!!! Parsing include file '%s'", include_path) + _, _, _, defs_tmp = preprocess_file( + include_file.contents_split, + file_path=include_path, + pp_defs=defs_tmp, + include_dirs=include_dirs, + debug=debug, ) + log.debug("!!! Completed parsing include file") + except FortranFileNotFoundError as e: + log.debug("%s !!! %s - Ln:%d", line.strip(), str(e), i + 1) # Substitute (if any) read in preprocessor macros for def_tmp, value in defs_tmp.items(): diff --git a/test/test_parser.py b/test/test_parser.py index 2478638b..ad49867c 100644 --- a/test/test_parser.py +++ b/test/test_parser.py @@ -1,50 +1,55 @@ +import pytest from setup_tests import test_dir -from fortls.parsers.internal.parser import FortranFile +from fortls.parsers.internal.parser import ( + FortranFile, + FortranFileNotFoundError, + preprocess_file, +) def test_line_continuations(): file_path = test_dir / "parse" / "line_continuations.f90" file = FortranFile(str(file_path)) - err_str, _ = file.load_from_disk() - assert err_str is None - try: - file.parse() - assert True - except Exception as e: - print(e) - assert False + file.load_from_disk() + file.parse() def test_submodule(): file_path = test_dir / "parse" / "submodule.f90" file = FortranFile(str(file_path)) - err_str, _ = file.load_from_disk() - assert err_str is None - try: - ast = file.parse() - assert True - assert ast.scope_list[0].name == "val" - assert ast.scope_list[0].ancestor_name == "p1" - assert ast.scope_list[1].name == "" - assert ast.scope_list[1].ancestor_name == "p2" - except Exception as e: - print(e) - assert False + file.load_from_disk() + ast = file.parse() + assert ast.scope_list[0].name == "val" + assert ast.scope_list[0].ancestor_name == "p1" + assert ast.scope_list[1].name == "" + assert ast.scope_list[1].ancestor_name == "p2" def test_private_visibility_interfaces(): file_path = test_dir / "vis" / "private.f90" file = FortranFile(str(file_path)) - err_str, _ = file.load_from_disk() + file.load_from_disk() file.parse() - assert err_str is None def test_end_scopes_semicolon(): file_path = test_dir / "parse" / "trailing_semicolon.f90" file = FortranFile(str(file_path)) - err_str, _ = file.load_from_disk() + file.load_from_disk() ast = file.parse() - assert err_str is None assert not ast.end_errors + + +def test_load_from_disk_exception(): + file = FortranFile("/path/to/nonexistent/file.f90") + with pytest.raises(FortranFileNotFoundError): + file.load_from_disk() + + +def test_preprocess_missing_includes_exception(): + preprocess_file(["#include 'nonexistent_file.f90'"]) + + +def test_preprocess_eval_if_exception(): + preprocess_file(["#if (1=and=1)", 'print*, "1==1"', "#endif"])