Skip to content

Commit 0fa0534

Browse files
feat: introduce lsp (#4127)
Co-authored-by: Jo <46752250+georgesittas@users.noreply.github.com>
1 parent a81cfda commit 0fa0534

File tree

3 files changed

+128
-1
lines changed

3 files changed

+128
-1
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
.PHONY: docs
22

33
install-dev:
4-
pip3 install -e ".[dev,web,slack,dlt]" ./examples/custom_materializations
4+
pip3 install -e ".[dev,web,slack,dlt,lsp]" ./examples/custom_materializations
55

66
install-doc:
77
pip3 install -r ./docs/requirements.txt

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,16 @@ web = [
120120
"sse-starlette>=0.2.2",
121121
"pyarrow",
122122
]
123+
lsp = [
124+
"pygls",
125+
"lsprotocol"
126+
]
123127
risingwave = ["psycopg2"]
124128

125129
[project.scripts]
126130
sqlmesh = "sqlmesh.cli.main:cli"
127131
sqlmesh_cicd = "sqlmesh.cicd.bot:bot"
132+
sqlmesh_lsp = "sqlmesh.lsp.main:main"
128133

129134
[project.entry-points."airflow.plugins"]
130135
sqlmesh_airflow = "sqlmesh.schedulers.airflow.plugin:SqlmeshAirflowPlugin"

sqlmesh/lsp/main.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env python
2+
"""A Language Server Protocol (LSP) server for SQL with SQLMesh integration, refactored without globals."""
3+
4+
import itertools
5+
import logging
6+
import typing as t
7+
from contextlib import suppress
8+
from pathlib import Path
9+
10+
from lsprotocol import types
11+
from pygls.server import LanguageServer
12+
from pygls.workspace import TextDocument
13+
14+
from sqlmesh._version import __version__
15+
from sqlmesh.core.audit.definition import ModelAudit
16+
from sqlmesh.core.context import Context
17+
from sqlmesh.core.model import Model
18+
19+
20+
class SQLMeshLanguageServer:
21+
def __init__(
22+
self,
23+
context_class: t.Type[Context],
24+
server_name: str = "sqlmesh_lsp",
25+
version: str = __version__,
26+
):
27+
"""
28+
:param context_class: A class that inherits from `Context`.
29+
:param server_name: Name for the language server.
30+
:param version: Version string.
31+
"""
32+
self.server = LanguageServer(server_name, version)
33+
self.context_class = context_class
34+
self.context: t.Optional[Context] = None
35+
36+
# Register LSP features (e.g., formatting, hover, etc.)
37+
self._register_features()
38+
39+
def _register_features(self) -> None:
40+
"""Register LSP features on the internal LanguageServer instance."""
41+
42+
@self.server.feature(types.TEXT_DOCUMENT_FORMATTING)
43+
def formatting(
44+
ls: LanguageServer, params: types.DocumentFormattingParams
45+
) -> t.List[types.TextEdit]:
46+
"""Format the document using SQLMesh `format_model_expressions`."""
47+
try:
48+
document = self.ensure_context_for_document(
49+
ls.workspace.get_document(params.text_document.uri)
50+
)
51+
52+
if self.context is None:
53+
raise RuntimeError(f"No context found for document: {document.path}")
54+
55+
# Perform formatting using the loaded context
56+
self.context.format(paths=(Path(document.path),))
57+
with open(document.path, "r+", encoding="utf-8") as file:
58+
new_text = file.read()
59+
60+
# Return a single edit that replaces the entire file.
61+
return [
62+
types.TextEdit(
63+
range=types.Range(
64+
start=types.Position(line=0, character=0),
65+
end=types.Position(
66+
line=len(document.lines),
67+
character=len(document.lines[-1]) if document.lines else 0,
68+
),
69+
),
70+
new_text=new_text,
71+
)
72+
]
73+
except Exception as e:
74+
ls.show_message(f"Error formatting SQL: {e}", types.MessageType.Error)
75+
return []
76+
77+
def ensure_context_for_document(self, document: TextDocument) -> TextDocument:
78+
"""
79+
Ensure that a context exists for the given document if applicable by searching
80+
for a config.py or config.yml file in the parent directories.
81+
"""
82+
# If the context is already loaded, check if this document belongs to it.
83+
if self.context is not None:
84+
self.context.load() # Reload or refresh context
85+
return document
86+
87+
# No context yet: try to find config and load it
88+
path = Path(document.path).resolve()
89+
if path.suffix not in (".sql", ".py"):
90+
return document
91+
92+
loaded = False
93+
# Ascend directories to look for config
94+
while path.parents and not loaded:
95+
for ext in ("py", "yml", "yaml"):
96+
config_path = path / f"config.{ext}"
97+
if config_path.exists():
98+
with suppress(Exception):
99+
# Use user-provided instantiator to build the context
100+
self.context = self.context_class(paths=[path])
101+
self.server.show_message(f"Context loaded for: {path}")
102+
loaded = True
103+
# Re-check context for document now that it's loaded
104+
return self.ensure_context_for_document(document)
105+
path = path.parent
106+
107+
return document
108+
109+
def start(self) -> None:
110+
"""Start the server with I/O transport."""
111+
logging.basicConfig(level=logging.DEBUG)
112+
self.server.start_io()
113+
114+
115+
def main() -> None:
116+
# Example instantiator that just uses the same signature as your original `Context` usage.
117+
sqlmesh_server = SQLMeshLanguageServer(context_class=Context)
118+
sqlmesh_server.start()
119+
120+
121+
if __name__ == "__main__":
122+
main()

0 commit comments

Comments
 (0)