neml2-langserv 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neml2_langserv-0.1.1/PKG-INFO +63 -0
- neml2_langserv-0.1.1/README.md +48 -0
- neml2_langserv-0.1.1/neml2_langserv/__init__.py +0 -0
- neml2_langserv-0.1.1/neml2_langserv/__main__.py +19 -0
- neml2_langserv-0.1.1/neml2_langserv/hit_parser.py +129 -0
- neml2_langserv-0.1.1/neml2_langserv/server.py +240 -0
- neml2_langserv-0.1.1/neml2_langserv/syntax_client.py +93 -0
- neml2_langserv-0.1.1/neml2_langserv.egg-info/PKG-INFO +63 -0
- neml2_langserv-0.1.1/neml2_langserv.egg-info/SOURCES.txt +12 -0
- neml2_langserv-0.1.1/neml2_langserv.egg-info/dependency_links.txt +1 -0
- neml2_langserv-0.1.1/neml2_langserv.egg-info/requires.txt +7 -0
- neml2_langserv-0.1.1/neml2_langserv.egg-info/top_level.txt +1 -0
- neml2_langserv-0.1.1/pyproject.toml +26 -0
- neml2_langserv-0.1.1/setup.cfg +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: neml2-langserv
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Language server backing the NEML2 VS Code extension
|
|
5
|
+
Project-URL: Homepage, https://github.com/applied-material-modeling/neml2-langserv
|
|
6
|
+
Project-URL: Issues, https://github.com/applied-material-modeling/neml2-langserv/issues
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: pygls>=1.3
|
|
10
|
+
Requires-Dist: neml2>=2.1.4
|
|
11
|
+
Requires-Dist: nmhit>=0.1.2
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: build; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# NEML2 Language Support
|
|
17
|
+
|
|
18
|
+
VS Code extension providing language support for [NEML2](https://github.com/applied-material-modeling/neml2) input files.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Completion** — type names for `type = ` assignments, and option names with inline type hints inside typed blocks
|
|
23
|
+
- **Hover documentation** — docstrings for types and options shown on hover
|
|
24
|
+
- **Format on save** — re-indents the document consistently via the `nmhit` formatter
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
A Python environment with the [`neml2-langserv`](https://pypi.org/project/neml2-langserv/) package installed:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install neml2-langserv
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That single install pulls in everything the language server needs — [`neml2`](https://pypi.org/project/neml2/) (≥ 2.1.4) for the type/option metadata, [`nmhit`](https://pypi.org/project/nmhit/) (≥ 0.1.2) for the formatter, and `pygls` for the LSP transport.
|
|
35
|
+
|
|
36
|
+
The extension runs the server with whichever Python interpreter is selected in the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python). If `neml2-langserv` is not installed in that interpreter on first activation, the extension prompts you to install it.
|
|
37
|
+
|
|
38
|
+
## Setup
|
|
39
|
+
|
|
40
|
+
NEML2 input files share the `.i` extension with MOOSE input files. To tell the extension that a file is a NEML2 input, add the following as the **first line**:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
# neml2
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Files without this marker are treated as MOOSE input (or plain text) and the NEML2 language server will not activate for them.
|
|
47
|
+
|
|
48
|
+
## Format on save
|
|
49
|
+
|
|
50
|
+
Enable the built-in VS Code setting to auto-format on save:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
// .vscode/settings.json
|
|
54
|
+
{
|
|
55
|
+
"[neml2]": {
|
|
56
|
+
"editor.formatOnSave": true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## MOOSE compatibility
|
|
62
|
+
|
|
63
|
+
This extension does **not** claim the `.i` extension globally, so existing MOOSE workflows are unaffected. Only files whose first line matches `# neml2` (optionally followed by other text) are switched to the `neml2` language mode.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# NEML2 Language Support
|
|
2
|
+
|
|
3
|
+
VS Code extension providing language support for [NEML2](https://github.com/applied-material-modeling/neml2) input files.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Completion** — type names for `type = ` assignments, and option names with inline type hints inside typed blocks
|
|
8
|
+
- **Hover documentation** — docstrings for types and options shown on hover
|
|
9
|
+
- **Format on save** — re-indents the document consistently via the `nmhit` formatter
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
A Python environment with the [`neml2-langserv`](https://pypi.org/project/neml2-langserv/) package installed:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pip install neml2-langserv
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That single install pulls in everything the language server needs — [`neml2`](https://pypi.org/project/neml2/) (≥ 2.1.4) for the type/option metadata, [`nmhit`](https://pypi.org/project/nmhit/) (≥ 0.1.2) for the formatter, and `pygls` for the LSP transport.
|
|
20
|
+
|
|
21
|
+
The extension runs the server with whichever Python interpreter is selected in the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python). If `neml2-langserv` is not installed in that interpreter on first activation, the extension prompts you to install it.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
NEML2 input files share the `.i` extension with MOOSE input files. To tell the extension that a file is a NEML2 input, add the following as the **first line**:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
# neml2
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Files without this marker are treated as MOOSE input (or plain text) and the NEML2 language server will not activate for them.
|
|
32
|
+
|
|
33
|
+
## Format on save
|
|
34
|
+
|
|
35
|
+
Enable the built-in VS Code setting to auto-format on save:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
// .vscode/settings.json
|
|
39
|
+
{
|
|
40
|
+
"[neml2]": {
|
|
41
|
+
"editor.formatOnSave": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## MOOSE compatibility
|
|
47
|
+
|
|
48
|
+
This extension does **not** claim the `.i` extension globally, so existing MOOSE workflows are unaffected. Only files whose first line matches `# neml2` (optionally followed by other text) are switched to the `neml2` language mode.
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from .server import server
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
parser = argparse.ArgumentParser(description="NEML2 Language Server")
|
|
8
|
+
parser.add_argument("--stdio", action="store_true", help="use stdio transport")
|
|
9
|
+
parser.add_argument("--tcp", type=int, metavar="PORT", help="use TCP transport on PORT")
|
|
10
|
+
args = parser.parse_args()
|
|
11
|
+
|
|
12
|
+
if args.tcp:
|
|
13
|
+
server.start_tcp("127.0.0.1", args.tcp)
|
|
14
|
+
else:
|
|
15
|
+
server.start_io()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Tolerant HIT document context parser.
|
|
2
|
+
|
|
3
|
+
Uses only bracket delimiters ([name] / []) — no indentation logic.
|
|
4
|
+
Bracket depth: 0 = file root, 1 = top-level section, 2 = sub-block.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
_OPEN = re.compile(r"^\s*\[([^\]/][^\]]*)\]\s*(?:#.*)?$")
|
|
11
|
+
_CLOSE = re.compile(r"^\s*\[\]\s*(?:#.*)?$")
|
|
12
|
+
_KV = re.compile(r"^\s*(\w[\w/]*)\s*[:=]=?\s*(.*?)\s*(?:#.*)?$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BlockContext:
|
|
17
|
+
section: str = ""
|
|
18
|
+
block_name: str = ""
|
|
19
|
+
block_type: str = ""
|
|
20
|
+
options_set: set[str] = field(default_factory=set)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_context(lines: list[str], cursor_line: int) -> BlockContext | None:
|
|
24
|
+
"""Return the HIT context at *cursor_line* (0-indexed)."""
|
|
25
|
+
depth = 0
|
|
26
|
+
ctx = BlockContext()
|
|
27
|
+
|
|
28
|
+
for i, line in enumerate(lines):
|
|
29
|
+
if i > cursor_line:
|
|
30
|
+
break
|
|
31
|
+
|
|
32
|
+
close = _CLOSE.match(line)
|
|
33
|
+
if close:
|
|
34
|
+
if depth == 2:
|
|
35
|
+
ctx.block_name = ""
|
|
36
|
+
ctx.block_type = ""
|
|
37
|
+
ctx.options_set = set()
|
|
38
|
+
if depth == 1:
|
|
39
|
+
ctx.section = ""
|
|
40
|
+
depth = max(0, depth - 1)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
open_m = _OPEN.match(line)
|
|
44
|
+
if open_m:
|
|
45
|
+
name = open_m.group(1).strip()
|
|
46
|
+
if depth == 0:
|
|
47
|
+
ctx.section = name
|
|
48
|
+
depth = 1
|
|
49
|
+
elif depth == 1:
|
|
50
|
+
ctx.block_name = name
|
|
51
|
+
ctx.block_type = ""
|
|
52
|
+
ctx.options_set = set()
|
|
53
|
+
depth = 2
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
if depth == 2:
|
|
57
|
+
kv = _KV.match(line)
|
|
58
|
+
if kv:
|
|
59
|
+
key, val = kv.group(1), kv.group(2).strip()
|
|
60
|
+
if key == "type":
|
|
61
|
+
ctx.block_type = val.strip("'\"")
|
|
62
|
+
else:
|
|
63
|
+
ctx.options_set.add(key)
|
|
64
|
+
|
|
65
|
+
if depth < 2:
|
|
66
|
+
return None
|
|
67
|
+
return ctx
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ParsedBlock:
|
|
72
|
+
context: BlockContext
|
|
73
|
+
start_line: int
|
|
74
|
+
end_line: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_all_blocks(lines: list[str]) -> list[ParsedBlock]:
|
|
78
|
+
"""Return every sub-block found in the document."""
|
|
79
|
+
blocks: list[ParsedBlock] = []
|
|
80
|
+
depth = 0
|
|
81
|
+
section = ""
|
|
82
|
+
block_name = ""
|
|
83
|
+
block_type = ""
|
|
84
|
+
options_set: set[str] = set()
|
|
85
|
+
block_start = 0
|
|
86
|
+
|
|
87
|
+
for i, line in enumerate(lines):
|
|
88
|
+
close = _CLOSE.match(line)
|
|
89
|
+
if close:
|
|
90
|
+
if depth == 2:
|
|
91
|
+
blocks.append(
|
|
92
|
+
ParsedBlock(
|
|
93
|
+
BlockContext(section, block_name, block_type, options_set),
|
|
94
|
+
block_start,
|
|
95
|
+
i,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
block_name = ""
|
|
99
|
+
block_type = ""
|
|
100
|
+
options_set = set()
|
|
101
|
+
elif depth == 1:
|
|
102
|
+
section = ""
|
|
103
|
+
depth = max(0, depth - 1)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
open_m = _OPEN.match(line)
|
|
107
|
+
if open_m:
|
|
108
|
+
name = open_m.group(1).strip()
|
|
109
|
+
if depth == 0:
|
|
110
|
+
section = name
|
|
111
|
+
depth = 1
|
|
112
|
+
elif depth == 1:
|
|
113
|
+
block_name = name
|
|
114
|
+
block_type = ""
|
|
115
|
+
options_set = set()
|
|
116
|
+
block_start = i
|
|
117
|
+
depth = 2
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if depth == 2:
|
|
121
|
+
kv = _KV.match(line)
|
|
122
|
+
if kv:
|
|
123
|
+
key, val = kv.group(1), kv.group(2).strip()
|
|
124
|
+
if key == "type":
|
|
125
|
+
block_type = val.strip("'\"")
|
|
126
|
+
else:
|
|
127
|
+
options_set.add(key)
|
|
128
|
+
|
|
129
|
+
return blocks
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from importlib.metadata import PackageNotFoundError
|
|
3
|
+
from importlib.metadata import version as _pkg_version
|
|
4
|
+
|
|
5
|
+
import nmhit
|
|
6
|
+
from lsprotocol import types as lsp
|
|
7
|
+
from pygls.lsp.server import LanguageServer
|
|
8
|
+
|
|
9
|
+
from .hit_parser import get_context, parse_all_blocks
|
|
10
|
+
from .syntax_client import NEML2_MIN_VERSION, NMHIT_MIN_VERSION, get_client
|
|
11
|
+
|
|
12
|
+
server = LanguageServer("neml2-ls", "v0.1")
|
|
13
|
+
|
|
14
|
+
def _version_tuple(v: str) -> tuple[int, ...]:
|
|
15
|
+
return tuple(int(x) for x in v.split(".")[:3])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_pkg(pkg: str, min_ver: str) -> str | None:
|
|
19
|
+
"""Return a warning string if pkg is missing or below min_ver, else None."""
|
|
20
|
+
try:
|
|
21
|
+
installed = _pkg_version(pkg)
|
|
22
|
+
if _version_tuple(installed) < _version_tuple(min_ver):
|
|
23
|
+
return (
|
|
24
|
+
f"'{pkg}' {installed} is installed but >={min_ver} is required. "
|
|
25
|
+
"Some features may not work correctly."
|
|
26
|
+
)
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
return (
|
|
29
|
+
f"'{pkg}' is not installed (>={min_ver} required). "
|
|
30
|
+
"Please install it in the active Python environment."
|
|
31
|
+
)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@server.feature(lsp.INITIALIZED)
|
|
36
|
+
def _on_initialized(ls: LanguageServer, params: lsp.InitializedParams) -> None:
|
|
37
|
+
for pkg, min_ver, severity in [
|
|
38
|
+
("neml2", NEML2_MIN_VERSION, lsp.MessageType.Error),
|
|
39
|
+
("nmhit", NMHIT_MIN_VERSION, lsp.MessageType.Warning),
|
|
40
|
+
]:
|
|
41
|
+
msg = _check_pkg(pkg, min_ver)
|
|
42
|
+
if msg:
|
|
43
|
+
ls.window_show_message(
|
|
44
|
+
lsp.ShowMessageParams(type=severity, message=f"NEML2: {msg}")
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# helpers
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
_FTYPE_KIND = {
|
|
52
|
+
"PARAMETER": lsp.CompletionItemKind.Variable,
|
|
53
|
+
"INPUT": lsp.CompletionItemKind.Field,
|
|
54
|
+
"OUTPUT": lsp.CompletionItemKind.Field,
|
|
55
|
+
"BUFFER": lsp.CompletionItemKind.Variable,
|
|
56
|
+
"NONE": lsp.CompletionItemKind.Property,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_TYPE_ASSIGN = re.compile(r"^\s*type\s*[:=]=?\s*\S*$")
|
|
60
|
+
_KEY_POS = re.compile(r"^\s*[\w/]*$")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _doc_lines(lines: list[str]) -> list[str]:
|
|
64
|
+
"""Return document lines (handles both list[str] and str)."""
|
|
65
|
+
return lines
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _word_at(line: str, character: int) -> str:
|
|
69
|
+
start = character
|
|
70
|
+
while start > 0 and (line[start - 1].isalnum() or line[start - 1] in "_:/"):
|
|
71
|
+
start -= 1
|
|
72
|
+
end = character
|
|
73
|
+
while end < len(line) and (line[end].isalnum() or line[end] in "_:/"):
|
|
74
|
+
end += 1
|
|
75
|
+
return line[start:end]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fmt_option(opt: dict) -> str:
|
|
79
|
+
ftype = opt.get("ftype", "")
|
|
80
|
+
header = f"**{opt['name']}**" + (f" `{ftype}`" if ftype and ftype != "NONE" else "")
|
|
81
|
+
lines = [header]
|
|
82
|
+
if opt.get("type"):
|
|
83
|
+
lines.append(f"*Type:* `{opt['type']}`")
|
|
84
|
+
if opt.get("required"):
|
|
85
|
+
lines.append("*Required*")
|
|
86
|
+
if opt.get("doc"):
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append(opt["doc"])
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_lines(ls: LanguageServer, uri: str) -> list[str]:
|
|
93
|
+
doc = ls.workspace.get_text_document(uri)
|
|
94
|
+
return doc.source.splitlines()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# completion
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@server.feature(
|
|
103
|
+
lsp.TEXT_DOCUMENT_COMPLETION,
|
|
104
|
+
lsp.CompletionOptions(trigger_characters=["=", " ", "\t"]),
|
|
105
|
+
)
|
|
106
|
+
def completions(
|
|
107
|
+
ls: LanguageServer, params: lsp.CompletionParams
|
|
108
|
+
) -> list[lsp.CompletionItem]:
|
|
109
|
+
lines = _get_lines(ls, params.text_document.uri)
|
|
110
|
+
line_idx = params.position.line
|
|
111
|
+
if line_idx >= len(lines):
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
current_line = lines[line_idx]
|
|
115
|
+
ctx = get_context(lines, line_idx)
|
|
116
|
+
if ctx is None:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
syntax = get_client()
|
|
120
|
+
if syntax is None:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
# After `type =` → offer all registered types for this section
|
|
124
|
+
if _TYPE_ASSIGN.match(current_line):
|
|
125
|
+
try:
|
|
126
|
+
types = syntax.list_types(ctx.section)
|
|
127
|
+
except Exception:
|
|
128
|
+
return []
|
|
129
|
+
return [
|
|
130
|
+
lsp.CompletionItem(
|
|
131
|
+
label=t["type"],
|
|
132
|
+
kind=lsp.CompletionItemKind.Class,
|
|
133
|
+
detail=t["section"],
|
|
134
|
+
documentation=lsp.MarkupContent(
|
|
135
|
+
kind=lsp.MarkupKind.PlainText, value=t.get("doc", "")
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
for t in types
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
# At an option key position → offer option names for the current type
|
|
142
|
+
if ctx.block_type and _KEY_POS.match(current_line):
|
|
143
|
+
try:
|
|
144
|
+
info = syntax.get_options(ctx.block_type)
|
|
145
|
+
except Exception:
|
|
146
|
+
return []
|
|
147
|
+
if not info:
|
|
148
|
+
return []
|
|
149
|
+
remaining = [o for o in info["options"] if o["name"] not in ctx.options_set]
|
|
150
|
+
return [
|
|
151
|
+
lsp.CompletionItem(
|
|
152
|
+
label=o["name"],
|
|
153
|
+
kind=_FTYPE_KIND.get(o["ftype"], lsp.CompletionItemKind.Property),
|
|
154
|
+
detail=o.get("type") or "",
|
|
155
|
+
documentation=lsp.MarkupContent(
|
|
156
|
+
kind=lsp.MarkupKind.Markdown, value=_fmt_option(o)
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
for o in remaining
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# hover
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@server.feature(lsp.TEXT_DOCUMENT_HOVER)
|
|
171
|
+
def hover(ls: LanguageServer, params: lsp.HoverParams) -> lsp.Hover | None:
|
|
172
|
+
lines = _get_lines(ls, params.text_document.uri)
|
|
173
|
+
line_idx = params.position.line
|
|
174
|
+
if line_idx >= len(lines):
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
word = _word_at(lines[line_idx], params.position.character)
|
|
178
|
+
if not word:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
ctx = get_context(lines, line_idx)
|
|
182
|
+
syntax = get_client()
|
|
183
|
+
if syntax is None:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
# Hover on a type name
|
|
187
|
+
try:
|
|
188
|
+
info = syntax.get_options(word)
|
|
189
|
+
if info:
|
|
190
|
+
md = f"**{word}** _{info['section']}_\n\n{info.get('doc', '')}"
|
|
191
|
+
return lsp.Hover(
|
|
192
|
+
contents=lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=md)
|
|
193
|
+
)
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# Hover on an option key within a typed block
|
|
198
|
+
if ctx and ctx.block_type:
|
|
199
|
+
try:
|
|
200
|
+
info = syntax.get_options(ctx.block_type)
|
|
201
|
+
if info:
|
|
202
|
+
by_name = {o["name"]: o for o in info["options"]}
|
|
203
|
+
if word in by_name:
|
|
204
|
+
return lsp.Hover(
|
|
205
|
+
contents=lsp.MarkupContent(
|
|
206
|
+
kind=lsp.MarkupKind.Markdown,
|
|
207
|
+
value=_fmt_option(by_name[word]),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# formatting
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@server.feature(lsp.TEXT_DOCUMENT_FORMATTING)
|
|
222
|
+
def formatting(
|
|
223
|
+
ls: LanguageServer, params: lsp.DocumentFormattingParams
|
|
224
|
+
) -> list[lsp.TextEdit] | None:
|
|
225
|
+
doc = ls.workspace.get_text_document(params.text_document.uri)
|
|
226
|
+
source = doc.source
|
|
227
|
+
try:
|
|
228
|
+
root = nmhit.parse_text(source)
|
|
229
|
+
formatted = root.render(indent=0, indent_text=" ")
|
|
230
|
+
except Exception:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
lines = source.splitlines()
|
|
234
|
+
last_line = len(lines) - 1
|
|
235
|
+
last_char = len(lines[last_line]) if lines else 0
|
|
236
|
+
whole_doc = lsp.Range(
|
|
237
|
+
start=lsp.Position(line=0, character=0),
|
|
238
|
+
end=lsp.Position(line=last_line, character=last_char),
|
|
239
|
+
)
|
|
240
|
+
return [lsp.TextEdit(range=whole_doc, new_text=formatted)]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
NEML2_MIN_VERSION = "2.1.4"
|
|
12
|
+
NMHIT_MIN_VERSION = "0.1.2"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _neml2_ok() -> bool:
|
|
16
|
+
try:
|
|
17
|
+
v = _pkg_version("neml2")
|
|
18
|
+
min_t = tuple(int(x) for x in NEML2_MIN_VERSION.split(".")[:3])
|
|
19
|
+
return tuple(int(x) for x in v.split(".")[:3]) >= min_t
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_binary() -> Path:
|
|
25
|
+
"""Locate the neml2-syntax binary bundled with the neml2 Python package."""
|
|
26
|
+
for pkg_dir in neml2.__path__:
|
|
27
|
+
candidate = Path(pkg_dir) / "bin" / "neml2-syntax"
|
|
28
|
+
if candidate.exists():
|
|
29
|
+
return candidate
|
|
30
|
+
searched = [str(Path(p) / "bin" / "neml2-syntax") for p in neml2.__path__]
|
|
31
|
+
raise RuntimeError(f"neml2-syntax not found; searched: {searched}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SyntaxClient:
|
|
35
|
+
"""Long-lived wrapper around `neml2-syntax --server`."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
exe = _find_binary()
|
|
39
|
+
self._proc = subprocess.Popen(
|
|
40
|
+
[str(exe), "--server"],
|
|
41
|
+
stdin=subprocess.PIPE,
|
|
42
|
+
stdout=subprocess.PIPE,
|
|
43
|
+
stderr=subprocess.DEVNULL,
|
|
44
|
+
text=True,
|
|
45
|
+
bufsize=1,
|
|
46
|
+
)
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._next_id = 1
|
|
49
|
+
atexit.register(self.close)
|
|
50
|
+
|
|
51
|
+
def _request(self, method: str, **params: Any) -> Any:
|
|
52
|
+
with self._lock:
|
|
53
|
+
req_id = self._next_id
|
|
54
|
+
self._next_id += 1
|
|
55
|
+
payload = {"id": req_id, "method": method, **params}
|
|
56
|
+
assert self._proc.stdin is not None
|
|
57
|
+
assert self._proc.stdout is not None
|
|
58
|
+
self._proc.stdin.write(json.dumps(payload) + "\n")
|
|
59
|
+
self._proc.stdin.flush()
|
|
60
|
+
line = self._proc.stdout.readline()
|
|
61
|
+
resp = json.loads(line)
|
|
62
|
+
if "error" in resp:
|
|
63
|
+
raise RuntimeError(f"neml2-syntax error: {resp['error']}")
|
|
64
|
+
return resp["result"]
|
|
65
|
+
|
|
66
|
+
def list_sections(self) -> list[str]:
|
|
67
|
+
return self._request("list_sections")
|
|
68
|
+
|
|
69
|
+
def list_types(self, section: str = "") -> list[dict]:
|
|
70
|
+
return self._request("list_types", section=section)
|
|
71
|
+
|
|
72
|
+
def get_options(self, type_name: str) -> dict | None:
|
|
73
|
+
return self._request("get_options", type=type_name)
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
if self._proc.poll() is None:
|
|
77
|
+
self._proc.terminate()
|
|
78
|
+
try:
|
|
79
|
+
self._proc.wait(timeout=2)
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
self._proc.kill()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_client: SyntaxClient | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_client() -> SyntaxClient | None:
|
|
88
|
+
if not _neml2_ok():
|
|
89
|
+
return None
|
|
90
|
+
global _client
|
|
91
|
+
if _client is None:
|
|
92
|
+
_client = SyntaxClient()
|
|
93
|
+
return _client
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: neml2-langserv
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Language server backing the NEML2 VS Code extension
|
|
5
|
+
Project-URL: Homepage, https://github.com/applied-material-modeling/neml2-langserv
|
|
6
|
+
Project-URL: Issues, https://github.com/applied-material-modeling/neml2-langserv/issues
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: pygls>=1.3
|
|
10
|
+
Requires-Dist: neml2>=2.1.4
|
|
11
|
+
Requires-Dist: nmhit>=0.1.2
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: build; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# NEML2 Language Support
|
|
17
|
+
|
|
18
|
+
VS Code extension providing language support for [NEML2](https://github.com/applied-material-modeling/neml2) input files.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Completion** — type names for `type = ` assignments, and option names with inline type hints inside typed blocks
|
|
23
|
+
- **Hover documentation** — docstrings for types and options shown on hover
|
|
24
|
+
- **Format on save** — re-indents the document consistently via the `nmhit` formatter
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
A Python environment with the [`neml2-langserv`](https://pypi.org/project/neml2-langserv/) package installed:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install neml2-langserv
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That single install pulls in everything the language server needs — [`neml2`](https://pypi.org/project/neml2/) (≥ 2.1.4) for the type/option metadata, [`nmhit`](https://pypi.org/project/nmhit/) (≥ 0.1.2) for the formatter, and `pygls` for the LSP transport.
|
|
35
|
+
|
|
36
|
+
The extension runs the server with whichever Python interpreter is selected in the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python). If `neml2-langserv` is not installed in that interpreter on first activation, the extension prompts you to install it.
|
|
37
|
+
|
|
38
|
+
## Setup
|
|
39
|
+
|
|
40
|
+
NEML2 input files share the `.i` extension with MOOSE input files. To tell the extension that a file is a NEML2 input, add the following as the **first line**:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
# neml2
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Files without this marker are treated as MOOSE input (or plain text) and the NEML2 language server will not activate for them.
|
|
47
|
+
|
|
48
|
+
## Format on save
|
|
49
|
+
|
|
50
|
+
Enable the built-in VS Code setting to auto-format on save:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
// .vscode/settings.json
|
|
54
|
+
{
|
|
55
|
+
"[neml2]": {
|
|
56
|
+
"editor.formatOnSave": true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## MOOSE compatibility
|
|
62
|
+
|
|
63
|
+
This extension does **not** claim the `.i` extension globally, so existing MOOSE workflows are unaffected. Only files whose first line matches `# neml2` (optionally followed by other text) are switched to the `neml2` language mode.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
neml2_langserv/__init__.py
|
|
4
|
+
neml2_langserv/__main__.py
|
|
5
|
+
neml2_langserv/hit_parser.py
|
|
6
|
+
neml2_langserv/server.py
|
|
7
|
+
neml2_langserv/syntax_client.py
|
|
8
|
+
neml2_langserv.egg-info/PKG-INFO
|
|
9
|
+
neml2_langserv.egg-info/SOURCES.txt
|
|
10
|
+
neml2_langserv.egg-info/dependency_links.txt
|
|
11
|
+
neml2_langserv.egg-info/requires.txt
|
|
12
|
+
neml2_langserv.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
neml2_langserv
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "neml2-langserv"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Language server backing the NEML2 VS Code extension"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pygls>=1.3",
|
|
13
|
+
"neml2>=2.1.4",
|
|
14
|
+
"nmhit>=0.1.2",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest", "build"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/applied-material-modeling/neml2-langserv"
|
|
22
|
+
Issues = "https://github.com/applied-material-modeling/neml2-langserv/issues"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["neml2_langserv*"]
|