Skip to content

Commit 97fd169

Browse files
committed
Add simple langserver to show editor parsing errors and outline
1 parent 00d4735 commit 97fd169

13 files changed

Lines changed: 637 additions & 311 deletions

File tree

‎declaracad/__init__.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ def launch_editor(args):
116116
editor.main(**args.__dict__)
117117

118118

119+
def launch_langserver(args):
120+
init_logging("%(message)s")
121+
from declaracad.apps import langserver
122+
123+
langserver.main(**args.__dict__)
124+
125+
119126
def launch_workbench(args):
120127
init_logging()
121128
from declaracad.apps import workbench
@@ -188,11 +195,16 @@ def main():
188195
editor = subparsers.add_parser("edit", help="Edit a file")
189196
editor.set_defaults(func=launch_editor)
190197
editor.add_argument("filename", help="File to edit")
198+
editor.add_argument("--langserver", action="store_true", help="Start langserver")
191199

192200
console = subparsers.add_parser("console", help="Open an ipython console")
193201
console.set_defaults(func=launch_console)
194202
editor.add_argument("-c", help="Command to run")
195203

204+
langserver = subparsers.add_parser("langserver", help="Run the langserver")
205+
langserver.set_defaults(func=launch_langserver)
206+
langserver.add_argument("port", help="Port to connect to")
207+
196208
args = parser.parse_args()
197209

198210
# If DECLARACAD_CPROFILE is defined run the profiler

‎declaracad/apps/editor.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import enamlx
1212
from enaml.workbench.plugin_manifest import PluginManifest
1313

14-
from declaracad.core.app import Application
14+
from declaracad.core.app import AsyncApplication as Application
1515
from declaracad.editor.plugin import Document, EditorPlugin
1616

1717
with enaml.imports():
@@ -28,8 +28,12 @@ def main(filename: str, **kwargs):
2828
"""
2929
enamlx.install() # Needed for KeyEvent
3030
app = Application()
31-
plugin = EditorPlugin(manifest=PluginManifest(id="declaracad.editor"))
32-
plugin.start()
33-
editor = StandaloneEditor(plugin=plugin, doc=Document(name=filename, plugin=plugin))
31+
doc = Document(name=filename)
32+
plugin = EditorPlugin(
33+
manifest=PluginManifest(id="declaracad.editor"),
34+
enable_langserver=kwargs.get("langserver") is True,
35+
)
36+
plugin.start(doc)
37+
editor = StandaloneEditor(plugin=plugin, doc=plugin.active_document)
3438
editor.show()
3539
app.start()

‎declaracad/apps/langserver.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Copyright (c) 2026, Jairus Martin.
3+
4+
Distributed under the terms of the GPL v3 License.
5+
6+
The full license is in the file LICENSE, distributed with this software.
7+
8+
"""
9+
10+
import asyncio
11+
12+
from declaracad.editor import langserver
13+
14+
15+
def main(port: int, **kwargs):
16+
"""A minimal language server.
17+
18+
Parameters
19+
----------
20+
port: str
21+
The port the declaracad application is listening on.
22+
"""
23+
asyncio.run(langserver.main(port))

‎declaracad/apps/viewer.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020

2121
from declaracad.console.plugin import ConsolePlugin
2222
from declaracad.core.app import AsyncApplication as Application
23+
from declaracad.core.protocol import JsonRpcProtocol, RemoteLogger
2324
from declaracad.core.utils import log
24-
from declaracad.viewer.protocol import JsonRpcProtocol, RemoteLogger
2525

2626
with enaml.imports():
2727
from declaracad.viewer.standalone import ViewerWindow
Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import jsonpickle
77
from atom.api import Atom, Bool, Bytes, ContainerList, Dict, Instance, Int, Value
8-
from enaml.application import timed_call
98

109
from declaracad.core.utils import log
1110

@@ -23,6 +22,15 @@ class JsonRpcProtocol(Atom, asyncio.Protocol):
2322
#: Set when the protocol is ready
2423
connected = Bool(False)
2524

