yuho 5.0.0__py3-none-any.whl
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.
- yuho/__init__.py +16 -0
- yuho/ast/__init__.py +196 -0
- yuho/ast/builder.py +926 -0
- yuho/ast/constant_folder.py +280 -0
- yuho/ast/dead_code.py +199 -0
- yuho/ast/exhaustiveness.py +503 -0
- yuho/ast/nodes.py +907 -0
- yuho/ast/overlap.py +291 -0
- yuho/ast/reachability.py +293 -0
- yuho/ast/scope_analysis.py +490 -0
- yuho/ast/transformer.py +490 -0
- yuho/ast/type_check.py +471 -0
- yuho/ast/type_inference.py +425 -0
- yuho/ast/visitor.py +239 -0
- yuho/cli/__init__.py +14 -0
- yuho/cli/commands/__init__.py +1 -0
- yuho/cli/commands/api.py +431 -0
- yuho/cli/commands/ast_viz.py +334 -0
- yuho/cli/commands/check.py +218 -0
- yuho/cli/commands/config.py +311 -0
- yuho/cli/commands/contribute.py +122 -0
- yuho/cli/commands/diff.py +487 -0
- yuho/cli/commands/explain.py +240 -0
- yuho/cli/commands/fmt.py +253 -0
- yuho/cli/commands/generate.py +316 -0
- yuho/cli/commands/graph.py +410 -0
- yuho/cli/commands/init.py +120 -0
- yuho/cli/commands/library.py +656 -0
- yuho/cli/commands/lint.py +503 -0
- yuho/cli/commands/lsp.py +36 -0
- yuho/cli/commands/preview.py +377 -0
- yuho/cli/commands/repl.py +444 -0
- yuho/cli/commands/serve.py +44 -0
- yuho/cli/commands/test.py +528 -0
- yuho/cli/commands/transpile.py +121 -0
- yuho/cli/commands/wizard.py +370 -0
- yuho/cli/completions.py +182 -0
- yuho/cli/error_formatter.py +193 -0
- yuho/cli/main.py +1064 -0
- yuho/config/__init__.py +46 -0
- yuho/config/loader.py +235 -0
- yuho/config/mask.py +194 -0
- yuho/config/schema.py +147 -0
- yuho/library/__init__.py +84 -0
- yuho/library/index.py +328 -0
- yuho/library/install.py +699 -0
- yuho/library/lockfile.py +330 -0
- yuho/library/package.py +421 -0
- yuho/library/resolver.py +791 -0
- yuho/library/signature.py +335 -0
- yuho/llm/__init__.py +45 -0
- yuho/llm/config.py +75 -0
- yuho/llm/factory.py +123 -0
- yuho/llm/prompts.py +146 -0
- yuho/llm/providers.py +383 -0
- yuho/llm/utils.py +470 -0
- yuho/lsp/__init__.py +14 -0
- yuho/lsp/code_action_handler.py +518 -0
- yuho/lsp/completion_handler.py +85 -0
- yuho/lsp/diagnostics.py +100 -0
- yuho/lsp/hover_handler.py +130 -0
- yuho/lsp/server.py +1425 -0
- yuho/mcp/__init__.py +10 -0
- yuho/mcp/server.py +1452 -0
- yuho/parser/__init__.py +8 -0
- yuho/parser/source_location.py +108 -0
- yuho/parser/wrapper.py +311 -0
- yuho/testing/__init__.py +48 -0
- yuho/testing/coverage.py +274 -0
- yuho/testing/fixtures.py +263 -0
- yuho/transpile/__init__.py +52 -0
- yuho/transpile/alloy_transpiler.py +546 -0
- yuho/transpile/base.py +100 -0
- yuho/transpile/blocks_transpiler.py +338 -0
- yuho/transpile/english_transpiler.py +470 -0
- yuho/transpile/graphql_transpiler.py +404 -0
- yuho/transpile/json_transpiler.py +217 -0
- yuho/transpile/jsonld_transpiler.py +250 -0
- yuho/transpile/latex_preamble.py +161 -0
- yuho/transpile/latex_transpiler.py +406 -0
- yuho/transpile/latex_utils.py +206 -0
- yuho/transpile/mermaid_transpiler.py +357 -0
- yuho/transpile/registry.py +275 -0
- yuho/verify/__init__.py +43 -0
- yuho/verify/alloy.py +352 -0
- yuho/verify/combined.py +218 -0
- yuho/verify/z3_solver.py +1155 -0
- yuho-5.0.0.dist-info/METADATA +186 -0
- yuho-5.0.0.dist-info/RECORD +91 -0
- yuho-5.0.0.dist-info/WHEEL +4 -0
- yuho-5.0.0.dist-info/entry_points.txt +2 -0
yuho/lsp/server.py
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Yuho Language Server Protocol implementation using pygls.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional, Any, Tuple
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from lsprotocol import types as lsp
|
|
10
|
+
from pygls.server import LanguageServer
|
|
11
|
+
from pygls.workspace import TextDocument
|
|
12
|
+
except ImportError:
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"LSP dependencies not installed. Install with: pip install yuho[lsp]"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from yuho.parser import Parser, SourceLocation
|
|
18
|
+
from yuho.parser.wrapper import ParseError, ParseResult
|
|
19
|
+
from yuho.ast import ASTBuilder, ModuleNode
|
|
20
|
+
|
|
21
|
+
# Import refactored handlers
|
|
22
|
+
from yuho.lsp.diagnostics import collect_diagnostics
|
|
23
|
+
from yuho.lsp.completion_handler import get_completions, YUHO_KEYWORDS, YUHO_TYPES
|
|
24
|
+
from yuho.lsp.hover_handler import get_hover
|
|
25
|
+
from yuho.lsp.code_action_handler import (
|
|
26
|
+
get_code_actions,
|
|
27
|
+
get_line_text,
|
|
28
|
+
extract_match_arm_pattern,
|
|
29
|
+
suggest_pattern_name,
|
|
30
|
+
get_struct_literal_info,
|
|
31
|
+
get_inline_variable_info,
|
|
32
|
+
find_similar_variants,
|
|
33
|
+
get_type_conversion,
|
|
34
|
+
extract_undefined_symbol,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DocumentState:
|
|
41
|
+
"""Cached state for a document."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, uri: str, source: str):
|
|
44
|
+
self.uri = uri
|
|
45
|
+
self.source = source
|
|
46
|
+
self.parse_result: Optional[ParseResult] = None
|
|
47
|
+
self.ast: Optional[ModuleNode] = None
|
|
48
|
+
self.version = 0
|
|
49
|
+
|
|
50
|
+
def update(self, source: str, version: int):
|
|
51
|
+
"""Update document source."""
|
|
52
|
+
self.source = source
|
|
53
|
+
self.version = version
|
|
54
|
+
self._parse()
|
|
55
|
+
|
|
56
|
+
def _parse(self):
|
|
57
|
+
"""Parse the document and build AST."""
|
|
58
|
+
parser = Parser()
|
|
59
|
+
self.parse_result = parser.parse(self.source, file=self.uri)
|
|
60
|
+
|
|
61
|
+
if self.parse_result.is_valid:
|
|
62
|
+
try:
|
|
63
|
+
builder = ASTBuilder(self.source, self.uri)
|
|
64
|
+
self.ast = builder.build(self.parse_result.root_node)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.warning(f"AST build error: {e}")
|
|
67
|
+
self.ast = None
|
|
68
|
+
else:
|
|
69
|
+
self.ast = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class YuhoLanguageServer(LanguageServer):
|
|
73
|
+
"""
|
|
74
|
+
Language Server for Yuho .yh files.
|
|
75
|
+
|
|
76
|
+
Provides:
|
|
77
|
+
- Diagnostics from parsing and semantic analysis
|
|
78
|
+
- Completion for keywords, types, and symbols
|
|
79
|
+
- Hover information
|
|
80
|
+
- Go to definition
|
|
81
|
+
- Find references
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self):
|
|
85
|
+
super().__init__(name="yuho-lsp", version="5.0.0")
|
|
86
|
+
|
|
87
|
+
# Document cache
|
|
88
|
+
self._documents: Dict[str, DocumentState] = {}
|
|
89
|
+
|
|
90
|
+
# Workspace folders for cross-file operations
|
|
91
|
+
self._workspace_folders: List[str] = []
|
|
92
|
+
|
|
93
|
+
# Symbol index for workspace-wide lookups
|
|
94
|
+
self._symbol_index: Dict[str, Dict[str, Any]] = {}
|
|
95
|
+
|
|
96
|
+
# Register handlers
|
|
97
|
+
self._register_handlers()
|
|
98
|
+
|
|
99
|
+
def _index_workspace_symbols(self, folder_uri: str) -> None:
|
|
100
|
+
"""Index all Yuho files in workspace folder."""
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
from urllib.parse import urlparse, unquote
|
|
103
|
+
|
|
104
|
+
parsed = urlparse(folder_uri)
|
|
105
|
+
folder_path = Path(unquote(parsed.path))
|
|
106
|
+
|
|
107
|
+
if not folder_path.exists():
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
for yh_file in folder_path.rglob("*.yh"):
|
|
111
|
+
file_uri = f"file://{yh_file}"
|
|
112
|
+
if file_uri not in self._documents:
|
|
113
|
+
try:
|
|
114
|
+
content = yh_file.read_text()
|
|
115
|
+
doc_state = DocumentState(file_uri, content)
|
|
116
|
+
doc_state._parse()
|
|
117
|
+
|
|
118
|
+
# Index symbols
|
|
119
|
+
if doc_state.ast:
|
|
120
|
+
for struct in doc_state.ast.type_defs:
|
|
121
|
+
self._symbol_index[struct.name] = {
|
|
122
|
+
"kind": "struct",
|
|
123
|
+
"uri": file_uri,
|
|
124
|
+
"location": struct.source_location,
|
|
125
|
+
}
|
|
126
|
+
for func in doc_state.ast.function_defs:
|
|
127
|
+
self._symbol_index[func.name] = {
|
|
128
|
+
"kind": "function",
|
|
129
|
+
"uri": file_uri,
|
|
130
|
+
"location": func.source_location,
|
|
131
|
+
}
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _register_handlers(self):
|
|
137
|
+
"""Register LSP request/notification handlers."""
|
|
138
|
+
|
|
139
|
+
@self.feature(lsp.TEXT_DOCUMENT_DID_OPEN)
|
|
140
|
+
def did_open(params: lsp.DidOpenTextDocumentParams):
|
|
141
|
+
"""Handle document open."""
|
|
142
|
+
uri = params.text_document.uri
|
|
143
|
+
text = params.text_document.text
|
|
144
|
+
version = params.text_document.version
|
|
145
|
+
|
|
146
|
+
doc_state = DocumentState(uri, text)
|
|
147
|
+
doc_state.update(text, version)
|
|
148
|
+
self._documents[uri] = doc_state
|
|
149
|
+
|
|
150
|
+
self._publish_diagnostics(uri, doc_state)
|
|
151
|
+
|
|
152
|
+
@self.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
|
|
153
|
+
def did_change(params: lsp.DidChangeTextDocumentParams):
|
|
154
|
+
"""Handle document change."""
|
|
155
|
+
uri = params.text_document.uri
|
|
156
|
+
version = params.text_document.version
|
|
157
|
+
|
|
158
|
+
if uri not in self._documents:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
doc_state = self._documents[uri]
|
|
162
|
+
|
|
163
|
+
# Apply changes (incremental)
|
|
164
|
+
for change in params.content_changes:
|
|
165
|
+
if isinstance(change, lsp.TextDocumentContentChangeEvent_Type1):
|
|
166
|
+
# Full document update
|
|
167
|
+
doc_state.update(change.text, version)
|
|
168
|
+
else:
|
|
169
|
+
# Incremental update - for now, just use full text
|
|
170
|
+
doc_state.update(change.text, version)
|
|
171
|
+
|
|
172
|
+
self._publish_diagnostics(uri, doc_state)
|
|
173
|
+
|
|
174
|
+
@self.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
|
|
175
|
+
def did_close(params: lsp.DidCloseTextDocumentParams):
|
|
176
|
+
"""Handle document close."""
|
|
177
|
+
uri = params.text_document.uri
|
|
178
|
+
if uri in self._documents:
|
|
179
|
+
del self._documents[uri]
|
|
180
|
+
|
|
181
|
+
@self.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
|
|
182
|
+
def did_save(params: lsp.DidSaveTextDocumentParams):
|
|
183
|
+
"""Handle document save."""
|
|
184
|
+
# Re-parse on save if text included
|
|
185
|
+
if params.text:
|
|
186
|
+
uri = params.text_document.uri
|
|
187
|
+
if uri in self._documents:
|
|
188
|
+
self._documents[uri].update(params.text, self._documents[uri].version + 1)
|
|
189
|
+
self._publish_diagnostics(uri, self._documents[uri])
|
|
190
|
+
|
|
191
|
+
@self.feature(lsp.TEXT_DOCUMENT_COMPLETION)
|
|
192
|
+
def completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
|
193
|
+
"""Provide code completion."""
|
|
194
|
+
uri = params.text_document.uri
|
|
195
|
+
position = params.position
|
|
196
|
+
|
|
197
|
+
return self._get_completions(uri, position)
|
|
198
|
+
|
|
199
|
+
@self.feature(lsp.TEXT_DOCUMENT_HOVER)
|
|
200
|
+
def hover(params: lsp.HoverParams) -> Optional[lsp.Hover]:
|
|
201
|
+
"""Provide hover information."""
|
|
202
|
+
uri = params.text_document.uri
|
|
203
|
+
position = params.position
|
|
204
|
+
|
|
205
|
+
return self._get_hover(uri, position)
|
|
206
|
+
|
|
207
|
+
@self.feature(lsp.TEXT_DOCUMENT_DEFINITION)
|
|
208
|
+
def definition(params: lsp.DefinitionParams) -> Optional[lsp.Location]:
|
|
209
|
+
"""Go to definition."""
|
|
210
|
+
uri = params.text_document.uri
|
|
211
|
+
position = params.position
|
|
212
|
+
|
|
213
|
+
return self._get_definition(uri, position)
|
|
214
|
+
|
|
215
|
+
@self.feature(lsp.TEXT_DOCUMENT_REFERENCES)
|
|
216
|
+
def references(params: lsp.ReferenceParams) -> List[lsp.Location]:
|
|
217
|
+
"""Find all references."""
|
|
218
|
+
uri = params.text_document.uri
|
|
219
|
+
position = params.position
|
|
220
|
+
|
|
221
|
+
return self._get_references(uri, position)
|
|
222
|
+
|
|
223
|
+
@self.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
|
|
224
|
+
def document_symbol(params: lsp.DocumentSymbolParams) -> List[lsp.DocumentSymbol]:
|
|
225
|
+
"""Get document symbols."""
|
|
226
|
+
uri = params.text_document.uri
|
|
227
|
+
|
|
228
|
+
return self._get_document_symbols(uri)
|
|
229
|
+
|
|
230
|
+
@self.feature(lsp.TEXT_DOCUMENT_FORMATTING)
|
|
231
|
+
def formatting(params: lsp.DocumentFormattingParams) -> List[lsp.TextEdit]:
|
|
232
|
+
"""Format document."""
|
|
233
|
+
uri = params.text_document.uri
|
|
234
|
+
|
|
235
|
+
return self._format_document(uri)
|
|
236
|
+
|
|
237
|
+
@self.feature(lsp.TEXT_DOCUMENT_RANGE_FORMATTING)
|
|
238
|
+
def range_formatting(params: lsp.DocumentRangeFormattingParams) -> List[lsp.TextEdit]:
|
|
239
|
+
"""Format a selection of the document."""
|
|
240
|
+
uri = params.text_document.uri
|
|
241
|
+
range_ = params.range
|
|
242
|
+
|
|
243
|
+
return self._format_range(uri, range_)
|
|
244
|
+
|
|
245
|
+
@self.feature(lsp.TEXT_DOCUMENT_RENAME)
|
|
246
|
+
def rename(params: lsp.RenameParams) -> Optional[lsp.WorkspaceEdit]:
|
|
247
|
+
"""Rename symbol across workspace."""
|
|
248
|
+
uri = params.text_document.uri
|
|
249
|
+
position = params.position
|
|
250
|
+
new_name = params.new_name
|
|
251
|
+
|
|
252
|
+
return self._rename_symbol(uri, position, new_name)
|
|
253
|
+
|
|
254
|
+
@self.feature(lsp.TEXT_DOCUMENT_PREPARE_RENAME)
|
|
255
|
+
def prepare_rename(params: lsp.PrepareRenameParams) -> Optional[lsp.PrepareRenameResult_Type1]:
|
|
256
|
+
"""Check if rename is valid at position."""
|
|
257
|
+
uri = params.text_document.uri
|
|
258
|
+
position = params.position
|
|
259
|
+
|
|
260
|
+
return self._prepare_rename(uri, position)
|
|
261
|
+
|
|
262
|
+
@self.feature(lsp.WORKSPACE_SYMBOL)
|
|
263
|
+
def workspace_symbol(params: lsp.WorkspaceSymbolParams) -> List[lsp.SymbolInformation]:
|
|
264
|
+
"""Search for symbols across workspace."""
|
|
265
|
+
query = params.query
|
|
266
|
+
|
|
267
|
+
return self._workspace_symbol_search(query)
|
|
268
|
+
|
|
269
|
+
@self.feature(lsp.TEXT_DOCUMENT_CODE_ACTION)
|
|
270
|
+
def code_action(params: lsp.CodeActionParams) -> List[lsp.CodeAction]:
|
|
271
|
+
"""Provide quick fixes and refactorings."""
|
|
272
|
+
uri = params.text_document.uri
|
|
273
|
+
range_ = params.range
|
|
274
|
+
context = params.context
|
|
275
|
+
|
|
276
|
+
return self._get_code_actions(uri, range_, context)
|
|
277
|
+
|
|
278
|
+
@self.feature(lsp.TEXT_DOCUMENT_CODE_LENS)
|
|
279
|
+
def code_lens(params: lsp.CodeLensParams) -> List[lsp.CodeLens]:
|
|
280
|
+
"""Provide code lens annotations."""
|
|
281
|
+
uri = params.text_document.uri
|
|
282
|
+
|
|
283
|
+
return self._get_code_lenses(uri)
|
|
284
|
+
|
|
285
|
+
@self.feature(lsp.TEXT_DOCUMENT_FOLDING_RANGE)
|
|
286
|
+
def folding_range(params: lsp.FoldingRangeParams) -> List[lsp.FoldingRange]:
|
|
287
|
+
"""Provide folding ranges for code folding."""
|
|
288
|
+
uri = params.text_document.uri
|
|
289
|
+
|
|
290
|
+
return self._get_folding_ranges(uri)
|
|
291
|
+
|
|
292
|
+
@self.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION)
|
|
293
|
+
def did_change_configuration(params: lsp.DidChangeConfigurationParams):
|
|
294
|
+
"""Handle configuration changes."""
|
|
295
|
+
settings = params.settings
|
|
296
|
+
self._update_configuration(settings)
|
|
297
|
+
|
|
298
|
+
@self.feature(
|
|
299
|
+
lsp.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
|
|
300
|
+
lsp.SemanticTokensOptions(
|
|
301
|
+
legend=lsp.SemanticTokensLegend(
|
|
302
|
+
token_types=[
|
|
303
|
+
"namespace", "type", "class", "enum", "interface",
|
|
304
|
+
"struct", "typeParameter", "parameter", "variable",
|
|
305
|
+
"property", "enumMember", "event", "function", "method",
|
|
306
|
+
"macro", "keyword", "modifier", "comment", "string",
|
|
307
|
+
"number", "regexp", "operator",
|
|
308
|
+
],
|
|
309
|
+
token_modifiers=[
|
|
310
|
+
"declaration", "definition", "readonly", "static",
|
|
311
|
+
"deprecated", "abstract", "async", "modification",
|
|
312
|
+
"documentation", "defaultLibrary",
|
|
313
|
+
],
|
|
314
|
+
),
|
|
315
|
+
full=True,
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
def semantic_tokens_full(params: lsp.SemanticTokensParams) -> Optional[lsp.SemanticTokens]:
|
|
319
|
+
"""Provide semantic tokens for enhanced syntax highlighting."""
|
|
320
|
+
uri = params.text_document.uri
|
|
321
|
+
|
|
322
|
+
return self._get_semantic_tokens(uri)
|
|
323
|
+
|
|
324
|
+
@self.feature(lsp.TEXT_DOCUMENT_INLAY_HINT)
|
|
325
|
+
def inlay_hint(params: lsp.InlayHintParams) -> List[lsp.InlayHint]:
|
|
326
|
+
"""Provide inlay hints showing inferred types inline."""
|
|
327
|
+
uri = params.text_document.uri
|
|
328
|
+
range_ = params.range
|
|
329
|
+
|
|
330
|
+
return self._get_inlay_hints(uri, range_)
|
|
331
|
+
|
|
332
|
+
@self.feature(lsp.TEXT_DOCUMENT_SIGNATURE_HELP)
|
|
333
|
+
def signature_help(params: lsp.SignatureHelpParams) -> Optional[lsp.SignatureHelp]:
|
|
334
|
+
"""Provide signature help for function parameters."""
|
|
335
|
+
uri = params.text_document.uri
|
|
336
|
+
position = params.position
|
|
337
|
+
|
|
338
|
+
return self._get_signature_help(uri, position)
|
|
339
|
+
|
|
340
|
+
@self.feature(lsp.TEXT_DOCUMENT_SELECTION_RANGE)
|
|
341
|
+
def selection_range(params: lsp.SelectionRangeParams) -> Optional[List[lsp.SelectionRange]]:
|
|
342
|
+
"""Provide selection ranges for expand/shrink selection."""
|
|
343
|
+
uri = params.text_document.uri
|
|
344
|
+
positions = params.positions
|
|
345
|
+
|
|
346
|
+
return self._get_selection_ranges(uri, positions)
|
|
347
|
+
|
|
348
|
+
def _publish_diagnostics(self, uri: str, doc_state: DocumentState):
|
|
349
|
+
"""Publish diagnostics for a document."""
|
|
350
|
+
diagnostics = collect_diagnostics(doc_state)
|
|
351
|
+
self.publish_diagnostics(uri, diagnostics)
|
|
352
|
+
|
|
353
|
+
def _get_completions(self, uri: str, position: lsp.Position) -> lsp.CompletionList:
|
|
354
|
+
"""Get completion items for position."""
|
|
355
|
+
doc_state = self._documents.get(uri)
|
|
356
|
+
return get_completions(doc_state, uri, position)
|
|
357
|
+
|
|
358
|
+
def _get_hover(self, uri: str, position: lsp.Position) -> Optional[lsp.Hover]:
|
|
359
|
+
"""Get hover information for position showing type info and doc-comments."""
|
|
360
|
+
doc_state = self._documents.get(uri)
|
|
361
|
+
if not doc_state:
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
word = self._get_word_at_position(doc_state.source, position)
|
|
365
|
+
return get_hover(doc_state, word, self._type_to_str)
|
|
366
|
+
|
|
367
|
+
def _get_definition(self, uri: str, position: lsp.Position) -> Optional[lsp.Location]:
|
|
368
|
+
"""Get definition location for identifier at position."""
|
|
369
|
+
doc_state = self._documents.get(uri)
|
|
370
|
+
if not doc_state:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
word = self._get_word_at_position(doc_state.source, position)
|
|
374
|
+
if not word:
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
# Check AST for definitions
|
|
378
|
+
if doc_state.ast:
|
|
379
|
+
# Check struct definitions
|
|
380
|
+
for struct in doc_state.ast.type_defs:
|
|
381
|
+
if struct.name == word and struct.source_location:
|
|
382
|
+
return lsp.Location(
|
|
383
|
+
uri=uri,
|
|
384
|
+
range=self._loc_to_range(struct.source_location),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Check function definitions
|
|
388
|
+
for func in doc_state.ast.function_defs:
|
|
389
|
+
if func.name == word and func.source_location:
|
|
390
|
+
return lsp.Location(
|
|
391
|
+
uri=uri,
|
|
392
|
+
range=self._loc_to_range(func.source_location),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Check statute definitions (by section number)
|
|
396
|
+
for statute in doc_state.ast.statutes:
|
|
397
|
+
if (statute.section_number == word or
|
|
398
|
+
f"S{statute.section_number}" == word) and statute.source_location:
|
|
399
|
+
return lsp.Location(
|
|
400
|
+
uri=uri,
|
|
401
|
+
range=self._loc_to_range(statute.source_location),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Check imports - navigate to the .yh file
|
|
405
|
+
for imp in doc_state.ast.imports:
|
|
406
|
+
# If user clicked on an imported name
|
|
407
|
+
if imp.imported_names and word in imp.imported_names:
|
|
408
|
+
# Try to resolve the import path
|
|
409
|
+
import_location = self._resolve_import_path(uri, imp.path)
|
|
410
|
+
if import_location:
|
|
411
|
+
return lsp.Location(
|
|
412
|
+
uri=import_location,
|
|
413
|
+
range=lsp.Range(
|
|
414
|
+
start=lsp.Position(line=0, character=0),
|
|
415
|
+
end=lsp.Position(line=0, character=0),
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
def _get_references(self, uri: str, position: lsp.Position) -> List[lsp.Location]:
|
|
422
|
+
"""Get all references to symbol at position."""
|
|
423
|
+
doc_state = self._documents.get(uri)
|
|
424
|
+
if not doc_state:
|
|
425
|
+
return []
|
|
426
|
+
|
|
427
|
+
word = self._get_word_at_position(doc_state.source, position)
|
|
428
|
+
if not word:
|
|
429
|
+
return []
|
|
430
|
+
|
|
431
|
+
locations: List[lsp.Location] = []
|
|
432
|
+
|
|
433
|
+
# Find all occurrences of the word in the source
|
|
434
|
+
lines = doc_state.source.splitlines()
|
|
435
|
+
for line_num, line in enumerate(lines):
|
|
436
|
+
col = 0
|
|
437
|
+
while True:
|
|
438
|
+
pos = line.find(word, col)
|
|
439
|
+
if pos == -1:
|
|
440
|
+
break
|
|
441
|
+
|
|
442
|
+
# Check if it's a whole word (not part of a larger identifier)
|
|
443
|
+
before_ok = (pos == 0 or not (line[pos - 1].isalnum() or line[pos - 1] == '_'))
|
|
444
|
+
after_pos = pos + len(word)
|
|
445
|
+
after_ok = (after_pos >= len(line) or not (line[after_pos].isalnum() or line[after_pos] == '_'))
|
|
446
|
+
|
|
447
|
+
if before_ok and after_ok:
|
|
448
|
+
locations.append(lsp.Location(
|
|
449
|
+
uri=uri,
|
|
450
|
+
range=lsp.Range(
|
|
451
|
+
start=lsp.Position(line=line_num, character=pos),
|
|
452
|
+
end=lsp.Position(line=line_num, character=pos + len(word)),
|
|
453
|
+
),
|
|
454
|
+
))
|
|
455
|
+
|
|
456
|
+
col = after_pos
|
|
457
|
+
|
|
458
|
+
return locations
|
|
459
|
+
|
|
460
|
+
def _get_word_at_position(self, source: str, position: lsp.Position) -> Optional[str]:
|
|
461
|
+
"""Extract the word/identifier at the given position."""
|
|
462
|
+
lines = source.splitlines()
|
|
463
|
+
if position.line >= len(lines):
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
line = lines[position.line]
|
|
467
|
+
if position.character >= len(line):
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
# Find word boundaries
|
|
471
|
+
start = position.character
|
|
472
|
+
end = position.character
|
|
473
|
+
|
|
474
|
+
# Expand backwards
|
|
475
|
+
while start > 0 and (line[start - 1].isalnum() or line[start - 1] == '_'):
|
|
476
|
+
start -= 1
|
|
477
|
+
|
|
478
|
+
# Expand forwards
|
|
479
|
+
while end < len(line) and (line[end].isalnum() or line[end] == '_'):
|
|
480
|
+
end += 1
|
|
481
|
+
|
|
482
|
+
if start == end:
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
return line[start:end]
|
|
486
|
+
|
|
487
|
+
def _type_to_str(self, type_node) -> str:
|
|
488
|
+
"""Convert a type node to a string representation."""
|
|
489
|
+
if type_node is None:
|
|
490
|
+
return "unknown"
|
|
491
|
+
|
|
492
|
+
from yuho.ast import nodes
|
|
493
|
+
if isinstance(type_node, nodes.BuiltinType):
|
|
494
|
+
return type_node.name
|
|
495
|
+
elif isinstance(type_node, nodes.NamedType):
|
|
496
|
+
return type_node.name
|
|
497
|
+
elif isinstance(type_node, nodes.OptionalType):
|
|
498
|
+
return f"{self._type_to_str(type_node.inner)}?"
|
|
499
|
+
elif isinstance(type_node, nodes.ArrayType):
|
|
500
|
+
return f"[{self._type_to_str(type_node.element_type)}]"
|
|
501
|
+
elif isinstance(type_node, nodes.GenericType):
|
|
502
|
+
args = ", ".join(self._type_to_str(a) for a in type_node.type_args)
|
|
503
|
+
return f"{type_node.base}<{args}>"
|
|
504
|
+
return str(type_node)
|
|
505
|
+
|
|
506
|
+
def _resolve_import_path(self, current_uri: str, import_path: str) -> Optional[str]:
|
|
507
|
+
"""Resolve an import path to a file URI."""
|
|
508
|
+
import os
|
|
509
|
+
from urllib.parse import urlparse, unquote
|
|
510
|
+
|
|
511
|
+
# Parse current URI to get directory
|
|
512
|
+
parsed = urlparse(current_uri)
|
|
513
|
+
current_path = unquote(parsed.path)
|
|
514
|
+
current_dir = os.path.dirname(current_path)
|
|
515
|
+
|
|
516
|
+
# Try different resolution strategies
|
|
517
|
+
candidates = [
|
|
518
|
+
os.path.join(current_dir, import_path),
|
|
519
|
+
os.path.join(current_dir, f"{import_path}.yh"),
|
|
520
|
+
os.path.join(current_dir, "lib", import_path),
|
|
521
|
+
os.path.join(current_dir, "lib", f"{import_path}.yh"),
|
|
522
|
+
]
|
|
523
|
+
|
|
524
|
+
for candidate in candidates:
|
|
525
|
+
if os.path.isfile(candidate):
|
|
526
|
+
return f"file://{candidate}"
|
|
527
|
+
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
def _get_signature_help(
|
|
531
|
+
self, uri: str, position: lsp.Position
|
|
532
|
+
) -> Optional[lsp.SignatureHelp]:
|
|
533
|
+
"""Get signature help for function call at position."""
|
|
534
|
+
doc_state = self._documents.get(uri)
|
|
535
|
+
if not doc_state:
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
# Get the line up to cursor
|
|
539
|
+
lines = doc_state.source.splitlines()
|
|
540
|
+
if position.line >= len(lines):
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
line = lines[position.line]
|
|
544
|
+
line_to_cursor = line[:position.character]
|
|
545
|
+
|
|
546
|
+
# Find function call context
|
|
547
|
+
func_name, param_index = self._parse_function_call_context(line_to_cursor)
|
|
548
|
+
if not func_name:
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
# Find the function definition
|
|
552
|
+
if doc_state.ast:
|
|
553
|
+
for func in doc_state.ast.function_defs:
|
|
554
|
+
if func.name == func_name:
|
|
555
|
+
return self._build_signature_help(func, param_index)
|
|
556
|
+
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
def _parse_function_call_context(self, line_to_cursor: str) -> tuple:
|
|
560
|
+
"""
|
|
561
|
+
Parse the line to find function call context.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
Tuple of (function_name, parameter_index) or (None, 0)
|
|
565
|
+
"""
|
|
566
|
+
# Find the last unclosed parenthesis
|
|
567
|
+
paren_depth = 0
|
|
568
|
+
func_end = -1
|
|
569
|
+
|
|
570
|
+
for i in range(len(line_to_cursor) - 1, -1, -1):
|
|
571
|
+
char = line_to_cursor[i]
|
|
572
|
+
if char == ')':
|
|
573
|
+
paren_depth += 1
|
|
574
|
+
elif char == '(':
|
|
575
|
+
if paren_depth == 0:
|
|
576
|
+
func_end = i
|
|
577
|
+
break
|
|
578
|
+
paren_depth -= 1
|
|
579
|
+
|
|
580
|
+
if func_end < 0:
|
|
581
|
+
return (None, 0)
|
|
582
|
+
|
|
583
|
+
# Extract function name before the opening paren
|
|
584
|
+
func_name_part = line_to_cursor[:func_end].rstrip()
|
|
585
|
+
|
|
586
|
+
# Find start of identifier
|
|
587
|
+
func_start = len(func_name_part)
|
|
588
|
+
while func_start > 0 and (func_name_part[func_start - 1].isalnum() or
|
|
589
|
+
func_name_part[func_start - 1] == '_'):
|
|
590
|
+
func_start -= 1
|
|
591
|
+
|
|
592
|
+
func_name = func_name_part[func_start:]
|
|
593
|
+
if not func_name or not func_name[0].isalpha():
|
|
594
|
+
return (None, 0)
|
|
595
|
+
|
|
596
|
+
# Count commas to determine parameter index
|
|
597
|
+
args_part = line_to_cursor[func_end + 1:]
|
|
598
|
+
param_index = 0
|
|
599
|
+
paren_depth = 0
|
|
600
|
+
|
|
601
|
+
for char in args_part:
|
|
602
|
+
if char == '(':
|
|
603
|
+
paren_depth += 1
|
|
604
|
+
elif char == ')':
|
|
605
|
+
paren_depth -= 1
|
|
606
|
+
elif char == ',' and paren_depth == 0:
|
|
607
|
+
param_index += 1
|
|
608
|
+
|
|
609
|
+
return (func_name, param_index)
|
|
610
|
+
|
|
611
|
+
def _build_signature_help(self, func, active_param: int) -> lsp.SignatureHelp:
|
|
612
|
+
"""Build SignatureHelp from function definition."""
|
|
613
|
+
# Build parameter info
|
|
614
|
+
parameters = []
|
|
615
|
+
params_str_parts = []
|
|
616
|
+
|
|
617
|
+
for param in func.params:
|
|
618
|
+
type_str = self._type_to_str(param.type_annotation)
|
|
619
|
+
param_str = f"{param.name}: {type_str}"
|
|
620
|
+
params_str_parts.append(param_str)
|
|
621
|
+
|
|
622
|
+
parameters.append(lsp.ParameterInformation(
|
|
623
|
+
label=param_str,
|
|
624
|
+
documentation=f"Parameter `{param.name}` of type `{type_str}`",
|
|
625
|
+
))
|
|
626
|
+
|
|
627
|
+
# Build full signature string
|
|
628
|
+
params_str = ", ".join(params_str_parts)
|
|
629
|
+
ret_str = ""
|
|
630
|
+
if func.return_type:
|
|
631
|
+
ret_str = f" -> {self._type_to_str(func.return_type)}"
|
|
632
|
+
|
|
633
|
+
signature_str = f"fn {func.name}({params_str}){ret_str}"
|
|
634
|
+
|
|
635
|
+
signature = lsp.SignatureInformation(
|
|
636
|
+
label=signature_str,
|
|
637
|
+
parameters=parameters,
|
|
638
|
+
active_parameter=min(active_param, len(parameters) - 1) if parameters else None,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return lsp.SignatureHelp(
|
|
642
|
+
signatures=[signature],
|
|
643
|
+
active_signature=0,
|
|
644
|
+
active_parameter=active_param,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def _get_selection_ranges(
|
|
648
|
+
self, uri: str, positions: List[lsp.Position]
|
|
649
|
+
) -> Optional[List[lsp.SelectionRange]]:
|
|
650
|
+
"""
|
|
651
|
+
Get selection ranges for expand/shrink selection feature.
|
|
652
|
+
|
|
653
|
+
Returns nested ranges from smallest to largest enclosing syntax element.
|
|
654
|
+
"""
|
|
655
|
+
doc_state = self._documents.get(uri)
|
|
656
|
+
if not doc_state:
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
results = []
|
|
660
|
+
for position in positions:
|
|
661
|
+
selection_range = self._build_selection_range(doc_state, position)
|
|
662
|
+
results.append(selection_range)
|
|
663
|
+
|
|
664
|
+
return results
|
|
665
|
+
|
|
666
|
+
def _build_selection_range(
|
|
667
|
+
self, doc_state: DocumentState, position: lsp.Position
|
|
668
|
+
) -> lsp.SelectionRange:
|
|
669
|
+
"""Build nested selection ranges for a position."""
|
|
670
|
+
import re
|
|
671
|
+
|
|
672
|
+
line = position.line
|
|
673
|
+
char = position.character
|
|
674
|
+
lines = doc_state.source.splitlines()
|
|
675
|
+
|
|
676
|
+
if line >= len(lines):
|
|
677
|
+
# Return minimal range
|
|
678
|
+
return lsp.SelectionRange(
|
|
679
|
+
range=lsp.Range(start=position, end=position)
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
line_text = lines[line]
|
|
683
|
+
ranges: List[lsp.Range] = []
|
|
684
|
+
|
|
685
|
+
# Level 1: Word at cursor
|
|
686
|
+
word_start = char
|
|
687
|
+
word_end = char
|
|
688
|
+
|
|
689
|
+
while word_start > 0 and (line_text[word_start - 1].isalnum() or line_text[word_start - 1] == '_'):
|
|
690
|
+
word_start -= 1
|
|
691
|
+
while word_end < len(line_text) and (line_text[word_end].isalnum() or line_text[word_end] == '_'):
|
|
692
|
+
word_end += 1
|
|
693
|
+
|
|
694
|
+
if word_start != word_end:
|
|
695
|
+
ranges.append(lsp.Range(
|
|
696
|
+
start=lsp.Position(line=line, character=word_start),
|
|
697
|
+
end=lsp.Position(line=line, character=word_end),
|
|
698
|
+
))
|
|
699
|
+
|
|
700
|
+
# Level 2: String literal if inside one
|
|
701
|
+
for match in re.finditer(r'"[^"]*"', line_text):
|
|
702
|
+
if match.start() <= char <= match.end():
|
|
703
|
+
ranges.append(lsp.Range(
|
|
704
|
+
start=lsp.Position(line=line, character=match.start()),
|
|
705
|
+
end=lsp.Position(line=line, character=match.end()),
|
|
706
|
+
))
|
|
707
|
+
break
|
|
708
|
+
|
|
709
|
+
# Level 3: Bracketed expression (parentheses, braces, brackets)
|
|
710
|
+
for open_char, close_char in [('(', ')'), ('{', '}'), ('[', ']')]:
|
|
711
|
+
bracket_range = self._find_enclosing_brackets(
|
|
712
|
+
lines, line, char, open_char, close_char
|
|
713
|
+
)
|
|
714
|
+
if bracket_range:
|
|
715
|
+
ranges.append(bracket_range)
|
|
716
|
+
|
|
717
|
+
# Level 4: Current line (non-whitespace)
|
|
718
|
+
line_content_start = len(line_text) - len(line_text.lstrip())
|
|
719
|
+
line_content_end = len(line_text.rstrip())
|
|
720
|
+
if line_content_end > line_content_start:
|
|
721
|
+
ranges.append(lsp.Range(
|
|
722
|
+
start=lsp.Position(line=line, character=line_content_start),
|
|
723
|
+
end=lsp.Position(line=line, character=line_content_end),
|
|
724
|
+
))
|
|
725
|
+
|
|
726
|
+
# Level 5: Full line including newline
|
|
727
|
+
ranges.append(lsp.Range(
|
|
728
|
+
start=lsp.Position(line=line, character=0),
|
|
729
|
+
end=lsp.Position(line=line + 1, character=0),
|
|
730
|
+
))
|
|
731
|
+
|
|
732
|
+
# Level 6: Block (based on indentation)
|
|
733
|
+
block_range = self._find_indentation_block(lines, line)
|
|
734
|
+
if block_range:
|
|
735
|
+
ranges.append(block_range)
|
|
736
|
+
|
|
737
|
+
# Level 7: Entire document
|
|
738
|
+
ranges.append(lsp.Range(
|
|
739
|
+
start=lsp.Position(line=0, character=0),
|
|
740
|
+
end=lsp.Position(line=len(lines), character=0),
|
|
741
|
+
))
|
|
742
|
+
|
|
743
|
+
# Remove duplicates and sort by size (smallest first)
|
|
744
|
+
unique_ranges = []
|
|
745
|
+
seen = set()
|
|
746
|
+
for r in ranges:
|
|
747
|
+
key = (r.start.line, r.start.character, r.end.line, r.end.character)
|
|
748
|
+
if key not in seen:
|
|
749
|
+
seen.add(key)
|
|
750
|
+
unique_ranges.append(r)
|
|
751
|
+
|
|
752
|
+
# Sort by range size
|
|
753
|
+
def range_size(r: lsp.Range) -> int:
|
|
754
|
+
start = r.start.line * 10000 + r.start.character
|
|
755
|
+
end = r.end.line * 10000 + r.end.character
|
|
756
|
+
return end - start
|
|
757
|
+
|
|
758
|
+
unique_ranges.sort(key=range_size)
|
|
759
|
+
|
|
760
|
+
# Build nested SelectionRange (smallest to largest)
|
|
761
|
+
if not unique_ranges:
|
|
762
|
+
return lsp.SelectionRange(
|
|
763
|
+
range=lsp.Range(start=position, end=position)
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
# Build from largest to smallest (parent first)
|
|
767
|
+
result = lsp.SelectionRange(range=unique_ranges[-1])
|
|
768
|
+
for r in reversed(unique_ranges[:-1]):
|
|
769
|
+
result = lsp.SelectionRange(range=r, parent=result)
|
|
770
|
+
|
|
771
|
+
return result
|
|
772
|
+
|
|
773
|
+
def _find_enclosing_brackets(
|
|
774
|
+
self, lines: List[str], line: int, char: int, open_char: str, close_char: str
|
|
775
|
+
) -> Optional[lsp.Range]:
|
|
776
|
+
"""Find the smallest enclosing bracket pair."""
|
|
777
|
+
# Flatten to single string with line tracking
|
|
778
|
+
line_text = lines[line]
|
|
779
|
+
|
|
780
|
+
# Search backwards for opening bracket
|
|
781
|
+
open_line, open_char_pos = line, char - 1
|
|
782
|
+
depth = 0
|
|
783
|
+
|
|
784
|
+
while open_line >= 0:
|
|
785
|
+
search_line = lines[open_line]
|
|
786
|
+
start_pos = open_char_pos if open_line == line else len(search_line) - 1
|
|
787
|
+
|
|
788
|
+
for i in range(start_pos, -1, -1):
|
|
789
|
+
c = search_line[i]
|
|
790
|
+
if c == close_char:
|
|
791
|
+
depth += 1
|
|
792
|
+
elif c == open_char:
|
|
793
|
+
if depth == 0:
|
|
794
|
+
# Found opening bracket, now find closing
|
|
795
|
+
close_line, close_char_pos = self._find_closing_bracket(
|
|
796
|
+
lines, open_line, i, open_char, close_char
|
|
797
|
+
)
|
|
798
|
+
if close_line is not None:
|
|
799
|
+
return lsp.Range(
|
|
800
|
+
start=lsp.Position(line=open_line, character=i),
|
|
801
|
+
end=lsp.Position(line=close_line, character=close_char_pos + 1),
|
|
802
|
+
)
|
|
803
|
+
return None
|
|
804
|
+
depth -= 1
|
|
805
|
+
|
|
806
|
+
open_line -= 1
|
|
807
|
+
open_char_pos = len(lines[open_line]) - 1 if open_line >= 0 else 0
|
|
808
|
+
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
def _find_closing_bracket(
|
|
812
|
+
self, lines: List[str], start_line: int, start_char: int, open_char: str, close_char: str
|
|
813
|
+
) -> Tuple[Optional[int], int]:
|
|
814
|
+
"""Find the matching closing bracket."""
|
|
815
|
+
depth = 1
|
|
816
|
+
line_num = start_line
|
|
817
|
+
char_pos = start_char + 1
|
|
818
|
+
|
|
819
|
+
while line_num < len(lines):
|
|
820
|
+
search_line = lines[line_num]
|
|
821
|
+
start_pos = char_pos if line_num == start_line else 0
|
|
822
|
+
|
|
823
|
+
for i in range(start_pos, len(search_line)):
|
|
824
|
+
c = search_line[i]
|
|
825
|
+
if c == open_char:
|
|
826
|
+
depth += 1
|
|
827
|
+
elif c == close_char:
|
|
828
|
+
depth -= 1
|
|
829
|
+
if depth == 0:
|
|
830
|
+
return (line_num, i)
|
|
831
|
+
|
|
832
|
+
line_num += 1
|
|
833
|
+
|
|
834
|
+
return (None, 0)
|
|
835
|
+
|
|
836
|
+
def _find_indentation_block(self, lines: List[str], line: int) -> Optional[lsp.Range]:
|
|
837
|
+
"""Find block based on indentation level."""
|
|
838
|
+
if line >= len(lines):
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
current_line = lines[line]
|
|
842
|
+
if not current_line.strip():
|
|
843
|
+
return None
|
|
844
|
+
|
|
845
|
+
current_indent = len(current_line) - len(current_line.lstrip())
|
|
846
|
+
|
|
847
|
+
# Find block start (first line with same or less indentation)
|
|
848
|
+
block_start = line
|
|
849
|
+
for i in range(line - 1, -1, -1):
|
|
850
|
+
l = lines[i]
|
|
851
|
+
if not l.strip():
|
|
852
|
+
continue
|
|
853
|
+
indent = len(l) - len(l.lstrip())
|
|
854
|
+
if indent < current_indent:
|
|
855
|
+
break
|
|
856
|
+
block_start = i
|
|
857
|
+
|
|
858
|
+
# Find block end
|
|
859
|
+
block_end = line
|
|
860
|
+
for i in range(line + 1, len(lines)):
|
|
861
|
+
l = lines[i]
|
|
862
|
+
if not l.strip():
|
|
863
|
+
continue
|
|
864
|
+
indent = len(l) - len(l.lstrip())
|
|
865
|
+
if indent < current_indent:
|
|
866
|
+
break
|
|
867
|
+
block_end = i
|
|
868
|
+
|
|
869
|
+
if block_start == block_end:
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
return lsp.Range(
|
|
873
|
+
start=lsp.Position(line=block_start, character=0),
|
|
874
|
+
end=lsp.Position(line=block_end + 1, character=0),
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
def _get_document_symbols(self, uri: str) -> List[lsp.DocumentSymbol]:
|
|
878
|
+
"""Get document symbol hierarchy."""
|
|
879
|
+
doc_state = self._documents.get(uri)
|
|
880
|
+
if not doc_state or not doc_state.ast:
|
|
881
|
+
return []
|
|
882
|
+
|
|
883
|
+
symbols: List[lsp.DocumentSymbol] = []
|
|
884
|
+
|
|
885
|
+
# Structs
|
|
886
|
+
for struct in doc_state.ast.type_defs:
|
|
887
|
+
loc = struct.source_location
|
|
888
|
+
if loc:
|
|
889
|
+
symbols.append(lsp.DocumentSymbol(
|
|
890
|
+
name=struct.name,
|
|
891
|
+
kind=lsp.SymbolKind.Struct,
|
|
892
|
+
range=self._loc_to_range(loc),
|
|
893
|
+
selection_range=self._loc_to_range(loc),
|
|
894
|
+
))
|
|
895
|
+
|
|
896
|
+
# Functions
|
|
897
|
+
for func in doc_state.ast.function_defs:
|
|
898
|
+
loc = func.source_location
|
|
899
|
+
if loc:
|
|
900
|
+
symbols.append(lsp.DocumentSymbol(
|
|
901
|
+
name=func.name,
|
|
902
|
+
kind=lsp.SymbolKind.Function,
|
|
903
|
+
range=self._loc_to_range(loc),
|
|
904
|
+
selection_range=self._loc_to_range(loc),
|
|
905
|
+
))
|
|
906
|
+
|
|
907
|
+
# Statutes
|
|
908
|
+
for statute in doc_state.ast.statutes:
|
|
909
|
+
loc = statute.source_location
|
|
910
|
+
if loc:
|
|
911
|
+
title = statute.title.value if statute.title else statute.section_number
|
|
912
|
+
symbols.append(lsp.DocumentSymbol(
|
|
913
|
+
name=f"S{statute.section_number}: {title}",
|
|
914
|
+
kind=lsp.SymbolKind.Module,
|
|
915
|
+
range=self._loc_to_range(loc),
|
|
916
|
+
selection_range=self._loc_to_range(loc),
|
|
917
|
+
))
|
|
918
|
+
|
|
919
|
+
return symbols
|
|
920
|
+
|
|
921
|
+
def _format_document(self, uri: str) -> List[lsp.TextEdit]:
|
|
922
|
+
"""Format the entire document."""
|
|
923
|
+
doc_state = self._documents.get(uri)
|
|
924
|
+
if not doc_state or not doc_state.ast:
|
|
925
|
+
return []
|
|
926
|
+
|
|
927
|
+
# Use the formatter from CLI
|
|
928
|
+
try:
|
|
929
|
+
from yuho.cli.commands.fmt import _format_module
|
|
930
|
+
formatted = _format_module(doc_state.ast)
|
|
931
|
+
|
|
932
|
+
# Create edit replacing entire document
|
|
933
|
+
lines = doc_state.source.splitlines()
|
|
934
|
+
return [lsp.TextEdit(
|
|
935
|
+
range=lsp.Range(
|
|
936
|
+
start=lsp.Position(line=0, character=0),
|
|
937
|
+
end=lsp.Position(line=len(lines), character=0),
|
|
938
|
+
),
|
|
939
|
+
new_text=formatted,
|
|
940
|
+
)]
|
|
941
|
+
except Exception as e:
|
|
942
|
+
logger.warning(f"Format error: {e}")
|
|
943
|
+
return []
|
|
944
|
+
|
|
945
|
+
def _format_range(self, uri: str, range_: lsp.Range) -> List[lsp.TextEdit]:
|
|
946
|
+
"""Format a range of the document."""
|
|
947
|
+
doc_state = self._documents.get(uri)
|
|
948
|
+
if not doc_state:
|
|
949
|
+
return []
|
|
950
|
+
|
|
951
|
+
# For now, format the entire document - proper range formatting
|
|
952
|
+
# would require parsing and reformatting just the selection
|
|
953
|
+
return self._format_document(uri)
|
|
954
|
+
|
|
955
|
+
def _rename_symbol(
|
|
956
|
+
self, uri: str, position: lsp.Position, new_name: str
|
|
957
|
+
) -> Optional[lsp.WorkspaceEdit]:
|
|
958
|
+
"""Rename symbol at position across all documents."""
|
|
959
|
+
doc_state = self._documents.get(uri)
|
|
960
|
+
if not doc_state:
|
|
961
|
+
return None
|
|
962
|
+
|
|
963
|
+
word = self._get_word_at_position(doc_state.source, position)
|
|
964
|
+
if not word:
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
# Validate new name is a valid identifier
|
|
968
|
+
if not new_name or not new_name[0].isalpha() and new_name[0] != '_':
|
|
969
|
+
return None
|
|
970
|
+
if not all(c.isalnum() or c == '_' for c in new_name):
|
|
971
|
+
return None
|
|
972
|
+
|
|
973
|
+
# Check if this is a renameable symbol (struct, function, variable, statute)
|
|
974
|
+
is_renameable = False
|
|
975
|
+
if doc_state.ast:
|
|
976
|
+
for struct in doc_state.ast.type_defs:
|
|
977
|
+
if struct.name == word:
|
|
978
|
+
is_renameable = True
|
|
979
|
+
break
|
|
980
|
+
for func in doc_state.ast.function_defs:
|
|
981
|
+
if func.name == word:
|
|
982
|
+
is_renameable = True
|
|
983
|
+
break
|
|
984
|
+
|
|
985
|
+
if not is_renameable:
|
|
986
|
+
return None
|
|
987
|
+
|
|
988
|
+
# Collect edits across all open documents
|
|
989
|
+
changes: Dict[str, List[lsp.TextEdit]] = {}
|
|
990
|
+
|
|
991
|
+
for doc_uri, doc in self._documents.items():
|
|
992
|
+
edits = self._find_and_replace_symbol(doc, word, new_name)
|
|
993
|
+
if edits:
|
|
994
|
+
changes[doc_uri] = edits
|
|
995
|
+
|
|
996
|
+
# Also search workspace files not currently open
|
|
997
|
+
for folder_uri in self._workspace_folders:
|
|
998
|
+
self._search_workspace_for_symbol(folder_uri, word, new_name, changes)
|
|
999
|
+
|
|
1000
|
+
if not changes:
|
|
1001
|
+
return None
|
|
1002
|
+
|
|
1003
|
+
return lsp.WorkspaceEdit(changes=changes)
|
|
1004
|
+
|
|
1005
|
+
def _search_workspace_for_symbol(
|
|
1006
|
+
self,
|
|
1007
|
+
folder_uri: str,
|
|
1008
|
+
old_name: str,
|
|
1009
|
+
new_name: str,
|
|
1010
|
+
changes: Dict[str, List[lsp.TextEdit]],
|
|
1011
|
+
) -> None:
|
|
1012
|
+
"""Search workspace folder for symbol occurrences."""
|
|
1013
|
+
from pathlib import Path
|
|
1014
|
+
from urllib.parse import urlparse, unquote
|
|
1015
|
+
|
|
1016
|
+
parsed = urlparse(folder_uri)
|
|
1017
|
+
folder_path = Path(unquote(parsed.path))
|
|
1018
|
+
|
|
1019
|
+
if not folder_path.exists():
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
for yh_file in folder_path.rglob("*.yh"):
|
|
1023
|
+
file_uri = f"file://{yh_file}"
|
|
1024
|
+
|
|
1025
|
+
# Skip already-open documents
|
|
1026
|
+
if file_uri in self._documents:
|
|
1027
|
+
continue
|
|
1028
|
+
|
|
1029
|
+
try:
|
|
1030
|
+
content = yh_file.read_text()
|
|
1031
|
+
|
|
1032
|
+
# Quick check if symbol appears in file
|
|
1033
|
+
if old_name not in content:
|
|
1034
|
+
continue
|
|
1035
|
+
|
|
1036
|
+
# Create temporary doc state for editing
|
|
1037
|
+
temp_doc = DocumentState(file_uri, content)
|
|
1038
|
+
edits = self._find_and_replace_symbol(temp_doc, old_name, new_name)
|
|
1039
|
+
|
|
1040
|
+
if edits:
|
|
1041
|
+
changes[file_uri] = edits
|
|
1042
|
+
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _find_and_replace_symbol(
|
|
1048
|
+
self, doc_state: DocumentState, old_name: str, new_name: str
|
|
1049
|
+
) -> List[lsp.TextEdit]:
|
|
1050
|
+
"""Find all occurrences of symbol and create edits to replace with new name."""
|
|
1051
|
+
edits: List[lsp.TextEdit] = []
|
|
1052
|
+
lines = doc_state.source.splitlines()
|
|
1053
|
+
|
|
1054
|
+
for line_num, line in enumerate(lines):
|
|
1055
|
+
col = 0
|
|
1056
|
+
while True:
|
|
1057
|
+
pos = line.find(old_name, col)
|
|
1058
|
+
if pos == -1:
|
|
1059
|
+
break
|
|
1060
|
+
|
|
1061
|
+
# Check if it's a whole word
|
|
1062
|
+
before_ok = pos == 0 or not (line[pos - 1].isalnum() or line[pos - 1] == '_')
|
|
1063
|
+
after_pos = pos + len(old_name)
|
|
1064
|
+
after_ok = after_pos >= len(line) or not (line[after_pos].isalnum() or line[after_pos] == '_')
|
|
1065
|
+
|
|
1066
|
+
if before_ok and after_ok:
|
|
1067
|
+
edits.append(lsp.TextEdit(
|
|
1068
|
+
range=lsp.Range(
|
|
1069
|
+
start=lsp.Position(line=line_num, character=pos),
|
|
1070
|
+
end=lsp.Position(line=line_num, character=after_pos),
|
|
1071
|
+
),
|
|
1072
|
+
new_text=new_name,
|
|
1073
|
+
))
|
|
1074
|
+
|
|
1075
|
+
col = after_pos
|
|
1076
|
+
|
|
1077
|
+
return edits
|
|
1078
|
+
|
|
1079
|
+
def _prepare_rename(
|
|
1080
|
+
self, uri: str, position: lsp.Position
|
|
1081
|
+
) -> Optional[lsp.PrepareRenameResult_Type1]:
|
|
1082
|
+
"""Check if symbol at position can be renamed."""
|
|
1083
|
+
doc_state = self._documents.get(uri)
|
|
1084
|
+
if not doc_state:
|
|
1085
|
+
return None
|
|
1086
|
+
|
|
1087
|
+
word = self._get_word_at_position(doc_state.source, position)
|
|
1088
|
+
if not word:
|
|
1089
|
+
return None
|
|
1090
|
+
|
|
1091
|
+
# Check if this is a renameable symbol
|
|
1092
|
+
is_renameable = False
|
|
1093
|
+
if doc_state.ast:
|
|
1094
|
+
for struct in doc_state.ast.type_defs:
|
|
1095
|
+
if struct.name == word:
|
|
1096
|
+
is_renameable = True
|
|
1097
|
+
break
|
|
1098
|
+
for func in doc_state.ast.function_defs:
|
|
1099
|
+
if func.name == word:
|
|
1100
|
+
is_renameable = True
|
|
1101
|
+
break
|
|
1102
|
+
|
|
1103
|
+
if not is_renameable:
|
|
1104
|
+
return None
|
|
1105
|
+
|
|
1106
|
+
# Find the range of the word
|
|
1107
|
+
lines = doc_state.source.splitlines()
|
|
1108
|
+
line = lines[position.line] if position.line < len(lines) else ""
|
|
1109
|
+
start = position.character
|
|
1110
|
+
end = position.character
|
|
1111
|
+
|
|
1112
|
+
while start > 0 and (line[start - 1].isalnum() or line[start - 1] == '_'):
|
|
1113
|
+
start -= 1
|
|
1114
|
+
while end < len(line) and (line[end].isalnum() or line[end] == '_'):
|
|
1115
|
+
end += 1
|
|
1116
|
+
|
|
1117
|
+
return lsp.PrepareRenameResult_Type1(
|
|
1118
|
+
range=lsp.Range(
|
|
1119
|
+
start=lsp.Position(line=position.line, character=start),
|
|
1120
|
+
end=lsp.Position(line=position.line, character=end),
|
|
1121
|
+
),
|
|
1122
|
+
placeholder=word,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
def _workspace_symbol_search(self, query: str) -> List[lsp.SymbolInformation]:
|
|
1126
|
+
"""Search for symbols matching query across all documents."""
|
|
1127
|
+
results: List[lsp.SymbolInformation] = []
|
|
1128
|
+
query_lower = query.lower()
|
|
1129
|
+
|
|
1130
|
+
for uri, doc_state in self._documents.items():
|
|
1131
|
+
if not doc_state.ast:
|
|
1132
|
+
continue
|
|
1133
|
+
|
|
1134
|
+
# Search structs
|
|
1135
|
+
for struct in doc_state.ast.type_defs:
|
|
1136
|
+
if query_lower in struct.name.lower():
|
|
1137
|
+
loc = struct.source_location
|
|
1138
|
+
if loc:
|
|
1139
|
+
results.append(lsp.SymbolInformation(
|
|
1140
|
+
name=struct.name,
|
|
1141
|
+
kind=lsp.SymbolKind.Struct,
|
|
1142
|
+
location=lsp.Location(
|
|
1143
|
+
uri=uri,
|
|
1144
|
+
range=self._loc_to_range(loc),
|
|
1145
|
+
),
|
|
1146
|
+
))
|
|
1147
|
+
|
|
1148
|
+
# Search functions
|
|
1149
|
+
for func in doc_state.ast.function_defs:
|
|
1150
|
+
if query_lower in func.name.lower():
|
|
1151
|
+
loc = func.source_location
|
|
1152
|
+
if loc:
|
|
1153
|
+
results.append(lsp.SymbolInformation(
|
|
1154
|
+
name=func.name,
|
|
1155
|
+
kind=lsp.SymbolKind.Function,
|
|
1156
|
+
location=lsp.Location(
|
|
1157
|
+
uri=uri,
|
|
1158
|
+
range=self._loc_to_range(loc),
|
|
1159
|
+
),
|
|
1160
|
+
))
|
|
1161
|
+
|
|
1162
|
+
# Search statutes
|
|
1163
|
+
for statute in doc_state.ast.statutes:
|
|
1164
|
+
title = statute.title.value if statute.title else ""
|
|
1165
|
+
if query_lower in f"s{statute.section_number}".lower() or query_lower in title.lower():
|
|
1166
|
+
loc = statute.source_location
|
|
1167
|
+
if loc:
|
|
1168
|
+
results.append(lsp.SymbolInformation(
|
|
1169
|
+
name=f"S{statute.section_number}: {title}",
|
|
1170
|
+
kind=lsp.SymbolKind.Module,
|
|
1171
|
+
location=lsp.Location(
|
|
1172
|
+
uri=uri,
|
|
1173
|
+
range=self._loc_to_range(loc),
|
|
1174
|
+
),
|
|
1175
|
+
))
|
|
1176
|
+
|
|
1177
|
+
return results
|
|
1178
|
+
|
|
1179
|
+
def _get_code_actions(
|
|
1180
|
+
self, uri: str, range_: lsp.Range, context: lsp.CodeActionContext
|
|
1181
|
+
) -> List[lsp.CodeAction]:
|
|
1182
|
+
"""Provide code actions (quick fixes, refactorings)."""
|
|
1183
|
+
doc_state = self._documents.get(uri)
|
|
1184
|
+
return get_code_actions(doc_state, uri, range_, context)
|
|
1185
|
+
|
|
1186
|
+
def _get_code_lenses(self, uri: str) -> List[lsp.CodeLens]:
|
|
1187
|
+
"""Provide code lenses (actionable annotations)."""
|
|
1188
|
+
lenses: List[lsp.CodeLens] = []
|
|
1189
|
+
doc_state = self._documents.get(uri)
|
|
1190
|
+
|
|
1191
|
+
if not doc_state or not doc_state.ast:
|
|
1192
|
+
return lenses
|
|
1193
|
+
|
|
1194
|
+
# Add "Run tests" lens above statute definitions
|
|
1195
|
+
for statute in doc_state.ast.statutes:
|
|
1196
|
+
loc = statute.source_location
|
|
1197
|
+
if loc:
|
|
1198
|
+
lenses.append(lsp.CodeLens(
|
|
1199
|
+
range=lsp.Range(
|
|
1200
|
+
start=lsp.Position(line=loc.line - 1, character=0),
|
|
1201
|
+
end=lsp.Position(line=loc.line - 1, character=0),
|
|
1202
|
+
),
|
|
1203
|
+
command=lsp.Command(
|
|
1204
|
+
title=f"▶ Run tests for S{statute.section_number}",
|
|
1205
|
+
command="yuho.runStatuteTests",
|
|
1206
|
+
arguments=[uri, statute.section_number],
|
|
1207
|
+
),
|
|
1208
|
+
))
|
|
1209
|
+
|
|
1210
|
+
# Add "Transpile" lens above statute definitions
|
|
1211
|
+
for statute in doc_state.ast.statutes:
|
|
1212
|
+
loc = statute.source_location
|
|
1213
|
+
if loc:
|
|
1214
|
+
lenses.append(lsp.CodeLens(
|
|
1215
|
+
range=lsp.Range(
|
|
1216
|
+
start=lsp.Position(line=loc.line - 1, character=0),
|
|
1217
|
+
end=lsp.Position(line=loc.line - 1, character=0),
|
|
1218
|
+
),
|
|
1219
|
+
command=lsp.Command(
|
|
1220
|
+
title="📄 Transpile to English",
|
|
1221
|
+
command="yuho.transpileStatute",
|
|
1222
|
+
arguments=[uri, statute.section_number, "english"],
|
|
1223
|
+
),
|
|
1224
|
+
))
|
|
1225
|
+
|
|
1226
|
+
return lenses
|
|
1227
|
+
|
|
1228
|
+
def _get_folding_ranges(self, uri: str) -> List[lsp.FoldingRange]:
|
|
1229
|
+
"""Provide folding ranges for code folding."""
|
|
1230
|
+
ranges: List[lsp.FoldingRange] = []
|
|
1231
|
+
doc_state = self._documents.get(uri)
|
|
1232
|
+
|
|
1233
|
+
if not doc_state or not doc_state.ast:
|
|
1234
|
+
return ranges
|
|
1235
|
+
|
|
1236
|
+
# Fold structs
|
|
1237
|
+
for struct in doc_state.ast.type_defs:
|
|
1238
|
+
loc = struct.source_location
|
|
1239
|
+
if loc and loc.end_line > loc.line:
|
|
1240
|
+
ranges.append(lsp.FoldingRange(
|
|
1241
|
+
start_line=loc.line - 1,
|
|
1242
|
+
end_line=loc.end_line - 1,
|
|
1243
|
+
kind=lsp.FoldingRangeKind.Region,
|
|
1244
|
+
))
|
|
1245
|
+
|
|
1246
|
+
# Fold functions
|
|
1247
|
+
for func in doc_state.ast.function_defs:
|
|
1248
|
+
loc = func.source_location
|
|
1249
|
+
if loc and loc.end_line > loc.line:
|
|
1250
|
+
ranges.append(lsp.FoldingRange(
|
|
1251
|
+
start_line=loc.line - 1,
|
|
1252
|
+
end_line=loc.end_line - 1,
|
|
1253
|
+
kind=lsp.FoldingRangeKind.Region,
|
|
1254
|
+
))
|
|
1255
|
+
|
|
1256
|
+
# Fold statutes
|
|
1257
|
+
for statute in doc_state.ast.statutes:
|
|
1258
|
+
loc = statute.source_location
|
|
1259
|
+
if loc and loc.end_line > loc.line:
|
|
1260
|
+
ranges.append(lsp.FoldingRange(
|
|
1261
|
+
start_line=loc.line - 1,
|
|
1262
|
+
end_line=loc.end_line - 1,
|
|
1263
|
+
kind=lsp.FoldingRangeKind.Region,
|
|
1264
|
+
))
|
|
1265
|
+
|
|
1266
|
+
return ranges
|
|
1267
|
+
|
|
1268
|
+
def _update_configuration(self, settings: Any) -> None:
|
|
1269
|
+
"""Update server configuration from client settings."""
|
|
1270
|
+
if not settings:
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
# Handle yuho-specific settings
|
|
1274
|
+
yuho_settings = settings.get("yuho", {})
|
|
1275
|
+
if yuho_settings:
|
|
1276
|
+
# Update logging level
|
|
1277
|
+
if "logLevel" in yuho_settings:
|
|
1278
|
+
level = yuho_settings["logLevel"].upper()
|
|
1279
|
+
if level in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
|
1280
|
+
logger.setLevel(getattr(logging, level))
|
|
1281
|
+
|
|
1282
|
+
# Could add more configuration options here
|
|
1283
|
+
|
|
1284
|
+
def _get_semantic_tokens(self, uri: str) -> Optional[lsp.SemanticTokens]:
|
|
1285
|
+
"""Compute semantic tokens for the document."""
|
|
1286
|
+
doc_state = self._documents.get(uri)
|
|
1287
|
+
if not doc_state or not doc_state.ast:
|
|
1288
|
+
return None
|
|
1289
|
+
|
|
1290
|
+
# Token type indices (matching the legend defined in handler)
|
|
1291
|
+
TOKEN_TYPES = {
|
|
1292
|
+
"namespace": 0, "type": 1, "class": 2, "enum": 3, "interface": 4,
|
|
1293
|
+
"struct": 5, "typeParameter": 6, "parameter": 7, "variable": 8,
|
|
1294
|
+
"property": 9, "enumMember": 10, "event": 11, "function": 12,
|
|
1295
|
+
"method": 13, "macro": 14, "keyword": 15, "modifier": 16,
|
|
1296
|
+
"comment": 17, "string": 18, "number": 19, "regexp": 20, "operator": 21,
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
# Modifier bit flags
|
|
1300
|
+
MODIFIERS = {
|
|
1301
|
+
"declaration": 0, "definition": 1, "readonly": 2, "static": 3,
|
|
1302
|
+
"deprecated": 4, "abstract": 5, "async": 6, "modification": 7,
|
|
1303
|
+
"documentation": 8, "defaultLibrary": 9,
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
tokens: List[tuple] = [] # (line, col, length, type_idx, modifier_bits)
|
|
1307
|
+
|
|
1308
|
+
# Collect tokens from structs
|
|
1309
|
+
for struct in doc_state.ast.type_defs:
|
|
1310
|
+
loc = struct.source_location
|
|
1311
|
+
if loc:
|
|
1312
|
+
# Struct name token
|
|
1313
|
+
tokens.append((
|
|
1314
|
+
loc.line - 1, # 0-indexed
|
|
1315
|
+
loc.col - 1,
|
|
1316
|
+
len(struct.name),
|
|
1317
|
+
TOKEN_TYPES["struct"],
|
|
1318
|
+
(1 << MODIFIERS["definition"]),
|
|
1319
|
+
))
|
|
1320
|
+
|
|
1321
|
+
# Collect tokens from functions
|
|
1322
|
+
for func in doc_state.ast.function_defs:
|
|
1323
|
+
loc = func.source_location
|
|
1324
|
+
if loc:
|
|
1325
|
+
tokens.append((
|
|
1326
|
+
loc.line - 1,
|
|
1327
|
+
loc.col - 1,
|
|
1328
|
+
len(func.name),
|
|
1329
|
+
TOKEN_TYPES["function"],
|
|
1330
|
+
(1 << MODIFIERS["definition"]),
|
|
1331
|
+
))
|
|
1332
|
+
|
|
1333
|
+
# Parameters
|
|
1334
|
+
for param in func.params:
|
|
1335
|
+
if param.source_location:
|
|
1336
|
+
tokens.append((
|
|
1337
|
+
param.source_location.line - 1,
|
|
1338
|
+
param.source_location.col - 1,
|
|
1339
|
+
len(param.name),
|
|
1340
|
+
TOKEN_TYPES["parameter"],
|
|
1341
|
+
0,
|
|
1342
|
+
))
|
|
1343
|
+
|
|
1344
|
+
# Collect tokens from statutes
|
|
1345
|
+
for statute in doc_state.ast.statutes:
|
|
1346
|
+
loc = statute.source_location
|
|
1347
|
+
if loc:
|
|
1348
|
+
tokens.append((
|
|
1349
|
+
loc.line - 1,
|
|
1350
|
+
loc.col - 1,
|
|
1351
|
+
len(f"statute"),
|
|
1352
|
+
TOKEN_TYPES["keyword"],
|
|
1353
|
+
0,
|
|
1354
|
+
))
|
|
1355
|
+
|
|
1356
|
+
# Sort tokens by position
|
|
1357
|
+
tokens.sort(key=lambda t: (t[0], t[1]))
|
|
1358
|
+
|
|
1359
|
+
# Encode as relative positions (LSP semantic tokens format)
|
|
1360
|
+
data: List[int] = []
|
|
1361
|
+
prev_line = 0
|
|
1362
|
+
prev_col = 0
|
|
1363
|
+
|
|
1364
|
+
for line, col, length, type_idx, modifiers in tokens:
|
|
1365
|
+
delta_line = line - prev_line
|
|
1366
|
+
delta_col = col if delta_line > 0 else col - prev_col
|
|
1367
|
+
|
|
1368
|
+
data.extend([delta_line, delta_col, length, type_idx, modifiers])
|
|
1369
|
+
|
|
1370
|
+
prev_line = line
|
|
1371
|
+
prev_col = col
|
|
1372
|
+
|
|
1373
|
+
return lsp.SemanticTokens(data=data)
|
|
1374
|
+
|
|
1375
|
+
def _get_inlay_hints(self, uri: str, range_: lsp.Range) -> List[lsp.InlayHint]:
|
|
1376
|
+
"""Provide inlay hints for inferred types and parameter names."""
|
|
1377
|
+
hints: List[lsp.InlayHint] = []
|
|
1378
|
+
doc_state = self._documents.get(uri)
|
|
1379
|
+
|
|
1380
|
+
if not doc_state or not doc_state.ast:
|
|
1381
|
+
return hints
|
|
1382
|
+
|
|
1383
|
+
# Add type hints for variable declarations without explicit types
|
|
1384
|
+
for var in doc_state.ast.variables:
|
|
1385
|
+
loc = var.source_location
|
|
1386
|
+
if not loc:
|
|
1387
|
+
continue
|
|
1388
|
+
|
|
1389
|
+
# Check if in range
|
|
1390
|
+
if loc.line - 1 < range_.start.line or loc.line - 1 > range_.end.line:
|
|
1391
|
+
continue
|
|
1392
|
+
|
|
1393
|
+
# If variable has a value but type could be inferred
|
|
1394
|
+
if var.value and var.type_annotation:
|
|
1395
|
+
type_str = self._type_to_str(var.type_annotation)
|
|
1396
|
+
hints.append(lsp.InlayHint(
|
|
1397
|
+
position=lsp.Position(
|
|
1398
|
+
line=loc.line - 1,
|
|
1399
|
+
character=loc.col + len(var.name),
|
|
1400
|
+
),
|
|
1401
|
+
label=f": {type_str}",
|
|
1402
|
+
kind=lsp.InlayHintKind.Type,
|
|
1403
|
+
padding_left=False,
|
|
1404
|
+
padding_right=True,
|
|
1405
|
+
))
|
|
1406
|
+
|
|
1407
|
+
# Add parameter name hints for function calls
|
|
1408
|
+
# (Requires deeper AST traversal - simplified for now)
|
|
1409
|
+
|
|
1410
|
+
return hints
|
|
1411
|
+
|
|
1412
|
+
def _loc_to_range(self, loc: SourceLocation) -> lsp.Range:
|
|
1413
|
+
"""Convert SourceLocation to LSP Range."""
|
|
1414
|
+
return lsp.Range(
|
|
1415
|
+
start=lsp.Position(line=loc.line - 1, character=loc.col - 1),
|
|
1416
|
+
end=lsp.Position(line=loc.end_line - 1, character=loc.end_col - 1),
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
def start_io(self):
|
|
1420
|
+
"""Start the server using stdio."""
|
|
1421
|
+
self.start_io()
|
|
1422
|
+
|
|
1423
|
+
def start_tcp(self, host: str, port: int):
|
|
1424
|
+
"""Start the server on TCP."""
|
|
1425
|
+
self.start_tcp(host, port)
|