Skip to content

Commit be114e9

Browse files
committed
Introduce LSP Support
This addresses #903 Signed-off-by: Siddharth Sharma <[email protected]>
1 parent 25f69e8 commit be114e9

File tree

6 files changed

+245
-8
lines changed

6 files changed

+245
-8
lines changed

.editorconfig

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# EditorConfig is awesome: https://EditorConfig.org
2+
3+
# top-most EditorConfig file
4+
root = true
5+
6+
[*]
7+
end_of_line = lf
8+
charset = utf-8
9+
trim_trailing_whitespace = true
10+
insert_final_newline = true
11+
12+
[*.py]
13+
indent_style = space
14+
indent_size = 4

pyang/context.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,18 @@ def add_parsed_module(self, module):
141141

142142
return module
143143

144-
def del_module(self, module):
144+
def del_module(self, module, revision=None):
145145
"""Remove a module from the context"""
146-
rev = util.get_latest_revision(module)
147-
del self.modules[(module.arg, rev)]
146+
if revision is None:
147+
revision = util.get_latest_revision(module)
148+
del self.modules[(module.arg, revision)]
148149

149150
def get_module(self, modulename, revision=None):
150151
"""Return the module if it exists in the context"""
151152
if revision is None and modulename in self.revs:
152153
(revision, _handle) = self._get_latest_rev(self.revs[modulename])
153154
if revision is not None:
154-
if (modulename,revision) in self.modules:
155+
if (modulename, revision) in self.modules:
155156
return self.modules[(modulename, revision)]
156157
else:
157158
return None