25+
#: Log messages
26+
debug = Bool()
27+
28+
# Asyncio event lop
29+
loop = Value()
30+
31+
def _default_loop(self):
32+
return asyncio.get_event_loop()
33+
2634
def invoke_method(self, method: str, *args, **kwargs) -> asyncio.Future[Any]:
2735
"""Invoke the method with the attribute "on_{method}" on the remote
2836
connection.
@@ -42,9 +50,10 @@ def send_message(self, message: dict[str, Any], attempts: int = 10):
4250
log.error(f"Could not send message: {message} after several attempts")
4351
return
4452
log.debug(f"Note: Message delayed {self}: {message}")
45-
timed_call(1000, self.send_message, message, attempts - 1)
53+
self.loop.call_later(1, self.send_message, message, attempts - 1)
4654
return
47-
log.debug(message)
55+
if self.debug:
56+
log.debug(message)
4857
encoded_msg = jsonpickle.dumps(message).encode()
4958
self.transport.write(encoded_msg + b"\r\n")
5059

@@ -72,7 +81,8 @@ def line_received(self, line: str) -> Optional[asyncio.Future]:
7281
"""
7382
if not line:
7483
return None
75-
log.info(f"Received message '{line}'")
84+
if self.debug:
85+
log.info(f"Received message '{line}'")
7686
try:
7787
request: dict[str, Any] = jsonpickle.loads(line)
7888
except Exception as e:
@@ -142,6 +152,7 @@ class RemoteLogger(Atom):
142152
protocol = Instance(JsonRpcProtocol)
143153

144154
#: Original stderr and stdout
155+
attached = Bool()
145156
stdout = Value()
146157
stderr = Value()
147158

‎declaracad/core/utils.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,19 @@ def source_hash(source: str, is_file: bool = False) -> Optional[bytes]:
103103
with open(source, "rb") as f:
104104
return hashlib.md5(f.read()).digest()
105105
return hashlib.md5(source.encode()).digest()
106+
107+
108+
def get_bootstrap_cmd() -> list[str]:
109+
"""Get the command to the main executable depending on how it's run.
110+
If frozen use declaracad, otherwise use python -m declaracad
111+
112+
Returns
113+
-------
114+
cmd: List[str]
115+
The command to run declaracad
116+
"""
117+
is_frozen = getattr(sys, "frozen", False)
118+
cmd = [sys.executable]
119+
if not sys.executable.endswith("declaracad") and not is_frozen:
120+
cmd.extend(["-m", "declaracad"])
121+
return cmd

