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::{get_definition, get_references_with_fallback};
14use crate::diagnostics::error_to_diagnostic;
15use crate::document::DocumentManager;
16use crate::document_symbols::{get_document_symbols, get_workspace_symbols};
17use crate::folding::get_folding_ranges;
18use crate::formatting::{format_document, format_on_type, format_range};
19use crate::hover::get_hover;
20use crate::inlay_hints::{InlayHintConfig, get_inlay_hints_with_context};
21use crate::rename::{prepare_rename, rename};
22use crate::semantic_tokens::{get_legend, get_semantic_tokens};
23use crate::signature_help::get_signature_help;
24use crate::util::{
25    mask_leading_prefix_for_parse, offset_to_line_col, parser_source, position_to_offset,
26};
27use dashmap::DashMap;
28use shape_ast::ParseErrorKind;
29use shape_ast::ast::Program;
30use shape_ast::parser::parse_program;
31use std::collections::HashSet;
32use tower_lsp_server::ls_types::{
33    CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem,
34    CallHierarchyOutgoingCall, CallHierarchyOutgoingCallsParams, CallHierarchyPrepareParams,
35    CallHierarchyServerCapability, CodeActionKind, CodeActionOptions, CodeActionParams,
36    CodeActionProviderCapability, CodeActionResponse, CodeLens, CodeLensOptions, CodeLensParams,
37    CompletionOptions, CompletionParams, CompletionResponse, Diagnostic, DiagnosticSeverity,
38    DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
39    DidOpenTextDocumentParams, DocumentFormattingParams, DocumentOnTypeFormattingOptions,
40    DocumentOnTypeFormattingParams, DocumentRangeFormattingParams, DocumentSymbolParams,
41    DocumentSymbolResponse, FoldingRange, FoldingRangeParams, FoldingRangeProviderCapability,
42    GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams, HoverProviderCapability,
43    InitializeParams, InitializeResult, InitializedParams, InlayHint, InlayHintOptions,
44    InlayHintParams, InlayHintServerCapabilities, Location, MessageType, OneOf, Position,
45    PrepareRenameResponse, Range, ReferenceParams, RenameOptions, RenameParams, SemanticToken,
46    SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult,
47    SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, SignatureHelp,
48    SignatureHelpOptions, SignatureHelpParams, TextDocumentPositionParams,
49    TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Uri, WorkDoneProgressOptions,
50    WorkspaceEdit, WorkspaceSymbolParams, WorkspaceSymbolResponse,
51};
52use tower_lsp_server::{Client, LanguageServer, jsonrpc::Result};
53
54/// The main Shape Language Server
55pub struct ShapeLanguageServer {
56    /// LSP client for sending notifications and requests
57    client: Client,
58    /// Document manager for tracking open files
59    documents: DocumentManager,
60    /// Project root detected from workspace folder via shape.toml
61    project_root: std::sync::OnceLock<std::path::PathBuf>,
62    /// Cache of last successfully parsed programs per URI.
63    /// Used as fallback when current parse fails (e.g., during editing).
64    last_good_programs: DashMap<Uri, Program>,
65    /// Manager for child language servers handling foreign function blocks.
66    foreign_lsp: crate::foreign_lsp::ForeignLspManager,
67}
68
69impl ShapeLanguageServer {
70    /// Create a new language server instance
71    pub fn new(client: Client) -> Self {
72        // Use current directory as default workspace; updated in initialize().
73        let default_workspace =
74            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
75        Self {
76            client,
77            documents: DocumentManager::new(),
78            project_root: std::sync::OnceLock::new(),
79            last_good_programs: DashMap::new(),
80            foreign_lsp: crate::foreign_lsp::ForeignLspManager::new(default_workspace),
81        }
82    }
83
84    /// Check if a URI points to a shape.toml file.
85    fn is_shape_toml(uri: &Uri) -> bool {
86        uri.as_str().ends_with("shape.toml")
87    }
88
89    /// Analyze a shape.toml document and publish diagnostics.
90    async fn analyze_toml_document(&self, uri: &Uri) {
91        let doc = match self.documents.get(uri) {
92            Some(doc) => doc,
93            None => return,
94        };
95
96        let text = doc.text();
97        let diagnostics = crate::toml_support::diagnostics::validate_toml(&text);
98
99        self.client
100            .publish_diagnostics(uri.clone(), diagnostics, None)
101            .await;
102    }
103
104    /// Analyze a document and publish diagnostics
105    async fn analyze_document(&self, uri: &Uri) {
106        // Get the document
107        let doc = match self.documents.get(uri) {
108            Some(doc) => doc,
109            None => return,
110        };
111
112        // Parse the document
113        let text = doc.text();
114
115        // Validate frontmatter if present (check for project-level sections)
116        let mut frontmatter_diagnostics = Vec::new();
117        let frontmatter_prefix_len;
118        {
119            use shape_runtime::frontmatter::{
120                FrontmatterDiagnosticSeverity, parse_frontmatter, parse_frontmatter_validated,
121            };
122            let (config, fm_diags, rest) = parse_frontmatter_validated(&text);
123            frontmatter_prefix_len = text.len().saturating_sub(rest.len());
124            for diag in fm_diags {
125                let severity = match diag.severity {
126                    FrontmatterDiagnosticSeverity::Error => DiagnosticSeverity::ERROR,
127                    FrontmatterDiagnosticSeverity::Warning => DiagnosticSeverity::WARNING,
128                };
129                let range = diag
130                    .location
131                    .map(|loc| Range {
132                        start: Position {
133                            line: loc.line,
134                            character: loc.character,
135                        },
136                        end: Position {
137                            line: loc.line,
138                            character: loc.character + loc.length.max(1),
139                        },
140                    })
141                    .unwrap_or_else(frontmatter_fallback_range);
142                frontmatter_diagnostics.push(Diagnostic {
143                    range,
144                    severity: Some(severity),
145                    message: diag.message,
146                    source: Some("shape".to_string()),
147                    ..Default::default()
148                });
149            }
150
151            // Frontmatter and shape.toml are mutually exclusive.
152            if config.is_some() {
153                if let (Some(project_root), Some(path)) =
154                    (self.project_root.get(), uri.to_file_path())
155                {
156                    if path.as_ref().starts_with(project_root) {
157                        frontmatter_diagnostics.push(Diagnostic {
158                            range: Range {
159                                start: Position {
160                                    line: 0,
161                                    character: 0,
162                                },
163                                end: Position {
164                                    line: 0,
165                                    character: 3,
166                                },
167                            },
168                            severity: Some(DiagnosticSeverity::ERROR),
169                            message: "Frontmatter and shape.toml are mutually exclusive; use one configuration source.".to_string(),
170                            source: Some("shape".to_string()),
171                            ..Default::default()
172                        });
173                    }
174                }
175            }
176
177            // Validate frontmatter extension paths when running as a standalone script.
178            if let (Some(frontmatter), Some(script_path)) =
179                (parse_frontmatter(&text).0, uri.to_file_path())
180            {
181                let path_ranges = frontmatter_extension_path_ranges(&text);
182                if let Some(script_dir) = script_path.as_ref().parent() {
183                    for (index, extension) in frontmatter.extensions.into_iter().enumerate() {
184                        let resolved = if extension.path.is_absolute() {
185                            extension.path.clone()
186                        } else {
187                            script_dir.join(&extension.path)
188                        };
189                        if !resolved.exists() {
190                            let range = path_ranges
191                                .get(index)
192                                .cloned()
193                                .unwrap_or_else(frontmatter_fallback_range);
194                            frontmatter_diagnostics.push(Diagnostic {
195                                range,
196                                severity: Some(DiagnosticSeverity::ERROR),
197                                message: format!(
198                                    "Extension '{}' path does not exist: {}",
199                                    extension.name,
200                                    resolved.display()
201                                ),
202                                source: Some("shape".to_string()),
203                                ..Default::default()
204                            });
205                        }
206                    }
207                }
208            }
209        }
210
211        let parse_source = mask_leading_prefix_for_parse(&text, frontmatter_prefix_len);
212        let diagnostics = match parse_program(parse_source.as_ref()) {
213            Ok(program) => {
214                // Parsing succeeded — cache the good program for fallback
215                self.last_good_programs.insert(uri.clone(), program.clone());
216
217                // Update virtual documents for foreign function blocks
218                let file_path = uri.to_file_path();
219                let foreign_startup_diagnostics = self
220                    .foreign_lsp
221                    .update_documents(
222                        uri.as_str(),
223                        &text,
224                        &program.items,
225                        file_path.as_deref(),
226                        self.project_root.get().map(|p| p.as_path()),
227                    )
228                    .await;
229
230                let module_cache = self.documents.get_module_cache();
231                let mut diagnostics = crate::analysis::analyze_program_semantics(
232                    &program,
233                    &text,
234                    uri.to_file_path().as_deref(),
235                    Some(&module_cache),
236                    self.project_root.get().map(|p| p.as_path()),
237                );
238                diagnostics.extend(crate::doc_diagnostics::validate_program_docs(
239                    &program,
240                    &text,
241                    Some(&module_cache),
242                    uri.to_file_path().as_deref(),
243                    self.project_root.get().map(|p| p.as_path()),
244                ));
245                diagnostics.extend(foreign_startup_diagnostics);
246                diagnostics.extend(self.foreign_lsp.get_diagnostics(uri.as_str()).await);
247                diagnostics
248            }
249            Err(error) => {
250                // Parsing failed — try resilient parse for partial results
251                let partial = shape_ast::parse_program_resilient(parse_source.as_ref());
252                let mut diagnostics = Vec::new();
253                let has_non_grammar_partial_error = partial
254                    .errors
255                    .iter()
256                    .any(|e| !matches!(e.kind, ParseErrorKind::GrammarFailure));
257
258                // Prefer strict parser diagnostics for pure grammar failures.
259                // Add resilient diagnostics when they provide specific recovered spans/messages.
260                if partial.errors.is_empty() || !has_non_grammar_partial_error {
261                    diagnostics.extend(error_to_diagnostic(&error));
262                }
263
264                // Add diagnostics for recovery errors
265                if has_non_grammar_partial_error {
266                    for parse_error in &partial.errors {
267                        if matches!(parse_error.kind, ParseErrorKind::GrammarFailure) {
268                            continue;
269                        }
270                        let (start_line, start_col) = offset_to_line_col(&text, parse_error.span.0);
271                        let (end_line, end_col) = offset_to_line_col(&text, parse_error.span.1);
272                        diagnostics.push(Diagnostic {
273                            range: Range {
274                                start: Position {
275                                    line: start_line,
276                                    character: start_col,
277                                },
278                                end: Position {
279                                    line: end_line,
280                                    character: end_col,
281                                },
282                            },
283                            severity: Some(DiagnosticSeverity::ERROR),
284                            message: parse_error.message.clone(),
285                            source: Some("shape".to_string()),
286                            ..Default::default()
287                        });
288                    }
289                }
290
291                if !partial.items.is_empty() {
292                    // Cache the partial result so hover/completions have something to work with
293                    self.last_good_programs
294                        .insert(uri.clone(), partial.into_program());
295                }
296
297                diagnostics
298            }
299        };
300
301        // Combine frontmatter diagnostics with parse/semantic diagnostics
302        let mut all_diagnostics = frontmatter_diagnostics;
303        all_diagnostics.extend(diagnostics);
304
305        // Publish diagnostics to the client
306        self.client
307            .publish_diagnostics(uri.clone(), all_diagnostics, None)
308            .await;
309    }
310}
311
312impl LanguageServer for ShapeLanguageServer {
313    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
314        // Log initialization
315        self.client
316            .log_message(MessageType::INFO, "Shape Language Server initializing")
317            .await;
318
319        // Detect project root from workspace folders
320        let mut workspace_folder: Option<std::path::PathBuf> = None;
321        if let Some(folders) = params.workspace_folders.as_ref() {
322            if let Some(folder) = folders.first() {
323                if let Some(folder_path) = folder.uri.to_file_path() {
324                    workspace_folder = Some(folder_path.to_path_buf());
325                    if let Some(project) = shape_runtime::project::find_project_root(&folder_path) {
326                        self.client
327                            .log_message(
328                                MessageType::INFO,
329                                format!(
330                                    "Detected project root: {} ({})",
331                                    project.config.project.name,
332                                    project.root_path.display()
333                                ),
334                            )
335                            .await;
336                        let _ = self.project_root.set(project.root_path);
337                    }
338                }
339            }
340        }
341
342        // Update the foreign LSP manager's workspace dir so child servers
343        // receive the correct rootUri and can discover project config files
344        // (e.g. pyrightconfig.json, virtualenvs).
345        if let Some(dir) = self
346            .project_root
347            .get()
348            .cloned()
349            .or_else(|| workspace_folder.clone())
350        {
351            self.foreign_lsp.set_workspace_dir(dir);
352        }
353
354        let workspace_hint = self
355            .project_root
356            .get()
357            .map(|path| path.as_path())
358            .or(workspace_folder.as_deref());
359        let configured_extensions = configured_extensions_from_lsp_value(
360            params.initialization_options.as_ref(),
361            workspace_hint,
362        );
363        if !configured_extensions.is_empty() {
364            self.client
365                .log_message(
366                    MessageType::INFO,
367                    format!(
368                        "Configured {} always-load extension(s) from LSP initialization options",
369                        configured_extensions.len()
370                    ),
371                )
372                .await;
373        }
374        self.foreign_lsp
375            .set_configured_extensions(configured_extensions)
376            .await;
377
378        Ok(InitializeResult {
379            capabilities: ServerCapabilities {
380                // Use full text document sync (incremental requires proper range handling)
381                text_document_sync: Some(TextDocumentSyncCapability::Kind(
382                    TextDocumentSyncKind::FULL,
383                )),
384
385                // Enable completion support
386                completion_provider: Some(CompletionOptions {
387                    resolve_provider: Some(false),
388                    trigger_characters: Some(vec![
389                        ".".to_string(),
390                        "(".to_string(),
391                        " ".to_string(),
392                        "@".to_string(),
393                        ":".to_string(),
394                    ]),
395                    work_done_progress_options: WorkDoneProgressOptions {
396                        work_done_progress: None,
397                    },
398                    all_commit_characters: None,
399                    completion_item: None,
400                }),
401
402                // Enable hover support
403                hover_provider: Some(HoverProviderCapability::Simple(true)),
404
405                // Enable signature help
406                signature_help_provider: Some(SignatureHelpOptions {
407                    trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
408                    retrigger_characters: None,
409                    work_done_progress_options: WorkDoneProgressOptions {
410                        work_done_progress: None,
411                    },
412                }),
413
414                // Enable document symbols (outline view)
415                document_symbol_provider: Some(OneOf::Left(true)),
416
417                // Enable workspace symbols (find symbol across workspace)
418                workspace_symbol_provider: Some(OneOf::Left(true)),
419
420                // Enable go-to-definition
421                definition_provider: Some(OneOf::Left(true)),
422
423                // Enable find references
424                references_provider: Some(OneOf::Left(true)),
425
426                // Enable semantic tokens for syntax highlighting
427                semantic_tokens_provider: Some(
428                    SemanticTokensServerCapabilities::SemanticTokensOptions(
429                        SemanticTokensOptions {
430                            work_done_progress_options: WorkDoneProgressOptions {
431                                work_done_progress: None,
432                            },
433                            legend: get_legend(),
434                            range: Some(false),
435                            full: Some(SemanticTokensFullOptions::Bool(true)),
436                        },
437                    ),
438                ),
439
440                // Enable inlay hints (type hints, parameter hints)
441                inlay_hint_provider: Some(OneOf::Right(InlayHintServerCapabilities::Options(
442                    InlayHintOptions {
443                        work_done_progress_options: WorkDoneProgressOptions {
444                            work_done_progress: None,
445                        },
446                        resolve_provider: Some(false),
447                    },
448                ))),
449
450                // Enable code actions (quick fixes, refactoring)
451                code_action_provider: Some(CodeActionProviderCapability::Options(
452                    CodeActionOptions {
453                        code_action_kinds: Some(vec![
454                            CodeActionKind::QUICKFIX,
455                            CodeActionKind::REFACTOR,
456                            CodeActionKind::REFACTOR_EXTRACT,
457                            CodeActionKind::REFACTOR_REWRITE,
458                            CodeActionKind::SOURCE,
459                            CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
460                            CodeActionKind::SOURCE_FIX_ALL,
461                        ]),
462                        work_done_progress_options: WorkDoneProgressOptions {
463                            work_done_progress: None,
464                        },
465                        resolve_provider: Some(false),
466                    },
467                )),
468
469                // Enable document formatting
470                document_formatting_provider: Some(OneOf::Left(true)),
471
472                // Enable range formatting
473                document_range_formatting_provider: Some(OneOf::Left(true)),
474
475                // Enable on-type formatting for indentation while typing.
476                document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions {
477                    first_trigger_character: "}".to_string(),
478                    more_trigger_character: Some(vec!["\n".to_string()]),
479                }),
480
481                // Enable rename support
482                rename_provider: Some(OneOf::Right(RenameOptions {
483                    prepare_provider: Some(true),
484                    work_done_progress_options: WorkDoneProgressOptions {
485                        work_done_progress: None,
486                    },
487                })),
488
489                // Enable code lens
490                code_lens_provider: Some(CodeLensOptions {
491                    resolve_provider: Some(true),
492                }),
493
494                // Enable folding ranges
495                folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
496
497                // Enable call hierarchy
498                call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
499
500                ..ServerCapabilities::default()
501            },
502            server_info: Some(ServerInfo {
503                name: "Shape Language Server".to_string(),
504                version: Some(env!("CARGO_PKG_VERSION").to_string()),
505            }),
506            ..InitializeResult::default()
507        })
508    }
509
510    async fn initialized(&self, _params: InitializedParams) {
511        self.client
512            .log_message(MessageType::INFO, "Shape Language Server initialized")
513            .await;
514    }
515
516    async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
517        let workspace_hint = self.project_root.get().map(|path| path.as_path());
518        let configured_extensions =
519            configured_extensions_from_lsp_value(Some(&params.settings), workspace_hint);
520        self.foreign_lsp
521            .set_configured_extensions(configured_extensions.clone())
522            .await;
523        self.client
524            .log_message(
525                MessageType::INFO,
526                format!(
527                    "Updated always-load extensions from configuration change ({} configured)",
528                    configured_extensions.len()
529                ),
530            )
531            .await;
532    }
533
534    async fn shutdown(&self) -> Result<()> {
535        self.client
536            .log_message(MessageType::INFO, "Shape Language Server shutting down")
537            .await;
538        // Gracefully shut down all child language servers
539        self.foreign_lsp.shutdown().await;
540        Ok(())
541    }
542
543    async fn did_open(&self, params: DidOpenTextDocumentParams) {
544        let uri = params.text_document.uri;
545        let version = params.text_document.version;
546        let text = params.text_document.text;
547
548        self.client
549            .log_message(
550                MessageType::INFO,
551                format!("Document opened: {}", uri.as_str()),
552            )
553            .await;
554
555        // Store the document
556        self.documents.open(uri.clone(), version, text);
557
558        // Route to TOML analysis for shape.toml files
559        if Self::is_shape_toml(&uri) {
560            self.analyze_toml_document(&uri).await;
561            return;
562        }
563
564        // Analyze and publish diagnostics
565        self.analyze_document(&uri).await;
566    }
567
568    async fn did_change(&self, params: DidChangeTextDocumentParams) {
569        let uri = params.text_document.uri;
570        let version = params.text_document.version;
571
572        // For now, we only handle full document updates
573        // In the future, we can optimize this to handle incremental changes
574        if let Some(change) = params.content_changes.into_iter().next() {
575            let text = change.text;
576            self.documents.update(&uri, version, text);
577
578            self.client
579                .log_message(
580                    MessageType::INFO,
581                    format!("Document changed: {} (version {})", uri.as_str(), version),
582                )
583                .await;
584
585            // Route to TOML analysis for shape.toml files
586            if Self::is_shape_toml(&uri) {
587                self.analyze_toml_document(&uri).await;
588                return;
589            }
590
591            // Analyze and publish diagnostics
592            self.analyze_document(&uri).await;
593        }
594    }
595
596    async fn did_close(&self, params: DidCloseTextDocumentParams) {
597        let uri = params.text_document.uri;
598
599        self.client
600            .log_message(
601                MessageType::INFO,
602                format!("Document closed: {}", uri.as_str()),
603            )
604            .await;
605
606        // Clear diagnostics for the closed document
607        self.client
608            .publish_diagnostics(uri.clone(), vec![], None)
609            .await;
610
611        // Remove cached program for closed document
612        self.last_good_programs.remove(&uri);
613
614        self.documents.close(&uri);
615    }
616
617    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
618        let uri = params.text_document_position.text_document.uri;
619        let position = params.text_document_position.position;
620
621        self.client
622            .log_message(
623                MessageType::INFO,
624                format!(
625                    "Completion requested at {}:{}:{}",
626                    uri.as_str(),
627                    position.line,
628                    position.character
629                ),
630            )
631            .await;
632
633        // Route to TOML completions for shape.toml files
634        if Self::is_shape_toml(&uri) {
635            let doc = match self.documents.get(&uri) {
636                Some(doc) => doc,
637                None => return Ok(None),
638            };
639            let text = doc.text();
640            let items = crate::toml_support::completions::get_toml_completions(&text, position);
641            return Ok(Some(CompletionResponse::Array(items)));
642        }
643
644        // Get the document
645        let doc = match self.documents.get(&uri) {
646            Some(doc) => doc,
647            None => return Ok(None),
648        };
649
650        let text = doc.text();
651
652        // Route to frontmatter completions for script frontmatter blocks.
653        if is_position_in_frontmatter(&text, position) {
654            let items =
655                crate::toml_support::completions::get_frontmatter_completions(&text, position);
656            return Ok(Some(CompletionResponse::Array(items)));
657        }
658
659        // Check if the cursor is inside a foreign function body
660        if let Some(cached_program) = self.last_good_programs.get(&uri) {
661            if crate::foreign_lsp::is_position_in_foreign_block(
662                &cached_program.items,
663                &text,
664                position,
665            ) {
666                let completions = self
667                    .foreign_lsp
668                    .handle_completion(uri.as_str(), position, &cached_program.items, &text)
669                    .await;
670                if let Some(items) = completions {
671                    return Ok(Some(CompletionResponse::Array(items)));
672                }
673                // If delegation failed, fall through to Shape completions
674            }
675        }
676
677        let cached_symbols = self.documents.get_cached_symbols(&uri);
678        let cached_types = self.documents.get_cached_types(&uri);
679
680        // Get completions (with cached symbols as fallback)
681        let module_cache = self.documents.get_module_cache();
682        let file_path = uri.to_file_path();
683        let (completions, updated_symbols, updated_types) = get_completions_with_context(
684            &text,
685            position,
686            &cached_symbols,
687            &cached_types,
688            Some(&module_cache),
689            file_path.as_deref(),
690            self.project_root.get().map(|p| p.as_path()),
691        );
692
693        // Update cached symbols if parsing succeeded
694        if let Some(symbols) = updated_symbols {
695            self.documents.update_cached_symbols(&uri, symbols);
696        }
697        if let Some(types) = updated_types {
698            self.documents.update_cached_types(&uri, types);
699        }
700
701        Ok(Some(CompletionResponse::Array(completions)))
702    }
703
704    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
705        let uri = params.text_document_position_params.text_document.uri;
706        let position = params.text_document_position_params.position;
707
708        self.client
709            .log_message(
710                MessageType::INFO,
711                format!(
712                    "Hover requested at {}:{}:{}",
713                    uri.as_str(),
714                    position.line,
715                    position.character
716                ),
717            )
718            .await;
719
720        // Route to TOML hover for shape.toml files
721        if Self::is_shape_toml(&uri) {
722            let doc = match self.documents.get(&uri) {
723                Some(doc) => doc,
724                None => return Ok(None),
725            };
726            let text = doc.text();
727            return Ok(crate::toml_support::hover::get_toml_hover(&text, position));
728        }
729
730        // Get the document
731        let doc = match self.documents.get(&uri) {
732            Some(doc) => doc,
733            None => return Ok(None),
734        };
735
736        let text = doc.text();
737
738        // Check if the cursor is inside a foreign function body
739        if let Some(cached_program) = self.last_good_programs.get(&uri) {
740            if crate::foreign_lsp::is_position_in_foreign_block(
741                &cached_program.items,
742                &text,
743                position,
744            ) {
745                let hover = self
746                    .foreign_lsp
747                    .handle_hover(uri.as_str(), position, &cached_program.items, &text)
748                    .await;
749                if hover.is_some() {
750                    return Ok(hover);
751                }
752                // If delegation failed, fall through to Shape hover
753            }
754        }
755
756        // Get hover information, passing module cache for imported symbol lookup
757        let module_cache = self.documents.get_module_cache();
758        let file_path = uri.to_file_path();
759        let cached = self.last_good_programs.get(&uri);
760        let cached_ref = cached.as_ref().map(|r| r.value());
761        let hover = get_hover(
762            &text,
763            position,
764            Some(&module_cache),
765            file_path.as_deref(),
766            cached_ref,
767        );
768
769        Ok(hover)
770    }
771
772    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
773        let uri = params.text_document_position_params.text_document.uri;
774        let position = params.text_document_position_params.position;
775
776        // Get the document
777        let doc = match self.documents.get(&uri) {
778            Some(doc) => doc,
779            None => return Ok(None),
780        };
781
782        let text = doc.text();
783
784        if let Some(cached_program) = self.last_good_programs.get(&uri) {
785            if crate::foreign_lsp::is_position_in_foreign_block(
786                &cached_program.items,
787                &text,
788                position,
789            ) {
790                let signature_help = self
791                    .foreign_lsp
792                    .handle_signature_help(uri.as_str(), position, &cached_program.items, &text)
793                    .await;
794                if signature_help.is_some() {
795                    return Ok(signature_help);
796                }
797            }
798        }
799
800        // Get signature help
801        let sig_help = get_signature_help(&text, position);
802
803        Ok(sig_help)
804    }
805
806    async fn document_symbol(
807        &self,
808        params: DocumentSymbolParams,
809    ) -> Result<Option<DocumentSymbolResponse>> {
810        let uri = params.text_document.uri;
811
812        // Get the document
813        let doc = match self.documents.get(&uri) {
814            Some(doc) => doc,
815            None => return Ok(None),
816        };
817
818        let text = doc.text();
819
820        // Get document symbols
821        let symbols = get_document_symbols(&text);
822
823        Ok(symbols)
824    }
825
826    async fn symbol(
827        &self,
828        params: WorkspaceSymbolParams,
829    ) -> Result<Option<WorkspaceSymbolResponse>> {
830        let query = params.query;
831        let mut all_symbols = Vec::new();
832
833        // Get symbols from all open documents
834        for uri in self.documents.all_uris() {
835            if let Some(doc) = self.documents.get(&uri) {
836                let text = doc.text();
837                let symbols = get_workspace_symbols(&text, &uri, &query);
838                all_symbols.extend(symbols);
839            }
840        }
841
842        if all_symbols.is_empty() {
843            Ok(None)
844        } else {
845            Ok(Some(WorkspaceSymbolResponse::Flat(all_symbols)))
846        }
847    }
848
849    async fn goto_definition(
850        &self,
851        params: GotoDefinitionParams,
852    ) -> Result<Option<GotoDefinitionResponse>> {
853        let uri = params.text_document_position_params.text_document.uri;
854        let position = params.text_document_position_params.position;
855
856        // Get the document
857        let doc = match self.documents.get(&uri) {
858            Some(doc) => doc,
859            None => return Ok(None),
860        };
861
862        let text = doc.text();
863
864        if let Some(cached_program) = self.last_good_programs.get(&uri) {
865            if crate::foreign_lsp::is_position_in_foreign_block(
866                &cached_program.items,
867                &text,
868                position,
869            ) {
870                let definition = self
871                    .foreign_lsp
872                    .handle_definition(uri.as_str(), position, &cached_program.items, &text)
873                    .await;
874                if definition.is_some() {
875                    return Ok(definition);
876                }
877            }
878        }
879
880        // Get module cache for cross-file navigation
881        let module_cache = self.documents.get_module_cache();
882
883        // Discover annotations for go-to-definition on annotation names
884        let mut annotation_discovery = AnnotationDiscovery::new();
885        let parse_source = parser_source(&text);
886        if let Ok(program) = parse_program(parse_source.as_ref()) {
887            annotation_discovery.discover_from_program(&program);
888            if let Some(file_path) = uri.to_file_path() {
889                annotation_discovery.discover_from_imports_with_cache(
890                    &program,
891                    &file_path,
892                    &module_cache,
893                    self.project_root.get().map(|p| p.as_path()),
894                );
895            }
896        }
897
898        let cached = self.last_good_programs.get(&uri);
899        let cached_ref = cached.as_ref().map(|r| r.value());
900        let definition = get_definition(
901            &text,
902            position,
903            &uri,
904            Some(&module_cache),
905            Some(&annotation_discovery),
906            cached_ref,
907        );
908
909        Ok(definition)
910    }
911
912    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
913        let uri = params.text_document_position.text_document.uri;
914        let position = params.text_document_position.position;
915
916        // Get the document
917        let doc = match self.documents.get(&uri) {
918            Some(doc) => doc,
919            None => return Ok(None),
920        };
921
922        let text = doc.text();
923
924        if let Some(cached_program) = self.last_good_programs.get(&uri) {
925            if crate::foreign_lsp::is_position_in_foreign_block(
926                &cached_program.items,
927                &text,
928                position,
929            ) {
930                let references = self
931                    .foreign_lsp
932                    .handle_references(uri.as_str(), position, &cached_program.items, &text)
933                    .await;
934                if references.is_some() {
935                    return Ok(references);
936                }
937            }
938        }
939
940        // Get references with cached program fallback
941        let cached = self.last_good_programs.get(&uri);
942        let cached_ref = cached.as_ref().map(|r| r.value());
943        let references = get_references_with_fallback(&text, position, &uri, cached_ref);
944
945        Ok(references)
946    }
947
948    async fn semantic_tokens_full(
949        &self,
950        params: SemanticTokensParams,
951    ) -> Result<Option<SemanticTokensResult>> {
952        let uri = params.text_document.uri;
953
954        self.client
955            .log_message(
956                MessageType::INFO,
957                format!("Semantic tokens requested for {}", uri.as_str()),
958            )
959            .await;
960
961        // Get the document
962        let doc = match self.documents.get(&uri) {
963            Some(doc) => doc,
964            None => return Ok(None),
965        };
966
967        let text = doc.text();
968
969        // Base Shape semantic tokens
970        let mut tokens = get_semantic_tokens(&text);
971
972        if let Some(ref mut base_tokens) = tokens {
973            let mut absolute = decode_semantic_tokens(&base_tokens.data);
974
975            let frontmatter_tokens =
976                crate::toml_support::semantic_tokens::collect_frontmatter_semantic_tokens(&text);
977            absolute.extend(
978                frontmatter_tokens
979                    .into_iter()
980                    .map(|token| AbsoluteSemanticToken {
981                        line: token.line,
982                        start_char: token.start_char,
983                        length: token.length,
984                        token_type: token.token_type,
985                        modifiers: token.modifiers,
986                    }),
987            );
988
989            if self.last_good_programs.contains_key(&uri) {
990                let foreign_tokens = self.foreign_lsp.collect_semantic_tokens(uri.as_str()).await;
991                absolute.extend(
992                    foreign_tokens
993                        .into_iter()
994                        .map(|token| AbsoluteSemanticToken {
995                            line: token.line,
996                            start_char: token.start_char,
997                            length: token.length,
998                            token_type: token.token_type,
999                            modifiers: token.token_modifiers_bitset,
1000                        }),
1001                );
1002            }
1003
1004            absolute.sort_by_key(|token| {
1005                (
1006                    token.line,
1007                    token.start_char,
1008                    token.length,
1009                    token.token_type,
1010                    token.modifiers,
1011                )
1012            });
1013            absolute.dedup_by_key(|token| {
1014                (
1015                    token.line,
1016                    token.start_char,
1017                    token.length,
1018                    token.token_type,
1019                    token.modifiers,
1020                )
1021            });
1022            base_tokens.data = encode_semantic_tokens(&absolute);
1023        }
1024
1025        Ok(tokens.map(SemanticTokensResult::Tokens))
1026    }
1027
1028    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1029        let uri = params.text_document.uri;
1030        let range = params.range;
1031
1032        // Get the document
1033        let doc = match self.documents.get(&uri) {
1034            Some(doc) => doc,
1035            None => return Ok(None),
1036        };
1037
1038        let text = doc.text();
1039
1040        // Get inlay hints with default config, using cached program as fallback
1041        let config = InlayHintConfig::default();
1042        let cached = self.last_good_programs.get(&uri);
1043        let cached_ref = cached.as_ref().map(|r| r.value());
1044        let file_path = uri.to_file_path();
1045        let hints = get_inlay_hints_with_context(
1046            &text,
1047            range,
1048            &config,
1049            cached_ref,
1050            file_path.as_deref(),
1051            self.project_root.get().map(|p| p.as_path()),
1052        );
1053
1054        if hints.is_empty() {
1055            Ok(None)
1056        } else {
1057            Ok(Some(hints))
1058        }
1059    }
1060
1061    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1062        let uri = params.text_document.uri;
1063        let range = params.range;
1064        let diagnostics = params.context.diagnostics;
1065
1066        // Get the document
1067        let doc = match self.documents.get(&uri) {
1068            Some(doc) => doc,
1069            None => return Ok(None),
1070        };
1071
1072        let text = doc.text();
1073
1074        // Get code actions
1075        let module_cache = self.documents.get_module_cache();
1076        let actions = get_code_actions(
1077            &text,
1078            &uri,
1079            range,
1080            &diagnostics,
1081            Some(&module_cache),
1082            params.context.only.as_deref(),
1083        );
1084
1085        if actions.is_empty() {
1086            Ok(None)
1087        } else {
1088            Ok(Some(actions))
1089        }
1090    }
1091
1092    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1093        let uri = params.text_document.uri;
1094
1095        // Get the document
1096        let doc = match self.documents.get(&uri) {
1097            Some(doc) => doc,
1098            None => return Ok(None),
1099        };
1100
1101        let text = doc.text();
1102
1103        // Format the document
1104        let edits = format_document(&text, &params.options);
1105
1106        if edits.is_empty() {
1107            Ok(None)
1108        } else {
1109            Ok(Some(edits))
1110        }
1111    }
1112
1113    async fn range_formatting(
1114        &self,
1115        params: DocumentRangeFormattingParams,
1116    ) -> Result<Option<Vec<TextEdit>>> {
1117        let uri = params.text_document.uri;
1118        let range = params.range;
1119
1120        // Get the document
1121        let doc = match self.documents.get(&uri) {
1122            Some(doc) => doc,
1123            None => return Ok(None),
1124        };
1125
1126        let text = doc.text();
1127
1128        // Format the range
1129        let edits = format_range(&text, range, &params.options);
1130
1131        if edits.is_empty() {
1132            Ok(None)
1133        } else {
1134            Ok(Some(edits))
1135        }
1136    }
1137
1138    async fn on_type_formatting(
1139        &self,
1140        params: DocumentOnTypeFormattingParams,
1141    ) -> Result<Option<Vec<TextEdit>>> {
1142        let uri = params.text_document_position.text_document.uri;
1143        let position = params.text_document_position.position;
1144
1145        let doc = match self.documents.get(&uri) {
1146            Some(doc) => doc,
1147            None => return Ok(None),
1148        };
1149
1150        let text = doc.text();
1151        let edits = format_on_type(&text, position, &params.ch, &params.options);
1152
1153        if edits.is_empty() {
1154            Ok(None)
1155        } else {
1156            Ok(Some(edits))
1157        }
1158    }
1159
1160    async fn prepare_rename(
1161        &self,
1162        params: TextDocumentPositionParams,
1163    ) -> Result<Option<PrepareRenameResponse>> {
1164        let uri = params.text_document.uri;
1165        let position = params.position;
1166
1167        // Get the document
1168        let doc = match self.documents.get(&uri) {
1169            Some(doc) => doc,
1170            None => return Ok(None),
1171        };
1172
1173        let text = doc.text();
1174
1175        // Prepare rename
1176        let response = prepare_rename(&text, position);
1177
1178        Ok(response)
1179    }
1180
1181    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1182        let uri = params.text_document_position.text_document.uri;
1183        let position = params.text_document_position.position;
1184        let new_name = params.new_name;
1185
1186        // Get the document
1187        let doc = match self.documents.get(&uri) {
1188            Some(doc) => doc,
1189            None => return Ok(None),
1190        };
1191
1192        let text = doc.text();
1193
1194        let cached = self.last_good_programs.get(&uri);
1195        let cached_ref = cached.as_ref().map(|r| r.value());
1196        let edit = rename(&text, &uri, position, &new_name, cached_ref);
1197
1198        Ok(edit)
1199    }
1200
1201    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1202        let uri = params.text_document.uri;
1203
1204        // Get the document
1205        let doc = match self.documents.get(&uri) {
1206            Some(doc) => doc,
1207            None => return Ok(None),
1208        };
1209
1210        let text = doc.text();
1211
1212        // Get code lenses
1213        let lenses = get_code_lenses(&text, &uri);
1214
1215        if lenses.is_empty() {
1216            Ok(None)
1217        } else {
1218            Ok(Some(lenses))
1219        }
1220    }
1221
1222    async fn code_lens_resolve(&self, lens: CodeLens) -> Result<CodeLens> {
1223        Ok(resolve_code_lens(lens))
1224    }
1225
1226    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1227        let uri = params.text_document.uri;
1228
1229        let doc = match self.documents.get(&uri) {
1230            Some(doc) => doc,
1231            None => return Ok(None),
1232        };
1233
1234        let text = doc.text();
1235        let parse_source = parser_source(&text);
1236        let program = match parse_program(parse_source.as_ref()) {
1237            Ok(p) => p,
1238            Err(_) => {
1239                // Fall back to cached program
1240                match self.last_good_programs.get(&uri) {
1241                    Some(cached) => cached.value().clone(),
1242                    None => return Ok(None),
1243                }
1244            }
1245        };
1246
1247        let ranges = get_folding_ranges(&text, &program);
1248        if ranges.is_empty() {
1249            Ok(None)
1250        } else {
1251            Ok(Some(ranges))
1252        }
1253    }
1254
1255    async fn prepare_call_hierarchy(
1256        &self,
1257        params: CallHierarchyPrepareParams,
1258    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1259        let uri = params.text_document_position_params.text_document.uri;
1260        let position = params.text_document_position_params.position;
1261
1262        let doc = match self.documents.get(&uri) {
1263            Some(doc) => doc,
1264            None => return Ok(None),
1265        };
1266
1267        let text = doc.text();
1268        Ok(ch_prepare(&text, position, &uri))
1269    }
1270
1271    async fn incoming_calls(
1272        &self,
1273        params: CallHierarchyIncomingCallsParams,
1274    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1275        let uri = &params.item.uri;
1276
1277        let doc = match self.documents.get(uri) {
1278            Some(doc) => doc,
1279            None => return Ok(None),
1280        };
1281
1282        let text = doc.text();
1283        let results = ch_incoming(&text, &params.item, uri);
1284        if results.is_empty() {
1285            Ok(None)
1286        } else {
1287            Ok(Some(results))
1288        }
1289    }
1290
1291    async fn outgoing_calls(
1292        &self,
1293        params: CallHierarchyOutgoingCallsParams,
1294    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1295        let uri = &params.item.uri;
1296
1297        let doc = match self.documents.get(uri) {
1298            Some(doc) => doc,
1299            None => return Ok(None),
1300        };
1301
1302        let text = doc.text();
1303        let results = ch_outgoing(&text, &params.item, uri);
1304        if results.is_empty() {
1305            Ok(None)
1306        } else {
1307            Ok(Some(results))
1308        }
1309    }
1310}
1311
1312fn frontmatter_fallback_range() -> Range {
1313    Range {
1314        start: Position {
1315            line: 0,
1316            character: 0,
1317        },
1318        end: Position {
1319            line: 0,
1320            character: 3,
1321        },
1322    }
1323}
1324
1325#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1326struct AbsoluteSemanticToken {
1327    line: u32,
1328    start_char: u32,
1329    length: u32,
1330    token_type: u32,
1331    modifiers: u32,
1332}
1333
1334fn decode_semantic_tokens(tokens: &[SemanticToken]) -> Vec<AbsoluteSemanticToken> {
1335    let mut decoded = Vec::with_capacity(tokens.len());
1336    let mut line = 0u32;
1337    let mut col = 0u32;
1338
1339    for token in tokens {
1340        line += token.delta_line;
1341        if token.delta_line == 0 {
1342            col += token.delta_start;
1343        } else {
1344            col = token.delta_start;
1345        }
1346        decoded.push(AbsoluteSemanticToken {
1347            line,
1348            start_char: col,
1349            length: token.length,
1350            token_type: token.token_type,
1351            modifiers: token.token_modifiers_bitset,
1352        });
1353    }
1354
1355    decoded
1356}
1357
1358fn encode_semantic_tokens(tokens: &[AbsoluteSemanticToken]) -> Vec<SemanticToken> {
1359    let mut encoded = Vec::with_capacity(tokens.len());
1360    let mut prev_line = 0u32;
1361    let mut prev_start = 0u32;
1362
1363    for token in tokens {
1364        let delta_line = token.line.saturating_sub(prev_line);
1365        let delta_start = if delta_line == 0 {
1366            token.start_char.saturating_sub(prev_start)
1367        } else {
1368            token.start_char
1369        };
1370        encoded.push(SemanticToken {
1371            delta_line,
1372            delta_start,
1373            length: token.length,
1374            token_type: token.token_type,
1375            token_modifiers_bitset: token.modifiers,
1376        });
1377        prev_line = token.line;
1378        prev_start = token.start_char;
1379    }
1380
1381    encoded
1382}
1383
1384fn frontmatter_extension_path_ranges(source: &str) -> Vec<Range> {
1385    let lines: Vec<&str> = source.split('\n').collect();
1386    if lines.is_empty() {
1387        return Vec::new();
1388    }
1389
1390    let delimiter_lines: Vec<usize> = lines
1391        .iter()
1392        .enumerate()
1393        .filter_map(|(idx, line)| {
1394            let trimmed = line.trim_end_matches('\r').trim();
1395            if trimmed == "---" { Some(idx) } else { None }
1396        })
1397        .take(2)
1398        .collect();
1399
1400    if delimiter_lines.len() < 2 {
1401        return Vec::new();
1402    }
1403
1404    let start = delimiter_lines[0] + 1;
1405    let end = delimiter_lines[1];
1406    let mut in_extensions = false;
1407    let mut ranges = Vec::new();
1408
1409    for (line_idx, raw_line) in lines.iter().enumerate().take(end).skip(start) {
1410        let trimmed = raw_line.trim();
1411        if trimmed.starts_with("[[extensions]]") {
1412            in_extensions = true;
1413            continue;
1414        }
1415
1416        if trimmed.starts_with("[[") || (trimmed.starts_with('[') && trimmed.ends_with(']')) {
1417            in_extensions = false;
1418            continue;
1419        }
1420
1421        if !in_extensions {
1422            continue;
1423        }
1424
1425        let Some(eq_pos) = raw_line.find('=') else {
1426            continue;
1427        };
1428        let key = raw_line[..eq_pos].trim();
1429        if key != "path" {
1430            continue;
1431        }
1432
1433        let key_start = raw_line.find("path").unwrap_or_else(|| {
1434            raw_line[..eq_pos]
1435                .find(|c: char| !c.is_whitespace())
1436                .unwrap_or(0)
1437        });
1438        let line_len = raw_line.trim_end_matches('\r').len();
1439        let end_char = line_len.max(key_start + 4);
1440
1441        ranges.push(Range {
1442            start: Position {
1443                line: line_idx as u32,
1444                character: key_start as u32,
1445            },
1446            end: Position {
1447                line: line_idx as u32,
1448                character: end_char as u32,
1449            },
1450        });
1451    }
1452
1453    ranges
1454}
1455
1456fn is_position_in_frontmatter(source: &str, position: Position) -> bool {
1457    if source.starts_with("#!") && position.line == 0 {
1458        return false;
1459    }
1460
1461    let (_, _, rest) = shape_runtime::frontmatter::parse_frontmatter_validated(source);
1462    let prefix_len = source.len().saturating_sub(rest.len());
1463    if prefix_len == 0 {
1464        return false;
1465    }
1466
1467    position_to_offset(source, position)
1468        .map(|offset| offset < prefix_len)
1469        .unwrap_or(false)
1470}
1471
1472fn configured_extensions_from_lsp_value(
1473    options: Option<&serde_json::Value>,
1474    workspace_root: Option<&std::path::Path>,
1475) -> Vec<crate::foreign_lsp::ConfiguredExtensionSpec> {
1476    let mut specs = Vec::new();
1477
1478    if let Some(options) = options {
1479        collect_configured_extensions_from_array(
1480            options.get("alwaysLoadExtensions"),
1481            workspace_root,
1482            &mut specs,
1483        );
1484        collect_configured_extensions_from_array(
1485            options.get("always_load_extensions"),
1486            workspace_root,
1487            &mut specs,
1488        );
1489
1490        if let Some(shape) = options.get("shape") {
1491            collect_configured_extensions_from_array(
1492                shape.get("alwaysLoadExtensions"),
1493                workspace_root,
1494                &mut specs,
1495            );
1496            collect_configured_extensions_from_array(
1497                shape.get("always_load_extensions"),
1498                workspace_root,
1499                &mut specs,
1500            );
1501        }
1502    }
1503
1504    // Auto-discover globally installed extensions from ~/.shape/extensions/
1505    collect_global_extensions(&mut specs);
1506
1507    let mut seen = HashSet::new();
1508    specs
1509        .into_iter()
1510        .filter(|spec| {
1511            let key = format!(
1512                "{}|{}|{}",
1513                spec.name,
1514                spec.path.display(),
1515                serde_json::to_string(&spec.config).unwrap_or_default()
1516            );
1517            seen.insert(key)
1518        })
1519        .collect()
1520}
1521
1522fn collect_global_extensions(out: &mut Vec<crate::foreign_lsp::ConfiguredExtensionSpec>) {
1523    let Some(home) = dirs::home_dir() else {
1524        return;
1525    };
1526    let ext_dir = home.join(".shape").join("extensions");
1527    if !ext_dir.is_dir() {
1528        return;
1529    }
1530    let Ok(entries) = std::fs::read_dir(&ext_dir) else {
1531        return;
1532    };
1533    for entry in entries.flatten() {
1534        let path = entry.path();
1535        let is_lib = path
1536            .extension()
1537            .and_then(|e| e.to_str())
1538            .map(|ext| ext == "so" || ext == "dylib" || ext == "dll")
1539            .unwrap_or(false);
1540        if !is_lib {
1541            continue;
1542        }
1543        let name = path
1544            .file_stem()
1545            .and_then(|s| s.to_str())
1546            .map(|s| {
1547                s.strip_prefix("libshape_ext_")
1548                    .or_else(|| s.strip_prefix("shape_ext_"))
1549                    .unwrap_or(s)
1550                    .to_string()
1551            })
1552            .unwrap_or_else(|| "extension".to_string());
1553        out.push(crate::foreign_lsp::ConfiguredExtensionSpec {
1554            name,
1555            path,
1556            config: serde_json::json!({}),
1557        });
1558    }
1559}
1560
1561fn collect_configured_extensions_from_array(
1562    value: Option<&serde_json::Value>,
1563    workspace_root: Option<&std::path::Path>,
1564    out: &mut Vec<crate::foreign_lsp::ConfiguredExtensionSpec>,
1565) {
1566    let Some(items) = value.and_then(|v| v.as_array()) else {
1567        return;
1568    };
1569    for item in items {
1570        if let Some(spec) = parse_configured_extension_item(item, workspace_root) {
1571            out.push(spec);
1572        }
1573    }
1574}
1575
1576fn parse_configured_extension_item(
1577    item: &serde_json::Value,
1578    workspace_root: Option<&std::path::Path>,
1579) -> Option<crate::foreign_lsp::ConfiguredExtensionSpec> {
1580    let (name, path, config) = if let Some(path) = item.as_str() {
1581        let path_buf = resolve_configured_extension_path(path, workspace_root);
1582        (
1583            configured_extension_name_from_path(&path_buf),
1584            path_buf,
1585            serde_json::json!({}),
1586        )
1587    } else if let Some(obj) = item.as_object() {
1588        let path_str = obj.get("path")?.as_str()?;
1589        let path_buf = resolve_configured_extension_path(path_str, workspace_root);
1590        let name = obj
1591            .get("name")
1592            .and_then(|value| value.as_str())
1593            .map(String::from)
1594            .unwrap_or_else(|| configured_extension_name_from_path(&path_buf));
1595        let config = obj
1596            .get("config")
1597            .cloned()
1598            .unwrap_or_else(|| serde_json::json!({}));
1599        (name, path_buf, config)
1600    } else {
1601        return None;
1602    };
1603
1604    Some(crate::foreign_lsp::ConfiguredExtensionSpec { name, path, config })
1605}
1606
1607fn resolve_configured_extension_path(
1608    path: &str,
1609    workspace_root: Option<&std::path::Path>,
1610) -> std::path::PathBuf {
1611    let path = std::path::PathBuf::from(path);
1612    if path.is_absolute() {
1613        return path;
1614    }
1615    workspace_root.map(|root| root.join(&path)).unwrap_or(path)
1616}
1617
1618fn configured_extension_name_from_path(path: &std::path::Path) -> String {
1619    path.file_stem()
1620        .and_then(|stem| stem.to_str())
1621        .map(String::from)
1622        .unwrap_or_else(|| "configured-extension".to_string())
1623}
1624
1625#[cfg(test)]
1626mod tests {
1627    use super::*;
1628    use crate::util::parser_source;
1629    use tower_lsp_server::LspService;
1630
1631    #[tokio::test]
1632    async fn test_server_creation() {
1633        let (service, _socket) = LspService::new(|client| ShapeLanguageServer::new(client));
1634
1635        // Just verify we can create the service
1636        drop(service);
1637    }
1638
1639    #[test]
1640    fn test_frontmatter_extension_path_ranges_points_to_path_line() {
1641        let source = r#"---
1642[[extensions]]
1643name = "duckdb"
1644path = "./extensions/libshape_ext_duckdb.so"
1645---
1646let x = 1
1647"#;
1648
1649        let ranges = frontmatter_extension_path_ranges(source);
1650        assert_eq!(ranges.len(), 1);
1651        assert_eq!(ranges[0].start.line, 3);
1652        assert_eq!(ranges[0].start.character, 0);
1653    }
1654
1655    #[test]
1656    fn test_frontmatter_extension_path_ranges_handles_shebang() {
1657        let source = r#"#!/usr/bin/env shape
1658---
1659[[extensions]]
1660name = "duckdb"
1661path = "./extensions/libshape_ext_duckdb.so"
1662---
1663let x = 1
1664"#;
1665
1666        let ranges = frontmatter_extension_path_ranges(source);
1667        assert_eq!(ranges.len(), 1);
1668        assert_eq!(ranges[0].start.line, 4);
1669    }
1670
1671    #[test]
1672    fn test_validate_imports_accepts_namespace_import_from_frontmatter_extension() {
1673        let source = r#"---
1674# shape.toml
1675[[extensions]]
1676name = "duckdb"
1677path = "./extensions/libshape_ext_duckdb.so"
1678---
1679use duckdb
1680let conn = duckdb.connect("duckdb://analytics.db")
1681"#;
1682
1683        let tmp = tempfile::tempdir().unwrap();
1684        let file_path = tmp.path().join("script.shape");
1685        std::fs::write(&file_path, source).unwrap();
1686
1687        let parse_src = parser_source(source);
1688        let program = parse_program(parse_src.as_ref()).expect("program should parse");
1689        let module_cache = crate::module_cache::ModuleCache::new();
1690        let mut compiler = shape_vm::BytecodeCompiler::new();
1691
1692        let diagnostics = crate::analysis::validate_imports_and_register_items(
1693            &program,
1694            source,
1695            &file_path,
1696            &module_cache,
1697            None,
1698            &mut compiler,
1699        );
1700
1701        assert!(
1702            diagnostics.iter().all(|diag| {
1703                !diag.message.contains("Cannot resolve module ''")
1704                    && !diag.message.contains("Cannot resolve module 'duckdb'")
1705            }),
1706            "namespace import from frontmatter extension should not emit resolution errors: {:?}",
1707            diagnostics
1708        );
1709    }
1710
1711    #[test]
1712    fn test_validate_imports_reports_unknown_namespace_module_name() {
1713        let source = "use missingmod\nlet x = 1\n";
1714        let tmp = tempfile::tempdir().unwrap();
1715        let file_path = tmp.path().join("script.shape");
1716        std::fs::write(&file_path, source).unwrap();
1717
1718        let parse_src = parser_source(source);
1719        let program = parse_program(parse_src.as_ref()).expect("program should parse");
1720        let module_cache = crate::module_cache::ModuleCache::new();
1721        let mut compiler = shape_vm::BytecodeCompiler::new();
1722
1723        let diagnostics = crate::analysis::validate_imports_and_register_items(
1724            &program,
1725            source,
1726            &file_path,
1727            &module_cache,
1728            None,
1729            &mut compiler,
1730        );
1731
1732        assert!(
1733            diagnostics
1734                .iter()
1735                .any(|diag| diag.message.contains("Cannot resolve module 'missingmod'")),
1736            "expected unknown namespace module diagnostic, got {:?}",
1737            diagnostics
1738        );
1739    }
1740
1741    #[test]
1742    fn test_is_position_in_frontmatter() {
1743        let source = r#"---
1744name = "script"
1745[[extensions]]
1746name = "duckdb"
1747path = "./extensions/libshape_ext_duckdb.so"
1748---
1749let x = 1
1750"#;
1751
1752        assert!(is_position_in_frontmatter(
1753            source,
1754            Position {
1755                line: 1,
1756                character: 0
1757            }
1758        ));
1759        assert!(is_position_in_frontmatter(
1760            source,
1761            Position {
1762                line: 3,
1763                character: 2
1764            }
1765        ));
1766        assert!(!is_position_in_frontmatter(
1767            source,
1768            Position {
1769                line: 6,
1770                character: 0
1771            }
1772        ));
1773    }
1774
1775    #[test]
1776    fn test_is_position_in_frontmatter_ignores_shebang_line() {
1777        let source = r#"#!/usr/bin/env shape
1778---
1779name = "script"
1780---
1781print("hello")
1782"#;
1783
1784        assert!(!is_position_in_frontmatter(
1785            source,
1786            Position {
1787                line: 0,
1788                character: 5
1789            }
1790        ));
1791        assert!(is_position_in_frontmatter(
1792            source,
1793            Position {
1794                line: 2,
1795                character: 1
1796            }
1797        ));
1798        assert!(!is_position_in_frontmatter(
1799            source,
1800            Position {
1801                line: 4,
1802                character: 0
1803            }
1804        ));
1805    }
1806
1807    #[test]
1808    fn test_configured_extensions_from_lsp_value_parses_top_level_array() {
1809        let value = serde_json::json!({
1810            "alwaysLoadExtensions": [
1811                "./extensions/libshape_ext_python.so",
1812                {
1813                    "name": "duckdb",
1814                    "path": "/tmp/libshape_ext_duckdb.so",
1815                    "config": { "mode": "readonly" }
1816                }
1817            ]
1818        });
1819        let workspace_root = std::path::Path::new("/workspace");
1820
1821        let specs = configured_extensions_from_lsp_value(Some(&value), Some(workspace_root));
1822        assert_eq!(specs.len(), 2);
1823        assert_eq!(
1824            specs[0].path,
1825            std::path::PathBuf::from("/workspace").join("./extensions/libshape_ext_python.so")
1826        );
1827        assert_eq!(specs[0].config, serde_json::json!({}));
1828        assert_eq!(specs[1].name, "duckdb");
1829        assert_eq!(
1830            specs[1].path,
1831            std::path::PathBuf::from("/tmp/libshape_ext_duckdb.so")
1832        );
1833        assert_eq!(specs[1].config, serde_json::json!({ "mode": "readonly" }));
1834    }
1835
1836    #[test]
1837    fn test_configured_extensions_from_lsp_value_parses_nested_shape_key_and_dedupes() {
1838        let value = serde_json::json!({
1839            "shape": {
1840                "always_load_extensions": [
1841                    "/tmp/libshape_ext_python.so",
1842                    "/tmp/libshape_ext_python.so"
1843                ]
1844            }
1845        });
1846
1847        let specs = configured_extensions_from_lsp_value(Some(&value), None);
1848        assert_eq!(specs.len(), 1);
1849        assert_eq!(
1850            specs[0].path,
1851            std::path::PathBuf::from("/tmp/libshape_ext_python.so")
1852        );
1853        assert_eq!(specs[0].name, "libshape_ext_python");
1854    }
1855}