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.
Files changed (91) hide show
  1. yuho/__init__.py +16 -0
  2. yuho/ast/__init__.py +196 -0
  3. yuho/ast/builder.py +926 -0
  4. yuho/ast/constant_folder.py +280 -0
  5. yuho/ast/dead_code.py +199 -0
  6. yuho/ast/exhaustiveness.py +503 -0
  7. yuho/ast/nodes.py +907 -0
  8. yuho/ast/overlap.py +291 -0
  9. yuho/ast/reachability.py +293 -0
  10. yuho/ast/scope_analysis.py +490 -0
  11. yuho/ast/transformer.py +490 -0
  12. yuho/ast/type_check.py +471 -0
  13. yuho/ast/type_inference.py +425 -0
  14. yuho/ast/visitor.py +239 -0
  15. yuho/cli/__init__.py +14 -0
  16. yuho/cli/commands/__init__.py +1 -0
  17. yuho/cli/commands/api.py +431 -0
  18. yuho/cli/commands/ast_viz.py +334 -0
  19. yuho/cli/commands/check.py +218 -0
  20. yuho/cli/commands/config.py +311 -0
  21. yuho/cli/commands/contribute.py +122 -0
  22. yuho/cli/commands/diff.py +487 -0
  23. yuho/cli/commands/explain.py +240 -0
  24. yuho/cli/commands/fmt.py +253 -0
  25. yuho/cli/commands/generate.py +316 -0
  26. yuho/cli/commands/graph.py +410 -0
  27. yuho/cli/commands/init.py +120 -0
  28. yuho/cli/commands/library.py +656 -0
  29. yuho/cli/commands/lint.py +503 -0
  30. yuho/cli/commands/lsp.py +36 -0
  31. yuho/cli/commands/preview.py +377 -0
  32. yuho/cli/commands/repl.py +444 -0
  33. yuho/cli/commands/serve.py +44 -0
  34. yuho/cli/commands/test.py +528 -0
  35. yuho/cli/commands/transpile.py +121 -0
  36. yuho/cli/commands/wizard.py +370 -0
  37. yuho/cli/completions.py +182 -0
  38. yuho/cli/error_formatter.py +193 -0
  39. yuho/cli/main.py +1064 -0
  40. yuho/config/__init__.py +46 -0
  41. yuho/config/loader.py +235 -0
  42. yuho/config/mask.py +194 -0
  43. yuho/config/schema.py +147 -0
  44. yuho/library/__init__.py +84 -0
  45. yuho/library/index.py +328 -0
  46. yuho/library/install.py +699 -0
  47. yuho/library/lockfile.py +330 -0
  48. yuho/library/package.py +421 -0
  49. yuho/library/resolver.py +791 -0
  50. yuho/library/signature.py +335 -0
  51. yuho/llm/__init__.py +45 -0
  52. yuho/llm/config.py +75 -0
  53. yuho/llm/factory.py +123 -0
  54. yuho/llm/prompts.py +146 -0
  55. yuho/llm/providers.py +383 -0
  56. yuho/llm/utils.py +470 -0
  57. yuho/lsp/__init__.py +14 -0
  58. yuho/lsp/code_action_handler.py +518 -0
  59. yuho/lsp/completion_handler.py +85 -0
  60. yuho/lsp/diagnostics.py +100 -0
  61. yuho/lsp/hover_handler.py +130 -0
  62. yuho/lsp/server.py +1425 -0
  63. yuho/mcp/__init__.py +10 -0
  64. yuho/mcp/server.py +1452 -0
  65. yuho/parser/__init__.py +8 -0
  66. yuho/parser/source_location.py +108 -0
  67. yuho/parser/wrapper.py +311 -0
  68. yuho/testing/__init__.py +48 -0
  69. yuho/testing/coverage.py +274 -0
  70. yuho/testing/fixtures.py +263 -0
  71. yuho/transpile/__init__.py +52 -0
  72. yuho/transpile/alloy_transpiler.py +546 -0
  73. yuho/transpile/base.py +100 -0
  74. yuho/transpile/blocks_transpiler.py +338 -0
  75. yuho/transpile/english_transpiler.py +470 -0
  76. yuho/transpile/graphql_transpiler.py +404 -0
  77. yuho/transpile/json_transpiler.py +217 -0
  78. yuho/transpile/jsonld_transpiler.py +250 -0
  79. yuho/transpile/latex_preamble.py +161 -0
  80. yuho/transpile/latex_transpiler.py +406 -0
  81. yuho/transpile/latex_utils.py +206 -0
  82. yuho/transpile/mermaid_transpiler.py +357 -0
  83. yuho/transpile/registry.py +275 -0
  84. yuho/verify/__init__.py +43 -0
  85. yuho/verify/alloy.py +352 -0
  86. yuho/verify/combined.py +218 -0
  87. yuho/verify/z3_solver.py +1155 -0
  88. yuho-5.0.0.dist-info/METADATA +186 -0
  89. yuho-5.0.0.dist-info/RECORD +91 -0
  90. yuho-5.0.0.dist-info/WHEEL +4 -0
  91. 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)