‎declaracad/editor/langserver.py‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
Copyright (c) 2026, Jairus Martin.
3+
4+
Distributed under the terms of the GPL v3 License.
5+
6+
The full license is in the file LICENSE, distributed with this software.
7+
"""
8+
9+
import ast as python_ast
10+
import asyncio
11+
import os
12+
from ast import parse as python_parse
13+
from typing import Optional
14+
15+
from atom.api import Atom, Enum, Int, List, Str, Typed
16+
from enaml.core import enaml_ast
17+
from enaml.core.parser import parse as enaml_parse
18+
19+
from declaracad.core.protocol import JsonRpcProtocol
20+
from declaracad.core.utils import log
21+
22+
23+
class Outline(Atom):
24+
lineno = Int()
25+
label = Str()
26+
type = Enum("function", "class", "enamldef", "error")
27+
28+
29+
class Problem(Atom):
30+
lineno = Int()
31+
offset = Int()
32+
end_lineno = Int()
33+
end_offset = Int()
34+
msg = Str()
35+
type = Str()
36+
level = Enum("error", "warning", "info", "hint")
37+
38+
@classmethod
39+
def from_syntaxerror(cls, e: SyntaxError) -> "Problem":
40+
return cls(
41+
lineno=e.lineno,
42+
offset=e.offset,
43+
end_offset=e.end_offset,
44+
end_lineno=e.end_lineno,
45+
msg=e.msg,
46+
level="error",
47+
type=e.__class__.__name__,
48+
)
49+
50+
51+
class ParseResult(Atom):
52+
problems = List(Problem)
53+
outline = List(Outline)
54+
55+
56+
def parse_python(filename: str, source: str) -> ParseResult:
57+
result = ParseResult()
58+
try:
59+
ast = python_parse(source, filename)
60+
outline = []
61+
# Walk ast and pull out nodes we're insterested in
62+
for node in ast.body:
63+
if isinstance(node, python_ast.ClassDef):
64+
outline.append(
65+
Outline(lineno=node.lineno, type="class", label=node.name)
66+
)
67+
elif isinstance(node, python_ast.FunctionDef):
68+
outline.append(
69+
Outline(lineno=node.lineno, type="function", label=node.name)
70+
)
71+
result.outline = outline
72+
except SyntaxError as e:
73+
log.debug(e)
74+
result.problems = [Problem.from_syntaxerror(e)]
75+
result.outline = [Outline(lineno=e.lineno, label=f"{e}", type="error")]
76+
return result
77+
78+
79+
def parse_enaml(filename: str, source: str) -> ParseResult:
80+
result = ParseResult()
81+
try:
82+
ast = enaml_parse(source, filename)
83+
outline = []
84+
# Walk ast and pull out nodes we're insterested in
85+
for node in ast.body:
86+
if isinstance(node, enaml_ast.EnamlDef):
87+
outline.append(
88+
Outline(lineno=node.lineno, type="enamldef", label=node.typename)
89+
)
90+
elif isinstance(node, enaml_ast.PythonModule):
91+
for n in node.ast.body:
92+
if isinstance(n, python_ast.ClassDef):
93+
outline.append(
94+
Outline(lineno=n.lineno, type="class", label=n.name)
95+
)
96+
elif isinstance(n, python_ast.FunctionDef):
97+
outline.append(
98+
Outline(lineno=n.lineno, type="function", label=n.name)
99+
)
100+
result.outline = outline
101+
except SyntaxError as e:
102+
log.debug(e)
103+
result.problems = [Problem.from_syntaxerror(e)]
104+
result.outline = [Outline(lineno=e.lineno, label=f"{e}", type="error")]
105+
return result
106+
107+
108+
def parse_gcode(filename: str, source: str) -> ParseResult:
109+
from declaracad.cnc import gcode
110+
111+
return [gcode.parse(source)]
112+
113+
114+
PARSERS = {
115+
"enaml": parse_enaml,
116+
"python": parse_python,
117+
}
118+
119+
120+
class LangServerProtocol(JsonRpcProtocol):
121+
"""A very simple langserver to provide background auto-complete,
122+
linting, and parsing services for the editors.
123+
"""
124+
125+
finished = Typed(asyncio.Event, ())
126+
127+
def connection_made(self, transport):
128+
self.transport = transport
129+
self.connected = True
130+
log.debug("Connected to workbench")
131+
132+
def connection_lost(self, exc):
133+
self.connected = False
134+
self.finished.set()
135+
136+
def on_parse(self, filename: str, source: str) -> Optional[ParseResult]:
137+
"""Parse a file and return any errors"""
138+
log.debug(f"Parsing {filename}")
139+
try:
140+
if not filename:
141+
return
142+
ext = os.path.splitext(filename)[-1].lstrip(".")
143+
if ext in PARSERS:
144+
return PARSERS[ext](filename, source)
145+
except Exception as e:
146+
log.exception(e)
147+
return None
148+
149+
150+
async def main(port: int):
151+
try:
152+
log.debug("Running langserver")
153+
loop = asyncio.get_event_loop()
154+
transport, protocol = await loop.create_connection(
155+
lambda: LangServerProtocol(), "127.0.0.1", port
156+
)
157+
log.debug("Remote langserver connected!")
158+
await protocol.finished.wait()
159+
log.debug("Remote langserver finished!")
160+
except Exception as e:
161+
log.error(f"Could not connect to workbench: {e}")
162+
return

‎declaracad/editor/outline.enaml‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ from enaml.widgets.api import Container
1515
from enamlx.widgets.api import TreeView, TreeViewItem, TreeViewColumn
1616
from declaracad.core.api import DockItem
1717
from declaracad.core.utils import load_icon
18+
from .plugin import Outline
1819

20+
OUTLINE_ICONS = {
21+
"function": "tag",
22+
"class": "brick",
23+
"enamldef": "bricks",
24+
"error": "exclamation",
25+
}
1926

20-
def format_node(node):
27+
def format_node(node: Outline):
2128
""" Create a node for the outline
2229

2330
Returns
@@ -47,6 +54,14 @@ enamldef OutlineDockItem(DockItem): view:
4754
name = 'code-outline-item'
4855
icon = load_icon('chart_organisation')
4956

57+
func goto_item(item: Outline):
58+
editor = plugin.get_editor()
59+
editor.goto_position(item.lineno, 0)
60+
61+
# Retract the item if shown
62+
dock_area = view.parent
63+
dock_area.update_layout(RetractItem(item=view.name))
64+
5065
Container:
5166
padding = 0
5267
TreeView: tree:
@@ -58,21 +73,11 @@ enamldef OutlineDockItem(DockItem): view:
5873
Looper:
5974
iterable << tree.items
6075
TreeViewItem:
61-
attr obj << format_node(loop.item)
62-
icon << load_icon(obj[0])
63-
text << obj[1]
64-
double_clicked ::
65-
editor = plugin.get_editor()
66-
# Go to the line
67-
widget = editor.proxy.widget
68-
widget.setCursorPosition(max(0,loop.item.lineno-2), 0)
69-
widget.ensureCursorVisible()
70-
# Retract the item if shown
71-
dock_area = view.parent
72-
dock_area.update_layout(RetractItem(item=view.name))
73-
76+
attr item: Outline << loop.item
77+
icon << load_icon(OUTLINE_ICONS.get(item.type, "tag"))
78+
text << item.label
79+
double_clicked :: goto_item(item)
7480
TreeViewColumn:
75-
text << obj[2]
81+
text << item.type
7682
TreeViewColumn:
77-
text << str(getattr(loop.item, 'lineno', 0))
78-
83+
text << f"{item.lineno}"

0 commit comments

Comments
 (0)