pyang/lsp/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""pyang library to serve Microsoft LSP"""
2+
3+
__version__ = "0.0.1"
4+
__date__ = "2024-05-10"

pyang/lsp/server.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""pyang LSP handling"""
2+
3+
from __future__ import absolute_import
4+
import optparse
5+
from pathlib import Path
6+
7+
from pyang import error
8+
from pyang import context
9+
from pyang import plugin
10+
from pyang import syntax
11+
12+
from lsprotocol import types as lsp
13+
14+
from pygls.server import LanguageServer
15+
16+
SERVER_NAME = "pyangls"
17+
SERVER_VERSION = "v0.1"
18+
19+
SERVER_MODE_IO = "io"
20+
SERVER_MODE_TCP = "tcp"
21+
SERVER_MODE_WS = "ws"
22+
supported_modes = [
23+
SERVER_MODE_IO,
24+
SERVER_MODE_TCP,
25+
SERVER_MODE_WS,
26+
]
27+
default_mode = SERVER_MODE_IO
28+
default_host = "127.0.0.1"
29+
default_port = 2087
30+
31+
class PyangLanguageServer(LanguageServer):
32+
def __init__(self, *args):
33+
self.ctx : context.Context
34+
super().__init__(*args)
35+
36+
pyangls = PyangLanguageServer(SERVER_NAME, SERVER_VERSION)
37+
38+
def _validate(ls: LanguageServer,
39+
params: lsp.DidChangeTextDocumentParams | lsp.DidOpenTextDocumentParams):
40+
ls.show_message_log("Validating YANG...")
41+
42+
text_doc = ls.workspace.get_text_document(params.text_document.uri)
43+
source = text_doc.source
44+
45+
pyangls.ctx.errors = []
46+
modules = []
47+
diagnostics = []
48+
if source:
49+
m = syntax.re_filename.search(Path(text_doc.filename).name)
50+
if m is not None:
51+
name, rev, in_format = m.groups()
52+
module = pyangls.ctx.get_module(name, rev)
53+
if module is not None:
54+
pyangls.ctx.del_module(module)
55+
module = pyangls.ctx.add_module(text_doc.path, source,
56+
in_format, name, rev,
57+
expect_failure_error=False,
58+
primary_module=True)
59+
else:
60+
module = pyangls.ctx.add_module(text_doc.path, source,
61+
primary_module=True)
62+
if module is not None:
63+
modules.append(module)
64+
p : plugin.PyangPlugin
65+
for p in plugin.plugins:
66+
p.pre_validate_ctx(pyangls.ctx, modules)
67+
68+
pyangls.ctx.validate()
69+
module.prune()
70+
71+
diagnostics = build_diagnostics()
72+
73+
ls.publish_diagnostics(text_doc.uri, diagnostics)
74+
75+
def build_diagnostics():
76+
"""Builds lsp diagnostics from pyang context"""
77+
diagnostics = []
78+
79+
for epos, etag, eargs in pyangls.ctx.errors:
80+
msg = error.err_to_str(etag, eargs)
81+
# pyang just stores line context, not keyword/argument context
82+
start_line = epos.line - 1
83+
start_col = 0
84+
end_line = epos.line - 1
85+
end_col = 1
86+
def level_to_severity(level):
87+
if level == 1 or level == 2:
88+
return lsp.DiagnosticSeverity.Error
89+
elif level == 3:
90+
return lsp.DiagnosticSeverity.Warning
91+
elif level == 4:
92+
return lsp.DiagnosticSeverity.Information
93+
else:
94+
return None
95+
d = lsp.Diagnostic(
96+
range=lsp.Range(
97+
start=lsp.Position(line=start_line, character=start_col),
98+
end=lsp.Position(line=end_line, character=end_col),
99+
),
100+
message=msg,
101+
severity=level_to_severity(error.err_level(etag)),
102+
code=etag,
103+
source=SERVER_NAME,
104+
)
105+
106+
diagnostics.append(d)
107+
108+
return diagnostics
109+
110+
111+
@pyangls.feature(
112+
lsp.TEXT_DOCUMENT_DIAGNOSTIC,
113+
lsp.DiagnosticOptions(
114+
identifier="pyangls",
115+
inter_file_dependencies=True,
116+
workspace_diagnostics=True,
117+
),
118+
)
119+
def text_document_diagnostic(
120+
params: lsp.DocumentDiagnosticParams,
121+
) -> lsp.DocumentDiagnosticReport:
122+
"""Returns diagnostic report."""
123+
return lsp.RelatedFullDocumentDiagnosticReport(
124+
items=_validate(pyangls, params),
125+
kind=lsp.DocumentDiagnosticReportKind.Full,
126+
)
127+
128+
129+
@pyangls.feature(lsp.WORKSPACE_DIAGNOSTIC)
130+
def workspace_diagnostic(
131+
params: lsp.WorkspaceDiagnosticParams,
132+
) -> lsp.WorkspaceDiagnosticReport:
133+
"""Returns diagnostic report."""
134+
documents = pyangls.workspace.text_documents.keys()
135+
136+
if len(documents) == 0:
137+
items = []
138+
else:
139+
first = list(documents)[0]
140+
document = pyangls.workspace.get_text_document(first)
141+
items = [
142+
lsp.WorkspaceFullDocumentDiagnosticReport(
143+
uri=document.uri,
144+
version=document.version,
145+
items=_validate(pyangls, params),
146+
kind=lsp.DocumentDiagnosticReportKind.Full,
147+
)
148+
]
149+
150+
return lsp.WorkspaceDiagnosticReport(items=items)
151+
152+
153+
@pyangls.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
154+
def did_change(ls: LanguageServer, params: lsp.DidChangeTextDocumentParams):
155+
"""Text document did change notification."""
156+
157+
_validate(ls, params)
158+
159+
160+
@pyangls.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
161+
def did_close(ls: PyangLanguageServer, params: lsp.DidCloseTextDocumentParams):
162+
"""Text document did close notification."""
163+
ls.show_message("Text Document Did Close")
164+
165+
166+
@pyangls.feature(lsp.TEXT_DOCUMENT_DID_OPEN)
167+
async def did_open(ls: LanguageServer, params: lsp.DidOpenTextDocumentParams):
168+
"""Text document did open notification."""
169+
ls.show_message("Text Document Did Open")
170+
_validate(ls, params)
171+
172+
173+
@pyangls.feature(lsp.TEXT_DOCUMENT_INLINE_VALUE)
174+
def inline_value(params: lsp.InlineValueParams):
175+
"""Returns inline value."""
176+
return [lsp.InlineValueText(range=params.range, text="Inline value")]
177+
178+
179+
def add_opts(optparser: optparse.OptionParser):
180+
optlist = [
181+
# use capitalized versions of std options help and version
182+
optparse.make_option("--lsp-mode",
183+
dest="pyangls_mode",
184+
default=default_mode,
185+
metavar="LSP_MODE",
186+
help="Provide LSP Service in this mode" \
187+
"Supported LSP server modes are: " +
188+
', '.join(supported_modes)),
189+
optparse.make_option("--lsp-host",
190+
dest="pyangls_host",
191+
default=default_host,
192+
metavar="LSP_HOST",
193+
help="Bind LSP Server to this address"),
194+
optparse.make_option("--lsp-port",
195+
dest="pyangls_port",
196+
type="int",
197+
default=default_port,
198+
metavar="LSP_PORT",
199+
help="Bind LSP Server to this port"),
200+
]
201+
g = optparser.add_option_group("LSP Server specific options")
202+
g.add_options(optlist)
203+
204+
def start_server(optargs, ctx: context.Context):
205+
pyangls.ctx = ctx
206+
if optargs.pyangls_mode == SERVER_MODE_TCP:
207+
pyangls.start_tcp(optargs.pyangls_host, optargs.pyangls_port)
208+
elif optargs.pyangls_mode == SERVER_MODE_WS:
209+
pyangls.start_ws(optargs.pyangls_host, optargs.pyangls_port)
210+
else:
211+
pyangls.start_io()

