Skip to main content

shape_lsp/
server.rs

1//! Main LSP server implementation
2//!
3//! Implements the Language Server Protocol for Shape.
4
5use crate::annotation_discovery::AnnotationDiscovery;
6use crate::call_hierarchy::{
7    incoming_calls as ch_incoming, outgoing_calls as ch_outgoing,
8    prepare_call_hierarchy as ch_prepare,
9};
10use crate::code_actions::get_code_actions;
11use crate::code_lens::{get_code_lenses, resolve_code_lens};
12use crate::completion::get_completions_with_context;
13use crate::definition::{
14    get_declaration, get_definition, get_document_highlights, get_implementations,
15    get_references_cross_file, get_type_definition,
16};
17use crate::diagnostics::error_to_diagnostic;
18use crate::document::DocumentManager;
19use crate::document_symbols::{get_document_symbols, get_workspace_symbols};
20use crate::folding::get_folding_ranges;
21use crate::formatting::{format_document, format_on_type, format_range};
22use crate::hover::get_hover;
23use crate::inlay_hints::{InlayHintConfig, get_inlay_hints_with_context};
24use crate::rename::{prepare_rename, rename_cross_file};
25use crate::semantic_tokens::{get_legend, get_semantic_tokens};
26use crate::signature_help::get_signature_help;
27use crate::util::{
28    mask_leading_prefix_for_parse, offset_to_line_col, parser_source, position_to_offset,
29};
30use dashmap::DashMap;
31use shape_ast::ParseErrorKind;
32use shape_ast::ast::Program;
33use shape_ast::parser::parse_program;
34use std::collections::HashSet;
35use tower_lsp_server::ls_types::request::{
36    GotoDeclarationParams, GotoDeclarationResponse, GotoImplementationParams,
37    GotoImplementationResponse, GotoTypeDefinitionParams, GotoTypeDefinitionResponse,
38};
39use tower_lsp_server::ls_types::{
40    CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
41    CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams, CallHierarchyPrepareParams,
42    CallHierarchyServerCapability, CodeActionKind, CodeActionOptions, CodeActionParams,
43    CodeActionProviderCapability, CodeActionResponse, CodeLens, CodeLensOptions, CodeLensParams,
44    CompletionItem, CompletionOptions, CompletionParams, CompletionResponse, DeclarationCapability,
45    Diagnostic, DiagnosticOptions, DiagnosticServerCapabilities, DiagnosticSeverity,
46    DidChangeConfigurationParams, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
47    DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentDiagnosticParams,
48    DocumentDiagnosticReport, DocumentDiagnosticReportResult, DocumentFormattingParams,
49    DocumentHighlight, DocumentHighlightParams, DocumentLink, DocumentLinkOptions,
50    DocumentLinkParams, DocumentOnTypeFormattingOptions, DocumentOnTypeFormattingParams,
51    DocumentRangeFormattingParams, DocumentSymbolParams, DocumentSymbolResponse,
52    ExecuteCommandOptions, ExecuteCommandParams, FileChangeType, FileOperationFilter,
53    FileOperationPattern, FileOperationPatternKind, FileOperationRegistrationOptions,
54    FoldingRange, FoldingRangeParams, FoldingRangeProviderCapability, FullDocumentDiagnosticReport,
55    GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams, HoverProviderCapability,
56    ImplementationProviderCapability, InitializeParams, InitializeResult, InitializedParams,
57    InlayHint, InlayHintOptions, InlayHintParams, InlayHintServerCapabilities, LSPAny, Location,
58    MessageType, OneOf, Position, PrepareRenameResponse, Range, ReferenceParams,
59    RelatedFullDocumentDiagnosticReport, RenameFilesParams, RenameOptions, RenameParams,
60    SemanticToken, SemanticTokens, SemanticTokensDelta, SemanticTokensDeltaParams,
61    SemanticTokensEdit, SemanticTokensFullDeltaResult, SemanticTokensFullOptions,
62    SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams,
63    SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities,
64    ServerCapabilities, ServerInfo, SignatureHelp, SignatureHelpOptions, SignatureHelpParams,
65    TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit,
66    TypeDefinitionProviderCapability, Uri, WorkDoneProgressOptions, WorkspaceDiagnosticParams,
67    WorkspaceDiagnosticReport, WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport,
68    WorkspaceFileOperationsServerCapabilities, WorkspaceEdit, WorkspaceFullDocumentDiagnosticReport,
69    WorkspaceServerCapabilities, WorkspaceSymbolParams, WorkspaceSymbolResponse,
70};
71use tower_lsp_server::{Client, LanguageServer, jsonrpc::Result};
72
73/// The main Shape Language Server
74pub struct ShapeLanguageServer {
75    /// LSP client for sending notifications and requests
76    client: Client,
77    /// Document manager for tracking open files
78    documents: DocumentManager,
79    /// Project root detected from workspace folder via shape.toml
80    project_root: std::sync::OnceLock<std::path::PathBuf>,
81    /// Cache of last successfully parsed programs per URI.
82    /// Used as fallback when current parse fails (e.g., during editing).
83    last_good_programs: DashMap<Uri, Program>,
84    /// Manager for child language servers handling foreign function blocks.
85    foreign_lsp: crate::foreign_lsp::ForeignLspManager,
86    /// Cache of last-returned semantic tokens per URI, keyed by `result_id`.
87    /// Used by `textDocument/semanticTokens/full/delta` (W2.8 feature 1.48) to
88    /// compute edit deltas instead of re-sending the full token list.
89    semantic_tokens_cache: DashMap<Uri, CachedSemanticTokens>,
90    /// Monotonic counter for generating unique `result_id` strings.
91    semantic_tokens_result_counter: std::sync::atomic::AtomicU64,
92    /// W2.4 / 1.70: inlay-hint configuration parsed from
93    /// `initializationOptions` and `workspace/didChangeConfiguration`.
94    inlay_hint_config: std::sync::RwLock<InlayHintConfig>,
95}
96
97/// Cached semantic tokens for one document; produced by `semantic_tokens_full`
98/// and consumed by `semantic_tokens_full_delta` to compute edits.
99#[derive(Clone)]
100struct CachedSemanticTokens {
101    /// `result_id` returned alongside `data`; the client echoes this as
102    /// `previous_result_id` on subsequent delta requests.
103    result_id: String,
104    /// The token sequence in LSP delta-encoded form (the same vector that was
105    /// shipped to the client).
106    data: Vec<SemanticToken>,
107}
108
109impl ShapeLanguageServer {
110    /// Create a new language server instance
111    pub fn new(client: Client) -> Self {
112        // Use current directory as default workspace; updated in initialize().
113        let default_workspace =
114            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
115        Self {
116            client,
117            documents: DocumentManager::new(),
118            project_root: std::sync::OnceLock::new(),
119            last_good_programs: DashMap::new(),
120            foreign_lsp: crate::foreign_lsp::ForeignLspManager::new(default_workspace),
121            semantic_tokens_cache: DashMap::new(),
122            semantic_tokens_result_counter: std::sync::atomic::AtomicU64::new(0),
123            inlay_hint_config: std::sync::RwLock::new(InlayHintConfig::default()),
124        }
125    }
126
127    /// Mint a fresh `result_id` for a semantic-tokens response.
128    /// The counter is process-local; sufficient because clients only ever
129    /// echo the most-recent id (per LSP §textDocument_semanticTokens).
130    fn next_semantic_tokens_result_id(&self) -> String {
131        let n = self
132            .semantic_tokens_result_counter
133            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
134        format!("st-{}", n)
135    }
136
137    /// Build the full delta-encoded semantic-token list for a document,
138    /// merging base Shape tokens, frontmatter (toml) tokens, and foreign-LSP
139    /// (Python/TypeScript embedded-block) tokens. Shared by
140    /// `semantic_tokens_full`, `semantic_tokens_range`, and
141    /// `semantic_tokens_full_delta` so all three deliveries return identical
142    /// token data for identical inputs.
143    ///
144    /// Returns `None` when the document is not open.
145    async fn collect_full_semantic_tokens(&self, uri: &Uri) -> Option<SemanticTokens> {
146        let doc = self.documents.get(uri)?;
147        let text = doc.text();
148
149        let mut tokens = get_semantic_tokens(&text)?;
150        let mut absolute = decode_semantic_tokens(&tokens.data);
151
152        let frontmatter_tokens =
153            crate::toml_support::semantic_tokens::collect_frontmatter_semantic_tokens(&text);
154        absolute.extend(
155            frontmatter_tokens
156                .into_iter()
157                .map(|token| AbsoluteSemanticToken {
158                    line: token.line,
159                    start_char: token.start_char,
160                    length: token.length,
161                    token_type: token.token_type,
162                    modifiers: token.modifiers,
163                }),
164        );
165
166        if self.last_good_programs.contains_key(uri) {
167            let foreign_tokens = self.foreign_lsp.collect_semantic_tokens(uri.as_str()).await;
168            absolute.extend(
169                foreign_tokens
170                    .into_iter()
171                    .map(|token| AbsoluteSemanticToken {
172                        line: token.line,
173                        start_char: token.start_char,
174                        length: token.length,
175                        token_type: token.token_type,
176                        modifiers: token.token_modifiers_bitset,
177                    }),
178            );
179        }
180
181        absolute.sort_by_key(|token| {
182            (
183                token.line,
184                token.start_char,
185                token.length,
186                token.token_type,
187                token.modifiers,
188            )
189        });
190        absolute.dedup_by_key(|token| {
191            (
192                token.line,
193                token.start_char,
194                token.length,
195                token.token_type,
196                token.modifiers,
197            )
198        });
199        tokens.data = encode_semantic_tokens(&absolute);
200        Some(tokens)
201    }
202
203    /// Check if a URI points to a shape.toml file.
204    fn is_shape_toml(uri: &Uri) -> bool {
205        uri.as_str().ends_with("shape.toml")
206    }
207
208    /// Analyze a shape.toml document and publish diagnostics.
209    async fn analyze_toml_document(&self, uri: &Uri) {
210        let doc = match self.documents.get(uri) {
211            Some(doc) => doc,
212            None => return,
213        };
214
215        let text = doc.text();
216        let diagnostics = crate::toml_support::diagnostics::validate_toml(&text);
217
218        self.client
219            .publish_diagnostics(uri.clone(), diagnostics, None)
220            .await;
221    }
222
223    /// W2.3 / 1.73 + 1.74 — compute the diagnostic list for a URI on
224    /// demand (without publishing). Used by the
225    /// `textDocument/diagnostic` + `workspace/diagnostic` pull handlers.
226    ///
227    /// Runs the same parse + semantic-analysis pipeline as the push
228    /// path (`analyze_document`) so push-mode and pull-mode produce
229    /// identical reports for the same source. This bypasses the
230    /// `publish_diagnostics` send and the parse-result caching side
231    /// effects so the pull handler is a true read-only operation
232    /// against the currently-tracked document text.
233    async fn compute_pull_diagnostics(&self, uri: &Uri) -> Vec<Diagnostic> {
234        let Some(doc) = self.documents.get(uri) else {
235            return Vec::new();
236        };
237        let text = doc.text();
238
239        // Parse (resilient — we only need diagnostics, not cache).
240        let parse_source = mask_leading_prefix_for_parse(&text, 0);
241        match parse_program(parse_source.as_ref()) {
242            Ok(program) => {
243                let module_cache = self.documents.get_module_cache();
244                let mut diagnostics = crate::analysis::analyze_program_semantics(
245                    &program,
246                    &text,
247                    uri.to_file_path().as_deref(),
248                    Some(&module_cache),
249                    self.project_root.get().map(|p| p.as_path()),
250                );
251                diagnostics.extend(crate::doc_diagnostics::validate_program_docs(
252                    &program,
253                    &text,
254                    Some(&module_cache),
255                    uri.to_file_path().as_deref(),
256                    self.project_root.get().map(|p| p.as_path()),
257                ));
258                diagnostics.extend(self.foreign_lsp.get_diagnostics(uri.as_str()).await);
259                diagnostics
260            }
261            Err(error) => error_to_diagnostic(&error),
262        }
263    }
264
265    /// Analyze a document and publish diagnostics
266    async fn analyze_document(&self, uri: &Uri) {
267        // Get the document
268        let doc = match self.documents.get(uri) {
269            Some(doc) => doc,
270            None => return,
271        };
272
273        // Parse the document
274        let text = doc.text();
275
276        // Validate frontmatter if present (check for project-level sections)
277        let mut frontmatter_diagnostics = Vec::new();
278        let frontmatter_prefix_len;
279        {
280            use shape_runtime::frontmatter::{
281                FrontmatterDiagnosticSeverity, parse_frontmatter, parse_frontmatter_validated,
282            };
283            let (config, fm_diags, rest) = parse_frontmatter_validated(&text);
284            frontmatter_prefix_len = text.len().saturating_sub(rest.len());
285            for diag in fm_diags {
286                let severity = match diag.severity {
287                    FrontmatterDiagnosticSeverity::Error => DiagnosticSeverity::ERROR,
288                    FrontmatterDiagnosticSeverity::Warning => DiagnosticSeverity::WARNING,
289                };
290                let range = diag
291                    .location
292                    .map(|loc| Range {
293                        start: Position {
294                            line: loc.line,
295                            character: loc.character,
296                        },
297                        end: Position {
298                            line: loc.line,
299                            character: loc.character + loc.length.max(1),
300                        },
301                    })
302                    .unwrap_or_else(frontmatter_fallback_range);
303                frontmatter_diagnostics.push(Diagnostic {
304                    range,
305                    severity: Some(severity),
306                    message: diag.message,
307                    source: Some("shape".to_string()),
308                    ..Default::default()
309                });
310            }
311
312            // Frontmatter and shape.toml are mutually exclusive.
313            if config.is_some() {
314                if let (Some(project_root), Some(path)) =
315                    (self.project_root.get(), uri.to_file_path())
316                {
317                    if path.as_ref().starts_with(project_root) {
318                        frontmatter_diagnostics.push(Diagnostic {
319                            range: Range {
320                                start: Position {
321                                    line: 0,
322                                    character: 0,
323                                },
324                                end: Position {
325                                    line: 0,
326                                    character: 3,
327                                },
328                            },
329                            severity: Some(DiagnosticSeverity::ERROR),
330                            message: "Frontmatter and shape.toml are mutually exclusive; use one configuration source.".to_string(),
331                            source: Some("shape".to_string()),
332                            ..Default::default()
333                        });
334                    }
335                }
336            }
337
338            // Validate frontmatter extension paths when running as a standalone script.
339            if let (Some(frontmatter), Some(script_path)) =
340                (parse_frontmatter(&text).0, uri.to_file_path())
341            {
342                let path_ranges = frontmatter_extension_path_ranges(&text);
343                if let Some(script_dir) = script_path.as_ref().parent() {
344                    for (index, extension) in frontmatter.extensions.into_iter().enumerate() {
345                        let resolved = if extension.path.is_absolute() {
346                            extension.path.clone()
347                        } else {
348                            script_dir.join(&extension.path)
349                        };
350                        if !resolved.exists() {
351                            let range = path_ranges
352                                .get(index)
353                                .cloned()
354                                .unwrap_or_else(frontmatter_fallback_range);
355                            frontmatter_diagnostics.push(Diagnostic {
356                                range,
357                                severity: Some(DiagnosticSeverity::ERROR),
358                                message: format!(
359                                    "Extension '{}' path does not exist: {}",
360                                    extension.name,
361                                    resolved.display()
362                                ),
363                                source: Some("shape".to_string()),
364                                ..Default::default()
365                            });
366                        }
367                    }
368                }
369            }
370        }
371
372        let parse_source = mask_leading_prefix_for_parse(&text, frontmatter_prefix_len);
373        let diagnostics = match parse_program(parse_source.as_ref()) {
374            Ok(program) => {
375                // Parsing succeeded — cache the good program for fallback
376                self.last_good_programs.insert(uri.clone(), program.clone());
377
378                // Update virtual documents for foreign function blocks
379                let file_path = uri.to_file_path();
380                let foreign_startup_diagnostics = self
381                    .foreign_lsp
382                    .update_documents(
383                        uri.as_str(),
384                        &text,
385                        &program.items,
386                        file_path.as_deref(),
387                        self.project_root.get().map(|p| p.as_path()),
388                    )
389                    .await;
390
391                let module_cache = self.documents.get_module_cache();
392                let mut diagnostics = crate::analysis::analyze_program_semantics(
393                    &program,
394                    &text,
395                    uri.to_file_path().as_deref(),
396                    Some(&module_cache),
397                    self.project_root.get().map(|p| p.as_path()),
398                );
399                diagnostics.extend(crate::doc_diagnostics::validate_program_docs(
400                    &program,
401                    &text,
402                    Some(&module_cache),
403                    uri.to_file_path().as_deref(),
404                    self.project_root.get().map(|p| p.as_path()),
405                ));
406                diagnostics.extend(foreign_startup_diagnostics);
407                diagnostics.extend(self.foreign_lsp.get_diagnostics(uri.as_str()).await);
408                diagnostics
409            }
410            Err(error) => {
411                // Parsing failed — try resilient parse for partial results
412                let partial = shape_ast::parse_program_resilient(parse_source.as_ref());
413                let mut diagnostics = Vec::new();
414                let has_non_grammar_partial_error = partial
415                    .errors
416                    .iter()
417                    .any(|e| !matches!(e.kind, ParseErrorKind::GrammarFailure));
418
419                // Prefer strict parser diagnostics for pure grammar failures.
420                // Add resilient diagnostics when they provide specific recovered spans/messages.
421                if partial.errors.is_empty() || !has_non_grammar_partial_error {
422                    diagnostics.extend(error_to_diagnostic(&error));
423                }
424
425                // Add diagnostics for recovery errors
426                if has_non_grammar_partial_error {
427                    for parse_error in &partial.errors {
428                        if matches!(parse_error.kind, ParseErrorKind::GrammarFailure) {
429                            continue;
430                        }
431                        let (start_line, start_col) = offset_to_line_col(&text, parse_error.span.0);
432                        let (end_line, end_col) = offset_to_line_col(&text, parse_error.span.1);
433                        diagnostics.push(Diagnostic {
434                            range: Range {
435                                start: Position {
436                                    line: start_line,
437                                    character: start_col,
438                                },
439                                end: Position {
440                                    line: end_line,
441                                    character: end_col,
442                                },
443                            },
444                            severity: Some(DiagnosticSeverity::ERROR),
445                            message: parse_error.message.clone(),
446                            source: Some("shape".to_string()),
447                            ..Default::default()
448                        });
449                    }
450                }
451
452                if !partial.items.is_empty() {
453                    // Cache the partial result so hover/completions have something to work with
454                    self.last_good_programs
455                        .insert(uri.clone(), partial.into_program());
456                }
457
458                diagnostics
459            }
460        };
461
462        // Combine frontmatter diagnostics with parse/semantic diagnostics
463        let mut all_diagnostics = frontmatter_diagnostics;
464        all_diagnostics.extend(diagnostics);
465
466        // Publish diagnostics to the client
467        self.client
468            .publish_diagnostics(uri.clone(), all_diagnostics, None)
469            .await;
470    }
471}
472
473impl LanguageServer for ShapeLanguageServer {
474    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
475        // Log initialization
476        self.client
477            .log_message(MessageType::INFO, "Shape Language Server initializing")
478            .await;
479
480        // Detect project root from workspace folders
481        let mut workspace_folder: Option<std::path::PathBuf> = None;
482        if let Some(folders) = params.workspace_folders.as_ref() {
483            if let Some(folder) = folders.first() {
484                if let Some(folder_path) = folder.uri.to_file_path() {
485                    workspace_folder = Some(folder_path.to_path_buf());
486                    if let Some(project) = shape_runtime::project::find_project_root(&folder_path) {
487                        self.client
488                            .log_message(
489                                MessageType::INFO,
490                                format!(
491                                    "Detected project root: {} ({})",
492                                    project.config.project.name,
493                                    project.root_path.display()
494                                ),
495                            )
496                            .await;
497                        let _ = self.project_root.set(project.root_path);
498                    }
499                }
500            }
501        }
502
503        // Update the foreign LSP manager's workspace dir so child servers
504        // receive the correct rootUri and can discover project config files
505        // (e.g. pyrightconfig.json, virtualenvs).
506        if let Some(dir) = self
507            .project_root
508            .get()
509            .cloned()
510            .or_else(|| workspace_folder.clone())
511        {
512            self.foreign_lsp.set_workspace_dir(dir);
513        }
514
515        let workspace_hint = self
516            .project_root
517            .get()
518            .map(|path| path.as_path())
519            .or(workspace_folder.as_deref());
520        let configured_extensions = configured_extensions_from_lsp_value(
521            params.initialization_options.as_ref(),
522            workspace_hint,
523        );
524        if !configured_extensions.is_empty() {
525            self.client
526                .log_message(
527                    MessageType::INFO,
528                    format!(
529                        "Configured {} always-load extension(s) from LSP initialization options",
530                        configured_extensions.len()
531                    ),
532                )
533                .await;
534        }
535        self.foreign_lsp
536            .set_configured_extensions(configured_extensions)
537            .await;
538
539        // W2.4 / 1.70: parse inlay-hint config from initialization options.
540        let initial_inlay_cfg =
541            InlayHintConfig::from_lsp_settings(params.initialization_options.as_ref());
542        if let Ok(mut guard) = self.inlay_hint_config.write() {
543            *guard = initial_inlay_cfg;
544        }
545
546        Ok(InitializeResult {
547            capabilities: ServerCapabilities {
548                // Use full text document sync (incremental requires proper range handling)
549                text_document_sync: Some(TextDocumentSyncCapability::Kind(
550                    TextDocumentSyncKind::FULL,
551                )),
552
553                // Enable completion support
554                //
555                // W2.2 (LSP feature 1.10 / 1.11): `resolve_provider` enables
556                // `completionItem/resolve` for lazy detail expansion; the
557                // `all_commit_characters` list lets clients auto-commit completion
558                // on common Shape punctuation when per-item commit chars are absent.
559                completion_provider: Some(CompletionOptions {
560                    resolve_provider: Some(true),
561                    trigger_characters: Some(vec![
562                        ".".to_string(),
563                        "(".to_string(),
564                        " ".to_string(),
565                        "@".to_string(),
566                        ":".to_string(),
567                    ]),
568                    work_done_progress_options: WorkDoneProgressOptions {
569                        work_done_progress: None,
570                    },
571                    all_commit_characters: Some(vec![
572                        ".".to_string(),
573                        "(".to_string(),
574                        ",".to_string(),
575                        ";".to_string(),
576                    ]),
577                    completion_item: None,
578                }),
579
580                // Enable hover support
581                hover_provider: Some(HoverProviderCapability::Simple(true)),
582
583                // Enable signature help
584                signature_help_provider: Some(SignatureHelpOptions {
585                    trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
586                    retrigger_characters: None,
587                    work_done_progress_options: WorkDoneProgressOptions {
588                        work_done_progress: None,
589                    },
590                }),
591
592                // Enable document symbols (outline view)
593                document_symbol_provider: Some(OneOf::Left(true)),
594
595                // Enable workspace symbols (find symbol across workspace)
596                workspace_symbol_provider: Some(OneOf::Left(true)),
597
598                // Enable go-to-definition
599                definition_provider: Some(OneOf::Left(true)),
600
601                // W2.5 / feature 1.35: declaration alias (no decl/def split in
602                // Shape; behaves identically to `textDocument/definition`).
603                declaration_provider: Some(DeclarationCapability::Simple(true)),
604
605                // W2.5 / feature 1.33: jump to the *type* of the expression at
606                // the cursor (vs jump to the symbol's own definition site).
607                type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
608
609                // W2.5 / feature 1.34: enumerate impl blocks for a trait or
610                // type at the cursor (current-file scope).
611                implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
612
613                // W2.5 / feature 1.60: highlight every occurrence of the
614                // symbol at the cursor within the active file (scope-aware).
615                document_highlight_provider: Some(OneOf::Left(true)),
616
617                // Enable find references
618                references_provider: Some(OneOf::Left(true)),
619
620                // Enable semantic tokens for syntax highlighting.
621                // W2.8 / feature 1.47: range delivery for fast editor scrolling.
622                // W2.8 / feature 1.48: delta delivery for small-edit wire savings.
623                semantic_tokens_provider: Some(
624                    SemanticTokensServerCapabilities::SemanticTokensOptions(
625                        SemanticTokensOptions {
626                            work_done_progress_options: WorkDoneProgressOptions {
627                                work_done_progress: None,
628                            },
629                            legend: get_legend(),
630                            range: Some(true),
631                            full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
632                        },
633                    ),
634                ),
635
636                // Enable inlay hints (type hints, parameter hints).
637                // W2.4 / 1.28: advertise the inlay-hint resolve provider so
638                // clients defer tooltip/edit synthesis to `inlayHint/resolve`.
639                inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options(
640                    InlayHintOptions {
641                        work_done_progress_options: WorkDoneProgressOptions {
642                            work_done_progress: None,
643                        },
644                        resolve_provider: Some(true),
645                    },
646                ))),
647
648                // Enable code actions (quick fixes, refactoring)
649                code_action_provider: Some(CodeActionProviderCapability::Options(
650                    CodeActionOptions {
651                        code_action_kinds: Some(vec![
652                            CodeActionKind::QUICKFIX,
653                            CodeActionKind::REFACTOR,
654                            CodeActionKind::REFACTOR_EXTRACT,
655                            CodeActionKind::REFACTOR_REWRITE,
656                            CodeActionKind::SOURCE,
657                            CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
658                            CodeActionKind::SOURCE_FIX_ALL,
659                        ]),
660                        work_done_progress_options: WorkDoneProgressOptions {
661                            work_done_progress: None,
662                        },
663                        resolve_provider: Some(false),
664                    },
665                )),
666
667                // Enable document formatting
668                document_formatting_provider: Some(OneOf::Left(true)),
669
670                // Enable range formatting
671                document_range_formatting_provider: Some(OneOf::Left(true)),
672
673                // Enable on-type formatting for indentation while typing.
674                document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions {
675                    first_trigger_character: "}".to_string(),
676                    more_trigger_character: Some(vec!["\n".to_string()]),
677                }),
678
679                // Enable rename support
680                rename_provider: Some(OneOf::Right(RenameOptions {
681                    prepare_provider: Some(true),
682                    work_done_progress_options: WorkDoneProgressOptions {
683                        work_done_progress: None,
684                    },
685                })),
686
687                // Enable code lens
688                code_lens_provider: Some(CodeLensOptions {
689                    resolve_provider: Some(true),
690                }),
691
692                // Enable folding ranges
693                folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
694
695                // Enable call hierarchy
696                call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
697
698                // Enable document links (clickable `@see` / `@link` doc-tag
699                // references in doc comments). Resolution is eager — every
700                // returned `DocumentLink` already carries its target URI.
701                document_link_provider: Some(DocumentLinkOptions {
702                    resolve_provider: Some(false),
703                    work_done_progress_options: WorkDoneProgressOptions {
704                        work_done_progress: None,
705                    },
706                }),
707
708                // W2.7 / 1.71: workspace/executeCommand — declare the
709                // server-side command vocabulary used by code-lens titles
710                // (`shape.findReferences`, `shape.findImplementations`,
711                // `shape.runTests`, etc.). The handler today acknowledges
712                // every registered command and returns `None` so clients
713                // route follow-up requests (e.g. textDocument/references)
714                // through the corresponding LSP method; declaring the
715                // capability is the load-bearing protocol step that lets
716                // editors enable the lens click-targets at all.
717                execute_command_provider: Some(ExecuteCommandOptions {
718                    commands: registered_commands(),
719                    work_done_progress_options: WorkDoneProgressOptions {
720                        work_done_progress: None,
721                    },
722                }),
723
724                // W2.3 / 1.73 + 1.74: pull-based diagnostics —
725                // `textDocument/diagnostic` + `workspace/diagnostic`.
726                //
727                // Shape's primary delivery is still push-based
728                // `publishDiagnostics` (LSP 3.0; preserved at
729                // `analyze_document::publish_diagnostics`). This
730                // capability declares the LSP 3.17 pull surface
731                // alongside it so power editors (Helix, Zed, recent
732                // Neovim) can opt into pull-mode. `inter_file_dependencies:
733                // true` because Shape's module imports mean editing one
734                // file can change diagnostics in another (matches
735                // rust-analyzer's setting). `workspace_diagnostics:
736                // true` advertises the `workspace/diagnostic` companion
737                // pull. `identifier: "shape"` tags the result so clients
738                // multiplexing reports across servers can route them.
739                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
740                    DiagnosticOptions {
741                        identifier: Some("shape".to_string()),
742                        inter_file_dependencies: true,
743                        workspace_diagnostics: true,
744                        work_done_progress_options: WorkDoneProgressOptions {
745                            work_done_progress: None,
746                        },
747                    },
748                )),
749
750                // W2.7 / 1.72: workspace.file_operations.will_rename —
751                // before files are renamed, return a `WorkspaceEdit` that
752                // rewrites `from OLD_MODULE_PATH use ...` to
753                // `from NEW_MODULE_PATH use ...` in every importing file.
754                // The filter narrows to `*.shape` so non-Shape file
755                // renames in mixed workspaces don't round-trip a request
756                // we have no edits for.
757                workspace: Some(WorkspaceServerCapabilities {
758                    workspace_folders: None,
759                    file_operations: Some(WorkspaceFileOperationsServerCapabilities {
760                        will_rename: Some(FileOperationRegistrationOptions {
761                            filters: vec![FileOperationFilter {
762                                scheme: Some("file".to_string()),
763                                pattern: FileOperationPattern {
764                                    glob: "**/*.shape".to_string(),
765                                    matches: Some(FileOperationPatternKind::File),
766                                    options: None,
767                                },
768                            }],
769                        }),
770                        ..Default::default()
771                    }),
772                }),
773
774                ..ServerCapabilities::default()
775            },
776            server_info: Some(ServerInfo {
777                name: "Shape Language Server".to_string(),
778                version: Some(env!("CARGO_PKG_VERSION").to_string()),
779            }),
780            ..InitializeResult::default()
781        })
782    }
783
784    async fn initialized(&self, _params: InitializedParams) {
785        self.client
786            .log_message(MessageType::INFO, "Shape Language Server initialized")
787            .await;
788    }
789
790    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
791        let workspace_hint = self.project_root.get().map(|path| path.as_path());
792        let configured_extensions =
793            configured_extensions_from_lsp_value(Some(&params.settings), workspace_hint);
794        self.foreign_lsp
795            .set_configured_extensions(configured_extensions.clone())
796            .await;
797
798        // W2.4 / 1.70: refresh inlay-hint config from client preferences.
799        let new_inlay_cfg = InlayHintConfig::from_lsp_settings(Some(&params.settings));
800        if let Ok(mut guard) = self.inlay_hint_config.write() {
801            *guard = new_inlay_cfg;
802        }
803        let _ = self.client.inlay_hint_refresh().await;
804
805        self.client
806            .log_message(
807                MessageType::INFO,
808                format!(
809                    "Updated always-load extensions from configuration change ({} configured); inlay-hint config refreshed",
810                    configured_extensions.len()
811                ),
812            )
813            .await;
814    }
815
816    async fn shutdown(&self) -> Result<()> {
817        self.client
818            .log_message(MessageType::INFO, "Shape Language Server shutting down")
819            .await;
820        // Gracefully shut down all child language servers
821        self.foreign_lsp.shutdown().await;
822        Ok(())
823    }
824
825    async fn did_open(&self, params: DidOpenTextDocumentParams) {
826        let uri = params.text_document.uri;
827        let version = params.text_document.version;
828        let text = params.text_document.text;
829
830        self.client
831            .log_message(
832                MessageType::INFO,
833                format!("Document opened: {}", uri.as_str()),
834            )
835            .await;
836
837        // Store the document
838        self.documents.open(uri.clone(), version, text);
839
840        // Route to TOML analysis for shape.toml files
841        if Self::is_shape_toml(&uri) {
842            self.analyze_toml_document(&uri).await;
843            return;
844        }
845
846        // Analyze and publish diagnostics
847        self.analyze_document(&uri).await;
848    }
849
850    async fn did_change(&self, params: DidChangeTextDocumentParams) {
851        let uri = params.text_document.uri;
852        let version = params.text_document.version;
853
854        // For now, we only handle full document updates
855        // In the future, we can optimize this to handle incremental changes
856        if let Some(change) = params.content_changes.into_iter().next() {
857            let text = change.text;
858            self.documents.update(&uri, version, text);
859
860            self.client
861                .log_message(
862                    MessageType::INFO,
863                    format!("Document changed: {} (version {})", uri.as_str(), version),
864                )
865                .await;
866
867            // Route to TOML analysis for shape.toml files
868            if Self::is_shape_toml(&uri) {
869                self.analyze_toml_document(&uri).await;
870                return;
871            }
872
873            // Analyze and publish diagnostics
874            self.analyze_document(&uri).await;
875        }
876    }
877
878    async fn did_close(&self, params: DidCloseTextDocumentParams) {
879        let uri = params.text_document.uri;
880
881        self.client
882            .log_message(
883                MessageType::INFO,
884                format!("Document closed: {}", uri.as_str()),
885            )
886            .await;
887
888        // Clear diagnostics for the closed document
889        self.client
890            .publish_diagnostics(uri.clone(), vec![], None)
891            .await;
892
893        // Remove cached program for closed document
894        self.last_good_programs.remove(&uri);
895
896        // Drop the semantic-tokens cache entry so closed files don't pin
897        // their last token snapshot in memory (W2.8 feature 1.48 hygiene).
898        self.semantic_tokens_cache.remove(&uri);
899
900        self.documents.close(&uri);
901    }
902
903    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
904        let uri = params.text_document_position.text_document.uri;
905        let position = params.text_document_position.position;
906
907        self.client
908            .log_message(
909                MessageType::INFO,
910                format!(
911                    "Completion requested at {}:{}:{}",
912                    uri.as_str(),
913                    position.line,
914                    position.character
915                ),
916            )
917            .await;
918
919        // Route to TOML completions for shape.toml files
920        if Self::is_shape_toml(&uri) {
921            let doc = match self.documents.get(&uri) {
922                Some(doc) => doc,
923                None => return Ok(None),
924            };
925            let text = doc.text();
926            let items = crate::toml_support::completions::get_toml_completions(&text, position);
927            return Ok(Some(CompletionResponse::Array(items)));
928        }
929
930        // Get the document
931        let doc = match self.documents.get(&uri) {
932            Some(doc) => doc,
933            None => return Ok(None),
934        };
935
936        let text = doc.text();
937
938        // Route to frontmatter completions for script frontmatter blocks.
939        if is_position_in_frontmatter(&text, position) {
940            let items =
941                crate::toml_support::completions::get_frontmatter_completions(&text, position);
942            return Ok(Some(CompletionResponse::Array(items)));
943        }
944
945        // Check if the cursor is inside a foreign function body
946        if let Some(cached_program) = self.last_good_programs.get(&uri) {
947            if crate::foreign_lsp::is_position_in_foreign_block(
948                &cached_program.items,
949                &text,
950                position,
951            ) {
952                let completions = self
953                    .foreign_lsp
954                    .handle_completion(uri.as_str(), position, &cached_program.items, &text)
955                    .await;
956                if let Some(items) = completions {
957                    return Ok(Some(CompletionResponse::Array(items)));
958                }
959                // If delegation failed, fall through to Shape completions
960            }
961        }
962
963        let cached_symbols = self.documents.get_cached_symbols(&uri);
964        let cached_types = self.documents.get_cached_types(&uri);
965
966        // Get completions (with cached symbols as fallback)
967        let module_cache = self.documents.get_module_cache();
968        let file_path = uri.to_file_path();
969        let (completions, updated_symbols, updated_types) = get_completions_with_context(
970            &text,
971            position,
972            &cached_symbols,
973            &cached_types,
974            Some(&module_cache),
975            file_path.as_deref(),
976            self.project_root.get().map(|p| p.as_path()),
977        );
978
979        // Update cached symbols if parsing succeeded
980        if let Some(symbols) = updated_symbols {
981            self.documents.update_cached_symbols(&uri, symbols);
982        }
983        if let Some(types) = updated_types {
984            self.documents.update_cached_types(&uri, types);
985        }
986
987        Ok(Some(CompletionResponse::Array(completions)))
988    }
989
990    /// `completionItem/resolve` — lazy detail expansion (LSP feature 1.10).
991    ///
992    /// Today every completion item ships its full payload (label, detail,
993    /// documentation, snippet) inline from the `completion` handler. The
994    /// resolver therefore behaves as an identity pass-through so the round-
995    /// trip is cheap and clients that route through resolve still get the
996    /// full item back. W2.2 lays the protocol foundation; downstream waves
997    /// can swap in genuinely-deferred metadata producers without changing
998    /// the registered capability.
999    async fn completion_resolve(&self, item: CompletionItem) -> Result<CompletionItem> {
1000        Ok(item)
1001    }
1002
1003    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1004        let uri = params.text_document_position_params.text_document.uri;
1005        let position = params.text_document_position_params.position;
1006
1007        self.client
1008            .log_message(
1009                MessageType::INFO,
1010                format!(
1011                    "Hover requested at {}:{}:{}",
1012                    uri.as_str(),
1013                    position.line,
1014                    position.character
1015                ),
1016            )
1017            .await;
1018
1019        // Route to TOML hover for shape.toml files
1020        if Self::is_shape_toml(&uri) {
1021            let doc = match self.documents.get(&uri) {
1022                Some(doc) => doc,
1023                None => return Ok(None),
1024            };
1025            let text = doc.text();
1026            return Ok(crate::toml_support::hover::get_toml_hover(&text, position));
1027        }
1028
1029        // Get the document
1030        let doc = match self.documents.get(&uri) {
1031            Some(doc) => doc,
1032            None => return Ok(None),
1033        };
1034
1035        let text = doc.text();
1036
1037        // Check if the cursor is inside a foreign function body
1038        if let Some(cached_program) = self.last_good_programs.get(&uri) {
1039            if crate::foreign_lsp::is_position_in_foreign_block(
1040                &cached_program.items,
1041                &text,
1042                position,
1043            ) {
1044                let hover = self
1045                    .foreign_lsp
1046                    .handle_hover(uri.as_str(), position, &cached_program.items, &text)
1047                    .await;
1048                if hover.is_some() {
1049                    return Ok(hover);
1050                }
1051                // If delegation failed, fall through to Shape hover
1052            }
1053        }
1054
1055        // Get hover information, passing module cache for imported symbol lookup
1056        let module_cache = self.documents.get_module_cache();
1057        let file_path = uri.to_file_path();
1058        let cached = self.last_good_programs.get(&uri);
1059        let cached_ref = cached.as_ref().map(|r| r.value());
1060        let hover = get_hover(
1061            &text,
1062            position,
1063            Some(&module_cache),
1064            file_path.as_deref(),
1065            cached_ref,
1066        );
1067
1068        Ok(hover)
1069    }
1070
1071    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1072        let uri = params.text_document_position_params.text_document.uri;
1073        let position = params.text_document_position_params.position;
1074
1075        // Get the document
1076        let doc = match self.documents.get(&uri) {
1077            Some(doc) => doc,
1078            None => return Ok(None),
1079        };
1080
1081        let text = doc.text();
1082
1083        if let Some(cached_program) = self.last_good_programs.get(&uri) {
1084            if crate::foreign_lsp::is_position_in_foreign_block(
1085                &cached_program.items,
1086                &text,
1087                position,
1088            ) {
1089                let signature_help = self
1090                    .foreign_lsp
1091                    .handle_signature_help(uri.as_str(), position, &cached_program.items, &text)
1092                    .await;
1093                if signature_help.is_some() {
1094                    return Ok(signature_help);
1095                }
1096            }
1097        }
1098
1099        // Get signature help
1100        let sig_help = get_signature_help(&text, position);
1101
1102        Ok(sig_help)
1103    }
1104
1105    async fn document_symbol(
1106        &self,
1107        params: DocumentSymbolParams,
1108    ) -> Result<Option<DocumentSymbolResponse>> {
1109        let uri = params.text_document.uri;
1110
1111        // Get the document
1112        let doc = match self.documents.get(&uri) {
1113            Some(doc) => doc,
1114            None => return Ok(None),
1115        };
1116
1117        let text = doc.text();
1118
1119        // Get document symbols
1120        let symbols = get_document_symbols(&text);
1121
1122        Ok(symbols)
1123    }
1124
1125    async fn symbol(
1126        &self,
1127        params: WorkspaceSymbolParams,
1128    ) -> Result<Option<WorkspaceSymbolResponse>> {
1129        let query = params.query;
1130        let mut all_symbols = Vec::new();
1131        let mut visited: HashSet<std::path::PathBuf> = HashSet::new();
1132
1133        // W2.7 / 1.53: workspace symbol eager indexing.
1134        //
1135        // Phase 1 — open documents (always live; text is freshest from the
1136        // editor and may differ from the on-disk file).
1137        for uri in self.documents.all_uris() {
1138            if let Some(doc) = self.documents.get(&uri) {
1139                if let Some(path_cow) = uri.to_file_path() {
1140                    visited.insert(path_cow.into_owned());
1141                }
1142                let text = doc.text();
1143                let symbols = get_workspace_symbols(&text, &uri, &query);
1144                all_symbols.extend(symbols);
1145            }
1146        }
1147
1148        // Phase 2 — on-disk workspace files (read once per request, skip
1149        // anything already covered by an open document). Uses the
1150        // module-cache enumerator (W2.6 groundwork) which caps at
1151        // MAX_WORKSPACE_FILES (4096) and excludes hidden + build dirs.
1152        if let Some(root) = self.project_root.get() {
1153            let module_cache = self.documents.get_module_cache();
1154            for path in module_cache.enumerate_workspace_shape_files(root.as_path()) {
1155                if !visited.insert(path.clone()) {
1156                    continue;
1157                }
1158                let Some(file_uri) = Uri::from_file_path(&path) else {
1159                    continue;
1160                };
1161                let Ok(text) = std::fs::read_to_string(&path) else {
1162                    continue;
1163                };
1164                let symbols = get_workspace_symbols(&text, &file_uri, &query);
1165                all_symbols.extend(symbols);
1166            }
1167        }
1168
1169        if all_symbols.is_empty() {
1170            Ok(None)
1171        } else {
1172            Ok(Some(WorkspaceSymbolResponse::Flat(all_symbols)))
1173        }
1174    }
1175
1176    async fn goto_definition(
1177        &self,
1178        params: GotoDefinitionParams,
1179    ) -> Result<Option<GotoDefinitionResponse>> {
1180        let uri = params.text_document_position_params.text_document.uri;
1181        let position = params.text_document_position_params.position;
1182
1183        // Get the document
1184        let doc = match self.documents.get(&uri) {
1185            Some(doc) => doc,
1186            None => return Ok(None),
1187        };
1188
1189        let text = doc.text();
1190
1191        if let Some(cached_program) = self.last_good_programs.get(&uri) {
1192            if crate::foreign_lsp::is_position_in_foreign_block(
1193                &cached_program.items,
1194                &text,
1195                position,
1196            ) {
1197                let definition = self
1198                    .foreign_lsp
1199                    .handle_definition(uri.as_str(), position, &cached_program.items, &text)
1200                    .await;
1201                if definition.is_some() {
1202                    return Ok(definition);
1203                }
1204            }
1205        }
1206
1207        // Get module cache for cross-file navigation
1208        let module_cache = self.documents.get_module_cache();
1209
1210        // Discover annotations for go-to-definition on annotation names
1211        let mut annotation_discovery = AnnotationDiscovery::new();
1212        let parse_source = parser_source(&text);
1213        if let Ok(program) = parse_program(parse_source.as_ref()) {
1214            annotation_discovery.discover_from_program(&program);
1215            if let Some(file_path) = uri.to_file_path() {
1216                annotation_discovery.discover_from_imports_with_cache(
1217                    &program,
1218                    &file_path,
1219                    &module_cache,
1220                    self.project_root.get().map(|p| p.as_path()),
1221                );
1222            }
1223        }
1224
1225        let cached = self.last_good_programs.get(&uri);
1226        let cached_ref = cached.as_ref().map(|r| r.value());
1227        let definition = get_definition(
1228            &text,
1229            position,
1230            &uri,
1231            Some(&module_cache),
1232            Some(&annotation_discovery),
1233            cached_ref,
1234        );
1235
1236        Ok(definition)
1237    }
1238
1239    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1240        let uri = params.text_document_position.text_document.uri;
1241        let position = params.text_document_position.position;
1242
1243        // Get the document
1244        let doc = match self.documents.get(&uri) {
1245            Some(doc) => doc,
1246            None => return Ok(None),
1247        };
1248
1249        let text = doc.text();
1250
1251        if let Some(cached_program) = self.last_good_programs.get(&uri) {
1252            if crate::foreign_lsp::is_position_in_foreign_block(
1253                &cached_program.items,
1254                &text,
1255                position,
1256            ) {
1257                let references = self
1258                    .foreign_lsp
1259                    .handle_references(uri.as_str(), position, &cached_program.items, &text)
1260                    .await;
1261                if references.is_some() {
1262                    return Ok(references);
1263                }
1264            }
1265        }
1266
1267        // W2.6 — cross-file references via ScopeTree-driven scan over open
1268        // documents + workspace .shape files. Same-file path still uses
1269        // ScopeTree::references_of (correctness preserved); cross-file
1270        // restricted to module-scope-visible top-level symbols.
1271        let cached = self.last_good_programs.get(&uri);
1272        let cached_ref = cached.as_ref().map(|r| r.value());
1273        let module_cache = self.documents.get_module_cache();
1274        let workspace_root = self.project_root.get().map(|p| p.as_path());
1275        let references = get_references_cross_file(
1276            &text,
1277            position,
1278            &uri,
1279            cached_ref,
1280            Some(&self.documents),
1281            Some(&module_cache),
1282            workspace_root,
1283        );
1284
1285        Ok(references)
1286    }
1287
1288    /// W2.5 / feature 1.33 (`textDocument/typeDefinition`): jump from an
1289    /// expression to the definition site of its *type*. Delegates to
1290    /// `definition::get_type_definition`, which infers the type via
1291    /// `type_inference::infer_variable_type`, strips generic wrappers, and
1292    /// re-uses the existing definition lookup for the resulting type name.
1293    async fn goto_type_definition(
1294        &self,
1295        params: GotoTypeDefinitionParams,
1296    ) -> Result<Option<GotoTypeDefinitionResponse>> {
1297        let uri = params.text_document_position_params.text_document.uri;
1298        let position = params.text_document_position_params.position;
1299
1300        let doc = match self.documents.get(&uri) {
1301            Some(doc) => doc,
1302            None => return Ok(None),
1303        };
1304        let text = doc.text();
1305
1306        let module_cache = self.documents.get_module_cache();
1307        let cached = self.last_good_programs.get(&uri);
1308        let cached_ref = cached.as_ref().map(|r| r.value());
1309        let response = get_type_definition(&text, position, &uri, Some(&module_cache), cached_ref);
1310
1311        Ok(response)
1312    }
1313
1314    /// W2.5 / feature 1.34 (`textDocument/implementation`): enumerate
1315    /// `impl Trait for Type` / `extend Type` blocks for the trait or type
1316    /// at the cursor. Current-file scope only — cross-workspace impl
1317    /// discovery requires the W2.6 / W2.7 workspace indexing work.
1318    async fn goto_implementation(
1319        &self,
1320        params: GotoImplementationParams,
1321    ) -> Result<Option<GotoImplementationResponse>> {
1322        let uri = params.text_document_position_params.text_document.uri;
1323        let position = params.text_document_position_params.position;
1324
1325        let doc = match self.documents.get(&uri) {
1326            Some(doc) => doc,
1327            None => return Ok(None),
1328        };
1329        let text = doc.text();
1330
1331        let cached = self.last_good_programs.get(&uri);
1332        let cached_ref = cached.as_ref().map(|r| r.value());
1333        let locations = get_implementations(&text, position, &uri, cached_ref);
1334
1335        Ok(locations.map(GotoImplementationResponse::Array))
1336    }
1337
1338    /// W2.5 / feature 1.35 (`textDocument/declaration`): alias of
1339    /// `textDocument/definition` — Shape has no declaration/definition split.
1340    /// VS Code's `Go to Declaration` command dispatches here.
1341    async fn goto_declaration(
1342        &self,
1343        params: GotoDeclarationParams,
1344    ) -> Result<Option<GotoDeclarationResponse>> {
1345        let uri = params.text_document_position_params.text_document.uri;
1346        let position = params.text_document_position_params.position;
1347
1348        let doc = match self.documents.get(&uri) {
1349            Some(doc) => doc,
1350            None => return Ok(None),
1351        };
1352        let text = doc.text();
1353
1354        let module_cache = self.documents.get_module_cache();
1355
1356        // Reuse the goto_definition annotation-discovery dance so that
1357        // declaration on an annotation name navigates to the @annotation def.
1358        let mut annotation_discovery = AnnotationDiscovery::new();
1359        let parse_source = parser_source(&text);
1360        if let Ok(program) = parse_program(parse_source.as_ref()) {
1361            annotation_discovery.discover_from_program(&program);
1362            if let Some(file_path) = uri.to_file_path() {
1363                annotation_discovery.discover_from_imports_with_cache(
1364                    &program,
1365                    &file_path,
1366                    &module_cache,
1367                    self.project_root.get().map(|p| p.as_path()),
1368                );
1369            }
1370        }
1371
1372        let cached = self.last_good_programs.get(&uri);
1373        let cached_ref = cached.as_ref().map(|r| r.value());
1374        let response = get_declaration(
1375            &text,
1376            position,
1377            &uri,
1378            Some(&module_cache),
1379            Some(&annotation_discovery),
1380            cached_ref,
1381        );
1382
1383        Ok(response)
1384    }
1385
1386    /// W2.5 / feature 1.60 (`textDocument/documentHighlight`): highlight
1387    /// every occurrence of the symbol at the cursor within the current
1388    /// file. Reuses the scope-aware `ScopeTree::references_of` path that
1389    /// powers `rename.rs`, so highlights respect lexical scope and
1390    /// variable shadowing.
1391    async fn document_highlight(
1392        &self,
1393        params: DocumentHighlightParams,
1394    ) -> Result<Option<Vec<DocumentHighlight>>> {
1395        let uri = params.text_document_position_params.text_document.uri;
1396        let position = params.text_document_position_params.position;
1397
1398        let doc = match self.documents.get(&uri) {
1399            Some(doc) => doc,
1400            None => return Ok(None),
1401        };
1402        let text = doc.text();
1403
1404        let cached = self.last_good_programs.get(&uri);
1405        let cached_ref = cached.as_ref().map(|r| r.value());
1406        let highlights = get_document_highlights(&text, position, cached_ref);
1407
1408        Ok(highlights)
1409    }
1410
1411    async fn semantic_tokens_full(
1412        &self,
1413        params: SemanticTokensParams,
1414    ) -> Result<Option<SemanticTokensResult>> {
1415        let uri = params.text_document.uri;
1416
1417        self.client
1418            .log_message(
1419                MessageType::INFO,
1420                format!("Semantic tokens requested for {}", uri.as_str()),
1421            )
1422            .await;
1423
1424        let Some(mut tokens) = self.collect_full_semantic_tokens(&uri).await else {
1425            return Ok(None);
1426        };
1427
1428        // Stamp a fresh result_id + cache for subsequent /delta requests
1429        // (W2.8 feature 1.48). Clients that don't support deltas simply
1430        // ignore the result_id; the cache entry then ages out next call.
1431        let result_id = self.next_semantic_tokens_result_id();
1432        tokens.result_id = Some(result_id.clone());
1433        self.semantic_tokens_cache.insert(
1434            uri,
1435            CachedSemanticTokens {
1436                result_id,
1437                data: tokens.data.clone(),
1438            },
1439        );
1440
1441        Ok(Some(SemanticTokensResult::Tokens(tokens)))
1442    }
1443
1444    async fn semantic_tokens_range(
1445        &self,
1446        params: SemanticTokensRangeParams,
1447    ) -> Result<Option<SemanticTokensRangeResult>> {
1448        let uri = params.text_document.uri;
1449        let range = params.range;
1450
1451        self.client
1452            .log_message(
1453                MessageType::INFO,
1454                format!(
1455                    "Semantic tokens (range) requested for {} [{}:{} - {}:{}]",
1456                    uri.as_str(),
1457                    range.start.line,
1458                    range.start.character,
1459                    range.end.line,
1460                    range.end.character,
1461                ),
1462            )
1463            .await;
1464
1465        let Some(tokens) = self.collect_full_semantic_tokens(&uri).await else {
1466            return Ok(None);
1467        };
1468
1469        // Decode the full document tokens to absolute coords, filter to those
1470        // whose START position falls within `range`, then re-encode. We do not
1471        // currently re-cache range results — `semantic_tokens_full_delta`
1472        // operates on the full-document cache alone (LSP §3.17 permits this).
1473        let absolute = decode_semantic_tokens(&tokens.data);
1474        let filtered: Vec<AbsoluteSemanticToken> = absolute
1475            .into_iter()
1476            .filter(|token| position_in_range(token.line, token.start_char, &range))
1477            .collect();
1478
1479        let result = SemanticTokens {
1480            // Range results have no result_id; per LSP they are not eligible
1481            // as the base for a subsequent /delta request.
1482            result_id: None,
1483            data: encode_semantic_tokens(&filtered),
1484        };
1485        Ok(Some(SemanticTokensRangeResult::Tokens(result)))
1486    }
1487
1488    async fn semantic_tokens_full_delta(
1489        &self,
1490        params: SemanticTokensDeltaParams,
1491    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1492        let uri = params.text_document.uri;
1493        let previous_result_id = params.previous_result_id;
1494
1495        self.client
1496            .log_message(
1497                MessageType::INFO,
1498                format!(
1499                    "Semantic tokens (delta) requested for {} (previous_result_id={})",
1500                    uri.as_str(),
1501                    previous_result_id,
1502                ),
1503            )
1504            .await;
1505
1506        let Some(mut tokens) = self.collect_full_semantic_tokens(&uri).await else {
1507            return Ok(None);
1508        };
1509
1510        let new_result_id = self.next_semantic_tokens_result_id();
1511
1512        // Fast path: cache miss / stale id → return the full token set
1513        // (LSP permits returning `SemanticTokens` from a /delta request).
1514        let cached = self
1515            .semantic_tokens_cache
1516            .get(&uri)
1517            .map(|entry| entry.clone());
1518        let edits = match cached {
1519            Some(cached) if cached.result_id == previous_result_id => {
1520                Some(compute_semantic_token_edits(&cached.data, &tokens.data))
1521            }
1522            _ => None,
1523        };
1524
1525        // Always refresh the cache to the latest token set so the next
1526        // delta request has a valid base.
1527        self.semantic_tokens_cache.insert(
1528            uri,
1529            CachedSemanticTokens {
1530                result_id: new_result_id.clone(),
1531                data: tokens.data.clone(),
1532            },
1533        );
1534
1535        match edits {
1536            Some(edits) => Ok(Some(SemanticTokensFullDeltaResult::TokensDelta(
1537                SemanticTokensDelta {
1538                    result_id: Some(new_result_id),
1539                    edits,
1540                },
1541            ))),
1542            None => {
1543                tokens.result_id = Some(new_result_id);
1544                Ok(Some(SemanticTokensFullDeltaResult::Tokens(tokens)))
1545            }
1546        }
1547    }
1548
1549    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1550        let uri = params.text_document.uri;
1551        let range = params.range;
1552
1553        // Get the document
1554        let doc = match self.documents.get(&uri) {
1555            Some(doc) => doc,
1556            None => return Ok(None),
1557        };
1558
1559        let text = doc.text();
1560
1561        // W2.4 / 1.70: use the live client-configured InlayHintConfig
1562        // instead of an in-place `Default::default()` (Audit §5 item 4).
1563        let config = self
1564            .inlay_hint_config
1565            .read()
1566            .map(|guard| guard.clone())
1567            .unwrap_or_default();
1568        let cached = self.last_good_programs.get(&uri);
1569        let cached_ref = cached.as_ref().map(|r| r.value());
1570        let file_path = uri.to_file_path();
1571        let hints = get_inlay_hints_with_context(
1572            &text,
1573            range,
1574            &config,
1575            cached_ref,
1576            file_path.as_deref(),
1577            self.project_root.get().map(|p| p.as_path()),
1578        );
1579
1580        if hints.is_empty() {
1581            Ok(None)
1582        } else {
1583            Ok(Some(hints))
1584        }
1585    }
1586
1587    /// W2.4 / 1.28: resolve a previously-returned inlay hint with additional
1588    /// detail. We currently fill the tooltip lazily based on the `data` field
1589    /// (set in `inlay_hints.rs`), so a hint emitted without an eager tooltip
1590    /// still surfaces context on hover.
1591    async fn inlay_hint_resolve(&self, mut hint: InlayHint) -> Result<InlayHint> {
1592        use tower_lsp_server::ls_types::InlayHintTooltip;
1593
1594        if hint.tooltip.is_some() {
1595            return Ok(hint);
1596        }
1597
1598        // The hint's `data` field carries the kind tag stamped in
1599        // `inlay_hints.rs::collect_chain_hint` / `collect_variable_type_hint`.
1600        let kind = hint
1601            .data
1602            .as_ref()
1603            .and_then(|v| v.get("kind"))
1604            .and_then(|v| v.as_str());
1605
1606        let tooltip = match kind {
1607            Some("chain") => Some(
1608                "Inferred type of the intermediate method-chain step (W2.4 / 1.27)."
1609                    .to_string(),
1610            ),
1611            Some("binding-kind") => Some(
1612                "LSP-side approximation of BindingStorageClass (ADR-006 §2). \
1613                 The compiler at crates/shape-vm/src/type_tracking.rs:286 is authoritative."
1614                    .to_string(),
1615            ),
1616            _ => None,
1617        };
1618
1619        if let Some(text) = tooltip {
1620            hint.tooltip = Some(InlayHintTooltip::String(text));
1621        }
1622
1623        Ok(hint)
1624    }
1625
1626    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1627        let uri = params.text_document.uri;
1628        let range = params.range;
1629        let diagnostics = params.context.diagnostics;
1630
1631        // Get the document
1632        let doc = match self.documents.get(&uri) {
1633            Some(doc) => doc,
1634            None => return Ok(None),
1635        };
1636
1637        let text = doc.text();
1638
1639        // Get code actions
1640        let module_cache = self.documents.get_module_cache();
1641        let actions = get_code_actions(
1642            &text,
1643            &uri,
1644            range,
1645            &diagnostics,
1646            Some(&module_cache),
1647            params.context.only.as_deref(),
1648        );
1649
1650        if actions.is_empty() {
1651            Ok(None)
1652        } else {
1653            Ok(Some(actions))
1654        }
1655    }
1656
1657    /// W2.3 / 1.73 — pull-based diagnostics
1658    /// (`textDocument/diagnostic`).
1659    ///
1660    /// Computes diagnostics for a single document on demand. Mirrors the
1661    /// existing push-based `analyze_document` pipeline (parse + semantic
1662    /// analyze + frontmatter + foreign-LSP merge) so push and pull
1663    /// produce identical reports for the same source. Returns a
1664    /// `RelatedFullDocumentDiagnosticReport`; we don't currently support
1665    /// `Unchanged` reports (would require diagnostic result-id caching
1666    /// per document version — follow-up tier, not parity-blocking).
1667    async fn diagnostic(
1668        &self,
1669        params: DocumentDiagnosticParams,
1670    ) -> Result<DocumentDiagnosticReportResult> {
1671        let uri = params.text_document.uri;
1672
1673        let items = self.compute_pull_diagnostics(&uri).await;
1674
1675        Ok(DocumentDiagnosticReportResult::Report(
1676            DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
1677                related_documents: None,
1678                full_document_diagnostic_report: FullDocumentDiagnosticReport {
1679                    result_id: None,
1680                    items,
1681                },
1682            }),
1683        ))
1684    }
1685
1686    /// W2.3 / 1.74 — workspace-wide pull diagnostics
1687    /// (`workspace/diagnostic`).
1688    ///
1689    /// Returns full diagnostic reports for every open document. Closed
1690    /// workspace files are NOT walked from disk here — the LSP spec
1691    /// allows servers to scope workspace pull to documents the server
1692    /// already has materialized analyses for, which keeps cost bounded
1693    /// and avoids re-parsing the whole workspace on every refresh
1694    /// request. The W2.6/W2.7 workspace-enumeration infra
1695    /// (`ModuleCache::enumerate_workspace_shape_files`) is reserved for
1696    /// the eager-indexing follow-up.
1697    async fn workspace_diagnostic(
1698        &self,
1699        _params: WorkspaceDiagnosticParams,
1700    ) -> Result<WorkspaceDiagnosticReportResult> {
1701        let mut items: Vec<WorkspaceDocumentDiagnosticReport> = Vec::new();
1702        for uri in self.documents.all_uris() {
1703            let diags = self.compute_pull_diagnostics(&uri).await;
1704            items.push(WorkspaceDocumentDiagnosticReport::Full(
1705                WorkspaceFullDocumentDiagnosticReport {
1706                    uri,
1707                    version: None,
1708                    full_document_diagnostic_report: FullDocumentDiagnosticReport {
1709                        result_id: None,
1710                        items: diags,
1711                    },
1712                },
1713            ));
1714        }
1715        Ok(WorkspaceDiagnosticReportResult::Report(
1716            WorkspaceDiagnosticReport { items },
1717        ))
1718    }
1719
1720    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1721        let uri = params.text_document.uri;
1722
1723        // Get the document
1724        let doc = match self.documents.get(&uri) {
1725            Some(doc) => doc,
1726            None => return Ok(None),
1727        };
1728
1729        let text = doc.text();
1730
1731        // Format the document
1732        let edits = format_document(&text, &params.options);
1733
1734        if edits.is_empty() {
1735            Ok(None)
1736        } else {
1737            Ok(Some(edits))
1738        }
1739    }
1740
1741    async fn range_formatting(
1742        &self,
1743        params: DocumentRangeFormattingParams,
1744    ) -> Result<Option<Vec<TextEdit>>> {
1745        let uri = params.text_document.uri;
1746        let range = params.range;
1747
1748        // Get the document
1749        let doc = match self.documents.get(&uri) {
1750            Some(doc) => doc,
1751            None => return Ok(None),
1752        };
1753
1754        let text = doc.text();
1755
1756        // Format the range
1757        let edits = format_range(&text, range, &params.options);
1758
1759        if edits.is_empty() {
1760            Ok(None)
1761        } else {
1762            Ok(Some(edits))
1763        }
1764    }
1765
1766    async fn on_type_formatting(
1767        &self,
1768        params: DocumentOnTypeFormattingParams,
1769    ) -> Result<Option<Vec<TextEdit>>> {
1770        let uri = params.text_document_position.text_document.uri;
1771        let position = params.text_document_position.position;
1772
1773        let doc = match self.documents.get(&uri) {
1774            Some(doc) => doc,
1775            None => return Ok(None),
1776        };
1777
1778        let text = doc.text();
1779        let edits = format_on_type(&text, position, &params.ch, &params.options);
1780
1781        if edits.is_empty() {
1782            Ok(None)
1783        } else {
1784            Ok(Some(edits))
1785        }
1786    }
1787
1788    async fn prepare_rename(
1789        &self,
1790        params: TextDocumentPositionParams,
1791    ) -> Result<Option<PrepareRenameResponse>> {
1792        let uri = params.text_document.uri;
1793        let position = params.position;
1794
1795        // Get the document
1796        let doc = match self.documents.get(&uri) {
1797            Some(doc) => doc,
1798            None => return Ok(None),
1799        };
1800
1801        let text = doc.text();
1802
1803        // Prepare rename
1804        let response = prepare_rename(&text, position);
1805
1806        Ok(response)
1807    }
1808
1809    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1810        let uri = params.text_document_position.text_document.uri;
1811        let position = params.text_document_position.position;
1812        let new_name = params.new_name;
1813
1814        // Get the document
1815        let doc = match self.documents.get(&uri) {
1816            Some(doc) => doc,
1817            None => return Ok(None),
1818        };
1819
1820        let text = doc.text();
1821
1822        // W2.6 — cross-file rename via ScopeTree-driven scan over open
1823        // documents + workspace .shape files. Same-file path remains
1824        // scope-aware; cross-file restricted to module-scope-visible
1825        // top-level symbols.
1826        let cached = self.last_good_programs.get(&uri);
1827        let cached_ref = cached.as_ref().map(|r| r.value());
1828        let module_cache = self.documents.get_module_cache();
1829        let workspace_root = self.project_root.get().map(|p| p.as_path());
1830        let edit = rename_cross_file(
1831            &text,
1832            &uri,
1833            position,
1834            &new_name,
1835            cached_ref,
1836            Some(&self.documents),
1837            Some(&module_cache),
1838            workspace_root,
1839        );
1840
1841        Ok(edit)
1842    }
1843
1844    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1845        let uri = params.text_document.uri;
1846
1847        // Get the document
1848        let doc = match self.documents.get(&uri) {
1849            Some(doc) => doc,
1850            None => return Ok(None),
1851        };
1852
1853        let text = doc.text();
1854
1855        // Get code lenses
1856        let lenses = get_code_lenses(&text, &uri);
1857
1858        if lenses.is_empty() {
1859            Ok(None)
1860        } else {
1861            Ok(Some(lenses))
1862        }
1863    }
1864
1865    async fn code_lens_resolve(&self, lens: CodeLens) -> Result<CodeLens> {
1866        Ok(resolve_code_lens(lens))
1867    }
1868
1869    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1870        let uri = params.text_document.uri;
1871
1872        let doc = match self.documents.get(&uri) {
1873            Some(doc) => doc,
1874            None => return Ok(None),
1875        };
1876
1877        let text = doc.text();
1878        let parse_source = parser_source(&text);
1879        let program = match parse_program(parse_source.as_ref()) {
1880            Ok(p) => p,
1881            Err(_) => {
1882                // Fall back to cached program
1883                match self.last_good_programs.get(&uri) {
1884                    Some(cached) => cached.value().clone(),
1885                    None => return Ok(None),
1886                }
1887            }
1888        };
1889
1890        let ranges = get_folding_ranges(&text, &program);
1891        if ranges.is_empty() {
1892            Ok(None)
1893        } else {
1894            Ok(Some(ranges))
1895        }
1896    }
1897
1898    async fn document_link(
1899        &self,
1900        params: DocumentLinkParams,
1901    ) -> Result<Option<Vec<DocumentLink>>> {
1902        let uri = params.text_document.uri;
1903
1904        let doc = match self.documents.get(&uri) {
1905            Some(doc) => doc,
1906            None => return Ok(None),
1907        };
1908
1909        let text = doc.text();
1910        let parse_source = parser_source(&text);
1911        let program = match parse_program(parse_source.as_ref()) {
1912            Ok(program) => program,
1913            Err(_) => match self.last_good_programs.get(&uri) {
1914                Some(cached) => cached.value().clone(),
1915                None => return Ok(None),
1916            },
1917        };
1918
1919        let module_cache = self.documents.get_module_cache();
1920        let file_path = uri.to_file_path();
1921        let links = crate::doc_links::collect_document_links(
1922            &program,
1923            &text,
1924            Some(&module_cache),
1925            file_path.as_deref(),
1926            self.project_root.get().map(|p| p.as_path()),
1927        );
1928
1929        if links.is_empty() {
1930            Ok(None)
1931        } else {
1932            Ok(Some(links))
1933        }
1934    }
1935
1936    async fn prepare_call_hierarchy(
1937        &self,
1938        params: CallHierarchyPrepareParams,
1939    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1940        let uri = params.text_document_position_params.text_document.uri;
1941        let position = params.text_document_position_params.position;
1942
1943        let doc = match self.documents.get(&uri) {
1944            Some(doc) => doc,
1945            None => return Ok(None),
1946        };
1947
1948        let text = doc.text();
1949        Ok(ch_prepare(&text, position, &uri))
1950    }
1951
1952    async fn incoming_calls(
1953        &self,
1954        params: CallHierarchyIncomingCallsParams,
1955    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1956        let uri = &params.item.uri;
1957
1958        let doc = match self.documents.get(uri) {
1959            Some(doc) => doc,
1960            None => return Ok(None),
1961        };
1962
1963        let text = doc.text();
1964        let results = ch_incoming(&text, &params.item, uri);
1965        if results.is_empty() {
1966            Ok(None)
1967        } else {
1968            Ok(Some(results))
1969        }
1970    }
1971
1972    /// W2.7 / 1.69: `workspace/didChangeWatchedFiles` — invalidate cached
1973    /// state when files change on disk outside the editor (e.g. `git
1974    /// checkout`, external build script). Drops the module-cache entry
1975    /// for each affected `.shape` path (via the W2.6 `invalidate` hook at
1976    /// `module_cache.rs:226`) and clears the corresponding
1977    /// `last_good_programs` snapshot so the next analysis re-reads from
1978    /// disk. Files in any open document are left alone — the editor owns
1979    /// their text and the cache will refresh on the next `did_change`.
1980    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
1981        let module_cache = self.documents.get_module_cache();
1982        let mut invalidated = 0usize;
1983        for event in &params.changes {
1984            // Only `.shape` files participate in module resolution; ignore
1985            // toml + others.
1986            let Some(path_cow) = event.uri.to_file_path() else {
1987                continue;
1988            };
1989            let path = path_cow.into_owned();
1990            if path.extension().and_then(|e| e.to_str()) != Some("shape") {
1991                continue;
1992            }
1993            module_cache.invalidate(&path);
1994            // For deletes / external changes, also drop the cached AST so
1995            // hover / completions don't keep serving a stale snapshot.
1996            if matches!(event.typ, FileChangeType::DELETED | FileChangeType::CHANGED) {
1997                self.last_good_programs.remove(&event.uri);
1998            }
1999            invalidated += 1;
2000        }
2001        if invalidated > 0 {
2002            self.client
2003                .log_message(
2004                    MessageType::INFO,
2005                    format!(
2006                        "didChangeWatchedFiles: invalidated {} module-cache entr{}",
2007                        invalidated,
2008                        if invalidated == 1 { "y" } else { "ies" }
2009                    ),
2010                )
2011                .await;
2012        }
2013    }
2014
2015    /// W2.7 / 1.71: `workspace/executeCommand` — acknowledge code-lens
2016    /// commands by name and return `None`.
2017    ///
2018    /// Today, the code-lens commands (`shape.findReferences`,
2019    /// `shape.findImplementations`, `shape.runTests`, etc.) are designed
2020    /// to be invoked by the client. Registering them server-side lets
2021    /// editors enable the lens click-targets in the first place (without
2022    /// the `executeCommand` capability, many clients suppress the lens
2023    /// entirely). The server replies with `None` so the client falls
2024    /// through to its own command handler (`textDocument/references` for
2025    /// `shape.findReferences`, the test runner integration for
2026    /// `shape.runTests`, etc.). Unknown commands are rejected with the
2027    /// LSP `MethodNotFound` error.
2028    async fn execute_command(&self, params: ExecuteCommandParams) -> Result<Option<LSPAny>> {
2029        if !registered_commands().contains(&params.command) {
2030            self.client
2031                .log_message(
2032                    MessageType::WARNING,
2033                    format!(
2034                        "executeCommand: unknown command {:?}; declared commands: {:?}",
2035                        params.command,
2036                        registered_commands()
2037                    ),
2038                )
2039                .await;
2040            return Err(tower_lsp_server::jsonrpc::Error::method_not_found());
2041        }
2042        self.client
2043            .log_message(
2044                MessageType::INFO,
2045                format!(
2046                    "executeCommand: {:?} acknowledged ({} arg{})",
2047                    params.command,
2048                    params.arguments.len(),
2049                    if params.arguments.len() == 1 { "" } else { "s" }
2050                ),
2051            )
2052            .await;
2053        Ok(None)
2054    }
2055
2056    /// W2.7 / 1.72: `workspace/willRenameFiles` — compute import-path
2057    /// rewrite edits before files are renamed.
2058    ///
2059    /// For each `(old_uri, new_uri)` pair, derives the corresponding
2060    /// Shape module path via [`crate::module_cache::path_to_module_path`]
2061    /// (relative to `project_root`) and produces text edits that rewrite
2062    /// `from OLD_PATH use ...` to `from NEW_PATH use ...` in every
2063    /// importing file (open documents + workspace `.shape` files via
2064    /// the W2.6 enumerator). Files outside the workspace root, files
2065    /// with non-`.shape` extensions, and module paths containing
2066    /// non-identifier path segments produce no edits — the client just
2067    /// renames the file and the user fixes any newly-broken imports by
2068    /// hand.
2069    async fn will_rename_files(
2070        &self,
2071        params: RenameFilesParams,
2072    ) -> Result<Option<WorkspaceEdit>> {
2073        let Some(workspace_root) = self.project_root.get() else {
2074            return Ok(None);
2075        };
2076        let module_cache = self.documents.get_module_cache();
2077
2078        // Collect (old_module, new_module) pairs we can actually rewrite.
2079        let mut renames: Vec<(String, String)> = Vec::new();
2080        for rename in &params.files {
2081            let Ok(old_uri) = rename.old_uri.parse::<Uri>() else {
2082                continue;
2083            };
2084            let Ok(new_uri) = rename.new_uri.parse::<Uri>() else {
2085                continue;
2086            };
2087            let Some(old_path) = old_uri.to_file_path() else {
2088                continue;
2089            };
2090            let Some(new_path) = new_uri.to_file_path() else {
2091                continue;
2092            };
2093            let Some(old_mod) = crate::module_cache::path_to_module_path(
2094                &old_path,
2095                workspace_root.as_path(),
2096            ) else {
2097                continue;
2098            };
2099            let Some(new_mod) = crate::module_cache::path_to_module_path(
2100                &new_path,
2101                workspace_root.as_path(),
2102            ) else {
2103                continue;
2104            };
2105            if old_mod == new_mod {
2106                continue;
2107            }
2108            renames.push((old_mod, new_mod));
2109        }
2110        if renames.is_empty() {
2111            return Ok(None);
2112        }
2113
2114        let mut changes: std::collections::HashMap<Uri, Vec<TextEdit>> =
2115            std::collections::HashMap::new();
2116        let mut visited: HashSet<std::path::PathBuf> = HashSet::new();
2117
2118        // Open documents first (live text wins over disk).
2119        for uri in self.documents.all_uris() {
2120            let Some(doc) = self.documents.get(&uri) else {
2121                continue;
2122            };
2123            if let Some(p) = uri.to_file_path() {
2124                visited.insert(p.into_owned());
2125            }
2126            let text = doc.text();
2127            let edits = collect_import_rewrites(&text, &renames);
2128            if !edits.is_empty() {
2129                changes.entry(uri).or_insert_with(Vec::new).extend(edits);
2130            }
2131        }
2132
2133        // Then workspace-on-disk files not already covered.
2134        for path in module_cache.enumerate_workspace_shape_files(workspace_root.as_path()) {
2135            if !visited.insert(path.clone()) {
2136                continue;
2137            }
2138            let Some(uri) = Uri::from_file_path(&path) else {
2139                continue;
2140            };
2141            let Ok(text) = std::fs::read_to_string(&path) else {
2142                continue;
2143            };
2144            let edits = collect_import_rewrites(&text, &renames);
2145            if !edits.is_empty() {
2146                changes.entry(uri).or_insert_with(Vec::new).extend(edits);
2147            }
2148        }
2149
2150        if changes.is_empty() {
2151            Ok(None)
2152        } else {
2153            Ok(Some(WorkspaceEdit {
2154                changes: Some(changes),
2155                document_changes: None,
2156                change_annotations: None,
2157            }))
2158        }
2159    }
2160
2161    async fn outgoing_calls(
2162        &self,
2163        params: CallHierarchyOutgoingCallsParams,
2164    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
2165        let uri = &params.item.uri;
2166
2167        let doc = match self.documents.get(uri) {
2168            Some(doc) => doc,
2169            None => return Ok(None),
2170        };
2171
2172        let text = doc.text();
2173        let results = ch_outgoing(&text, &params.item, uri);
2174        if results.is_empty() {
2175            Ok(None)
2176        } else {
2177            Ok(Some(results))
2178        }
2179    }
2180}
2181
2182/// W2.7 / 1.71 — canonical list of `workspace/executeCommand` command IDs
2183/// declared by the server. Mirrors every `Command::command` string emitted
2184/// by `code_lens.rs`; if a new lens-emitted command is added, append it
2185/// here so clients enable the lens click target.
2186fn registered_commands() -> Vec<String> {
2187    vec![
2188        "shape.findReferences".to_string(),
2189        "shape.findImplementations".to_string(),
2190        "shape.runTests".to_string(),
2191        "shape.debugTests".to_string(),
2192        "shape.showAnnotation".to_string(),
2193        "shape.showTraitMethod".to_string(),
2194    ]
2195}
2196
2197/// W2.7 / 1.72 — collect text edits that rewrite `from OLD_MODULE_PATH`
2198/// to `from NEW_MODULE_PATH` for every `(old, new)` pair in `renames`,
2199/// scanning `text` line by line. Matches the exact module path token
2200/// after the `from` keyword (skipping leading whitespace) so substring
2201/// collisions inside identifiers / string literals are avoided.
2202///
2203/// Multi-pair semantics: each line emits at most one edit. If a single
2204/// import targets a path that matches `renames[i].0` for multiple `i`,
2205/// the first matching pair wins — pairs should be uniqued by the caller.
2206fn collect_import_rewrites(text: &str, renames: &[(String, String)]) -> Vec<TextEdit> {
2207    use crate::util::offset_to_line_col;
2208    let mut edits = Vec::new();
2209    let mut offset = 0usize;
2210    for line in text.split_inclusive('\n') {
2211        let trimmed_offset = offset
2212            + (line.len() - line.trim_start().len());
2213        let body = line.trim_start();
2214        // Quick-reject: the import statement must start with `from `.
2215        if let Some(rest) = body.strip_prefix("from ") {
2216            // Skip extra whitespace between `from` and the module path.
2217            let path_start_in_body = "from ".len() + rest.len() - rest.trim_start().len();
2218            let path_start = trimmed_offset + path_start_in_body;
2219            let after_from = rest.trim_start();
2220            // Module path is followed by whitespace or `use`/`{`/`(`/`;` —
2221            // we read the path token as the longest run of identifier
2222            // characters or `::` separators starting at `path_start`.
2223            let mut end = 0usize;
2224            let bytes = after_from.as_bytes();
2225            while end < bytes.len() {
2226                let b = bytes[end];
2227                if b.is_ascii_alphanumeric() || b == b'_' {
2228                    end += 1;
2229                } else if end + 1 < bytes.len() && b == b':' && bytes[end + 1] == b':' {
2230                    end += 2;
2231                } else {
2232                    break;
2233                }
2234            }
2235            if end > 0 {
2236                let actual_path = &after_from[..end];
2237                for (old, new) in renames {
2238                    if actual_path == old {
2239                        let path_end = path_start + end;
2240                        let (sl, sc) = offset_to_line_col(text, path_start);
2241                        let (el, ec) = offset_to_line_col(text, path_end);
2242                        edits.push(TextEdit {
2243                            range: Range {
2244                                start: Position {
2245                                    line: sl,
2246                                    character: sc,
2247                                },
2248                                end: Position {
2249                                    line: el,
2250                                    character: ec,
2251                                },
2252                            },
2253                            new_text: new.clone(),
2254                        });
2255                        break;
2256                    }
2257                }
2258            }
2259        }
2260        offset += line.len();
2261    }
2262    edits
2263}
2264
2265fn frontmatter_fallback_range() -> Range {
2266    Range {
2267        start: Position {
2268            line: 0,
2269            character: 0,
2270        },
2271        end: Position {
2272            line: 0,
2273            character: 3,
2274        },
2275    }
2276}
2277
2278#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2279struct AbsoluteSemanticToken {
2280    line: u32,
2281    start_char: u32,
2282    length: u32,
2283    token_type: u32,
2284    modifiers: u32,
2285}
2286
2287fn decode_semantic_tokens(tokens: &[SemanticToken]) -> Vec<AbsoluteSemanticToken> {
2288    let mut decoded = Vec::with_capacity(tokens.len());
2289    let mut line = 0u32;
2290    let mut col = 0u32;
2291
2292    for token in tokens {
2293        line += token.delta_line;
2294        if token.delta_line == 0 {
2295            col += token.delta_start;
2296        } else {
2297            col = token.delta_start;
2298        }
2299        decoded.push(AbsoluteSemanticToken {
2300            line,
2301            start_char: col,
2302            length: token.length,
2303            token_type: token.token_type,
2304            modifiers: token.token_modifiers_bitset,
2305        });
2306    }
2307
2308    decoded
2309}
2310
2311fn encode_semantic_tokens(tokens: &[AbsoluteSemanticToken]) -> Vec<SemanticToken> {
2312    let mut encoded = Vec::with_capacity(tokens.len());
2313    let mut prev_line = 0u32;
2314    let mut prev_start = 0u32;
2315
2316    for token in tokens {
2317        let delta_line = token.line.saturating_sub(prev_line);
2318        let delta_start = if delta_line == 0 {
2319            token.start_char.saturating_sub(prev_start)
2320        } else {
2321            token.start_char
2322        };
2323        encoded.push(SemanticToken {
2324            delta_line,
2325            delta_start,
2326            length: token.length,
2327            token_type: token.token_type,
2328            token_modifiers_bitset: token.modifiers,
2329        });
2330        prev_line = token.line;
2331        prev_start = token.start_char;
2332    }
2333
2334    encoded
2335}
2336
2337/// True if the position `(line, character)` is within `range`, inclusive at
2338/// the start, exclusive at the end (LSP semantic-tokens-range convention).
2339/// We compare on the token's START position only; an oversized token that
2340/// straddles the range still appears in the result, which matches what
2341/// rust-analyzer does — clients render the whole token.
2342fn position_in_range(line: u32, character: u32, range: &Range) -> bool {
2343    let start = (range.start.line, range.start.character);
2344    let end = (range.end.line, range.end.character);
2345    let pos = (line, character);
2346    pos >= start && pos < end
2347}
2348
2349/// Compute the minimal `SemanticTokensEdit` sequence to transform `old` into
2350/// `new`. Uses common-prefix / common-suffix trimming around a single
2351/// replacement span — sufficient for the typical edit-locality pattern of
2352/// LSP semantic tokens (a single user edit only invalidates a contiguous
2353/// window of tokens). Both sequences are LSP-encoded `SemanticToken`s
2354/// (5 numbers per token); the returned edit indices are into the FLAT
2355/// `data` array (i.e. `index = token_index * 5 + field_offset`, where field
2356/// offsets 0..5 are deltaLine/deltaStart/length/tokenType/tokenModifiers).
2357///
2358/// Returning a single-edit slot is always-correct: clients apply edits
2359/// in-order against the previous flat array, and one replacement is the
2360/// shortest possible representation of any difference.
2361fn compute_semantic_token_edits(
2362    old: &[SemanticToken],
2363    new: &[SemanticToken],
2364) -> Vec<SemanticTokensEdit> {
2365    // Common prefix length, counted in tokens.
2366    let mut prefix = 0usize;
2367    let max_prefix = old.len().min(new.len());
2368    while prefix < max_prefix && semantic_tokens_eq(&old[prefix], &new[prefix]) {
2369        prefix += 1;
2370    }
2371
2372    // Common suffix length, counted in tokens, that does not overlap the prefix.
2373    let mut suffix = 0usize;
2374    let max_suffix = old.len().min(new.len()) - prefix;
2375    while suffix < max_suffix
2376        && semantic_tokens_eq(
2377            &old[old.len() - 1 - suffix],
2378            &new[new.len() - 1 - suffix],
2379        )
2380    {
2381        suffix += 1;
2382    }
2383
2384    let old_replace_start = prefix;
2385    let old_replace_end = old.len() - suffix;
2386    let new_replace_start = prefix;
2387    let new_replace_end = new.len() - suffix;
2388
2389    if old_replace_start == old_replace_end && new_replace_start == new_replace_end {
2390        // Sequences are identical → no edits.
2391        return Vec::new();
2392    }
2393
2394    // Convert token-index range to flat-u32-index range.
2395    let start = (old_replace_start * 5) as u32;
2396    let delete_count = ((old_replace_end - old_replace_start) * 5) as u32;
2397    let replacement: Vec<SemanticToken> =
2398        new[new_replace_start..new_replace_end].to_vec();
2399    let data = if replacement.is_empty() {
2400        None
2401    } else {
2402        Some(replacement)
2403    };
2404
2405    vec![SemanticTokensEdit {
2406        start,
2407        delete_count,
2408        data,
2409    }]
2410}
2411
2412fn semantic_tokens_eq(a: &SemanticToken, b: &SemanticToken) -> bool {
2413    a.delta_line == b.delta_line
2414        && a.delta_start == b.delta_start
2415        && a.length == b.length
2416        && a.token_type == b.token_type
2417        && a.token_modifiers_bitset == b.token_modifiers_bitset
2418}
2419
2420fn frontmatter_extension_path_ranges(source: &str) -> Vec<Range> {
2421    let lines: Vec<&str> = source.split('\n').collect();
2422    if lines.is_empty() {
2423        return Vec::new();
2424    }
2425
2426    let delimiter_lines: Vec<usize> = lines
2427        .iter()
2428        .enumerate()
2429        .filter_map(|(idx, line)| {
2430            let trimmed = line.trim_end_matches('\r').trim();
2431            if trimmed == "---" { Some(idx) } else { None }
2432        })
2433        .take(2)
2434        .collect();
2435
2436    if delimiter_lines.len() < 2 {
2437        return Vec::new();
2438    }
2439
2440    let start = delimiter_lines[0] + 1;
2441    let end = delimiter_lines[1];
2442    let mut in_extensions = false;
2443    let mut ranges = Vec::new();
2444
2445    for (line_idx, raw_line) in lines.iter().enumerate().take(end).skip(start) {
2446        let trimmed = raw_line.trim();
2447        if trimmed.starts_with("[[extensions]]") {
2448            in_extensions = true;
2449            continue;
2450        }
2451
2452        if trimmed.starts_with("[[") || (trimmed.starts_with('[') && trimmed.ends_with(']')) {
2453            in_extensions = false;
2454            continue;
2455        }
2456
2457        if !in_extensions {
2458            continue;
2459        }
2460
2461        let Some(eq_pos) = raw_line.find('=') else {
2462            continue;
2463        };
2464        let key = raw_line[..eq_pos].trim();
2465        if key != "path" {
2466            continue;
2467        }
2468
2469        let key_start = raw_line.find("path").unwrap_or_else(|| {
2470            raw_line[..eq_pos]
2471                .find(|c: char| !c.is_whitespace())
2472                .unwrap_or(0)
2473        });
2474        let line_len = raw_line.trim_end_matches('\r').len();
2475        let end_char = line_len.max(key_start + 4);
2476
2477        ranges.push(Range {
2478            start: Position {
2479                line: line_idx as u32,
2480                character: key_start as u32,
2481            },
2482            end: Position {
2483                line: line_idx as u32,
2484                character: end_char as u32,
2485            },
2486        });
2487    }
2488
2489    ranges
2490}
2491
2492fn is_position_in_frontmatter(source: &str, position: Position) -> bool {
2493    if source.starts_with("#!") && position.line == 0 {
2494        return false;
2495    }
2496
2497    let (_, _, rest) = shape_runtime::frontmatter::parse_frontmatter_validated(source);
2498    let prefix_len = source.len().saturating_sub(rest.len());
2499    if prefix_len == 0 {
2500        return false;
2501    }
2502
2503    position_to_offset(source, position)
2504        .map(|offset| offset < prefix_len)
2505        .unwrap_or(false)
2506}
2507
2508fn configured_extensions_from_lsp_value(
2509    options: Option<&serde_json::Value>,
2510    workspace_root: Option<&std::path::Path>,
2511) -> Vec<crate::foreign_lsp::ConfiguredExtensionSpec> {
2512    let mut specs = collect_configured_extensions_from_options(options, workspace_root);
2513
2514    // Auto-discover globally installed extensions from ~/.shape/extensions/
2515    collect_global_extensions(&mut specs);
2516
2517    dedup_extension_specs(specs)
2518}
2519
2520/// Parse configured extensions from LSP options JSON only (no global discovery).
2521fn collect_configured_extensions_from_options(
2522    options: Option<&serde_json::Value>,
2523    workspace_root: Option<&std::path::Path>,
2524) -> Vec<crate::foreign_lsp::ConfiguredExtensionSpec> {
2525    let mut specs = Vec::new();
2526
2527    if let Some(options) = options {
2528        collect_configured_extensions_from_array(
2529            options.get("alwaysLoadExtensions"),
2530            workspace_root,
2531            &mut specs,
2532        );
2533        collect_configured_extensions_from_array(
2534            options.get("always_load_extensions"),
2535            workspace_root,
2536            &mut specs,
2537        );
2538
2539        if let Some(shape) = options.get("shape") {
2540            collect_configured_extensions_from_array(
2541                shape.get("alwaysLoadExtensions"),
2542                workspace_root,
2543                &mut specs,
2544            );
2545            collect_configured_extensions_from_array(
2546                shape.get("always_load_extensions"),
2547                workspace_root,
2548                &mut specs,
2549            );
2550        }
2551    }
2552
2553    specs
2554}
2555
2556fn dedup_extension_specs(
2557    specs: Vec<crate::foreign_lsp::ConfiguredExtensionSpec>,
2558) -> Vec<crate::foreign_lsp::ConfiguredExtensionSpec> {
2559    let mut seen = HashSet::new();
2560    specs
2561        .into_iter()
2562        .filter(|spec| {
2563            let key = format!(
2564                "{}|{}|{}",
2565                spec.name,
2566                spec.path.display(),
2567                serde_json::to_string(&spec.config).unwrap_or_default()
2568            );
2569            seen.insert(key)
2570        })
2571        .collect()
2572}
2573
2574fn collect_global_extensions(out: &mut Vec<crate::foreign_lsp::ConfiguredExtensionSpec>) {
2575    let Some(home) = dirs::home_dir() else {
2576        return;
2577    };
2578    let ext_dir = home.join(".shape").join("extensions");
2579    if !ext_dir.is_dir() {
2580        return;
2581    }
2582    let Ok(entries) = std::fs::read_dir(&ext_dir) else {
2583        return;
2584    };
2585    for entry in entries.flatten() {
2586        let path = entry.path();
2587        let is_lib = path
2588            .extension()
2589            .and_then(|e| e.to_str())
2590            .map(|ext| ext == "so" || ext == "dylib" || ext == "dll")
2591            .unwrap_or(false);
2592        if !is_lib {
2593            continue;
2594        }
2595        let name = path
2596            .file_stem()
2597            .and_then(|s| s.to_str())
2598            .map(|s| {
2599                s.strip_prefix("libshape_ext_")
2600                    .or_else(|| s.strip_prefix("shape_ext_"))
2601                    .unwrap_or(s)
2602                    .to_string()
2603            })
2604            .unwrap_or_else(|| "extension".to_string());
2605        out.push(crate::foreign_lsp::ConfiguredExtensionSpec {
2606            name,
2607            path,
2608            config: serde_json::json!({}),
2609        });
2610    }
2611}
2612
2613fn collect_configured_extensions_from_array(
2614    value: Option<&serde_json::Value>,
2615    workspace_root: Option<&std::path::Path>,
2616    out: &mut Vec<crate::foreign_lsp::ConfiguredExtensionSpec>,
2617) {
2618    let Some(items) = value.and_then(|v| v.as_array()) else {
2619        return;
2620    };
2621    for item in items {
2622        if let Some(spec) = parse_configured_extension_item(item, workspace_root) {
2623            out.push(spec);
2624        }
2625    }
2626}
2627
2628fn parse_configured_extension_item(
2629    item: &serde_json::Value,
2630    workspace_root: Option<&std::path::Path>,
2631) -> Option<crate::foreign_lsp::ConfiguredExtensionSpec> {
2632    let (name, path, config) = if let Some(path) = item.as_str() {
2633        let path_buf = resolve_configured_extension_path(path, workspace_root);
2634        (
2635            configured_extension_name_from_path(&path_buf),
2636            path_buf,
2637            serde_json::json!({}),
2638        )
2639    } else if let Some(obj) = item.as_object() {
2640        let path_str = obj.get("path")?.as_str()?;
2641        let path_buf = resolve_configured_extension_path(path_str, workspace_root);
2642        let name = obj
2643            .get("name")
2644            .and_then(|value| value.as_str())
2645            .map(String::from)
2646            .unwrap_or_else(|| configured_extension_name_from_path(&path_buf));
2647        let config = obj
2648            .get("config")
2649            .cloned()
2650            .unwrap_or_else(|| serde_json::json!({}));
2651        (name, path_buf, config)
2652    } else {
2653        return None;
2654    };
2655
2656    Some(crate::foreign_lsp::ConfiguredExtensionSpec { name, path, config })
2657}
2658
2659fn resolve_configured_extension_path(
2660    path: &str,
2661    workspace_root: Option<&std::path::Path>,
2662) -> std::path::PathBuf {
2663    let path = std::path::PathBuf::from(path);
2664    if path.is_absolute() {
2665        return path;
2666    }
2667    workspace_root.map(|root| root.join(&path)).unwrap_or(path)
2668}
2669
2670fn configured_extension_name_from_path(path: &std::path::Path) -> String {
2671    path.file_stem()
2672        .and_then(|stem| stem.to_str())
2673        .map(String::from)
2674        .unwrap_or_else(|| "configured-extension".to_string())
2675}
2676
2677#[cfg(test)]
2678mod tests {
2679    use super::*;
2680    use crate::util::parser_source;
2681    use tower_lsp_server::LspService;
2682
2683    #[tokio::test]
2684    async fn test_server_creation() {
2685        let (service, _socket) = LspService::new(|client| ShapeLanguageServer::new(client));
2686
2687        // Just verify we can create the service
2688        drop(service);
2689    }
2690
2691    #[test]
2692    fn test_frontmatter_extension_path_ranges_points_to_path_line() {
2693        let source = r#"---
2694[[extensions]]
2695name = "duckdb"
2696path = "./extensions/libshape_ext_duckdb.so"
2697---
2698let x = 1
2699"#;
2700
2701        let ranges = frontmatter_extension_path_ranges(source);
2702        assert_eq!(ranges.len(), 1);
2703        assert_eq!(ranges[0].start.line, 3);
2704        assert_eq!(ranges[0].start.character, 0);
2705    }
2706
2707    #[test]
2708    fn test_frontmatter_extension_path_ranges_handles_shebang() {
2709        let source = r#"#!/usr/bin/env shape
2710---
2711[[extensions]]
2712name = "duckdb"
2713path = "./extensions/libshape_ext_duckdb.so"
2714---
2715let x = 1
2716"#;
2717
2718        let ranges = frontmatter_extension_path_ranges(source);
2719        assert_eq!(ranges.len(), 1);
2720        assert_eq!(ranges[0].start.line, 4);
2721    }
2722
2723    #[test]
2724    fn test_validate_imports_accepts_namespace_import_from_frontmatter_extension() {
2725        let source = r#"---
2726# shape.toml
2727[[extensions]]
2728name = "duckdb"
2729path = "./extensions/libshape_ext_duckdb.so"
2730---
2731use duckdb
2732let conn = duckdb.connect("duckdb://analytics.db")
2733"#;
2734
2735        let tmp = tempfile::tempdir().unwrap();
2736        let file_path = tmp.path().join("script.shape");
2737        std::fs::write(&file_path, source).unwrap();
2738
2739        let parse_src = parser_source(source);
2740        let program = parse_program(parse_src.as_ref()).expect("program should parse");
2741        let module_cache = crate::module_cache::ModuleCache::new();
2742        let mut compiler = shape_vm::BytecodeCompiler::new();
2743
2744        let diagnostics = crate::analysis::validate_imports_and_register_items(
2745            &program,
2746            source,
2747            &file_path,
2748            &module_cache,
2749            None,
2750            &mut compiler,
2751        );
2752
2753        assert!(
2754            diagnostics.iter().all(|diag| {
2755                !diag.message.contains("Cannot resolve module ''")
2756                    && !diag.message.contains("Cannot resolve module 'duckdb'")
2757            }),
2758            "namespace import from frontmatter extension should not emit resolution errors: {:?}",
2759            diagnostics
2760        );
2761    }
2762
2763    #[test]
2764    fn test_validate_imports_reports_unknown_namespace_module_name() {
2765        let source = "use missingmod\nlet x = 1\n";
2766        let tmp = tempfile::tempdir().unwrap();
2767        let file_path = tmp.path().join("script.shape");
2768        std::fs::write(&file_path, source).unwrap();
2769
2770        let parse_src = parser_source(source);
2771        let program = parse_program(parse_src.as_ref()).expect("program should parse");
2772        let module_cache = crate::module_cache::ModuleCache::new();
2773        let mut compiler = shape_vm::BytecodeCompiler::new();
2774
2775        let diagnostics = crate::analysis::validate_imports_and_register_items(
2776            &program,
2777            source,
2778            &file_path,
2779            &module_cache,
2780            None,
2781            &mut compiler,
2782        );
2783
2784        assert!(
2785            diagnostics
2786                .iter()
2787                .any(|diag| diag.message.contains("Cannot resolve module 'missingmod'")),
2788            "expected unknown namespace module diagnostic, got {:?}",
2789            diagnostics
2790        );
2791    }
2792
2793    #[test]
2794    fn test_is_position_in_frontmatter() {
2795        let source = r#"---
2796name = "script"
2797[[extensions]]
2798name = "duckdb"
2799path = "./extensions/libshape_ext_duckdb.so"
2800---
2801let x = 1
2802"#;
2803
2804        assert!(is_position_in_frontmatter(
2805            source,
2806            Position {
2807                line: 1,
2808                character: 0
2809            }
2810        ));
2811        assert!(is_position_in_frontmatter(
2812            source,
2813            Position {
2814                line: 3,
2815                character: 2
2816            }
2817        ));
2818        assert!(!is_position_in_frontmatter(
2819            source,
2820            Position {
2821                line: 6,
2822                character: 0
2823            }
2824        ));
2825    }
2826
2827    #[test]
2828    fn test_is_position_in_frontmatter_ignores_shebang_line() {
2829        let source = r#"#!/usr/bin/env shape
2830---
2831name = "script"
2832---
2833print("hello")
2834"#;
2835
2836        assert!(!is_position_in_frontmatter(
2837            source,
2838            Position {
2839                line: 0,
2840                character: 5
2841            }
2842        ));
2843        assert!(is_position_in_frontmatter(
2844            source,
2845            Position {
2846                line: 2,
2847                character: 1
2848            }
2849        ));
2850        assert!(!is_position_in_frontmatter(
2851            source,
2852            Position {
2853                line: 4,
2854                character: 0
2855            }
2856        ));
2857    }
2858
2859    #[test]
2860    fn test_configured_extensions_from_lsp_value_parses_top_level_array() {
2861        let value = serde_json::json!({
2862            "alwaysLoadExtensions": [
2863                "./extensions/libshape_ext_python.so",
2864                {
2865                    "name": "duckdb",
2866                    "path": "/tmp/libshape_ext_duckdb.so",
2867                    "config": { "mode": "readonly" }
2868                }
2869            ]
2870        });
2871        let workspace_root = std::path::Path::new("/workspace");
2872
2873        let specs = collect_configured_extensions_from_options(Some(&value), Some(workspace_root));
2874        assert_eq!(specs.len(), 2);
2875        assert_eq!(
2876            specs[0].path,
2877            std::path::PathBuf::from("/workspace").join("./extensions/libshape_ext_python.so")
2878        );
2879        assert_eq!(specs[0].config, serde_json::json!({}));
2880        assert_eq!(specs[1].name, "duckdb");
2881        assert_eq!(
2882            specs[1].path,
2883            std::path::PathBuf::from("/tmp/libshape_ext_duckdb.so")
2884        );
2885        assert_eq!(specs[1].config, serde_json::json!({ "mode": "readonly" }));
2886    }
2887
2888    #[test]
2889    fn test_configured_extensions_from_lsp_value_parses_nested_shape_key_and_dedupes() {
2890        let value = serde_json::json!({
2891            "shape": {
2892                "always_load_extensions": [
2893                    "/tmp/libshape_ext_python.so",
2894                    "/tmp/libshape_ext_python.so"
2895                ]
2896            }
2897        });
2898
2899        let specs = dedup_extension_specs(collect_configured_extensions_from_options(
2900            Some(&value),
2901            None,
2902        ));
2903        assert_eq!(specs.len(), 1);
2904        assert_eq!(
2905            specs[0].path,
2906            std::path::PathBuf::from("/tmp/libshape_ext_python.so")
2907        );
2908        assert_eq!(specs[0].name, "libshape_ext_python");
2909    }
2910
2911    // ------- W2.8 semantic-tokens delivery (range + delta) -------
2912
2913    fn mk_token(
2914        delta_line: u32,
2915        delta_start: u32,
2916        length: u32,
2917        token_type: u32,
2918    ) -> SemanticToken {
2919        SemanticToken {
2920            delta_line,
2921            delta_start,
2922            length,
2923            token_type,
2924            token_modifiers_bitset: 0,
2925        }
2926    }
2927
2928    #[test]
2929    fn position_in_range_inclusive_start_exclusive_end() {
2930        let range = Range {
2931            start: Position { line: 1, character: 0 },
2932            end: Position { line: 3, character: 0 },
2933        };
2934        // Inside
2935        assert!(position_in_range(1, 0, &range));
2936        assert!(position_in_range(2, 5, &range));
2937        // Exactly at end (exclusive)
2938        assert!(!position_in_range(3, 0, &range));
2939        // Before start
2940        assert!(!position_in_range(0, 99, &range));
2941        // After end
2942        assert!(!position_in_range(3, 1, &range));
2943    }
2944
2945    #[test]
2946    fn position_in_range_single_line() {
2947        let range = Range {
2948            start: Position { line: 5, character: 2 },
2949            end: Position { line: 5, character: 8 },
2950        };
2951        assert!(position_in_range(5, 2, &range));
2952        assert!(position_in_range(5, 7, &range));
2953        assert!(!position_in_range(5, 8, &range));
2954        assert!(!position_in_range(5, 1, &range));
2955        assert!(!position_in_range(4, 5, &range));
2956        assert!(!position_in_range(6, 0, &range));
2957    }
2958
2959    #[test]
2960    fn compute_semantic_token_edits_empty_when_identical() {
2961        let tokens = vec![mk_token(0, 0, 3, 8), mk_token(0, 4, 5, 5)];
2962        let edits = compute_semantic_token_edits(&tokens, &tokens);
2963        assert!(edits.is_empty(), "identical sequences must produce zero edits");
2964    }
2965
2966    #[test]
2967    fn compute_semantic_token_edits_full_replacement_when_no_overlap() {
2968        let old = vec![mk_token(0, 0, 3, 8)];
2969        let new = vec![mk_token(0, 0, 5, 5), mk_token(1, 2, 4, 8)];
2970        let edits = compute_semantic_token_edits(&old, &new);
2971        assert_eq!(edits.len(), 1);
2972        let edit = &edits[0];
2973        // Old token is fully replaced from index 0 with delete_count = 5 (1 token × 5 fields)
2974        assert_eq!(edit.start, 0);
2975        assert_eq!(edit.delete_count, 5);
2976        assert_eq!(
2977            edit.data.as_ref().map(|d| d.len()),
2978            Some(2),
2979            "new tokens carried verbatim"
2980        );
2981    }
2982
2983    #[test]
2984    fn compute_semantic_token_edits_preserves_common_prefix() {
2985        let old = vec![mk_token(0, 0, 3, 8), mk_token(0, 4, 5, 5)];
2986        let new = vec![mk_token(0, 0, 3, 8), mk_token(0, 4, 7, 1)];
2987        let edits = compute_semantic_token_edits(&old, &new);
2988        assert_eq!(edits.len(), 1);
2989        let edit = &edits[0];
2990        // Token 0 is common prefix → start = 5; old has one trailing token → delete_count = 5;
2991        // new has one trailing token in the replacement → data.len() == 1.
2992        assert_eq!(edit.start, 5);
2993        assert_eq!(edit.delete_count, 5);
2994        let data = edit.data.as_ref().expect("replacement data present");
2995        assert_eq!(data.len(), 1);
2996        assert_eq!(data[0].length, 7);
2997        assert_eq!(data[0].token_type, 1);
2998    }
2999
3000    #[test]
3001    fn compute_semantic_token_edits_preserves_common_suffix() {
3002        let old = vec![mk_token(0, 0, 3, 8), mk_token(0, 4, 5, 5)];
3003        let new = vec![mk_token(0, 0, 4, 1), mk_token(0, 4, 5, 5)];
3004        let edits = compute_semantic_token_edits(&old, &new);
3005        assert_eq!(edits.len(), 1);
3006        let edit = &edits[0];
3007        // Token 1 is common suffix → start = 0, delete_count = 5, replacement = 1 token.
3008        assert_eq!(edit.start, 0);
3009        assert_eq!(edit.delete_count, 5);
3010        let data = edit.data.as_ref().expect("replacement data present");
3011        assert_eq!(data.len(), 1);
3012        assert_eq!(data[0].length, 4);
3013    }
3014
3015    #[test]
3016    fn compute_semantic_token_edits_pure_insertion_emits_zero_delete() {
3017        let old = vec![mk_token(0, 0, 3, 8)];
3018        let new = vec![mk_token(0, 0, 3, 8), mk_token(1, 0, 4, 5)];
3019        let edits = compute_semantic_token_edits(&old, &new);
3020        assert_eq!(edits.len(), 1);
3021        let edit = &edits[0];
3022        // Insert one token after the existing prefix.
3023        assert_eq!(edit.start, 5);
3024        assert_eq!(edit.delete_count, 0);
3025        let data = edit.data.as_ref().expect("inserted tokens present");
3026        assert_eq!(data.len(), 1);
3027        assert_eq!(data[0].length, 4);
3028    }
3029
3030    #[test]
3031    fn compute_semantic_token_edits_pure_deletion_emits_none_data() {
3032        let old = vec![mk_token(0, 0, 3, 8), mk_token(1, 0, 4, 5)];
3033        let new = vec![mk_token(0, 0, 3, 8)];
3034        let edits = compute_semantic_token_edits(&old, &new);
3035        assert_eq!(edits.len(), 1);
3036        let edit = &edits[0];
3037        assert_eq!(edit.start, 5);
3038        assert_eq!(edit.delete_count, 5);
3039        // Pure deletion → data should be None (omitted in the wire payload).
3040        assert!(
3041            edit.data.is_none(),
3042            "pure deletion serializes as `data: null` (None)"
3043        );
3044    }
3045
3046    #[test]
3047    fn compute_semantic_token_edits_empty_to_empty_is_noop() {
3048        let edits = compute_semantic_token_edits(&[], &[]);
3049        assert!(edits.is_empty());
3050    }
3051
3052    #[test]
3053    fn compute_semantic_token_edits_empty_to_nonempty_is_insert() {
3054        let new = vec![mk_token(0, 0, 3, 8)];
3055        let edits = compute_semantic_token_edits(&[], &new);
3056        assert_eq!(edits.len(), 1);
3057        let edit = &edits[0];
3058        assert_eq!(edit.start, 0);
3059        assert_eq!(edit.delete_count, 0);
3060        assert_eq!(edit.data.as_ref().map(|d| d.len()), Some(1));
3061    }
3062
3063    #[test]
3064    fn compute_semantic_token_edits_nonempty_to_empty_is_delete() {
3065        let old = vec![mk_token(0, 0, 3, 8), mk_token(1, 0, 4, 5)];
3066        let edits = compute_semantic_token_edits(&old, &[]);
3067        assert_eq!(edits.len(), 1);
3068        let edit = &edits[0];
3069        assert_eq!(edit.start, 0);
3070        assert_eq!(edit.delete_count, 10);
3071        assert!(edit.data.is_none());
3072    }
3073
3074    #[test]
3075    fn semantic_tokens_eq_compares_all_fields() {
3076        let a = mk_token(0, 0, 3, 8);
3077        let b = mk_token(0, 0, 3, 8);
3078        assert!(semantic_tokens_eq(&a, &b));
3079
3080        let mut c = a;
3081        c.token_modifiers_bitset = 4;
3082        assert!(!semantic_tokens_eq(&a, &c));
3083
3084        let mut d = a;
3085        d.length = 99;
3086        assert!(!semantic_tokens_eq(&a, &d));
3087    }
3088
3089    // W2.7 / 1.71 — every command emitted by `code_lens.rs` must be
3090    // declared in `execute_command_provider.commands` so clients enable
3091    // the lens click target. If a new lens command is added without
3092    // mirroring it here, the LSP `execute_command` handler will reject
3093    // it with `MethodNotFound`.
3094    #[test]
3095    fn registered_commands_cover_every_code_lens_command_w27() {
3096        let declared: std::collections::HashSet<String> =
3097            registered_commands().into_iter().collect();
3098        // Anchored exactly to the strings emitted in `code_lens.rs`.
3099        for cmd in &[
3100            "shape.findReferences",
3101            "shape.findImplementations",
3102            "shape.runTests",
3103            "shape.debugTests",
3104            "shape.showAnnotation",
3105            "shape.showTraitMethod",
3106        ] {
3107            assert!(
3108                declared.contains(*cmd),
3109                "executeCommand provider missing code-lens command {:?}",
3110                cmd
3111            );
3112        }
3113    }
3114
3115    // W2.7 / 1.72 — willRenameFiles edit collection rewrites `from X`
3116    // to `from Y` for matched module paths.
3117    #[test]
3118    fn collect_import_rewrites_rewrites_matching_from_clause_w27() {
3119        let text = "from foo::bar use { x, y };\nlet a = 1\n";
3120        let renames = vec![("foo::bar".to_string(), "baz::qux".to_string())];
3121        let edits = collect_import_rewrites(text, &renames);
3122        assert_eq!(edits.len(), 1, "expected one rewrite, got {:?}", edits);
3123        let edit = &edits[0];
3124        assert_eq!(edit.new_text, "baz::qux");
3125        assert_eq!(edit.range.start.line, 0);
3126        // "from " is 5 chars; path starts at column 5.
3127        assert_eq!(edit.range.start.character, 5);
3128        assert_eq!(edit.range.end.character, 5 + "foo::bar".len() as u32);
3129    }
3130
3131    #[test]
3132    fn collect_import_rewrites_skips_non_matching_paths_w27() {
3133        let text = "from other::mod use { x };\nfrom foo::bar use { y };\n";
3134        let renames = vec![("foo::bar".to_string(), "new::path".to_string())];
3135        let edits = collect_import_rewrites(text, &renames);
3136        assert_eq!(edits.len(), 1, "only the foo::bar line should rewrite");
3137        assert_eq!(edits[0].range.start.line, 1);
3138    }
3139
3140    #[test]
3141    fn collect_import_rewrites_does_not_match_substring_inside_identifier_w27() {
3142        // The renamed module `foo` must NOT match `foobar` in another
3143        // import. The path-token reader stops at the end of the longest
3144        // identifier/`::` run, so `foo` ≠ `foobar`.
3145        let text = "from foobar use { x };\nfrom foo use { y };\n";
3146        let renames = vec![("foo".to_string(), "renamed".to_string())];
3147        let edits = collect_import_rewrites(text, &renames);
3148        assert_eq!(edits.len(), 1, "only the bare `from foo` line should match");
3149        assert_eq!(edits[0].range.start.line, 1);
3150        assert_eq!(edits[0].new_text, "renamed");
3151    }
3152
3153    #[test]
3154    fn collect_import_rewrites_handles_indented_imports_w27() {
3155        let text = "    from a::b use { x };\nfrom c::d use { y };\n";
3156        let renames = vec![
3157            ("a::b".to_string(), "AA::BB".to_string()),
3158            ("c::d".to_string(), "CC::DD".to_string()),
3159        ];
3160        let edits = collect_import_rewrites(text, &renames);
3161        assert_eq!(edits.len(), 2);
3162        // First line indented by 4: "from " ends at col 9, path starts at 9.
3163        assert_eq!(edits[0].range.start.character, 4 + 5);
3164        assert_eq!(edits[0].new_text, "AA::BB");
3165        assert_eq!(edits[1].range.start.line, 1);
3166        assert_eq!(edits[1].new_text, "CC::DD");
3167    }
3168
3169    #[test]
3170    fn collect_import_rewrites_returns_empty_when_no_imports_match_w27() {
3171        let text = "let x = 1\nlet y = 2\n";
3172        let renames = vec![("foo".to_string(), "bar".to_string())];
3173        assert!(collect_import_rewrites(text, &renames).is_empty());
3174    }
3175
3176    // W2.7 / 1.53 — workspace symbol handler should index closed
3177    // workspace files via `module_cache.enumerate_workspace_shape_files`
3178    // in addition to open documents. Direct construction of the handler
3179    // avoids the LspService Client-mock dance: we exercise the same
3180    // composition (`get_workspace_symbols` over open docs + workspace
3181    // enumerator) by calling each piece and asserting the union covers
3182    // closed files.
3183    #[tokio::test]
3184    async fn workspace_symbol_eager_indexes_closed_files_w27() {
3185        use crate::document_symbols::get_workspace_symbols;
3186        let tmp = tempfile::tempdir().unwrap();
3187        let root = tmp.path();
3188        // One open file with `fn open_sym` + one closed file on disk
3189        // with `fn closed_sym`. The closed file must surface in the
3190        // workspace symbol result.
3191        let open_path = root.join("open.shape");
3192        let closed_path = root.join("nested/closed.shape");
3193        std::fs::create_dir_all(closed_path.parent().unwrap()).unwrap();
3194        std::fs::write(&open_path, "fn open_sym() { 1 }\n").unwrap();
3195        std::fs::write(&closed_path, "fn closed_sym() { 2 }\n").unwrap();
3196
3197        let cache = crate::module_cache::ModuleCache::new();
3198        let files = cache.enumerate_workspace_shape_files(root);
3199        let mut found_closed = false;
3200        for path in files {
3201            if path != closed_path {
3202                continue;
3203            }
3204            let uri = Uri::from_file_path(&path).unwrap();
3205            let text = std::fs::read_to_string(&path).unwrap();
3206            let symbols = get_workspace_symbols(&text, &uri, "closed_sym");
3207            assert!(
3208                symbols.iter().any(|s| s.name == "closed_sym"),
3209                "closed-file symbol must appear in workspace results: {:?}",
3210                symbols
3211            );
3212            found_closed = true;
3213        }
3214        assert!(
3215            found_closed,
3216            "workspace enumerator must surface the closed.shape file"
3217        );
3218    }
3219
3220    // W2.7 / 1.69 — didChangeWatchedFiles should invalidate the cached
3221    // module entry for an externally-changed .shape file. We exercise
3222    // the underlying `ModuleCache::invalidate` contract end-to-end:
3223    // load a real `.shape` file into the cache, mutate it on disk,
3224    // call `invalidate`, then reload and assert the cache returned the
3225    // new contents (proving the prior entry was dropped).
3226    #[test]
3227    fn did_change_watched_files_invalidates_module_cache_w27() {
3228        let tmp = tempfile::tempdir().unwrap();
3229        let path = tmp.path().join("watched.shape");
3230        std::fs::write(&path, "pub fn before() { 1 }\n").unwrap();
3231        let cache = crate::module_cache::ModuleCache::new();
3232        let info1 = cache
3233            .load_module_with_context(&path, &path, None)
3234            .expect("first load must succeed");
3235        assert!(
3236            info1.exports.iter().any(|e| e.name == "before"),
3237            "before-invalidate export should be visible: {:?}",
3238            info1.exports
3239        );
3240
3241        // Mutate the file on disk + invalidate — simulating the watched
3242        // CHANGED arm in the server handler.
3243        std::fs::write(&path, "pub fn after() { 2 }\n").unwrap();
3244        cache.invalidate(&path);
3245
3246        let info2 = cache
3247            .load_module_with_context(&path, &path, None)
3248            .expect("second load must succeed");
3249        assert!(
3250            info2.exports.iter().any(|e| e.name == "after"),
3251            "after-invalidate export should reflect the new file contents: {:?}",
3252            info2.exports
3253        );
3254        assert!(
3255            !info2.exports.iter().any(|e| e.name == "before"),
3256            "stale pre-invalidate export must NOT linger: {:?}",
3257            info2.exports
3258        );
3259    }
3260}