diff --git a/CHANGELOG.md b/CHANGELOG.md index a041c633e..9544953fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Vim and Neovim syntax highlighting support (eng/recordflux/RecordFlux#1749) + ### Changed - Improve generation of predicate for single-field messages (eng/recordflux/RecordFlux#1761) diff --git a/doc/user_guide/90-rflx-install--help.txt b/doc/user_guide/90-rflx-install--help.txt index a1fdaae93..4ea57e1f4 100644 --- a/doc/user_guide/90-rflx-install--help.txt +++ b/doc/user_guide/90-rflx-install--help.txt @@ -1,8 +1,8 @@ usage: rflx install [-h] [--gnat-studio-dir GNAT_STUDIO_DIR] - {gnatstudio,vscode} + {gnatstudio,vscode,nvim,vim} positional arguments: - {gnatstudio,vscode} + {gnatstudio,vscode,nvim,vim} options: -h, --help show this help message and exit diff --git a/pyproject.toml.in b/pyproject.toml.in index 2b5c77194..d25b6d94e 100644 --- a/pyproject.toml.in +++ b/pyproject.toml.in @@ -34,6 +34,8 @@ packages = [ { include = "rflx" }, ] include = [ + { path = "Cargo.lock", format = "sdist" }, + { path = "Cargo.toml", format = "sdist" }, { path = "generated/adasat/*.gpr", format = "sdist" }, { path = "generated/adasat/src/*.ad?", format = "sdist" }, { path = "generated/gnatcoll-bindings/gmp/*.ad?", format = "sdist" }, @@ -46,14 +48,13 @@ include = [ { path = "generated/langkit/langkit/support/*.gpr", format = "sdist" }, { path = "generated/librflxlang.gpr", format = "sdist" }, { path = "generated/src/*", format = "sdist" }, - { path = "Cargo.lock", format = "sdist" }, - { path = "Cargo.toml", format = "sdist" }, { path = "librapidflux/Cargo.toml", format = "sdist" }, { path = "librapidflux/src/*.rs", format = "sdist" }, { path = "librapidflux/src/diagnostics/*.rs", format = "sdist" }, { path = "rapidflux/Cargo.toml", format = "sdist" }, { path = "rapidflux/src/*.rs", format = "sdist" }, { path = "rapidflux/src/diagnostics/*.rs", format = "sdist" }, + { path = "rflx/ide/vim/recordflux.vim" }, { path = "rflx/ide/vscode/recordflux.vsix" }, { path = "rflx/lang/__init__.py" }, { path = "rflx/lang/py.typed" }, diff --git a/rflx/cli.py b/rflx/cli.py index 2e4e8c298..cbb0586ba 100644 --- a/rflx/cli.py +++ b/rflx/cli.py @@ -41,6 +41,8 @@ class IDE(Enum): GNATSTUDIO = "gnatstudio" VSCODE = "vscode" + NVIM = "nvim" + VIM = "vim" def __str__(self) -> str: return self.value @@ -627,6 +629,34 @@ def validate(args: argparse.Namespace) -> None: raise fatal_error from e +def install_vim_ftdetect(config_dir: Path, editor: str) -> None: + ftdetect_dir = config_dir / "ftdetect" + ftdetect_file = ftdetect_dir / "recordflux.vim" + ftdetect_dir.mkdir(parents=True, exist_ok=True) + ftdetect_file.write_text("autocmd BufRead,BufNewFile *.rflx set filetype=recordflux\n") + + logging.info('Installed {editor} ftdetect file in "{path}"', editor=editor, path=ftdetect_file) + + +def install_syntax_file(config_dir: Path, editor: str) -> None: + syntax_dir = config_dir / "syntax" + syntax_dir.mkdir(parents=True, exist_ok=True) + with importlib_resources.as_file(vim_syntax_file()) as syntax_file: + file_path = syntax_dir / syntax_file.name + + shutil.copy(syntax_file, file_path) + + logging.info('Installed {editor} syntax file in "{path}"', editor=editor, path=file_path) + + +def install_vim_files(config_dir: Path, editor: str) -> None: + try: + install_vim_ftdetect(config_dir, editor) + install_syntax_file(config_dir, editor) + except OSError as e: + fail(f'failed to install {editor} files: "{e}"') + + def install(args: argparse.Namespace) -> None: if args.ide is IDE.GNATSTUDIO: # TODO(eng/recordflux/RecordFlux#1359): Replace importlib_resources by importlib.resources @@ -644,6 +674,39 @@ def install(args: argparse.Namespace) -> None: except (FileNotFoundError, subprocess.CalledProcessError) as e: fail(f"installation of VS Code extension failed: {e}") + elif args.ide is IDE.NVIM: + home_dir = os.environ.get("HOME") + xdg_config_home = os.environ.get("XDG_CONFIG_HOME") + if home_dir is None and xdg_config_home is None: + RecordFluxError( + [ + ErrorEntry("could not find config directory", Severity.ERROR), + ErrorEntry( + "make sure $HOME or $XDG_CONFIG_HOME variable is set", + Severity.HELP, + ), + ], + ).propagate() + + if xdg_config_home is not None: + config_path = Path(xdg_config_home) / "nvim" + else: + assert home_dir is not None + config_path = Path(home_dir) / ".config" / "nvim" + + install_vim_files(config_path, editor="nvim") + + elif args.ide is IDE.VIM: + home_dir = os.environ.get("HOME") + if home_dir is None: + RecordFluxError( + [ + ErrorEntry("could not locate home directory", Severity.ERROR), + ErrorEntry("make sure $HOME variable is set", Severity.HELP), + ], + ).propagate() + assert home_dir is not None + install_vim_files(Path(home_dir) / ".vim", editor="vim") else: assert_never(args.ide) # pragma: no cover @@ -673,5 +736,11 @@ def vscode_extension() -> Traversable: return path +def vim_syntax_file() -> Traversable: + path = importlib_resources.files("rflx") / "ide" / "vim" / "recordflux.vim" + assert isinstance(path, Traversable) + return path + + def cache(no_caching: bool, no_verification: bool) -> Cache: return NeverVerify() if no_verification else (AlwaysVerify() if no_caching else Cache()) diff --git a/rflx/ide/vim/recordflux.vim b/rflx/ide/vim/recordflux.vim new file mode 100644 index 000000000..7794dc0e4 --- /dev/null +++ b/rflx/ide/vim/recordflux.vim @@ -0,0 +1,133 @@ +" Keywords +syntax keyword rflxKeyword with +syntax keyword rflxKeyword package +syntax keyword rflxKeyword is +syntax keyword rflxKeyword type +syntax keyword rflxKeyword range +syntax keyword rflxKeyword all +syntax keyword rflxKeyword some +syntax keyword rflxKeyword Head +syntax match rflxKeyword "'" +syntax keyword rflxKeyword null +syntax keyword rflxKeyword message +syntax keyword rflxKeyword case +syntax keyword rflxKeyword when +syntax keyword rflxKeyword First +syntax keyword rflxKeyword Size +syntax keyword rflxKeyword Last +syntax match rflxKeyword "[.]\\{2,\\}" +syntax match rflxKeyword "Always_Valid" +syntax keyword rflxKeyword end +syntax keyword rflxKeyword Checksum +syntax match rflxKeyword "Byte_Order" +syntax match rflxKeyword "High_Order_First" +syntax match rflxKeyword "Low_Order_First" +syntax keyword rflxKeyword use +syntax keyword rflxKeyword new +syntax keyword rflxKeyword sequence +syntax keyword rflxKeyword of +syntax keyword rflxKeyword generic +syntax keyword rflxKeyword function +syntax keyword rflxKeyword return +syntax keyword rflxKeyword Channel +syntax keyword rflxKeyword Readable +syntax keyword rflxKeyword Writable +syntax keyword rflxKeyword session +syntax keyword rflxKeyword renames +syntax keyword rflxKeyword begin +syntax keyword rflxKeyword state +syntax keyword rflxKeyword Desc +syntax keyword rflxKeyword Append +syntax keyword rflxKeyword Extend +syntax keyword rflxKeyword Reset +syntax keyword rflxKeyword Read +syntax keyword rflxKeyword Write +syntax keyword rflxKeyword transition +syntax keyword rflxKeyword goto +syntax keyword rflxKeyword exception +hi link rflxKeyword Keyword + +" Conditional keywords +syntax keyword rflxConditional if +syntax keyword rflxConditional then +hi link rflxConditional Conditional + +" Punctuation (neovim specific). See `:h treesitter-highlight` +if has('nvim-0.9') + syntax match rflxPunctuation ";" + syntax match rflxPunctuation "(" + syntax match rflxPunctuation ")" + syntax match rflxPunctuation "\[" + syntax match rflxPunctuation "]" + syntax match rflxPunctuation "," + syntax match rflxPunctuation "[.]" + syntax match rflxPunctuation ":" + hi link rflxPunctuation @punctuation +endif + +" Operators +syntax match rflxOperator "=>" +syntax match rflxOperator ":=" +syntax match rflxOperator "+" +syntax match rflxOperator "-" +syntax match rflxOperator "[*]" +syntax match rflxOperator "[.]\{2,}" +syntax match rflxOperator "/" +syntax keyword rflxOperator mod +syntax match rflxOperator ":\{2,}" +syntax keyword rflxOperator and +syntax keyword rflxOperator or +syntax keyword rflxOperator not +syntax keyword rflxOperator Valid +syntax match rflxOperator "Has_Data" +syntax keyword rflxOperator Present +syntax match rflxOperator "=" +syntax match rflxOperator "/=" +syntax match rflxOperator "<=" +syntax match rflxOperator "<" +syntax match rflxOperator ">=" +syntax match rflxOperator ">" +syntax keyword rflxOperator for +syntax keyword rflxOperator in +syntax match rflxOperator "\"" +syntax match rflxOperator "not in" +syntax match rflxOperator "|" +syntax match rflxOperator "[*]\{2,}" +syntax match rflxOperator "Valid_Checksum" +hi link rflxOperator Operator + +" Boolean constants +syntax keyword rflxBoolean True +syntax keyword rflxBoolean False +hi link rflxBoolean Boolean + +" Builtin types +syntax keyword rflxType Opaque +syntax keyword rflxType Boolean +hi link rflxType Type + +" Match number literal such as `1` or in an explicit base `#16#FF#` +syntax match rflxNumber "\<\d[0-9_]*\(\.\d[0-9_]*\)\=\([Ee][+-]\=\d[0-9_]*\)\=\>" +syntax match rflxNumber + \ "\<\d\d\=#\x[0-9A-Fa-f_]*\(\.\x[0-9A-Fa-f_]*\)\=#\([Ee][+-]\=\d[0-9_]*\)\=" +hi link rflxNumber Number + +" TODO, FIXME, NOTE and XXX +syntax keyword rflxTodo contained TODO FIXME XXX NOTE +hi link rflxTodo Todo + +" Comments +syntax region rflxComment + \ oneline + \ contains=rflxTodo + \ start="--" + \ end="$" +hi link rflxComment Comment + +" String literals (e.g. "some text") +syntax region rflxString + \ oneline + \ contains=rflxTodo + \ start="\"" + \ end="\"" +hi link rflxString String diff --git a/tests/ide/vim_syntax_test.py b/tests/ide/vim_syntax_test.py new file mode 100644 index 000000000..5ae5d778c --- /dev/null +++ b/tests/ide/vim_syntax_test.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +SYNTAX_FILE = Path("rflx/ide/vim/recordflux.vim") + + +@pytest.mark.parametrize( + "args", + [ + (["vim", "--not-a-term"]), + (["nvim", "--headless"]), + ], +) +def test_vim_syntax_source(args: list[str]) -> None: + ret = subprocess.run( + [*args, "-c", f":source {SYNTAX_FILE}", "-c", ":quit"], + check=False, + stderr=subprocess.PIPE, + ) + assert ret.stderr.decode() == "" diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 9ece5d256..565e2723f 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import os import re import subprocess import sys @@ -10,6 +11,7 @@ from pathlib import Path from typing import ClassVar, NoReturn +import importlib_resources import pytest import rflx.specification @@ -746,6 +748,130 @@ def run_mock(cmd: object, check: object) -> subprocess.CompletedProcess[object]: ) +def test_install_nvim_xdg_location(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + config_path = tmp_path / "nvim" + config_path.mkdir(parents=True, exist_ok=True) + script_path = tmp_path / "recordflux.vim" + script_path.write_text("nvim syntax file content") + expected_script_location = config_path / "syntax" / "recordflux.vim" + expected_ftdetect_location = config_path / "ftdetect" / "recordflux.vim" + + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setattr(cli, "vim_syntax_file", lambda **_: script_path) + + assert cli.main(["rflx", "install", "nvim"]) == 0 + assert expected_script_location.read_text() == "nvim syntax file content" + assert ( + expected_ftdetect_location.read_text() + == "autocmd BufRead,BufNewFile *.rflx set filetype=recordflux\n" + ) + + +def test_install_nvim_home_location(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + config_path = tmp_path / ".config" / "nvim" + config_path.mkdir(parents=True, exist_ok=True) + script_path = tmp_path / "recordflux.vim" + script_path.write_text("nvim syntax file content") + expected_script_location = config_path / "syntax" / "recordflux.vim" + expected_ftdetect_location = config_path / "ftdetect" / "recordflux.vim" + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(cli, "vim_syntax_file", lambda **_: script_path) + + assert cli.main(["rflx", "install", "nvim"]) == 0 + assert expected_script_location.read_text() == "nvim syntax file content" + assert ( + expected_ftdetect_location.read_text() + == "autocmd BufRead,BufNewFile *.rflx set filetype=recordflux\n" + ) + + +def test_install_nvim_no_env_variable( + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + for variable in ("HOME", "XDG_CONFIG_HOME"): + if os.environ.get(variable) is not None: + monkeypatch.delenv(variable) + + assert cli.main(["rflx", "install", "nvim"]) == 1 + assert_stderr_regex( + r"^error: could not find config directory\n" + r"help: make sure \$HOME or \$XDG_CONFIG_HOME variable is set\n$", + capfd, + ) + + +def test_install_vim( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capfd: pytest.CaptureFixture[str], +) -> None: + script_path = tmp_path / "recordflux.vim" + script_path.write_text("vim syntax file content") + config_dir = tmp_path / ".vim" + expected_syntax_location = config_dir / "syntax" / "recordflux.vim" + expected_ftdetect_location = config_dir / "ftdetect" / "recordflux.vim" + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(cli, "vim_syntax_file", lambda *_: script_path) + + assert cli.main(["rflx", "install", "vim"]) == 0 + assert expected_syntax_location.read_text() == "vim syntax file content" + assert ( + expected_ftdetect_location.read_text() + == "autocmd BufRead,BufNewFile *.rflx set filetype=recordflux\n" + ) + assert_stderr_regex( + rf'^info: Installed vim ftdetect file in "{expected_ftdetect_location}"\n' + rf'info: Installed vim syntax file in "{expected_syntax_location}"\n$', + capfd, + ) + + +def test_install_vim_no_home( + monkeypatch: pytest.MonkeyPatch, + capfd: pytest.CaptureFixture[str], +) -> None: + monkeypatch.delenv("HOME") + assert cli.main(["rflx", "install", "vim"]) == 1 + assert_stderr_regex( + r"^error: could not locate home directory\nhelp: make sure \$HOME variable is set\n$", + capfd, + ) + + +def test_vim_syntax_file_ressource(monkeypatch: pytest.MonkeyPatch) -> None: + expected_location = Path("rflx") / "ide" / "vim" / "recordflux.vim" + monkeypatch.setattr(importlib_resources, "files", lambda _: Path("rflx")) + assert cli.vim_syntax_file() == expected_location + + +@pytest.mark.parametrize( + "editor", + ["vim", "nvim"], +) +def test_install_permission_denied( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capfd: pytest.CaptureFixture[str], + editor: str, +) -> None: + tmp_dir = tmp_path / "vim" + tmp_dir.mkdir() + perm_denied = tmp_dir / "recordflux.vim" + perm_denied.touch() + tmp_dir.chmod(0o444) + monkeypatch.setenv("HOME", str(tmp_dir)) + + assert cli.main(["rflx", "install", editor]) == 1 + assert_stderr_regex( + rf"^error: failed to install {editor} files: " + rf'"\[Errno 13\] Permission denied: \'[^\']+\'"\n$', + capfd, + ) + + def test_install_invalid() -> None: args = ["rflx", "install", "invalid"]