pyang/scripts/pyang_tool.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import optparse
66
import io
77
import shutil
8-
import codecs
98
from pathlib import Path
109

1110
import pyang
@@ -15,8 +14,8 @@
1514
from pyang import hello
1615
from pyang import context
1716
from pyang import repository
18-
from pyang import statements
1917
from pyang import syntax
18+
from pyang.lsp import server as pyangls
2019

2120

2221
def run():
@@ -131,6 +130,10 @@ def run():
131130
dest="format",
132131
help="Convert to FORMAT. Supported formats " \
133132
"are: " + ', '.join(fmts)),
133+
optparse.make_option("-l", "--lsp",
134+
dest="lsp",
135+
action="store_true",
136+
help="Run as LSP server instead of CLI tool."),
134137
optparse.make_option("-o", "--output",
135138
dest="outfile",
136139
help="Write the output to OUTFILE instead " \
@@ -218,6 +221,7 @@ def run():
218221
optparser.version = '%prog ' + pyang.__version__
219222
optparser.add_options(optlist)
220223

224+
pyangls.add_opts(optparser)
221225
for p in plugin.plugins:
222226
p.add_opts(optparser)
223227

@@ -268,6 +272,9 @@ def run():
268272
ctx.strict = o.strict
269273
ctx.max_status = o.max_status
270274

275+
if o.lsp:
276+
pyangls.start_server(o, ctx)
277+
271278
# make a map of features to support, per module
272279
if o.hello:
273280
for mn, rev in hel.yang_modules():

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def run_commands(self):
6060
" Provides a framework for plugins that can convert YANG modules" + \
6161
"to other formats.",
6262
url='https://github.com/mbj4668/pyang',
63-
install_requires = ["lxml"],
63+
install_requires = ["lxml", "pygls"],
6464
license='BSD',
6565
classifiers=[
6666
'Development Status :: 5 - Production/Stable',
@@ -82,7 +82,7 @@ def run_commands(self):
8282
'json2xml = pyang.scripts.json2xml:main',
8383
]
8484
},
85-
packages=['pyang', 'pyang.plugins', 'pyang.scripts', 'pyang.translators', 'pyang.transforms'],
85+
packages=['pyang', 'pyang.plugins', 'pyang.scripts', 'pyang.translators', 'pyang.transforms', 'pyang.lsp'],
8686
data_files=[
8787
('share/man/man1', man1),
8888
('share/yang/modules/iana', modules_iana),

0 commit comments

Comments
 (0)