Skip to main content

styx_lsp/
server.rs

1//! LSP server implementation
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use styx_cst::{Parse, parse};
7use styx_tree::Value;
8use tokio::sync::RwLock;
9use tower_lsp::jsonrpc::Result;
10use tower_lsp::lsp_types::*;
11use tower_lsp::{Client, LanguageServer, LspService, Server};
12
13use crate::extensions::{ExtensionManager, ExtensionResult, get_extension_info};
14use crate::schema_hints::find_matching_hint;
15use crate::schema_validation::{
16    find_object_at_offset, find_schema_declaration, find_tagged_context_at_offset,
17    get_document_fields, get_error_span, get_schema_fields, get_schema_fields_at_path,
18    load_document_schema, resolve_schema, validate_against_schema,
19};
20use crate::semantic_tokens::{compute_semantic_tokens, semantic_token_legend};
21use styx_lsp_ext as ext;
22
23/// Document state tracked by the server
24pub struct DocumentState {
25    /// Document content
26    pub content: String,
27    /// Parsed CST
28    pub parse: Parse,
29    /// Parsed tree (for schema validation)
30    pub tree: Option<Value>,
31    /// Document version
32    #[allow(dead_code)]
33    pub version: i32,
34}
35
36/// Shared document map type
37pub type DocumentMap = Arc<RwLock<HashMap<Url, DocumentState>>>;
38
39/// Information about a blocked LSP extension.
40struct BlockedExtensionInfo {
41    /// The schema ID that has the extension.
42    schema_id: String,
43    /// The command that needs to be allowed.
44    command: String,
45}
46
47/// The Styx language server
48pub struct StyxLanguageServer {
49    /// LSP client for sending notifications
50    client: Client,
51    /// Open documents
52    documents: DocumentMap,
53    /// Extension manager
54    extensions: Arc<ExtensionManager>,
55}
56
57impl StyxLanguageServer {
58    pub fn new(client: Client) -> Self {
59        let documents: DocumentMap = Arc::new(RwLock::new(HashMap::new()));
60        Self {
61            client,
62            documents: documents.clone(),
63            extensions: Arc::new(ExtensionManager::new(documents)),
64        }
65    }
66
67    /// Check if the document's schema has an LSP extension and spawn it if allowed.
68    ///
69    /// Returns information about blocked extensions if not allowed.
70    async fn check_for_extension(&self, tree: &Value, uri: &Url) -> Option<BlockedExtensionInfo> {
71        // Try to load the schema
72        let Ok(schema) = load_document_schema(tree, uri) else {
73            return None;
74        };
75
76        // Check if schema has an LSP extension
77        let ext_info = get_extension_info(&schema)?;
78
79        tracing::info!(
80            schema_id = %ext_info.schema_id,
81            launch = ?ext_info.config.launch,
82            "Document schema has LSP extension"
83        );
84
85        // Try to spawn the extension (will check allowlist internally)
86        match self
87            .extensions
88            .get_or_spawn(&ext_info.schema_id, &ext_info.config, uri.as_str())
89            .await
90        {
91            ExtensionResult::Running => None,
92            ExtensionResult::NotAllowed { command } => Some(BlockedExtensionInfo {
93                schema_id: ext_info.schema_id,
94                command,
95            }),
96            ExtensionResult::Failed => None,
97        }
98    }
99
100    /// Publish diagnostics for a document
101    #[allow(clippy::too_many_arguments)]
102    async fn publish_diagnostics(
103        &self,
104        uri: Url,
105        content: &str,
106        parsed: &Parse,
107        tree: Option<&Value>,
108        tree_error: Option<&styx_tree::BuildError>,
109        version: i32,
110        blocked_extension: Option<BlockedExtensionInfo>,
111    ) {
112        let mut diagnostics = self.compute_diagnostics(&uri, content, parsed, tree, tree_error);
113
114        // Add diagnostic for blocked extension if applicable
115        if let Some(blocked) = blocked_extension
116            && let Some(tree) = tree
117            && let Some(range) = find_schema_declaration_range(tree, content)
118        {
119            diagnostics.push(Diagnostic {
120                range,
121                severity: Some(DiagnosticSeverity::INFORMATION),
122                code: None,
123                code_description: None,
124                source: Some("styx-extension".to_string()),
125                message: format!(
126                    "LSP extension '{}' is not allowed. Use the code action to allow it.",
127                    blocked.command
128                ),
129                related_information: None,
130                tags: None,
131                data: Some(serde_json::json!({
132                    "type": "allow_extension",
133                    "schema_id": blocked.schema_id,
134                    "command": blocked.command,
135                })),
136            });
137        }
138
139        // Try to get diagnostics from extension
140        if let Some(tree) = tree
141            && let Ok(schema_file) = load_document_schema(tree, &uri)
142        {
143            let schema_id = &schema_file.meta.id;
144            if let Some(client) = self.extensions.get_client(schema_id).await {
145                let ext_params = ext::DiagnosticParams {
146                    document_uri: uri.to_string(),
147                    tree: tree.clone(),
148                    content: content.to_string(),
149                };
150
151                match client.diagnostics(ext_params).await {
152                    Ok(ext_diagnostics) => {
153                        tracing::debug!(
154                            count = ext_diagnostics.len(),
155                            "Got diagnostics from extension"
156                        );
157                        for diag in ext_diagnostics {
158                            let severity = match diag.severity {
159                                ext::DiagnosticSeverity::Error => DiagnosticSeverity::ERROR,
160                                ext::DiagnosticSeverity::Warning => DiagnosticSeverity::WARNING,
161                                ext::DiagnosticSeverity::Info => DiagnosticSeverity::INFORMATION,
162                                ext::DiagnosticSeverity::Hint => DiagnosticSeverity::HINT,
163                            };
164                            diagnostics.push(Diagnostic {
165                                range: Range {
166                                    start: offset_to_position(content, diag.span.start as usize),
167                                    end: offset_to_position(content, diag.span.end as usize),
168                                },
169                                severity: Some(severity),
170                                code: diag.code.map(NumberOrString::String),
171                                code_description: None,
172                                source: diag.source,
173                                message: diag.message,
174                                related_information: None,
175                                tags: None,
176                                data: diag.data.map(|v| convert_styx_value_to_json(&v)),
177                            });
178                        }
179                    }
180                    Err(e) => {
181                        tracing::warn!(error = ?e, "Extension diagnostics failed");
182                    }
183                }
184            }
185        }
186
187        self.client
188            .publish_diagnostics(uri, diagnostics, Some(version))
189            .await;
190    }
191
192    /// Compute diagnostics for document content
193    fn compute_diagnostics(
194        &self,
195        uri: &Url,
196        content: &str,
197        parsed: &Parse,
198        tree: Option<&Value>,
199        tree_error: Option<&styx_tree::BuildError>,
200    ) -> Vec<Diagnostic> {
201        let mut diagnostics = Vec::new();
202
203        // Phase 1: Parse errors from CST
204        for error in parsed.errors() {
205            let range = Range {
206                start: offset_to_position(content, error.offset as usize),
207                end: offset_to_position(content, error.offset as usize + 1),
208            };
209
210            diagnostics.push(Diagnostic {
211                range,
212                severity: Some(DiagnosticSeverity::ERROR),
213                code: None,
214                code_description: None,
215                source: Some("styx".to_string()),
216                message: error.message.clone(),
217                related_information: None,
218                tags: None,
219                data: None,
220            });
221        }
222
223        // Phase 1b: Tree build errors (catches errors CST misses)
224        if let Some(err) = tree_error {
225            let range = match err {
226                styx_tree::BuildError::Parse(_, span) => Range {
227                    start: offset_to_position(content, span.start as usize),
228                    end: offset_to_position(content, span.end as usize),
229                },
230                _ => Range {
231                    start: Position::new(0, 0),
232                    end: Position::new(0, 1),
233                },
234            };
235
236            diagnostics.push(Diagnostic {
237                range,
238                severity: Some(DiagnosticSeverity::ERROR),
239                code: None,
240                code_description: None,
241                source: Some("styx".to_string()),
242                message: err.to_string(),
243                related_information: None,
244                tags: None,
245                data: None,
246            });
247        }
248
249        // Phase 2: CST validation (duplicate keys, mixed separators)
250        let validation_diagnostics = styx_cst::validate(&parsed.syntax());
251        for diag in validation_diagnostics {
252            let range = Range {
253                start: offset_to_position(content, diag.range.start().into()),
254                end: offset_to_position(content, diag.range.end().into()),
255            };
256
257            let severity = match diag.severity {
258                styx_cst::Severity::Error => DiagnosticSeverity::ERROR,
259                styx_cst::Severity::Warning => DiagnosticSeverity::WARNING,
260                styx_cst::Severity::Hint => DiagnosticSeverity::HINT,
261            };
262
263            diagnostics.push(Diagnostic {
264                range,
265                severity: Some(severity),
266                code: None,
267                code_description: None,
268                source: Some("styx".to_string()),
269                message: diag.message,
270                related_information: None,
271                tags: None,
272                data: None,
273            });
274        }
275
276        // Phase 3: Schema validation
277        if let Some(tree) = tree {
278            // Only validate if there's a schema declaration
279            if let Ok(schema) = resolve_schema(tree, uri) {
280                // Create related_information linking to schema
281                let schema_location = Some(DiagnosticRelatedInformation {
282                    location: Location {
283                        uri: schema.uri.clone(),
284                        range: Range::default(),
285                    },
286                    message: format!("schema: {}", schema.uri),
287                });
288
289                match validate_against_schema(tree, uri) {
290                    Ok(result) => {
291                        // Add validation errors
292                        for error in &result.errors {
293                            // Use the span directly from the error if available
294                            let range = if let Some(span) = error.span {
295                                Range {
296                                    start: offset_to_position(content, span.start as usize),
297                                    end: offset_to_position(content, span.end as usize),
298                                }
299                            } else {
300                                // Fallback: try to find by path, or point to start of document
301                                if let Some((start, end)) = get_error_span(tree, &error.path) {
302                                    Range {
303                                        start: offset_to_position(content, start),
304                                        end: offset_to_position(content, end),
305                                    }
306                                } else {
307                                    Range {
308                                        start: Position::new(0, 0),
309                                        end: Position::new(0, 1),
310                                    }
311                                }
312                            };
313
314                            // Store quickfix data for code actions
315                            let data = error.quickfix_data();
316
317                            diagnostics.push(Diagnostic {
318                                range,
319                                severity: Some(DiagnosticSeverity::ERROR),
320                                code: None,
321                                code_description: None,
322                                source: Some("styx-schema".to_string()),
323                                message: error.diagnostic_message(),
324                                related_information: schema_location.clone().map(|loc| vec![loc]),
325                                tags: None,
326                                data,
327                            });
328                        }
329
330                        // Add validation warnings
331                        for warning in &result.warnings {
332                            // Use the span directly from the warning if available
333                            let range = if let Some(span) = warning.span {
334                                Range {
335                                    start: offset_to_position(content, span.start as usize),
336                                    end: offset_to_position(content, span.end as usize),
337                                }
338                            } else {
339                                Range {
340                                    start: Position::new(0, 0),
341                                    end: Position::new(0, 1),
342                                }
343                            };
344
345                            diagnostics.push(Diagnostic {
346                                range,
347                                severity: Some(DiagnosticSeverity::WARNING),
348                                code: None,
349                                code_description: None,
350                                source: Some("styx-schema".to_string()),
351                                message: warning.message.clone(),
352                                related_information: schema_location.clone().map(|loc| vec![loc]),
353                                tags: None,
354                                data: None,
355                            });
356                        }
357                    }
358                    Err(e) => {
359                        // Schema loading error
360                        diagnostics.push(Diagnostic {
361                            range: Range {
362                                start: Position::new(0, 0),
363                                end: Position::new(0, 1),
364                            },
365                            severity: Some(DiagnosticSeverity::ERROR),
366                            code: None,
367                            code_description: None,
368                            source: Some("styx-schema".to_string()),
369                            message: e,
370                            related_information: None,
371                            tags: None,
372                            data: None,
373                        });
374                    }
375                }
376            }
377        }
378
379        // Phase 4: Schema hint suggestions
380        // If no schema declaration but file matches a known pattern, suggest adding one
381        if let Some(tree) = tree
382            && find_schema_declaration(tree).is_none()
383            && let Some(hint_match) = find_matching_hint(uri)
384        {
385            // Create data for the code action
386            let data = serde_json::json!({
387                "type": "add_schema",
388                "declaration": hint_match.schema_declaration(),
389                "tool": hint_match.tool_name,
390            });
391
392            diagnostics.push(Diagnostic {
393                range: Range {
394                    start: Position::new(0, 0),
395                    end: Position::new(0, 0),
396                },
397                severity: Some(DiagnosticSeverity::HINT),
398                code: Some(NumberOrString::String("missing-schema".to_string())),
399                code_description: None,
400                source: Some("styx-hints".to_string()),
401                message: format!(
402                    "This file matches the {} pattern. Add @schema declaration?",
403                    hint_match.description()
404                ),
405                related_information: None,
406                tags: None,
407                data: Some(data),
408            });
409        }
410
411        diagnostics
412    }
413}
414
415#[tower_lsp::async_trait]
416impl LanguageServer for StyxLanguageServer {
417    async fn initialize(&self, _params: InitializeParams) -> Result<InitializeResult> {
418        Ok(InitializeResult {
419            capabilities: ServerCapabilities {
420                // Full document sync - we get the whole document on each change
421                text_document_sync: Some(TextDocumentSyncCapability::Kind(
422                    TextDocumentSyncKind::FULL,
423                )),
424                // Semantic tokens for highlighting
425                semantic_tokens_provider: Some(
426                    SemanticTokensServerCapabilities::SemanticTokensOptions(
427                        SemanticTokensOptions {
428                            work_done_progress_options: WorkDoneProgressOptions::default(),
429                            legend: semantic_token_legend(),
430                            range: Some(false),
431                            full: Some(SemanticTokensFullOptions::Bool(true)),
432                        },
433                    ),
434                ),
435                // Document links for schema references
436                document_link_provider: Some(DocumentLinkOptions {
437                    resolve_provider: Some(false),
438                    work_done_progress_options: WorkDoneProgressOptions::default(),
439                }),
440                // Go to definition
441                definition_provider: Some(OneOf::Left(true)),
442                // Hover information
443                hover_provider: Some(HoverProviderCapability::Simple(true)),
444                // Auto-completion
445                completion_provider: Some(CompletionOptions {
446                    trigger_characters: Some(vec![" ".into(), "\n".into()]),
447                    resolve_provider: Some(false),
448                    ..Default::default()
449                }),
450                // Code actions (quick fixes)
451                code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
452                // Find all references
453                references_provider: Some(OneOf::Left(true)),
454                // Inlay hints
455                inlay_hint_provider: Some(OneOf::Left(true)),
456                // Document formatting
457                document_formatting_provider: Some(OneOf::Left(true)),
458                // On-type formatting (for auto-indent on Enter)
459                document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions {
460                    first_trigger_character: "\n".to_string(),
461                    more_trigger_character: None,
462                }),
463                // Document symbols (outline)
464                document_symbol_provider: Some(OneOf::Left(true)),
465                // Execute command (for code action commands)
466                execute_command_provider: Some(ExecuteCommandOptions {
467                    commands: vec!["styx.allowExtension".to_string()],
468                    work_done_progress_options: WorkDoneProgressOptions::default(),
469                }),
470                ..Default::default()
471            },
472            server_info: Some(ServerInfo {
473                name: "styx-lsp".to_string(),
474                version: Some(env!("CARGO_PKG_VERSION").to_string()),
475            }),
476        })
477    }
478
479    async fn initialized(&self, _: InitializedParams) {
480        self.client
481            .log_message(
482                MessageType::INFO,
483                format!(
484                    "Styx language server initialized (PID: {})",
485                    std::process::id()
486                ),
487            )
488            .await;
489    }
490
491    async fn shutdown(&self) -> Result<()> {
492        Ok(())
493    }
494
495    async fn did_open(&self, params: DidOpenTextDocumentParams) {
496        let uri = params.text_document.uri;
497        let content = params.text_document.text;
498        let version = params.text_document.version;
499
500        // Parse the document (CST)
501        let parsed = parse(&content);
502
503        // Parse into tree for schema validation
504        let (tree, tree_error) = match styx_tree::parse(&content) {
505            Ok(tree) => (Some(tree), None),
506            Err(e) => (None, Some(e)),
507        };
508
509        // Check for LSP extension in schema
510        let blocked_extension = if let Some(ref tree) = tree {
511            self.check_for_extension(tree, &uri).await
512        } else {
513            None
514        };
515
516        // Publish diagnostics
517        self.publish_diagnostics(
518            uri.clone(),
519            &content,
520            &parsed,
521            tree.as_ref(),
522            tree_error.as_ref(),
523            version,
524            blocked_extension,
525        )
526        .await;
527
528        // Store document
529        {
530            let mut docs = self.documents.write().await;
531            docs.insert(
532                uri,
533                DocumentState {
534                    content,
535                    parse: parsed,
536                    tree,
537                    version,
538                },
539            );
540        }
541    }
542
543    async fn did_change(&self, params: DidChangeTextDocumentParams) {
544        let uri = params.text_document.uri;
545        let version = params.text_document.version;
546
547        // With FULL sync, we get the entire document content
548        if let Some(change) = params.content_changes.into_iter().next() {
549            let content = change.text;
550
551            // Parse the document (CST)
552            let parsed = parse(&content);
553
554            // Parse into tree for schema validation
555            let (tree, tree_error) = match styx_tree::parse(&content) {
556                Ok(tree) => (Some(tree), None),
557                Err(e) => (None, Some(e)),
558            };
559
560            // Check for LSP extension in schema (might have changed)
561            let blocked_extension = if let Some(ref tree) = tree {
562                self.check_for_extension(tree, &uri).await
563            } else {
564                None
565            };
566
567            // Publish diagnostics
568            self.publish_diagnostics(
569                uri.clone(),
570                &content,
571                &parsed,
572                tree.as_ref(),
573                tree_error.as_ref(),
574                version,
575                blocked_extension,
576            )
577            .await;
578
579            // Update stored document
580            {
581                let mut docs = self.documents.write().await;
582                docs.insert(
583                    uri,
584                    DocumentState {
585                        content,
586                        parse: parsed,
587                        tree,
588                        version,
589                    },
590                );
591            }
592        }
593    }
594
595    async fn did_close(&self, params: DidCloseTextDocumentParams) {
596        let uri = params.text_document.uri;
597
598        // Remove document
599        {
600            let mut docs = self.documents.write().await;
601            docs.remove(&uri);
602        }
603
604        // Clear diagnostics
605        self.client.publish_diagnostics(uri, vec![], None).await;
606    }
607
608    async fn semantic_tokens_full(
609        &self,
610        params: SemanticTokensParams,
611    ) -> Result<Option<SemanticTokensResult>> {
612        let uri = params.text_document.uri;
613
614        let docs = self.documents.read().await;
615        let Some(doc) = docs.get(&uri) else {
616            return Ok(None);
617        };
618
619        let tokens = compute_semantic_tokens(&doc.parse);
620
621        Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
622            result_id: None,
623            data: tokens,
624        })))
625    }
626
627    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
628        let uri = params.text_document.uri;
629
630        let docs = self.documents.read().await;
631        let Some(doc) = docs.get(&uri) else {
632            return Ok(None);
633        };
634
635        let Some(tree) = &doc.tree else {
636            return Ok(None);
637        };
638
639        let mut links = Vec::new();
640
641        // Find schema declaration and create a link for it
642        if let Some(range) = find_schema_declaration_range(tree, &doc.content)
643            && let Ok(schema) = resolve_schema(tree, &uri)
644        {
645            links.push(DocumentLink {
646                range,
647                target: Some(schema.uri.clone()),
648                tooltip: Some(format!("Open schema: {}", schema.uri)),
649                data: None,
650            });
651        }
652
653        Ok(Some(links))
654    }
655
656    async fn goto_definition(
657        &self,
658        params: GotoDefinitionParams,
659    ) -> Result<Option<GotoDefinitionResponse>> {
660        let uri = params.text_document_position_params.text_document.uri;
661        let position = params.text_document_position_params.position;
662
663        let docs = self.documents.read().await;
664        let Some(doc) = docs.get(&uri) else {
665            return Ok(None);
666        };
667
668        let Some(tree) = &doc.tree else {
669            return Ok(None);
670        };
671
672        let offset = position_to_offset(&doc.content, position);
673
674        // Try to resolve the schema for this document
675        let resolved = resolve_schema(tree, &uri).ok();
676
677        // Case 1: On the schema declaration line - jump to schema file
678        if let Some(range) = find_schema_declaration_range(tree, &doc.content)
679            && position >= range.start
680            && position <= range.end
681            && let Some(ref schema) = resolved
682        {
683            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
684                uri: schema.uri.clone(),
685                range: Range::default(),
686            })));
687        }
688
689        // Case 2: On a field name in a doc - jump to schema definition
690        if let Some(field_name) = find_field_key_at_offset(tree, offset)
691            && let Some(ref schema) = resolved
692            && let Some(field_range) = find_field_in_schema_source(&schema.source, &field_name)
693        {
694            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
695                uri: schema.uri.clone(),
696                range: field_range,
697            })));
698        }
699
700        // Case 3: In a schema file - jump to first open doc that uses this field
701        // Check if this looks like a schema file (has "schema" and "meta" blocks)
702        if is_schema_file(tree)
703            && let Some(field_name) = find_field_key_at_offset(tree, offset)
704        {
705            // Search open documents for one that uses this schema
706            for (doc_uri, doc_state) in docs.iter() {
707                if doc_uri == &uri {
708                    continue; // Skip the schema file itself
709                }
710                if let Some(ref doc_tree) = doc_state.tree {
711                    // Check if this doc references our schema
712                    if let Ok(doc_schema) = resolve_schema(doc_tree, doc_uri)
713                        && doc_schema.uri == uri
714                    {
715                        // This doc uses our schema - find the field
716                        if let Some(field_range) =
717                            find_field_in_doc(doc_tree, &field_name, &doc_state.content)
718                        {
719                            return Ok(Some(GotoDefinitionResponse::Scalar(Location {
720                                uri: doc_uri.clone(),
721                                range: field_range,
722                            })));
723                        }
724                    }
725                }
726            }
727        }
728
729        // Case 4: Try extension for domain-specific definition (e.g., $param → declaration)
730        if let Ok(schema_file) = load_document_schema(tree, &uri) {
731            let schema_id = &schema_file.meta.id;
732            tracing::debug!(%schema_id, "Trying extension for definition");
733            if let Some(client) = self.extensions.get_client(schema_id).await {
734                tracing::debug!("Got extension client, calling definition");
735                let field_path = find_field_path_at_offset(tree, offset).unwrap_or_default();
736                let context_obj = find_object_at_offset(tree, offset);
737                let tagged_context = find_tagged_context_at_offset(tree, offset);
738
739                let ext_params = ext::DefinitionParams {
740                    document_uri: uri.to_string(),
741                    cursor: ext::Cursor {
742                        line: position.line,
743                        character: position.character,
744                        offset: offset as u32,
745                    },
746                    path: field_path,
747                    context: context_obj.map(|c| Value {
748                        tag: None,
749                        payload: Some(styx_tree::Payload::Object(c.object)),
750                        span: None,
751                    }),
752                    tagged_context,
753                };
754
755                match client.definition(ext_params).await {
756                    Ok(locations) if !locations.is_empty() => {
757                        tracing::debug!(count = locations.len(), "Extension returned definitions");
758                        let lsp_locations: Vec<Location> = locations
759                            .into_iter()
760                            .filter_map(|loc| {
761                                let target_uri =
762                                    Url::parse(&loc.uri).unwrap_or_else(|_| uri.clone());
763                                // Get content for span→range conversion
764                                let content = if target_uri == uri {
765                                    &doc.content
766                                } else if let Some(target_doc) = docs.get(&target_uri) {
767                                    &target_doc.content
768                                } else {
769                                    // Can't convert span without content
770                                    tracing::warn!(
771                                        uri = %target_uri,
772                                        "Cannot convert span to range: document not open"
773                                    );
774                                    return None;
775                                };
776                                Some(Location {
777                                    uri: target_uri,
778                                    range: Range {
779                                        start: offset_to_position(content, loc.span.start as usize),
780                                        end: offset_to_position(content, loc.span.end as usize),
781                                    },
782                                })
783                            })
784                            .collect();
785                        if lsp_locations.is_empty() {
786                            return Ok(None);
787                        } else if lsp_locations.len() == 1 {
788                            return Ok(Some(GotoDefinitionResponse::Scalar(
789                                lsp_locations.into_iter().next().unwrap(),
790                            )));
791                        } else {
792                            return Ok(Some(GotoDefinitionResponse::Array(lsp_locations)));
793                        }
794                    }
795                    Ok(_) => {
796                        tracing::debug!("Extension returned empty definitions");
797                    }
798                    Err(e) => {
799                        tracing::warn!(error = ?e, "Extension definition failed");
800                    }
801                }
802            }
803        }
804
805        Ok(None)
806    }
807
808    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
809        let uri = params.text_document_position_params.text_document.uri;
810        let position = params.text_document_position_params.position;
811
812        let docs = self.documents.read().await;
813        let Some(doc) = docs.get(&uri) else {
814            return Ok(None);
815        };
816
817        let Some(tree) = &doc.tree else {
818            return Ok(None);
819        };
820
821        let offset = position_to_offset(&doc.content, position);
822
823        // Try to resolve the schema for this document
824        let resolved = resolve_schema(tree, &uri).ok();
825
826        // Case 1: Hover on schema declaration
827        if let Some(range) = find_schema_declaration_range(tree, &doc.content)
828            && position >= range.start
829            && position <= range.end
830            && let Some(ref schema) = resolved
831        {
832            let content = format!(
833                "**Schema**: `{}`\n\nClick to open schema.",
834                schema.uri.as_str()
835            );
836            return Ok(Some(Hover {
837                contents: HoverContents::Markup(MarkupContent {
838                    kind: MarkupKind::Markdown,
839                    value: content,
840                }),
841                range: Some(range),
842            }));
843        }
844
845        // Case 2: Try extension for domain-specific hover (takes priority over schema hover)
846        if let Ok(schema_file) = load_document_schema(tree, &uri) {
847            let schema_id = &schema_file.meta.id;
848            tracing::debug!(%schema_id, "Trying extension for hover");
849            if let Some(client) = self.extensions.get_client(schema_id).await {
850                tracing::debug!("Got extension client, calling hover");
851                let field_path = find_field_path_at_offset(tree, offset).unwrap_or_default();
852                let context_obj = find_object_at_offset(tree, offset);
853                let tagged_context = find_tagged_context_at_offset(tree, offset);
854
855                let ext_params = ext::HoverParams {
856                    document_uri: uri.to_string(),
857                    cursor: ext::Cursor {
858                        line: position.line,
859                        character: position.character,
860                        offset: offset as u32,
861                    },
862                    path: field_path,
863                    context: context_obj.map(|c| Value {
864                        tag: None,
865                        payload: Some(styx_tree::Payload::Object(c.object)),
866                        span: None,
867                    }),
868                    tagged_context,
869                };
870
871                match client.hover(ext_params).await {
872                    Ok(Some(result)) => {
873                        tracing::debug!(contents = %result.contents, "Extension returned hover");
874                        let range = result.range.map(|r| Range {
875                            start: Position {
876                                line: r.start.line,
877                                character: r.start.character,
878                            },
879                            end: Position {
880                                line: r.end.line,
881                                character: r.end.character,
882                            },
883                        });
884                        return Ok(Some(Hover {
885                            contents: HoverContents::Markup(MarkupContent {
886                                kind: MarkupKind::Markdown,
887                                value: result.contents,
888                            }),
889                            range,
890                        }));
891                    }
892                    Ok(None) => {
893                        tracing::debug!("Extension returned None for hover");
894                        // Extension returned None, fall through to schema-based hover
895                    }
896                    Err(e) => {
897                        tracing::warn!(error = ?e, "Extension hover failed");
898                    }
899                }
900            }
901        }
902
903        // Case 3: Fallback to schema-based field hover
904        if let Some(field_path) = find_field_path_at_offset(tree, offset)
905            && let Some(ref schema) = resolved
906        {
907            let path_refs: Vec<&str> = field_path.iter().map(|s| s.as_str()).collect();
908
909            if let Some(field_info) = get_field_info_from_schema(&schema.source, &path_refs) {
910                // Create a link to the field in the schema
911                let field_name = field_path.last().map(|s| s.as_str()).unwrap_or("");
912                let field_range = find_field_in_schema_source(&schema.source, field_name);
913                let schema_link = {
914                    let mut link_uri = schema.uri.clone();
915                    if let Some(range) = field_range {
916                        let line = range.start.line + 1;
917                        let col = range.start.character + 1;
918                        link_uri.set_fragment(Some(&format!("L{}:{}", line, col)));
919                    }
920                    Some(link_uri)
921                };
922
923                let content = format_field_hover(
924                    &field_path,
925                    &field_info,
926                    schema.uri.as_str(),
927                    schema_link.as_ref(),
928                );
929                return Ok(Some(Hover {
930                    contents: HoverContents::Markup(MarkupContent {
931                        kind: MarkupKind::Markdown,
932                        value: content,
933                    }),
934                    range: None,
935                }));
936            }
937        }
938
939        Ok(None)
940    }
941
942    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
943        let uri = params.text_document_position.text_document.uri;
944        let position = params.text_document_position.position;
945
946        let docs = self.documents.read().await;
947        let Some(doc) = docs.get(&uri) else {
948            return Ok(None);
949        };
950
951        let Some(tree) = &doc.tree else {
952            return Ok(None);
953        };
954
955        // Get resolved schema
956        let Ok(schema) = resolve_schema(tree, &uri) else {
957            return Ok(None);
958        };
959
960        // Find the object context at cursor position for context-aware completion
961        let offset = position_to_offset(&doc.content, position);
962        let context = find_object_at_offset(tree, offset);
963        let path = context.as_ref().map(|c| c.path.as_slice()).unwrap_or(&[]);
964
965        tracing::debug!(?offset, ?path, "completion: finding fields at path");
966
967        // Parse the schema to properly resolve type references and enum variants
968        let schema_fields: Vec<(String, String)> = if let Ok(schema_file) =
969            facet_styx::from_str::<facet_styx::SchemaFile>(&schema.source)
970        {
971            let fields = get_schema_fields_at_path(&schema_file, path);
972            tracing::debug!(
973                count = fields.len(),
974                ?path,
975                "schema fields from parsed SchemaFile"
976            );
977            fields
978                .into_iter()
979                .map(|f| {
980                    // Build type string from schema info
981                    let type_str = if f.optional {
982                        format!("@optional({})", schema_to_type_str(&f.schema))
983                    } else if let Some(default) = &f.default_value {
984                        format!("@default({} {})", default, schema_to_type_str(&f.schema))
985                    } else {
986                        schema_to_type_str(&f.schema)
987                    };
988                    (f.name, type_str)
989                })
990                .collect()
991        } else {
992            tracing::debug!("Failed to parse schema, falling back to source-based lookup");
993            // Fallback to source-based lookup if parsing fails
994            get_schema_fields_from_source_at_path(&schema.source, path)
995        };
996        let existing_fields = context
997            .as_ref()
998            .map(|c| {
999                c.object
1000                    .entries
1001                    .iter()
1002                    .filter_map(|e| e.key.as_str().map(|s| s.to_string()))
1003                    .collect()
1004            })
1005            .unwrap_or_else(|| get_existing_fields(tree));
1006
1007        // Get current word being typed for fuzzy matching and text_edit range
1008        let word_info = get_word_range_at_position(&doc.content, position);
1009        let current_word = word_info.as_ref().map(|(w, _)| w.clone());
1010        // Range for text_edit - either replace the word being typed, or insert at cursor
1011        let edit_range = word_info.map(|(_, r)| r).unwrap_or_else(|| Range {
1012            start: position,
1013            end: position,
1014        });
1015
1016        // Filter out existing fields
1017        let available_fields: Vec<_> = schema_fields
1018            .into_iter()
1019            .filter(|(name, _)| !existing_fields.contains(name))
1020            .collect();
1021
1022        // If there are too many fields, apply filtering
1023        const MAX_COMPLETIONS: usize = 50;
1024        let filtered_fields = if available_fields.len() > MAX_COMPLETIONS {
1025            if let Some(ref word) = current_word {
1026                // Filter by prefix or similarity
1027                let mut scored: Vec<_> = available_fields
1028                    .into_iter()
1029                    .filter_map(|(name, type_str)| {
1030                        let name_lower = name.to_lowercase();
1031                        let word_lower = word.to_lowercase();
1032
1033                        // Exact prefix match gets highest priority
1034                        if name_lower.starts_with(&word_lower) {
1035                            return Some((name, type_str, 0));
1036                        }
1037
1038                        // Contains match
1039                        if name_lower.contains(&word_lower) {
1040                            return Some((name, type_str, 1));
1041                        }
1042
1043                        // Fuzzy match using Levenshtein distance
1044                        let dist = levenshtein(&word_lower, &name_lower);
1045                        if dist <= 3 && dist < word.len().max(2) {
1046                            return Some((name, type_str, 2 + dist));
1047                        }
1048
1049                        None
1050                    })
1051                    .collect();
1052
1053                // Sort by score and take top MAX_COMPLETIONS
1054                scored.sort_by_key(|(_, _, score)| *score);
1055                scored
1056                    .into_iter()
1057                    .take(MAX_COMPLETIONS)
1058                    .map(|(name, type_str, _)| (name, type_str))
1059                    .collect()
1060            } else {
1061                // No word typed - just show required fields first, up to limit
1062                let mut sorted = available_fields;
1063                sorted.sort_by_key(|(_, type_str)| {
1064                    if type_str.starts_with("@optional") || type_str.starts_with("@default") {
1065                        1
1066                    } else {
1067                        0
1068                    }
1069                });
1070                sorted.into_iter().take(MAX_COMPLETIONS).collect()
1071            }
1072        } else {
1073            available_fields
1074        };
1075
1076        // Build completion items from schema
1077        let mut items: Vec<CompletionItem> = filtered_fields
1078            .into_iter()
1079            .map(|(name, type_str)| {
1080                let is_optional =
1081                    type_str.starts_with("@optional") || type_str.starts_with("@default");
1082
1083                // Add "did you mean" label modifier for fuzzy matches
1084                let label_details = current_word.as_ref().and_then(|word| {
1085                    let word_lower = word.to_lowercase();
1086                    let name_lower = name.to_lowercase();
1087                    if !name_lower.starts_with(&word_lower) && !word.is_empty() {
1088                        let dist = levenshtein(&word_lower, &name_lower);
1089                        if dist <= 3 && dist > 0 {
1090                            return Some(CompletionItemLabelDetails {
1091                                detail: Some(" (did you mean?)".to_string()),
1092                                description: None,
1093                            });
1094                        }
1095                    }
1096                    None
1097                });
1098
1099                CompletionItem {
1100                    label: name.clone(),
1101                    label_details,
1102                    kind: Some(CompletionItemKind::FIELD),
1103                    detail: Some(type_str),
1104                    text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1105                        range: edit_range,
1106                        new_text: format!("{} ", name),
1107                    })),
1108                    filter_text: Some(name.clone()),
1109                    sort_text: Some(if is_optional {
1110                        format!("1{}", name) // Optional fields sort after required
1111                    } else {
1112                        format!("0{}", name) // Required fields first
1113                    }),
1114                    ..Default::default()
1115                }
1116            })
1117            .collect();
1118
1119        // Try to get completions from extension
1120        if let Ok(schema_file) = load_document_schema(tree, &uri) {
1121            let schema_id = &schema_file.meta.id;
1122            if let Some(client) = self.extensions.get_client(schema_id).await {
1123                let tagged_context = find_tagged_context_at_offset(tree, offset);
1124                let ext_params = ext::CompletionParams {
1125                    document_uri: uri.to_string(),
1126                    cursor: ext::Cursor {
1127                        line: position.line,
1128                        character: position.character,
1129                        offset: offset as u32,
1130                    },
1131                    path: path.iter().map(|s| s.to_string()).collect(),
1132                    prefix: current_word.clone().unwrap_or_default(),
1133                    context: context.map(|c| Value {
1134                        tag: None,
1135                        payload: Some(styx_tree::Payload::Object(c.object.clone())),
1136                        span: None,
1137                    }),
1138                    tagged_context,
1139                };
1140
1141                match client.completions(ext_params).await {
1142                    Ok(ext_items) => {
1143                        tracing::debug!(count = ext_items.len(), "Got completions from extension");
1144                        for item in ext_items {
1145                            items.push(convert_ext_completion(item, edit_range));
1146                        }
1147                    }
1148                    Err(e) => {
1149                        tracing::warn!(error = ?e, "Extension completion failed");
1150                    }
1151                }
1152            }
1153        }
1154
1155        Ok(Some(CompletionResponse::Array(items)))
1156    }
1157
1158    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1159        let uri = params.text_document.uri;
1160        let mut actions = Vec::new();
1161
1162        // Process each diagnostic to generate code actions (quickfixes)
1163        for diag in &params.context.diagnostics {
1164            // Handle schema hint diagnostics (add @schema declaration)
1165            if diag.source.as_deref() == Some("styx-hints") {
1166                if let Some(data) = &diag.data
1167                    && let Some(fix_type) = data.get("type").and_then(|v| v.as_str())
1168                    && fix_type == "add_schema"
1169                    && let Some(declaration) = data.get("declaration").and_then(|v| v.as_str())
1170                {
1171                    let tool_name = data
1172                        .get("tool")
1173                        .and_then(|v| v.as_str())
1174                        .unwrap_or("schema");
1175
1176                    // Action 1: Add the suggested schema declaration
1177                    {
1178                        let edit = TextEdit {
1179                            range: Range {
1180                                start: Position::new(0, 0),
1181                                end: Position::new(0, 0),
1182                            },
1183                            new_text: format!("{}\n\n", declaration),
1184                        };
1185
1186                        let mut changes = std::collections::HashMap::new();
1187                        changes.insert(uri.clone(), vec![edit]);
1188
1189                        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1190                            title: format!("Add {} schema declaration", tool_name),
1191                            kind: Some(CodeActionKind::QUICKFIX),
1192                            diagnostics: Some(vec![diag.clone()]),
1193                            edit: Some(WorkspaceEdit {
1194                                changes: Some(changes),
1195                                ..Default::default()
1196                            }),
1197                            is_preferred: Some(true),
1198                            ..Default::default()
1199                        }));
1200                    }
1201
1202                    // Action 2: Stop reminding (insert @schema @ to explicitly disable)
1203                    {
1204                        let edit = TextEdit {
1205                            range: Range {
1206                                start: Position::new(0, 0),
1207                                end: Position::new(0, 0),
1208                            },
1209                            new_text: "@schema @\n\n".to_string(),
1210                        };
1211
1212                        let mut changes = std::collections::HashMap::new();
1213                        changes.insert(uri.clone(), vec![edit]);
1214
1215                        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1216                            title: "Don't use a schema for this file".to_string(),
1217                            kind: Some(CodeActionKind::QUICKFIX),
1218                            diagnostics: Some(vec![diag.clone()]),
1219                            edit: Some(WorkspaceEdit {
1220                                changes: Some(changes),
1221                                ..Default::default()
1222                            }),
1223                            is_preferred: Some(false),
1224                            ..Default::default()
1225                        }));
1226                    }
1227                }
1228                continue;
1229            }
1230
1231            // Handle extension allowlist diagnostics
1232            if diag.source.as_deref() == Some("styx-extension") {
1233                if let Some(data) = &diag.data
1234                    && let Some(fix_type) = data.get("type").and_then(|v| v.as_str())
1235                    && fix_type == "allow_extension"
1236                    && let Some(command) = data.get("command").and_then(|v| v.as_str())
1237                {
1238                    // Code action that triggers the execute_command to allow the extension
1239                    actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1240                        title: format!("Allow LSP extension '{}'", command),
1241                        kind: Some(CodeActionKind::QUICKFIX),
1242                        diagnostics: Some(vec![diag.clone()]),
1243                        command: Some(Command {
1244                            title: format!("Allow LSP extension '{}'", command),
1245                            command: "styx.allowExtension".to_string(),
1246                            arguments: Some(vec![serde_json::json!({
1247                                "command": command,
1248                            })]),
1249                        }),
1250                        is_preferred: Some(true),
1251                        ..Default::default()
1252                    }));
1253                }
1254                continue;
1255            }
1256
1257            // Only process styx-schema diagnostics below
1258            if diag.source.as_deref() != Some("styx-schema") {
1259                continue;
1260            }
1261
1262            // Check for quickfix data
1263            if let Some(data) = &diag.data
1264                && let Some(fix_type) = data.get("type").and_then(|v| v.as_str())
1265                && fix_type == "rename_field"
1266                && let (Some(from), Some(to)) = (
1267                    data.get("from").and_then(|v| v.as_str()),
1268                    data.get("to").and_then(|v| v.as_str()),
1269                )
1270            {
1271                // Create a text edit to rename the field
1272                let edit = TextEdit {
1273                    range: diag.range,
1274                    new_text: to.to_string(),
1275                };
1276
1277                let mut changes = std::collections::HashMap::new();
1278                changes.insert(uri.clone(), vec![edit]);
1279
1280                actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1281                    title: format!("Rename '{}' to '{}'", from, to),
1282                    kind: Some(CodeActionKind::QUICKFIX),
1283                    diagnostics: Some(vec![diag.clone()]),
1284                    edit: Some(WorkspaceEdit {
1285                        changes: Some(changes),
1286                        ..Default::default()
1287                    }),
1288                    is_preferred: Some(true),
1289                    ..Default::default()
1290                }));
1291            }
1292        }
1293
1294        // Try to get code actions from extension
1295        let docs = self.documents.read().await;
1296        if let Some(doc) = docs.get(&uri)
1297            && let Some(ref tree) = doc.tree
1298            && let Ok(schema_file) = load_document_schema(tree, &uri)
1299        {
1300            let schema_id = &schema_file.meta.id;
1301            if let Some(client) = self.extensions.get_client(schema_id).await {
1302                // Convert diagnostics to extension format (Range → Span)
1303                let ext_diagnostics: Vec<ext::Diagnostic> = params
1304                    .context
1305                    .diagnostics
1306                    .iter()
1307                    .map(|d| ext::Diagnostic {
1308                        span: styx_tree::Span {
1309                            start: position_to_offset(&doc.content, d.range.start) as u32,
1310                            end: position_to_offset(&doc.content, d.range.end) as u32,
1311                        },
1312                        severity: match d.severity {
1313                            Some(DiagnosticSeverity::ERROR) => ext::DiagnosticSeverity::Error,
1314                            Some(DiagnosticSeverity::WARNING) => ext::DiagnosticSeverity::Warning,
1315                            Some(DiagnosticSeverity::INFORMATION) => ext::DiagnosticSeverity::Info,
1316                            Some(DiagnosticSeverity::HINT) | None => ext::DiagnosticSeverity::Hint,
1317                            Some(_) => ext::DiagnosticSeverity::Info,
1318                        },
1319                        message: d.message.clone(),
1320                        source: d.source.clone(),
1321                        code: d.code.as_ref().map(|c| match c {
1322                            NumberOrString::String(s) => s.clone(),
1323                            NumberOrString::Number(n) => n.to_string(),
1324                        }),
1325                        data: d.data.as_ref().map(|json_val| {
1326                            // Convert JSON Value to styx Value
1327                            json_to_styx_value(json_val)
1328                        }),
1329                    })
1330                    .collect();
1331
1332                let ext_params = ext::CodeActionParams {
1333                    document_uri: uri.to_string(),
1334                    span: styx_tree::Span {
1335                        start: position_to_offset(&doc.content, params.range.start) as u32,
1336                        end: position_to_offset(&doc.content, params.range.end) as u32,
1337                    },
1338                    diagnostics: ext_diagnostics,
1339                };
1340
1341                match client.code_actions(ext_params).await {
1342                    Ok(ext_actions) => {
1343                        tracing::debug!(
1344                            count = ext_actions.len(),
1345                            "Got code actions from extension"
1346                        );
1347                        for action in ext_actions {
1348                            let kind = action.kind.map(|k| match k {
1349                                ext::CodeActionKind::QuickFix => CodeActionKind::QUICKFIX,
1350                                ext::CodeActionKind::Refactor => CodeActionKind::REFACTOR,
1351                                ext::CodeActionKind::Source => CodeActionKind::SOURCE,
1352                            });
1353
1354                            let edit = action.edit.map(|we| {
1355                                let changes: std::collections::HashMap<_, _> = we
1356                                    .changes
1357                                    .into_iter()
1358                                    .filter_map(|doc_edit| {
1359                                        let doc_uri = Url::parse(&doc_edit.uri)
1360                                            .unwrap_or_else(|_| uri.clone());
1361                                        // Get content for span→range conversion
1362                                        let content = if doc_uri == uri {
1363                                            &doc.content
1364                                        } else if let Some(target_doc) = docs.get(&doc_uri) {
1365                                            &target_doc.content
1366                                        } else {
1367                                            tracing::warn!(
1368                                                uri = %doc_uri,
1369                                                "Cannot convert span to range: document not open"
1370                                            );
1371                                            return None;
1372                                        };
1373                                        let edits: Vec<TextEdit> = doc_edit
1374                                            .edits
1375                                            .into_iter()
1376                                            .map(|e| TextEdit {
1377                                                range: Range {
1378                                                    start: offset_to_position(
1379                                                        content,
1380                                                        e.span.start as usize,
1381                                                    ),
1382                                                    end: offset_to_position(
1383                                                        content,
1384                                                        e.span.end as usize,
1385                                                    ),
1386                                                },
1387                                                new_text: e.new_text,
1388                                            })
1389                                            .collect();
1390                                        Some((doc_uri, edits))
1391                                    })
1392                                    .collect();
1393                                WorkspaceEdit {
1394                                    changes: Some(changes),
1395                                    ..Default::default()
1396                                }
1397                            });
1398
1399                            actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1400                                title: action.title,
1401                                kind,
1402                                edit,
1403                                is_preferred: Some(action.is_preferred),
1404                                ..Default::default()
1405                            }));
1406                        }
1407                    }
1408                    Err(e) => {
1409                        tracing::warn!(error = ?e, "Extension code actions failed");
1410                    }
1411                }
1412            }
1413        }
1414        drop(docs);
1415
1416        // Add schema-based refactoring actions
1417        let docs = self.documents.read().await;
1418        if let Some(doc) = docs.get(&uri)
1419            && let Some(ref tree) = doc.tree
1420        {
1421            // Try to load the schema
1422            if let Ok(schema_file) = load_document_schema(tree, &uri) {
1423                // Find the object at cursor position
1424                let cursor_offset = position_to_offset(&doc.content, params.range.start);
1425                let object_ctx = find_object_at_offset(tree, cursor_offset);
1426
1427                // Get schema fields for the current context (root or nested)
1428                let (schema_fields, existing_fields, context_name) =
1429                    if let Some(ref ctx) = object_ctx {
1430                        let fields = get_schema_fields_at_path(&schema_file, &ctx.path);
1431                        let existing: Vec<String> = ctx
1432                            .object
1433                            .entries
1434                            .iter()
1435                            .filter_map(|e| e.key.as_str().map(String::from))
1436                            .collect();
1437                        let name = if ctx.path.is_empty() {
1438                            String::new()
1439                        } else {
1440                            format!(" in '{}'", ctx.path.join("."))
1441                        };
1442                        (fields, existing, name)
1443                    } else {
1444                        let fields = get_schema_fields(&schema_file);
1445                        let existing = get_document_fields(tree);
1446                        (fields, existing, String::new())
1447                    };
1448
1449                // Find missing fields
1450                let missing_required: Vec<_> = schema_fields
1451                    .iter()
1452                    .filter(|f| !f.optional && !existing_fields.contains(&f.name))
1453                    .collect();
1454
1455                let missing_optional: Vec<_> = schema_fields
1456                    .iter()
1457                    .filter(|f| f.optional && !existing_fields.contains(&f.name))
1458                    .collect();
1459
1460                // Find insert position and indentation within the current object
1461                let insert_info = if let Some(ref ctx) = object_ctx {
1462                    if let Some(span) = ctx.span {
1463                        // Find insertion point within this object
1464                        let obj_content = &doc.content[span.start as usize..span.end as usize];
1465                        let obj_insert = find_field_insert_position(obj_content);
1466                        // Adjust position to be relative to document start
1467                        let obj_start_pos = offset_to_position(&doc.content, span.start as usize);
1468                        InsertPosition {
1469                            position: Position {
1470                                line: obj_start_pos.line + obj_insert.position.line,
1471                                character: if obj_insert.position.line == 0 {
1472                                    obj_start_pos.character + obj_insert.position.character
1473                                } else {
1474                                    obj_insert.position.character
1475                                },
1476                            },
1477                            indent: obj_insert.indent,
1478                        }
1479                    } else {
1480                        find_field_insert_position(&doc.content)
1481                    }
1482                } else {
1483                    find_field_insert_position(&doc.content)
1484                };
1485
1486                // Action: Fill required fields
1487                if !missing_required.is_empty() {
1488                    let new_text = generate_fields_text(&missing_required, &insert_info.indent);
1489                    let edit = TextEdit {
1490                        range: Range {
1491                            start: insert_info.position,
1492                            end: insert_info.position,
1493                        },
1494                        new_text,
1495                    };
1496
1497                    let mut changes = std::collections::HashMap::new();
1498                    changes.insert(uri.clone(), vec![edit]);
1499
1500                    actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1501                        title: format!(
1502                            "Fill {} required field{}{}",
1503                            missing_required.len(),
1504                            if missing_required.len() == 1 { "" } else { "s" },
1505                            context_name
1506                        ),
1507                        kind: Some(CodeActionKind::REFACTOR),
1508                        edit: Some(WorkspaceEdit {
1509                            changes: Some(changes),
1510                            ..Default::default()
1511                        }),
1512                        ..Default::default()
1513                    }));
1514                }
1515
1516                // Action: Fill all fields (required + optional)
1517                let all_missing: Vec<_> = schema_fields
1518                    .iter()
1519                    .filter(|f| !existing_fields.contains(&f.name))
1520                    .collect();
1521
1522                if !all_missing.is_empty() && !missing_optional.is_empty() {
1523                    let new_text = generate_fields_text(&all_missing, &insert_info.indent);
1524                    let edit = TextEdit {
1525                        range: Range {
1526                            start: insert_info.position,
1527                            end: insert_info.position,
1528                        },
1529                        new_text,
1530                    };
1531
1532                    let mut changes = std::collections::HashMap::new();
1533                    changes.insert(uri.clone(), vec![edit]);
1534
1535                    actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1536                        title: format!(
1537                            "Fill all {} field{}{}",
1538                            all_missing.len(),
1539                            if all_missing.len() == 1 { "" } else { "s" },
1540                            context_name
1541                        ),
1542                        kind: Some(CodeActionKind::REFACTOR),
1543                        edit: Some(WorkspaceEdit {
1544                            changes: Some(changes),
1545                            ..Default::default()
1546                        }),
1547                        ..Default::default()
1548                    }));
1549                }
1550
1551                // Action: Reorder fields to match schema (only at root level for now)
1552                if object_ctx
1553                    .as_ref()
1554                    .map(|c| c.path.is_empty())
1555                    .unwrap_or(true)
1556                {
1557                    let root_fields = get_schema_fields(&schema_file);
1558                    if let Some(reorder_edit) =
1559                        generate_reorder_edit(tree, &root_fields, &doc.content)
1560                    {
1561                        let mut changes = std::collections::HashMap::new();
1562                        changes.insert(uri.clone(), vec![reorder_edit]);
1563
1564                        actions.push(CodeActionOrCommand::CodeAction(CodeAction {
1565                            title: "Reorder fields to match schema".to_string(),
1566                            kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS),
1567                            edit: Some(WorkspaceEdit {
1568                                changes: Some(changes),
1569                                ..Default::default()
1570                            }),
1571                            ..Default::default()
1572                        }));
1573                    }
1574                }
1575            }
1576        }
1577
1578        if actions.is_empty() {
1579            Ok(None)
1580        } else {
1581            Ok(Some(actions))
1582        }
1583    }
1584
1585    async fn execute_command(
1586        &self,
1587        params: ExecuteCommandParams,
1588    ) -> Result<Option<serde_json::Value>> {
1589        match params.command.as_str() {
1590            "styx.allowExtension" => {
1591                // Extract the command to allow from the arguments
1592                if let Some(arg) = params.arguments.first()
1593                    && let Some(command) = arg.get("command").and_then(|v| v.as_str())
1594                {
1595                    tracing::info!(command, "Allowing LSP extension");
1596                    self.extensions.allow(command.to_string()).await;
1597
1598                    // Notify the user
1599                    self.client
1600                        .log_message(
1601                            MessageType::INFO,
1602                            format!("Allowed LSP extension: {}", command),
1603                        )
1604                        .await;
1605
1606                    // Re-publish diagnostics for all open documents to clear the warning
1607                    // and trigger extension spawning
1608                    let docs = self.documents.read().await;
1609                    for (uri, doc) in docs.iter() {
1610                        let blocked_extension = if let Some(ref tree) = doc.tree {
1611                            self.check_for_extension(tree, uri).await
1612                        } else {
1613                            None
1614                        };
1615                        self.publish_diagnostics(
1616                            uri.clone(),
1617                            &doc.content,
1618                            &doc.parse,
1619                            doc.tree.as_ref(),
1620                            None, // tree_error already reported on initial load
1621                            doc.version,
1622                            blocked_extension,
1623                        )
1624                        .await;
1625                    }
1626
1627                    // Request inlay hint refresh so hints appear immediately
1628                    let _ = self.client.inlay_hint_refresh().await;
1629                }
1630                Ok(None)
1631            }
1632            _ => {
1633                tracing::warn!(command = %params.command, "Unknown command");
1634                Ok(None)
1635            }
1636        }
1637    }
1638
1639    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1640        let uri = params.text_document_position.text_document.uri;
1641        let position = params.text_document_position.position;
1642
1643        let docs = self.documents.read().await;
1644        let doc = match docs.get(&uri) {
1645            Some(doc) => doc,
1646            None => return Ok(None),
1647        };
1648
1649        let Some(tree) = &doc.tree else {
1650            return Ok(None);
1651        };
1652
1653        let offset = position_to_offset(&doc.content, position);
1654
1655        // Find what field we're on
1656        let Some(field_name) = find_field_key_at_offset(tree, offset) else {
1657            return Ok(None);
1658        };
1659
1660        let mut locations = Vec::new();
1661
1662        // Check if we're in a schema file
1663        if is_schema_file(tree) {
1664            // We're in a schema - find all docs that use this field
1665            for (doc_uri, doc_state) in docs.iter() {
1666                if doc_uri == &uri {
1667                    continue;
1668                }
1669                if let Some(ref doc_tree) = doc_state.tree {
1670                    // Check if this doc references our schema
1671                    if let Ok(doc_schema) = resolve_schema(doc_tree, doc_uri)
1672                        && doc_schema.uri == uri
1673                    {
1674                        // This doc uses our schema - find the field usage
1675                        if let Some(range) =
1676                            find_field_in_doc(doc_tree, &field_name, &doc_state.content)
1677                        {
1678                            locations.push(Location {
1679                                uri: doc_uri.clone(),
1680                                range,
1681                            });
1682                        }
1683                    }
1684                }
1685            }
1686        } else {
1687            // We're in a doc - find the schema definition and other docs using this field
1688            if let Ok(schema) = resolve_schema(tree, &uri) {
1689                // Add the schema definition location
1690                if let Some(field_range) = find_field_in_schema_source(&schema.source, &field_name)
1691                {
1692                    locations.push(Location {
1693                        uri: schema.uri.clone(),
1694                        range: field_range,
1695                    });
1696
1697                    // Find other docs using the same schema
1698                    for (doc_uri, doc_state) in docs.iter() {
1699                        if let Some(ref doc_tree) = doc_state.tree
1700                            && let Ok(doc_schema) = resolve_schema(doc_tree, doc_uri)
1701                            && doc_schema.uri == schema.uri
1702                        {
1703                            // This doc uses the same schema
1704                            if let Some(range) =
1705                                find_field_in_doc(doc_tree, &field_name, &doc_state.content)
1706                            {
1707                                locations.push(Location {
1708                                    uri: doc_uri.clone(),
1709                                    range,
1710                                });
1711                            }
1712                        }
1713                    }
1714                }
1715            }
1716        }
1717
1718        if locations.is_empty() {
1719            Ok(None)
1720        } else {
1721            Ok(Some(locations))
1722        }
1723    }
1724
1725    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1726        let uri = params.text_document.uri;
1727
1728        // Clone what we need and drop the lock before any async extension calls
1729        // to avoid blocking document updates during the RPC
1730        let (tree, content) = {
1731            let docs = self.documents.read().await;
1732            let Some(doc) = docs.get(&uri) else {
1733                return Ok(None);
1734            };
1735            let Some(tree) = &doc.tree else {
1736                return Ok(None);
1737            };
1738            (tree.clone(), doc.content.clone())
1739        };
1740
1741        let mut hints = Vec::new();
1742
1743        // Check for schema declaration
1744        if let Some(range) = find_schema_declaration_range(&tree, &content)
1745            && let Ok(schema) = resolve_schema(&tree, &uri)
1746        {
1747            // Extract meta info from schema
1748            if let Some(meta) = get_schema_meta(&schema.source) {
1749                // Show schema name/description as inlay hint after the schema path
1750                // First line of description is the short desc
1751                let short_desc = meta
1752                    .description
1753                    .as_ref()
1754                    .and_then(|d| d.lines().next().map(|s| s.trim().to_string()));
1755
1756                // Build the label: "— short desc (version)" or just "— short desc"
1757                let label = match (&short_desc, &meta.version) {
1758                    (Some(desc), Some(ver)) => format!(" — {} ({})", desc, ver),
1759                    (Some(desc), None) => format!(" — {}", desc),
1760                    (None, Some(ver)) => format!(" — v{}", ver),
1761                    (None, None) => String::new(),
1762                };
1763
1764                if !label.is_empty() {
1765                    // Build tooltip with full description and id
1766                    let tooltip = {
1767                        let mut parts = Vec::new();
1768                        if let Some(id) = &meta.id {
1769                            parts.push(format!("Schema ID: {}", id));
1770                        }
1771                        // Show full description if it's multi-line
1772                        if let Some(desc) = &meta.description
1773                            && desc.contains('\n')
1774                        {
1775                            parts.push(String::new()); // blank line
1776                            parts.push(desc.clone());
1777                        }
1778                        if parts.is_empty() {
1779                            None
1780                        } else {
1781                            Some(InlayHintTooltip::String(parts.join("\n")))
1782                        }
1783                    };
1784
1785                    hints.push(InlayHint {
1786                        position: range.end,
1787                        label: InlayHintLabel::String(label),
1788                        kind: Some(InlayHintKind::TYPE),
1789                        text_edits: None,
1790                        tooltip,
1791                        padding_left: Some(false),
1792                        padding_right: Some(true),
1793                        data: None,
1794                    });
1795                }
1796            }
1797        }
1798
1799        // Try to get inlay hints from extension
1800        if let Ok(schema_file) = load_document_schema(&tree, &uri) {
1801            let schema_id = &schema_file.meta.id;
1802            tracing::debug!(%schema_id, "Trying extension for inlay hints");
1803            if let Some(client) = self.extensions.get_client(schema_id).await {
1804                tracing::debug!("Got extension client, calling inlay_hints");
1805                let ext_params = ext::InlayHintParams {
1806                    document_uri: uri.to_string(),
1807                    range: ext::Range {
1808                        start: ext::Position {
1809                            line: params.range.start.line,
1810                            character: params.range.start.character,
1811                        },
1812                        end: ext::Position {
1813                            line: params.range.end.line,
1814                            character: params.range.end.character,
1815                        },
1816                    },
1817                    context: Some(tree.clone()),
1818                };
1819
1820                match client.inlay_hints(ext_params).await {
1821                    Ok(ext_hints) => {
1822                        tracing::debug!(count = ext_hints.len(), "Got inlay hints from extension");
1823                        for hint in ext_hints {
1824                            // Extension already returns proper line/character positions
1825                            // (it calls offset_to_position internally via the host RPC)
1826                            let position = Position {
1827                                line: hint.position.line,
1828                                character: hint.position.character,
1829                            };
1830                            hints.push(InlayHint {
1831                                position,
1832                                label: InlayHintLabel::String(hint.label),
1833                                kind: hint.kind.map(|k| match k {
1834                                    ext::InlayHintKind::Type => InlayHintKind::TYPE,
1835                                    ext::InlayHintKind::Parameter => InlayHintKind::PARAMETER,
1836                                }),
1837                                text_edits: None,
1838                                tooltip: None,
1839                                padding_left: Some(hint.padding_left),
1840                                padding_right: Some(hint.padding_right),
1841                                data: None,
1842                            });
1843                        }
1844                    }
1845                    Err(e) => {
1846                        tracing::warn!(error = ?e, "Extension inlay hints failed");
1847                    }
1848                }
1849            }
1850        }
1851
1852        if hints.is_empty() {
1853            Ok(None)
1854        } else {
1855            Ok(Some(hints))
1856        }
1857    }
1858
1859    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1860        let uri = params.text_document.uri;
1861
1862        let docs = self.documents.read().await;
1863        let Some(doc) = docs.get(&uri) else {
1864            return Ok(None);
1865        };
1866
1867        // Only format if document parsed successfully
1868        if doc.tree.is_none() {
1869            return Ok(None);
1870        }
1871
1872        // Build indent string from editor preferences
1873        let indent = if params.options.insert_spaces {
1874            " ".repeat(params.options.tab_size as usize)
1875        } else {
1876            "\t".to_string()
1877        };
1878
1879        // Format the document using CST formatter (preserves comments)
1880        let options = styx_format::FormatOptions::default().indent(
1881            // Leak the string since FormatOptions expects &'static str
1882            // This is fine since we're not going to format millions of times
1883            Box::leak(indent.into_boxed_str()),
1884        );
1885
1886        let formatted = styx_format::format_source(&doc.content, options);
1887
1888        // Only return an edit if the content changed
1889        if formatted == doc.content {
1890            return Ok(None);
1891        }
1892
1893        // Replace the entire document
1894        let lines: Vec<&str> = doc.content.lines().collect();
1895        let last_line = lines.len().saturating_sub(1);
1896        let last_char = lines.last().map(|l| l.len()).unwrap_or(0);
1897
1898        Ok(Some(vec![TextEdit {
1899            range: Range {
1900                start: Position {
1901                    line: 0,
1902                    character: 0,
1903                },
1904                end: Position {
1905                    line: last_line as u32,
1906                    character: last_char as u32,
1907                },
1908            },
1909            new_text: formatted,
1910        }]))
1911    }
1912
1913    async fn document_symbol(
1914        &self,
1915        params: DocumentSymbolParams,
1916    ) -> Result<Option<DocumentSymbolResponse>> {
1917        let uri = params.text_document.uri;
1918
1919        let docs = self.documents.read().await;
1920        let Some(doc) = docs.get(&uri) else {
1921            return Ok(None);
1922        };
1923
1924        let Some(tree) = &doc.tree else {
1925            return Ok(None);
1926        };
1927
1928        let symbols = collect_document_symbols(tree, &doc.content);
1929
1930        if symbols.is_empty() {
1931            Ok(None)
1932        } else {
1933            Ok(Some(DocumentSymbolResponse::Nested(symbols)))
1934        }
1935    }
1936
1937    async fn on_type_formatting(
1938        &self,
1939        params: DocumentOnTypeFormattingParams,
1940    ) -> Result<Option<Vec<TextEdit>>> {
1941        let uri = params.text_document_position.text_document.uri;
1942        let position = params.text_document_position.position;
1943
1944        let docs = self.documents.read().await;
1945        let Some(doc) = docs.get(&uri) else {
1946            return Ok(None);
1947        };
1948
1949        // Only handle newline character
1950        if params.ch != "\n" {
1951            return Ok(None);
1952        }
1953
1954        // Convert position to byte offset
1955        let offset = position_to_offset(&doc.content, position);
1956
1957        // Find the nesting depth at this offset using the CST
1958        let depth = find_nesting_depth_cst(&doc.parse, offset);
1959
1960        // Build indent string from editor preferences
1961        let indent_unit = if params.options.insert_spaces {
1962            " ".repeat(params.options.tab_size as usize)
1963        } else {
1964            "\t".to_string()
1965        };
1966
1967        // Build the indentation string based on nesting depth
1968        let indent_str = indent_unit.repeat(depth);
1969
1970        // Insert indentation at the start of the current line
1971        Ok(Some(vec![TextEdit {
1972            range: Range {
1973                start: Position {
1974                    line: position.line,
1975                    character: 0,
1976                },
1977                end: Position {
1978                    line: position.line,
1979                    character: 0,
1980                },
1981            },
1982            new_text: indent_str,
1983        }]))
1984    }
1985}
1986
1987/// Find the nesting depth (number of objects/sequences) containing the given offset.
1988///
1989/// Uses the CST for accurate position information. Counts OBJECT and SEQUENCE
1990/// nodes in the ancestor chain from the given offset.
1991fn find_nesting_depth_cst(parse: &styx_cst::Parse, offset: usize) -> usize {
1992    use styx_cst::{SyntaxKind, TextSize, TokenAtOffset};
1993
1994    let root = parse.syntax();
1995    let offset = TextSize::new(offset as u32);
1996
1997    // Find the token at this offset
1998    let token = match root.token_at_offset(offset) {
1999        TokenAtOffset::None => return 0,
2000        TokenAtOffset::Single(t) => {
2001            // If we're exactly at the end of a closing delimiter, we're semantically outside
2002            if matches!(t.kind(), SyntaxKind::R_BRACE | SyntaxKind::R_PAREN)
2003                && t.text_range().end() == offset
2004            {
2005                // We're at the end of a closing brace - count depth excluding this container
2006                let mut depth: usize = 0;
2007                let mut node = t.parent();
2008                while let Some(n) = node {
2009                    if matches!(n.kind(), SyntaxKind::OBJECT | SyntaxKind::SEQUENCE) {
2010                        depth += 1;
2011                    }
2012                    node = n.parent();
2013                }
2014                // Subtract 1 because we're outside the innermost container
2015                return depth.saturating_sub(1);
2016            }
2017            t
2018        }
2019        TokenAtOffset::Between(left, right) => {
2020            // When between two tokens, choose based on what makes semantic sense:
2021            // - If left is a closing delimiter (} or )), cursor is OUTSIDE, prefer right
2022            // - Otherwise prefer left (e.g., after newline inside a block)
2023            match left.kind() {
2024                SyntaxKind::R_BRACE | SyntaxKind::R_PAREN => right,
2025                _ => left,
2026            }
2027        }
2028    };
2029
2030    // Walk up the ancestor chain, counting OBJECT and SEQUENCE nodes
2031    let mut depth = 0;
2032    let mut node = token.parent();
2033
2034    while let Some(n) = node {
2035        match n.kind() {
2036            SyntaxKind::OBJECT | SyntaxKind::SEQUENCE => {
2037                depth += 1;
2038            }
2039            _ => {}
2040        }
2041        node = n.parent();
2042    }
2043
2044    depth
2045}
2046
2047/// Collect document symbols recursively from a value tree
2048fn collect_document_symbols(value: &Value, content: &str) -> Vec<DocumentSymbol> {
2049    let mut symbols = Vec::new();
2050
2051    let Some(obj) = value.as_object() else {
2052        return symbols;
2053    };
2054
2055    for entry in &obj.entries {
2056        // Skip the @ (schema declaration)
2057        if entry.key.is_unit() {
2058            continue;
2059        }
2060
2061        let Some(name) = entry.key.as_str() else {
2062            continue;
2063        };
2064
2065        let Some(key_span) = entry.key.span else {
2066            continue;
2067        };
2068
2069        let Some(val_span) = entry.value.span else {
2070            continue;
2071        };
2072
2073        // Get the value text for the detail
2074        let val_text = &content[val_span.start as usize..val_span.end as usize];
2075
2076        // Determine the symbol kind based on the value type
2077        let (kind, detail, children): (SymbolKind, Option<String>, Vec<DocumentSymbol>) =
2078            if let Some(nested_obj) = entry.value.as_object() {
2079                // It's an object - recurse
2080                let nested_value = Value {
2081                    tag: entry.value.tag.clone(),
2082                    payload: Some(styx_tree::Payload::Object(nested_obj.clone())),
2083                    span: entry.value.span,
2084                };
2085                let children = collect_document_symbols(&nested_value, content);
2086                (SymbolKind::OBJECT, None, children)
2087            } else if entry.value.as_sequence().is_some() {
2088                (SymbolKind::ARRAY, Some("array".to_string()), Vec::new())
2089            } else if entry.value.as_str().is_some() {
2090                (SymbolKind::STRING, Some(val_text.to_string()), Vec::new())
2091            } else if entry.value.is_unit() {
2092                (SymbolKind::NULL, Some("@".to_string()), Vec::new())
2093            } else if entry.value.tag.is_some() {
2094                // Tagged value
2095                (SymbolKind::VARIABLE, Some(val_text.to_string()), Vec::new())
2096            } else {
2097                // Number, bool, or other scalar - just show the text
2098                (SymbolKind::CONSTANT, Some(val_text.to_string()), Vec::new())
2099            };
2100
2101        let selection_range = Range {
2102            start: offset_to_position(content, key_span.start as usize),
2103            end: offset_to_position(content, key_span.end as usize),
2104        };
2105
2106        let range = Range {
2107            start: offset_to_position(content, key_span.start as usize),
2108            end: offset_to_position(content, val_span.end as usize),
2109        };
2110
2111        #[allow(deprecated)]
2112        symbols.push(DocumentSymbol {
2113            name: name.to_string(),
2114            detail,
2115            kind,
2116            tags: None,
2117            deprecated: None,
2118            range,
2119            selection_range,
2120            children: if children.is_empty() {
2121                None
2122            } else {
2123                Some(children)
2124            },
2125        });
2126    }
2127
2128    symbols
2129}
2130
2131/// Find the schema declaration range in the source
2132fn find_schema_declaration_range(tree: &Value, content: &str) -> Option<Range> {
2133    let obj = tree.as_object()?;
2134
2135    for entry in &obj.entries {
2136        if entry.key.is_schema_tag() {
2137            let span = entry.value.span?;
2138            return Some(Range {
2139                start: offset_to_position(content, span.start as usize),
2140                end: offset_to_position(content, span.end as usize),
2141            });
2142        }
2143    }
2144
2145    None
2146}
2147
2148/// Convert byte offset to LSP Position
2149fn offset_to_position(content: &str, offset: usize) -> Position {
2150    let mut line = 0u32;
2151    let mut col = 0u32;
2152
2153    for (i, ch) in content.char_indices() {
2154        if i >= offset {
2155            break;
2156        }
2157        if ch == '\n' {
2158            line += 1;
2159            col = 0;
2160        } else {
2161            col += 1;
2162        }
2163    }
2164
2165    Position::new(line, col)
2166}
2167
2168/// Convert LSP Position to byte offset
2169fn position_to_offset(content: &str, position: Position) -> usize {
2170    let mut current_line = 0u32;
2171    let mut current_col = 0u32;
2172
2173    for (i, ch) in content.char_indices() {
2174        if current_line == position.line && current_col == position.character {
2175            return i;
2176        }
2177        if ch == '\n' {
2178            if current_line == position.line {
2179                // Position is past end of line
2180                return i;
2181            }
2182            current_line += 1;
2183            current_col = 0;
2184        } else {
2185            current_col += 1;
2186        }
2187    }
2188
2189    content.len()
2190}
2191
2192/// Find the field key at a given offset in the tree (returns just the immediate field name)
2193fn find_field_key_at_offset(tree: &Value, offset: usize) -> Option<String> {
2194    // Use the path-based function and return just the last element
2195    find_field_path_at_offset(tree, offset).and_then(|path| path.last().cloned())
2196}
2197
2198/// A path segment - either a field name or a sequence index
2199#[derive(Debug, Clone)]
2200enum PathSegment {
2201    Field(String),
2202    Index(usize),
2203}
2204
2205impl PathSegment {
2206    fn as_str(&self) -> String {
2207        match self {
2208            PathSegment::Field(name) => name.clone(),
2209            PathSegment::Index(i) => i.to_string(),
2210        }
2211    }
2212}
2213
2214/// Find the field path at the given offset (e.g., ["logging", "format", "timestamp"] or ["items", "0", "name"])
2215fn find_field_path_at_offset(tree: &Value, offset: usize) -> Option<Vec<String>> {
2216    find_path_segments_at_offset(tree, offset)
2217        .map(|segments| segments.iter().map(|s| s.as_str()).collect())
2218}
2219
2220/// Find path segments at the given offset, including sequence indices
2221fn find_path_segments_at_offset(tree: &Value, offset: usize) -> Option<Vec<PathSegment>> {
2222    find_path_in_value(tree, offset)
2223}
2224
2225/// Recursively find path segments in a value
2226fn find_path_in_value(value: &Value, offset: usize) -> Option<Vec<PathSegment>> {
2227    // Check if we're in an object
2228    if let Some(obj) = value.as_object() {
2229        for entry in &obj.entries {
2230            // Check if cursor is on the key
2231            if let Some(span) = entry.key.span {
2232                let start = span.start as usize;
2233                let end = span.end as usize;
2234                if offset >= start
2235                    && offset < end
2236                    && let Some(key) = entry.key.as_str()
2237                {
2238                    return Some(vec![PathSegment::Field(key.to_string())]);
2239                }
2240            }
2241            // Check if cursor is within this entry's value
2242            if let Some(span) = entry.value.span {
2243                let start = span.start as usize;
2244                let end = span.end as usize;
2245                if offset >= start && offset < end {
2246                    // Recurse into the value
2247                    if let Some(mut nested_path) = find_path_in_value(&entry.value, offset) {
2248                        // Prepend current key to the path
2249                        if let Some(key) = entry.key.as_str() {
2250                            nested_path.insert(0, PathSegment::Field(key.to_string()));
2251                        }
2252                        return Some(nested_path);
2253                    }
2254                    // We're on the value but not in a nested field - return this key
2255                    if let Some(key) = entry.key.as_str() {
2256                        return Some(vec![PathSegment::Field(key.to_string())]);
2257                    }
2258                }
2259            }
2260        }
2261    }
2262
2263    // Check if we're in a sequence
2264    if let Some(seq) = value.as_sequence() {
2265        for (index, item) in seq.items.iter().enumerate() {
2266            if let Some(span) = item.span {
2267                let start = span.start as usize;
2268                let end = span.end as usize;
2269                if offset >= start && offset < end {
2270                    // Recurse into the sequence item
2271                    if let Some(mut nested_path) = find_path_in_value(item, offset) {
2272                        nested_path.insert(0, PathSegment::Index(index));
2273                        return Some(nested_path);
2274                    }
2275                    // We're on this item but not deeper
2276                    return Some(vec![PathSegment::Index(index)]);
2277                }
2278            }
2279        }
2280    }
2281
2282    None
2283}
2284
2285/// Find a field definition in schema source by field name
2286fn find_field_in_schema_source(schema_source: &str, field_name: &str) -> Option<Range> {
2287    // Parse the schema file
2288    let tree = styx_tree::parse(schema_source).ok()?;
2289
2290    // Navigate to schema.@ (the root schema definition)
2291    let obj = tree.as_object()?;
2292
2293    for entry in &obj.entries {
2294        if entry.key.as_str() == Some("schema") {
2295            // Found schema block
2296            // Structure: schema { @ @object { name @string } }
2297            //   - entry.value is an Object containing the unit entry
2298            //   - get unit entry -> value is Object containing @object entry
2299            //   - get @object entry -> value is Object containing fields
2300            if let Some(schema_obj) = entry.value.as_object() {
2301                // Get the unit entry (@)
2302                if let Some(unit_value) = schema_obj.get_unit() {
2303                    // unit_value should be @object{...} - an object with @object key
2304                    if let Some(inner_obj) = unit_value.as_object() {
2305                        // This object has an @object entry
2306                        for inner_entry in &inner_obj.entries {
2307                            if inner_entry.key.tag_name() == Some("object") {
2308                                // Found @object - its value is the fields object
2309                                return find_field_in_object(
2310                                    &inner_entry.value,
2311                                    schema_source,
2312                                    field_name,
2313                                );
2314                            }
2315                        }
2316                    } else if unit_value.tag_name() == Some("object") {
2317                        // Direct @object value (no space before brace)
2318                        return find_field_in_object(unit_value, schema_source, field_name);
2319                    }
2320                }
2321            }
2322            // Fallback to original approach
2323            return find_field_in_object(&entry.value, schema_source, field_name);
2324        }
2325    }
2326
2327    None
2328}
2329
2330/// Recursively find a field in an object value
2331fn find_field_in_object(value: &Value, source: &str, field_name: &str) -> Option<Range> {
2332    // Check if it's a tagged value like @object{...}
2333    let obj = if let Some(obj) = value.as_object() {
2334        obj
2335    } else if value.tag.is_some() {
2336        // Tagged value - check if payload is an object
2337        match &value.payload {
2338            Some(styx_tree::Payload::Object(obj)) => obj,
2339            _ => return None,
2340        }
2341    } else {
2342        return None;
2343    };
2344
2345    for entry in &obj.entries {
2346        if entry.key.as_str() == Some(field_name) {
2347            // Found the field!
2348            if let Some(span) = entry.key.span {
2349                return Some(Range {
2350                    start: offset_to_position(source, span.start as usize),
2351                    end: offset_to_position(source, span.end as usize),
2352                });
2353            }
2354        }
2355
2356        // Check if this is the root definition (@) which contains the object schema
2357        if entry.key.is_unit()
2358            && let Some(found) = find_field_in_object(&entry.value, source, field_name)
2359        {
2360            return Some(found);
2361        }
2362
2363        // Recurse into nested objects
2364        if let Some(found) = find_field_in_object(&entry.value, source, field_name) {
2365            return Some(found);
2366        }
2367    }
2368
2369    None
2370}
2371
2372/// Schema metadata extracted from the meta block
2373struct SchemaMeta {
2374    id: Option<String>,
2375    version: Option<String>,
2376    description: Option<String>,
2377}
2378
2379/// Extract metadata from a schema file's meta block
2380fn get_schema_meta(schema_source: &str) -> Option<SchemaMeta> {
2381    let tree = styx_tree::parse(schema_source).ok()?;
2382    let obj = tree.as_object()?;
2383
2384    for entry in &obj.entries {
2385        if entry.key.as_str() == Some("meta") {
2386            let meta_obj = entry.value.as_object()?;
2387
2388            let id = meta_obj
2389                .get("id")
2390                .and_then(|v| v.as_str())
2391                .map(String::from);
2392            let version = meta_obj
2393                .get("version")
2394                .and_then(|v| v.as_str())
2395                .map(String::from);
2396            let description = meta_obj
2397                .get("description")
2398                .and_then(|v| v.as_str())
2399                .map(String::from);
2400
2401            return Some(SchemaMeta {
2402                id,
2403                version,
2404                description,
2405            });
2406        }
2407    }
2408
2409    None
2410}
2411
2412/// Get the schema type for a field from schema source
2413/// Information about a field from the schema
2414struct FieldInfo {
2415    /// The type annotation (e.g., "@string", "@optional(@bool)")
2416    type_str: String,
2417    /// Doc comment if present
2418    doc_comment: Option<String>,
2419}
2420
2421fn get_field_info_from_schema(schema_source: &str, field_path: &[&str]) -> Option<FieldInfo> {
2422    let tree = styx_tree::parse(schema_source).ok()?;
2423    let obj = tree.as_object()?;
2424
2425    // Find the schema block
2426    let schema_value = obj
2427        .entries
2428        .iter()
2429        .find(|e| e.key.as_str() == Some("schema"))
2430        .map(|e| &e.value)?;
2431
2432    get_field_info_in_schema(schema_value, field_path)
2433}
2434
2435/// Recursively get field info from a schema value, following a path.
2436/// The schema_block is the full "schema { ... }" block for resolving type references.
2437fn get_field_info_in_schema(schema_block: &Value, path: &[&str]) -> Option<FieldInfo> {
2438    let schema_obj = schema_block.as_object()?;
2439
2440    // Find the root schema (@ entry)
2441    let root = schema_obj
2442        .entries
2443        .iter()
2444        .find(|e| e.key.is_unit())
2445        .map(|e| &e.value)?;
2446
2447    get_field_info_in_type(root, path, schema_obj)
2448}
2449
2450/// Recursively get field info from a type value, following a path.
2451/// schema_defs contains all type definitions for resolving references like @Hint.
2452fn get_field_info_in_type(
2453    value: &Value,
2454    path: &[&str],
2455    schema_defs: &styx_tree::Object,
2456) -> Option<FieldInfo> {
2457    if path.is_empty() {
2458        return None;
2459    }
2460
2461    let field_name = path[0];
2462    let remaining_path = &path[1..];
2463
2464    // Check if value is a @seq type - if so, skip numeric indices and recurse into element type
2465    if let Some(seq_element_type) = extract_seq_element_type(value) {
2466        // If the path segment is a numeric index, skip it and continue into the element type
2467        if field_name.parse::<usize>().is_ok() {
2468            // Resolve type reference if needed (e.g., @seq(@Spec) -> look up Spec)
2469            let resolved = resolve_type_reference(seq_element_type, schema_defs);
2470            return get_field_info_in_type(resolved, remaining_path, schema_defs);
2471        }
2472    }
2473
2474    // Check if value is a @map type - if so, any key is valid and returns the value type
2475    if let Some(map_value_type) = extract_map_value_type(value) {
2476        if remaining_path.is_empty() {
2477            // We're hovering on a map key - return the value type
2478            return Some(FieldInfo {
2479                type_str: format_type_concise(map_value_type),
2480                doc_comment: None, // Map keys don't have individual doc comments
2481            });
2482        } else {
2483            // Need to go deeper into the map value type
2484            // If it's a type reference like @Hint, resolve it first
2485            let resolved = resolve_type_reference(map_value_type, schema_defs);
2486            return get_field_info_in_type(resolved, remaining_path, schema_defs);
2487        }
2488    }
2489
2490    // Check if value is an @object{@KeyType @ValueType} (catch-all pattern)
2491    // This handles schemas like `@ @object{@string @Decl}` where any string key is valid
2492    if let Some(catchall_value_type) = extract_object_catchall_value_type(value) {
2493        if remaining_path.is_empty() {
2494            // We're hovering on a catch-all key - return the value type
2495            return Some(FieldInfo {
2496                type_str: format_type_concise(catchall_value_type),
2497                doc_comment: None, // Catch-all keys don't have individual doc comments
2498            });
2499        } else {
2500            // Need to go deeper into the catch-all value type
2501            let resolved = resolve_type_reference(catchall_value_type, schema_defs);
2502            return get_field_info_in_type(resolved, remaining_path, schema_defs);
2503        }
2504    }
2505
2506    // Check if value is an @enum type - if so, search all variants for the field
2507    // This handles cases like `Decl @enum{query @Query}` where we need to look
2508    // inside variant types (like @Query) to find nested fields (like `from`)
2509    if let Some(enum_obj) = extract_enum_object(value) {
2510        // Search all enum variants for the field
2511        for entry in &enum_obj.entries {
2512            // Resolve the variant's value type and recurse
2513            let resolved = resolve_type_reference(&entry.value, schema_defs);
2514            if let Some(info) = get_field_info_in_type(resolved, path, schema_defs) {
2515                return Some(info);
2516            }
2517        }
2518        return None;
2519    }
2520
2521    // Try to get the object - handle various wrappings (also resolves type refs)
2522    let obj = extract_object_from_value_with_defs(value, schema_defs)?;
2523
2524    for entry in &obj.entries {
2525        if entry.key.as_str() == Some(field_name) {
2526            if remaining_path.is_empty() {
2527                // This is the target field
2528                return Some(FieldInfo {
2529                    type_str: format_type_concise(&entry.value),
2530                    doc_comment: entry.doc_comment.clone(),
2531                });
2532            } else {
2533                // Need to go deeper - unwrap wrappers like @optional
2534                return get_field_info_in_type(&entry.value, remaining_path, schema_defs);
2535            }
2536        }
2537
2538        // Check unit key entries (root schema)
2539        if entry.key.is_unit()
2540            && let Some(found) = get_field_info_in_type(&entry.value, path, schema_defs)
2541        {
2542            return Some(found);
2543        }
2544    }
2545
2546    None
2547}
2548
2549/// Resolve a type reference like @Hint to its definition in the schema.
2550/// Returns the original value if it's not a type reference.
2551fn resolve_type_reference<'a>(value: &'a Value, schema_defs: &'a styx_tree::Object) -> &'a Value {
2552    // Check if this is a type reference (tag with no payload or unit payload)
2553    if let Some(tag) = &value.tag {
2554        let is_type_ref = match &value.payload {
2555            None => true,
2556            Some(styx_tree::Payload::Scalar(s)) if s.text.is_empty() => true,
2557            _ => false,
2558        };
2559
2560        if is_type_ref {
2561            // Look for a definition with this name in schema_defs
2562            for entry in &schema_defs.entries {
2563                if entry.key.as_str() == Some(&tag.name) {
2564                    return &entry.value;
2565                }
2566            }
2567        }
2568    }
2569    value
2570}
2571
2572/// Extract the element type from a @seq type.
2573/// For @seq(@Spec), returns @Spec.
2574/// Also handles @optional(@seq(@T)) by unwrapping the optional first.
2575fn extract_seq_element_type(value: &Value) -> Option<&Value> {
2576    let tag = value.tag.as_ref()?;
2577
2578    // Direct @seq(@T)
2579    if tag.name == "seq" {
2580        let seq = value.as_sequence()?;
2581        return seq.items.first();
2582    }
2583
2584    // Handle @optional(@seq(@T)) - unwrap the optional and check inside
2585    if (tag.name == "optional" || tag.name == "default")
2586        && let Some(seq) = value.as_sequence()
2587    {
2588        for item in &seq.items {
2589            if let Some(element_type) = extract_seq_element_type(item) {
2590                return Some(element_type);
2591            }
2592        }
2593    }
2594
2595    None
2596}
2597
2598/// Extract the value type from a @map type.
2599/// For @map(@string @Hint), returns the @Hint value.
2600/// For @map(@V), returns @V (single-arg map uses value as both key and value).
2601fn extract_map_value_type(value: &Value) -> Option<&Value> {
2602    let tag = value.tag.as_ref()?;
2603    if tag.name != "map" {
2604        return None;
2605    }
2606
2607    // @map has a sequence payload: @map(@K @V) or @map(@V)
2608    let seq = value.as_sequence()?;
2609    match seq.items.len() {
2610        1 => Some(&seq.items[0]), // @map(@V) - single type used as value
2611        2 => Some(&seq.items[1]), // @map(@K @V) - second is value type
2612        _ => None,
2613    }
2614}
2615
2616/// Extract the inner object from an `@enum{...}` type.
2617///
2618/// In Styx schemas, `@enum{variant1 @Type1, variant2 @Type2}` defines an enum
2619/// with named variants. Each entry in the object is a variant name -> type mapping.
2620///
2621/// Returns Some(object) if the value is an @enum with object payload, None otherwise.
2622fn extract_enum_object(value: &Value) -> Option<&styx_tree::Object> {
2623    let tag = value.tag.as_ref()?;
2624    if tag.name != "enum" {
2625        return None;
2626    }
2627    value.as_object()
2628}
2629
2630/// Extract the catch-all value type from an `@object{@KeyType @ValueType}` pattern.
2631///
2632/// In Styx schemas, `@object{@string @Decl}` means "an object where any string key
2633/// maps to a value of type @Decl". The key entry has a tagged key (like `@string`)
2634/// rather than a scalar key.
2635///
2636/// Returns Some(value_type) if the object has exactly one entry with a tagged key
2637/// (indicating a catch-all pattern), None otherwise.
2638fn extract_object_catchall_value_type(value: &Value) -> Option<&Value> {
2639    // Must be @object{...}
2640    let tag = value.tag.as_ref()?;
2641    if tag.name != "object" {
2642        return None;
2643    }
2644
2645    let obj = value.as_object()?;
2646
2647    // Catch-all pattern: exactly one entry with a tagged key (like @string)
2648    if obj.entries.len() == 1 {
2649        let entry = &obj.entries[0];
2650        // Key must be tagged (e.g., @string), not a scalar
2651        if entry.key.tag.is_some() && entry.key.payload.is_none() {
2652            return Some(&entry.value);
2653        }
2654    }
2655
2656    None
2657}
2658
2659/// Extract an object from a value, unwrapping wrappers like @optional, @default, etc.
2660/// Also resolves type references using schema_defs.
2661fn extract_object_from_value_with_defs<'a>(
2662    value: &'a Value,
2663    schema_defs: &'a styx_tree::Object,
2664) -> Option<&'a styx_tree::Object> {
2665    // First resolve if it's a type reference
2666    let resolved = resolve_type_reference(value, schema_defs);
2667
2668    // Direct object
2669    if let Some(obj) = resolved.as_object() {
2670        return Some(obj);
2671    }
2672
2673    // Tagged object like @object{...}
2674    if resolved.tag.is_some() {
2675        match &resolved.payload {
2676            Some(styx_tree::Payload::Object(obj)) => return Some(obj),
2677            // Handle @optional(@object{...}) or @optional(@TypeRef) - tag with sequence payload
2678            Some(styx_tree::Payload::Sequence(seq)) => {
2679                // Look for object inside the sequence (recursively resolving type refs)
2680                for item in &seq.items {
2681                    if let Some(obj) = extract_object_from_value_with_defs(item, schema_defs) {
2682                        return Some(obj);
2683                    }
2684                }
2685            }
2686            _ => {}
2687        }
2688    }
2689
2690    None
2691}
2692
2693/// Extract an object from a value, unwrapping wrappers like @optional, @default, etc.
2694/// Simple version without type reference resolution.
2695fn extract_object_from_value(value: &Value) -> Option<&styx_tree::Object> {
2696    // Direct object
2697    if let Some(obj) = value.as_object() {
2698        return Some(obj);
2699    }
2700
2701    // Tagged object like @object{...}
2702    if value.tag.is_some() {
2703        match &value.payload {
2704            Some(styx_tree::Payload::Object(obj)) => return Some(obj),
2705            // Handle @optional(@object{...}) - tag with sequence payload
2706            Some(styx_tree::Payload::Sequence(seq)) => {
2707                // Look for @object inside the sequence
2708                for item in &seq.items {
2709                    if let Some(obj) = extract_object_from_value(item) {
2710                        return Some(obj);
2711                    }
2712                }
2713            }
2714            _ => {}
2715        }
2716    }
2717
2718    None
2719}
2720
2721/// Format a type value concisely (without expanding nested objects)
2722fn format_type_concise(value: &Value) -> String {
2723    let mut result = String::new();
2724
2725    if let Some(tag) = &value.tag {
2726        result.push('@');
2727        result.push_str(&tag.name);
2728    }
2729
2730    match &value.payload {
2731        None => {}
2732        Some(styx_tree::Payload::Scalar(s)) => {
2733            if value.tag.is_some() {
2734                result.push('(');
2735                result.push_str(&s.text);
2736                result.push(')');
2737            } else {
2738                result.push_str(&s.text);
2739            }
2740        }
2741        Some(styx_tree::Payload::Object(obj)) => {
2742            if value.tag.is_some() {
2743                // For tagged objects, show @tag{...} or @tag(@inner{...})
2744                result.push_str("{...}");
2745            } else {
2746                // Count fields for a hint
2747                let field_count = obj.entries.len();
2748                result.push_str(&format!("{{...}} ({} fields)", field_count));
2749            }
2750        }
2751        Some(styx_tree::Payload::Sequence(seq)) => {
2752            result.push('(');
2753            for (i, item) in seq.items.iter().enumerate() {
2754                if i > 0 {
2755                    result.push(' ');
2756                }
2757                result.push_str(&format_type_concise(item));
2758            }
2759            result.push(')');
2760        }
2761    }
2762
2763    if result.is_empty() {
2764        "@".to_string() // unit
2765    } else {
2766        result
2767    }
2768}
2769
2770/// Format a breadcrumb path like `@ › logging › format › timestamp`
2771/// The `@` can optionally be a link to the schema.
2772fn format_breadcrumb(path: &[String], schema_link: Option<&str>) -> String {
2773    let root = match schema_link {
2774        Some(uri) => format!("[@]({})", uri),
2775        None => "@".to_string(),
2776    };
2777    let mut result = root;
2778    for segment in path {
2779        result.push_str(" › ");
2780        result.push_str(segment);
2781    }
2782    result
2783}
2784
2785/// Format a hover message for a field
2786fn format_field_hover(
2787    field_path: &[String],
2788    field_info: &FieldInfo,
2789    _schema_path: &str,
2790    schema_uri: Option<&Url>,
2791) -> String {
2792    let mut content = String::new();
2793
2794    // Doc comment first (most important - what does this field do?)
2795    if let Some(doc) = &field_info.doc_comment {
2796        content.push_str(doc);
2797        content.push_str("\n\n");
2798    }
2799
2800    // Breadcrumb path with @ as schema link and type at the end:
2801    // [@](schema-uri) › hints › tracey › schema: `@SchemaRef`
2802    let schema_link = schema_uri.map(|u| u.as_str());
2803    let breadcrumb = format_breadcrumb(field_path, schema_link);
2804    content.push_str(&format!("{}: `{}`", breadcrumb, field_info.type_str));
2805
2806    content
2807}
2808
2809/// Convert a Schema to a displayable type string
2810fn schema_to_type_str(schema: &facet_styx::Schema) -> String {
2811    use facet_styx::Schema;
2812    match schema {
2813        Schema::String(_) => "@string".to_string(),
2814        Schema::Int(_) => "@int".to_string(),
2815        Schema::Float(_) => "@float".to_string(),
2816        Schema::Bool => "@bool".to_string(),
2817        Schema::Unit => "@unit".to_string(),
2818        Schema::Any => "@any".to_string(),
2819        Schema::Type { name: Some(n) } => format!("@{}", n),
2820        Schema::Type { name: None } => "@type".to_string(),
2821        Schema::Object(_) => "@object{...}".to_string(),
2822        Schema::Seq(_) => "@seq(...)".to_string(),
2823        Schema::Map(_) => "@map(...)".to_string(),
2824        Schema::Enum(_) => "@enum{...}".to_string(),
2825        Schema::Union(_) => "@union(...)".to_string(),
2826        Schema::OneOf(_) => "@oneof(...)".to_string(),
2827        Schema::Flatten(_) => "@flatten(...)".to_string(),
2828        Schema::Literal(s) => format!("\"{}\"", s),
2829        Schema::Optional(opt) => format!("@optional({})", schema_to_type_str(&opt.0.0)),
2830        Schema::Default(def) => format!("@default(..., {})", schema_to_type_str(&def.0.1)),
2831        Schema::Deprecated(dep) => format!("@deprecated({})", schema_to_type_str(&dep.0.1)),
2832        Schema::Tuple(t) => {
2833            let elements: Vec<_> = t.0.iter().map(|d| schema_to_type_str(&d.value)).collect();
2834            format!("@tuple({})", elements.join(" "))
2835        }
2836    }
2837}
2838
2839/// Get schema fields at a specific path (for nested object completion)
2840fn get_schema_fields_from_source_at_path(
2841    schema_source: &str,
2842    path: &[String],
2843) -> Vec<(String, String)> {
2844    let mut fields = Vec::new();
2845
2846    let Ok(tree) = styx_tree::parse(schema_source) else {
2847        return fields;
2848    };
2849
2850    let Some(obj) = tree.as_object() else {
2851        return fields;
2852    };
2853
2854    // Find the "schema" entry
2855    let schema_entry = obj
2856        .entries
2857        .iter()
2858        .find(|e| e.key.as_str() == Some("schema"));
2859    let Some(schema_entry) = schema_entry else {
2860        return fields;
2861    };
2862
2863    // Navigate to the object at the given path
2864    let target_value = if path.is_empty() {
2865        // Root level - use the schema root
2866        &schema_entry.value
2867    } else {
2868        // Navigate into nested object
2869        match navigate_schema_path(&schema_entry.value, path) {
2870            Some(v) => v,
2871            None => return fields,
2872        }
2873    };
2874
2875    collect_fields_from_object(target_value, schema_source, &mut fields);
2876    fields
2877}
2878
2879/// Navigate to a field in a schema tree by path, returning the field's type value
2880fn navigate_schema_path<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> {
2881    if path.is_empty() {
2882        return Some(value);
2883    }
2884
2885    let field_name = &path[0];
2886    let remaining = &path[1..];
2887
2888    // Get the object (unwrapping @object{...} if needed)
2889    let obj = extract_object_from_value(value)?;
2890
2891    for entry in &obj.entries {
2892        // Check named fields
2893        if entry.key.as_str() == Some(field_name.as_str()) {
2894            // Found the field - unwrap any wrappers like @optional to get to the inner type
2895            let inner = unwrap_type_wrappers(&entry.value);
2896            return navigate_schema_path(inner, remaining);
2897        }
2898
2899        // Check unit key (root definition)
2900        if entry.key.is_unit()
2901            && let Some(result) = navigate_schema_path(&entry.value, path)
2902        {
2903            return Some(result);
2904        }
2905    }
2906
2907    None
2908}
2909
2910/// Unwrap type wrappers like @optional(...) to get to the inner type
2911fn unwrap_type_wrappers(value: &Value) -> &Value {
2912    // Check for @optional, @default, etc. which wrap the actual type
2913    if let Some(tag) = value.tag_name()
2914        && matches!(tag, "optional" | "default" | "deprecated")
2915    {
2916        // The inner type is in the payload
2917        match &value.payload {
2918            // @optional(@object{...}) - parenthesized, so it's a sequence with one item
2919            Some(styx_tree::Payload::Sequence(seq)) => {
2920                if let Some(first) = seq.items.first() {
2921                    return unwrap_type_wrappers(first);
2922                }
2923            }
2924            // @optional @object{...} - the object is the payload directly
2925            Some(styx_tree::Payload::Object(obj)) => {
2926                // Check for unit entry pattern
2927                for entry in &obj.entries {
2928                    if entry.key.is_unit() {
2929                        return unwrap_type_wrappers(&entry.value);
2930                    }
2931                }
2932            }
2933            _ => {}
2934        }
2935    }
2936    value
2937}
2938
2939/// Collect fields from an object schema
2940fn collect_fields_from_object(value: &Value, source: &str, fields: &mut Vec<(String, String)>) {
2941    let obj = if let Some(obj) = value.as_object() {
2942        obj
2943    } else if value.tag.is_some() {
2944        match &value.payload {
2945            Some(styx_tree::Payload::Object(obj)) => obj,
2946            _ => return,
2947        }
2948    } else {
2949        return;
2950    };
2951
2952    for entry in &obj.entries {
2953        // If it's a named field (not @), add it
2954        if let Some(name) = entry.key.as_str()
2955            && let Some(span) = entry.value.span
2956        {
2957            let type_str = source[span.start as usize..span.end as usize].trim();
2958            fields.push((name.to_string(), type_str.to_string()));
2959        }
2960
2961        // If it's the root definition (@), recurse into it
2962        if entry.key.is_unit() {
2963            collect_fields_from_object(&entry.value, source, fields);
2964        }
2965    }
2966}
2967
2968/// Get existing field names in a document
2969fn get_existing_fields(tree: &Value) -> Vec<String> {
2970    let mut fields = Vec::new();
2971
2972    if let Some(obj) = tree.as_object() {
2973        for entry in &obj.entries {
2974            if let Some(name) = entry.key.as_str() {
2975                fields.push(name.to_string());
2976            }
2977        }
2978    }
2979
2980    fields
2981}
2982
2983/// Get the current word being typed and its range in the document.
2984/// Returns (word, Range) where Range is the span of the word.
2985fn get_word_range_at_position(content: &str, position: Position) -> Option<(String, Range)> {
2986    let offset = position_to_offset(content, position);
2987    if offset == 0 {
2988        return None;
2989    }
2990
2991    // Find the start of the current word by scanning backwards
2992    let before = &content[..offset];
2993    let word_start = before
2994        .rfind(|c: char| c.is_whitespace() || c == '{' || c == '}')
2995        .map(|i| i + 1)
2996        .unwrap_or(0);
2997
2998    let word = &before[word_start..];
2999    if word.is_empty() {
3000        None
3001    } else {
3002        // Calculate the start position of the word
3003        let start_position = offset_to_position(content, word_start);
3004        let range = Range {
3005            start: start_position,
3006            end: position,
3007        };
3008        Some((word.to_string(), range))
3009    }
3010}
3011
3012/// Compute Levenshtein distance between two strings
3013fn levenshtein(a: &str, b: &str) -> usize {
3014    let a_chars: Vec<char> = a.chars().collect();
3015    let b_chars: Vec<char> = b.chars().collect();
3016    let m = a_chars.len();
3017    let n = b_chars.len();
3018
3019    if m == 0 {
3020        return n;
3021    }
3022    if n == 0 {
3023        return m;
3024    }
3025
3026    let mut prev: Vec<usize> = (0..=n).collect();
3027    let mut curr = vec![0; n + 1];
3028
3029    for i in 1..=m {
3030        curr[0] = i;
3031        for j in 1..=n {
3032            let cost = if a_chars[i - 1] == b_chars[j - 1] {
3033                0
3034            } else {
3035                1
3036            };
3037            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
3038        }
3039        std::mem::swap(&mut prev, &mut curr);
3040    }
3041
3042    prev[n]
3043}
3044
3045/// Convert a styx_tree::Value to a serde_json::Value for LSP diagnostic data.
3046fn convert_styx_value_to_json(value: &styx_tree::Value) -> serde_json::Value {
3047    // Check for scalar
3048    if let Some(s) = value.as_str() {
3049        return serde_json::Value::String(s.to_string());
3050    }
3051
3052    // Check for object
3053    if let Some(styx_tree::Payload::Object(obj)) = &value.payload {
3054        let mut map = serde_json::Map::new();
3055        for entry in &obj.entries {
3056            if let Some(key) = entry.key.as_str() {
3057                map.insert(key.to_string(), convert_styx_value_to_json(&entry.value));
3058            }
3059        }
3060        return serde_json::Value::Object(map);
3061    }
3062
3063    // Check for sequence
3064    if let Some(styx_tree::Payload::Sequence(seq)) = &value.payload {
3065        let arr: Vec<_> = seq.items.iter().map(convert_styx_value_to_json).collect();
3066        return serde_json::Value::Array(arr);
3067    }
3068
3069    // Fallback to null
3070    serde_json::Value::Null
3071}
3072
3073/// Convert a serde_json::Value to a styx_tree::Value for extension diagnostic data.
3074fn json_to_styx_value(json: &serde_json::Value) -> styx_tree::Value {
3075    use styx_tree::{Entry, Object, Payload, Scalar, ScalarKind, Sequence, Value};
3076
3077    match json {
3078        serde_json::Value::Null => Value::unit(),
3079
3080        serde_json::Value::Bool(b) => Value {
3081            tag: None,
3082            payload: Some(Payload::Scalar(Scalar {
3083                text: b.to_string(),
3084                kind: ScalarKind::Bare,
3085                span: None,
3086            })),
3087            span: None,
3088        },
3089
3090        serde_json::Value::Number(n) => Value {
3091            tag: None,
3092            payload: Some(Payload::Scalar(Scalar {
3093                text: n.to_string(),
3094                kind: ScalarKind::Bare,
3095                span: None,
3096            })),
3097            span: None,
3098        },
3099
3100        serde_json::Value::String(s) => Value {
3101            tag: None,
3102            payload: Some(Payload::Scalar(Scalar {
3103                text: s.clone(),
3104                kind: ScalarKind::Bare,
3105                span: None,
3106            })),
3107            span: None,
3108        },
3109
3110        serde_json::Value::Array(arr) => {
3111            let items = arr.iter().map(json_to_styx_value).collect();
3112            Value {
3113                tag: None,
3114                payload: Some(Payload::Sequence(Sequence { items, span: None })),
3115                span: None,
3116            }
3117        }
3118
3119        serde_json::Value::Object(obj) => {
3120            let entries = obj
3121                .iter()
3122                .map(|(k, v)| Entry {
3123                    key: Value {
3124                        tag: None,
3125                        payload: Some(Payload::Scalar(Scalar {
3126                            text: k.clone(),
3127                            kind: ScalarKind::Bare,
3128                            span: None,
3129                        })),
3130                        span: None,
3131                    },
3132                    value: json_to_styx_value(v),
3133                    doc_comment: None,
3134                })
3135                .collect();
3136
3137            Value {
3138                tag: None,
3139                payload: Some(Payload::Object(Object {
3140                    entries,
3141                    span: None,
3142                })),
3143                span: None,
3144            }
3145        }
3146    }
3147}
3148
3149/// Convert an extension completion item to LSP completion item.
3150fn convert_ext_completion(item: ext::CompletionItem, edit_range: Range) -> CompletionItem {
3151    // Use text_edit if insert_text is provided (Zed ignores insert_text)
3152    let text_edit = item.insert_text.as_ref().map(|text| {
3153        CompletionTextEdit::Edit(TextEdit {
3154            range: edit_range,
3155            new_text: text.clone(),
3156        })
3157    });
3158
3159    CompletionItem {
3160        label: item.label.clone(),
3161        kind: item.kind.map(|k| match k {
3162            ext::CompletionKind::Field => CompletionItemKind::FIELD,
3163            ext::CompletionKind::Value => CompletionItemKind::VALUE,
3164            ext::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
3165            ext::CompletionKind::Type => CompletionItemKind::CLASS,
3166        }),
3167        detail: item.detail,
3168        documentation: item.documentation.map(|d| {
3169            Documentation::MarkupContent(MarkupContent {
3170                kind: MarkupKind::Markdown,
3171                value: d,
3172            })
3173        }),
3174        sort_text: item.sort_text,
3175        text_edit,
3176        filter_text: Some(item.label),
3177        // Extension completions sort after schema completions
3178        preselect: Some(false),
3179        ..Default::default()
3180    }
3181}
3182
3183/// Check if a tree looks like a schema file (has "schema" and "meta" blocks)
3184fn is_schema_file(tree: &Value) -> bool {
3185    let Some(obj) = tree.as_object() else {
3186        return false;
3187    };
3188
3189    let mut has_schema = false;
3190    let mut has_meta = false;
3191
3192    for entry in &obj.entries {
3193        match entry.key.as_str() {
3194            Some("schema") => has_schema = true,
3195            Some("meta") => has_meta = true,
3196            _ => {}
3197        }
3198    }
3199
3200    has_schema && has_meta
3201}
3202
3203/// Find a field usage in a document (not a schema)
3204fn find_field_in_doc(tree: &Value, field_name: &str, content: &str) -> Option<Range> {
3205    let obj = tree.as_object()?;
3206
3207    for entry in &obj.entries {
3208        if entry.key.as_str() == Some(field_name)
3209            && let Some(span) = entry.key.span
3210        {
3211            return Some(Range {
3212                start: offset_to_position(content, span.start as usize),
3213                end: offset_to_position(content, span.end as usize),
3214            });
3215        }
3216    }
3217
3218    None
3219}
3220
3221/// Info about where to insert new fields
3222struct InsertPosition {
3223    position: Position,
3224    indent: String,
3225}
3226
3227/// Find the position where new fields should be inserted and the indentation to use.
3228fn find_field_insert_position(content: &str) -> InsertPosition {
3229    let lines: Vec<&str> = content.lines().collect();
3230
3231    // Find the last non-empty, non-comment line
3232    for (i, line) in lines.iter().enumerate().rev() {
3233        let trimmed = line.trim();
3234        if !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with('#') {
3235            // Detect indentation from this line
3236            let indent_len = line.len() - line.trim_start().len();
3237            let indent = line[..indent_len].to_string();
3238
3239            return InsertPosition {
3240                position: Position {
3241                    line: i as u32,
3242                    character: line.len() as u32,
3243                },
3244                indent,
3245            };
3246        }
3247    }
3248
3249    // Fallback: end of document, no indentation
3250    InsertPosition {
3251        position: Position {
3252            line: lines.len().saturating_sub(1) as u32,
3253            character: lines.last().map(|l| l.len()).unwrap_or(0) as u32,
3254        },
3255        indent: String::new(),
3256    }
3257}
3258
3259/// Generate text for inserting missing fields.
3260fn generate_fields_text(fields: &[&crate::schema_validation::SchemaField], indent: &str) -> String {
3261    use crate::schema_validation::generate_placeholder;
3262
3263    let mut result = String::new();
3264
3265    for field in fields {
3266        result.push('\n');
3267        result.push_str(indent);
3268        result.push_str(&field.name);
3269        result.push(' ');
3270
3271        // Use default value if available, otherwise generate placeholder
3272        let value = field
3273            .default_value
3274            .clone()
3275            .unwrap_or_else(|| generate_placeholder(&field.schema));
3276        result.push_str(&value);
3277    }
3278
3279    result
3280}
3281
3282/// Generate a text edit to reorder fields to match schema order.
3283/// Returns None if the fields are already in order or can't be reordered.
3284fn generate_reorder_edit(
3285    tree: &Value,
3286    schema_fields: &[crate::schema_validation::SchemaField],
3287    content: &str,
3288) -> Option<TextEdit> {
3289    let obj = tree.as_object()?;
3290
3291    // Build schema field order map
3292    let schema_order: std::collections::HashMap<&str, usize> = schema_fields
3293        .iter()
3294        .enumerate()
3295        .map(|(i, f)| (f.name.as_str(), i))
3296        .collect();
3297
3298    // Collect document entries with their positions
3299    let mut doc_entries: Vec<(Option<usize>, &styx_tree::Entry)> = obj
3300        .entries
3301        .iter()
3302        .map(|e| {
3303            let order = e
3304                .key
3305                .as_str()
3306                .and_then(|name| schema_order.get(name).copied());
3307            (order, e)
3308        })
3309        .collect();
3310
3311    // Check if already in order
3312    let current_order: Vec<Option<usize>> = doc_entries.iter().map(|(o, _)| *o).collect();
3313    let mut sorted_order = current_order.clone();
3314    sorted_order.sort_by(|a, b| match (a, b) {
3315        (Some(x), Some(y)) => x.cmp(y),
3316        (Some(_), None) => std::cmp::Ordering::Less,
3317        (None, Some(_)) => std::cmp::Ordering::Greater,
3318        (None, None) => std::cmp::Ordering::Equal,
3319    });
3320
3321    if current_order == sorted_order {
3322        return None; // Already in order
3323    }
3324
3325    // Sort entries by schema order (keep @ declaration first, unknowns last)
3326    doc_entries.sort_by(|(a_order, a_entry), (b_order, b_entry)| {
3327        // @ declaration always first
3328        if a_entry.key.is_unit() {
3329            return std::cmp::Ordering::Less;
3330        }
3331        if b_entry.key.is_unit() {
3332            return std::cmp::Ordering::Greater;
3333        }
3334        // Then by schema order
3335        match (a_order, b_order) {
3336            (Some(x), Some(y)) => x.cmp(y),
3337            (Some(_), None) => std::cmp::Ordering::Less,
3338            (None, Some(_)) => std::cmp::Ordering::Greater,
3339            (None, None) => std::cmp::Ordering::Equal,
3340        }
3341    });
3342
3343    // Regenerate the document content, preserving original entry text
3344    let mut new_content = String::new();
3345
3346    for (i, (_, entry)) in doc_entries.iter().enumerate() {
3347        if i > 0 {
3348            new_content.push('\n');
3349        }
3350
3351        // Get the full entry text from the original source (key + value)
3352        // by using the key's start and value's end
3353        if let (Some(key_span), Some(val_span)) = (entry.key.span, entry.value.span) {
3354            // Find the start of the line to preserve indentation
3355            let key_start = key_span.start as usize;
3356            let line_start = content[..key_start].rfind('\n').map(|i| i + 1).unwrap_or(0);
3357            let indent = &content[line_start..key_start];
3358
3359            new_content.push_str(indent);
3360            new_content.push_str(&content[key_start..val_span.end as usize]);
3361        }
3362    }
3363
3364    // Find the range of the entire document content (excluding leading/trailing whitespace)
3365    let start_offset = obj.span?.start as usize;
3366    let end_offset = obj.span?.end as usize;
3367
3368    Some(TextEdit {
3369        range: Range {
3370            start: offset_to_position(content, start_offset),
3371            end: offset_to_position(content, end_offset),
3372        },
3373        new_text: new_content,
3374    })
3375}
3376
3377/// Run the LSP server on stdin/stdout
3378pub async fn run() -> eyre::Result<()> {
3379    // Set up logging (no ANSI colors since output goes to stderr for LSP)
3380    tracing_subscriber::fmt()
3381        .with_ansi(false)
3382        .with_env_filter(
3383            tracing_subscriber::EnvFilter::from_default_env()
3384                .add_directive(tracing::Level::INFO.into()),
3385        )
3386        .with_writer(std::io::stderr)
3387        .init();
3388
3389    eprintln!(
3390        "styx-lsp PID: {} - attach debugger now!",
3391        std::process::id()
3392    );
3393
3394    let stdin = tokio::io::stdin();
3395    let stdout = tokio::io::stdout();
3396
3397    let (service, socket) = LspService::new(StyxLanguageServer::new);
3398    Server::new(stdin, stdout, socket).serve(service).await;
3399
3400    Ok(())
3401}
3402
3403#[cfg(test)]
3404mod tests {
3405    use super::*;
3406
3407    #[test]
3408    fn test_find_field_in_schema_source() {
3409        let schema_source = r#"meta {
3410  name "test"
3411}
3412schema {
3413  @ @object{
3414    name @string
3415    port @int
3416  }
3417}"#;
3418
3419        // Should find 'name' field
3420        let range = find_field_in_schema_source(schema_source, "name");
3421        assert!(range.is_some(), "should find 'name' field");
3422
3423        // Should find 'port' field
3424        let range = find_field_in_schema_source(schema_source, "port");
3425        assert!(range.is_some(), "should find 'port' field");
3426
3427        // Should not find 'unknown' field
3428        let range = find_field_in_schema_source(schema_source, "unknown");
3429        assert!(range.is_none(), "should not find 'unknown' field");
3430    }
3431
3432    #[test]
3433    fn test_find_field_in_schema_no_space() {
3434        // Test @ @object{ ... } without space before brace (actual file format)
3435        let schema_no_space = r#"schema {
3436  @ @object{
3437    name @string
3438  }
3439}"#;
3440
3441        let range = find_field_in_schema_source(schema_no_space, "name");
3442        assert!(
3443            range.is_some(),
3444            "should find 'name' without space before brace"
3445        );
3446    }
3447
3448    #[test]
3449    fn test_offset_to_position() {
3450        let content = "line1\nline2\nline3";
3451        assert_eq!(offset_to_position(content, 0), Position::new(0, 0));
3452        assert_eq!(offset_to_position(content, 5), Position::new(0, 5));
3453        assert_eq!(offset_to_position(content, 6), Position::new(1, 0));
3454        assert_eq!(offset_to_position(content, 12), Position::new(2, 0));
3455    }
3456
3457    #[test]
3458    fn test_levenshtein() {
3459        assert_eq!(levenshtein("", ""), 0);
3460        assert_eq!(levenshtein("abc", "abc"), 0);
3461        assert_eq!(levenshtein("abc", "ab"), 1);
3462        assert_eq!(levenshtein("port", "prot"), 2);
3463        assert_eq!(levenshtein("name", "nme"), 1);
3464    }
3465
3466    #[test]
3467    fn test_find_field_path_at_offset() {
3468        // Test finding path in nested objects
3469        let content = "logging {\n    format {\n        timestamp true\n    }\n}";
3470        let tree = styx_tree::parse(content).unwrap();
3471
3472        // Position on "logging" key (offset 0-7)
3473        let path = find_field_path_at_offset(&tree, 3);
3474        assert_eq!(path, Some(vec!["logging".to_string()]));
3475
3476        // Position on "format" key (inside logging object)
3477        // "logging {\n    format" - format starts at offset 14
3478        let path = find_field_path_at_offset(&tree, 16);
3479        assert_eq!(
3480            path,
3481            Some(vec!["logging".to_string(), "format".to_string()])
3482        );
3483
3484        // Position on "timestamp" key (inside format object)
3485        // Find the offset of "timestamp"
3486        let timestamp_offset = content.find("timestamp").unwrap();
3487        let path = find_field_path_at_offset(&tree, timestamp_offset + 2);
3488        assert_eq!(
3489            path,
3490            Some(vec![
3491                "logging".to_string(),
3492                "format".to_string(),
3493                "timestamp".to_string()
3494            ])
3495        );
3496    }
3497
3498    #[test]
3499    fn test_format_breadcrumb() {
3500        // Without schema link
3501        assert_eq!(format_breadcrumb(&[], None), "@");
3502        assert_eq!(
3503            format_breadcrumb(&["logging".to_string()], None),
3504            "@ › logging"
3505        );
3506        assert_eq!(
3507            format_breadcrumb(&["logging".to_string(), "format".to_string()], None),
3508            "@ › logging › format"
3509        );
3510
3511        // With schema link - @ becomes a link
3512        assert_eq!(
3513            format_breadcrumb(&[], Some("file:///schema.styx")),
3514            "[@](file:///schema.styx)"
3515        );
3516        assert_eq!(
3517            format_breadcrumb(&["logging".to_string()], Some("file:///schema.styx")),
3518            "[@](file:///schema.styx) › logging"
3519        );
3520        assert_eq!(
3521            format_breadcrumb(
3522                &[
3523                    "logging".to_string(),
3524                    "format".to_string(),
3525                    "timestamp".to_string()
3526                ],
3527                Some("file:///schema.styx")
3528            ),
3529            "[@](file:///schema.styx) › logging › format › timestamp"
3530        );
3531    }
3532
3533    #[test]
3534    fn test_get_field_info_from_schema() {
3535        let schema_source = r#"meta {
3536    id test
3537}
3538schema {
3539    @ @object{
3540        name @string
3541        logging @optional(@object{
3542            level @string
3543            format @optional(@object{
3544                timestamp @optional(@bool)
3545            })
3546        })
3547    }
3548}"#;
3549
3550        // Top-level field
3551        let info = get_field_info_from_schema(schema_source, &["name"]);
3552        assert!(info.is_some(), "Should find 'name' field");
3553        let info = info.unwrap();
3554        assert_eq!(info.type_str, "@string");
3555
3556        // Nested field in @optional(@object{...})
3557        let info = get_field_info_from_schema(schema_source, &["logging", "level"]);
3558        assert!(info.is_some(), "Should find 'logging.level' field");
3559        let info = info.unwrap();
3560        assert_eq!(info.type_str, "@string");
3561
3562        // Deeply nested field
3563        let info = get_field_info_from_schema(schema_source, &["logging", "format", "timestamp"]);
3564        assert!(
3565            info.is_some(),
3566            "Should find 'logging.format.timestamp' field"
3567        );
3568        let info = info.unwrap();
3569        assert_eq!(info.type_str, "@optional(@bool)");
3570
3571        // Test map type - hovering on a map key
3572        let map_schema = r#"meta { id test }
3573schema {
3574    @ @object{
3575        hints @map(@string @Hint)
3576    }
3577    Hint @object{
3578        title @string
3579        patterns @seq(@string)
3580    }
3581}"#;
3582
3583        // Hovering on a map key like "captain" should return @Hint
3584        let info = get_field_info_from_schema(map_schema, &["hints", "captain"]);
3585        assert!(info.is_some(), "Should find map key 'hints.captain'");
3586        let info = info.unwrap();
3587        assert_eq!(info.type_str, "@Hint");
3588
3589        // Hovering on a field inside a map value like "captain.title"
3590        let info = get_field_info_from_schema(map_schema, &["hints", "captain", "title"]);
3591        assert!(info.is_some(), "Should find 'hints.captain.title'");
3592        let info = info.unwrap();
3593        assert_eq!(info.type_str, "@string");
3594
3595        // Test nested maps: @map(@string @map(@string @Foo))
3596        let nested_map_schema = r#"meta { id test }
3597schema {
3598    @ @object{
3599        data @map(@string @map(@string @Item))
3600    }
3601    Item @object{
3602        value @int
3603    }
3604}"#;
3605
3606        // First level map key
3607        let info = get_field_info_from_schema(nested_map_schema, &["data", "outer"]);
3608        assert!(info.is_some(), "Should find 'data.outer'");
3609        assert_eq!(info.unwrap().type_str, "@map(@string @Item)");
3610
3611        // Second level map key
3612        let info = get_field_info_from_schema(nested_map_schema, &["data", "outer", "inner"]);
3613        assert!(info.is_some(), "Should find 'data.outer.inner'");
3614        assert_eq!(info.unwrap().type_str, "@Item");
3615
3616        // Field inside nested map value
3617        let info =
3618            get_field_info_from_schema(nested_map_schema, &["data", "outer", "inner", "value"]);
3619        assert!(info.is_some(), "Should find 'data.outer.inner.value'");
3620        assert_eq!(info.unwrap().type_str, "@int");
3621
3622        // Test type reference at top level
3623        let type_ref_schema = r#"meta { id test }
3624schema {
3625    @ @object{
3626        config @Config
3627    }
3628    Config @object{
3629        port @int
3630        host @string
3631    }
3632}"#;
3633
3634        let info = get_field_info_from_schema(type_ref_schema, &["config", "port"]);
3635        assert!(info.is_some(), "Should find 'config.port'");
3636        assert_eq!(info.unwrap().type_str, "@int");
3637
3638        // Test optional type reference: @optional(@Config)
3639        let optional_ref_schema = r#"meta { id test }
3640schema {
3641    @ @object{
3642        config @optional(@Config)
3643    }
3644    Config @object{
3645        port @int
3646    }
3647}"#;
3648
3649        let info = get_field_info_from_schema(optional_ref_schema, &["config", "port"]);
3650        assert!(
3651            info.is_some(),
3652            "Should find 'config.port' through @optional(@Config)"
3653        );
3654        assert_eq!(info.unwrap().type_str, "@int");
3655
3656        // Test non-existent field
3657        let info = get_field_info_from_schema(schema_source, &["nonexistent"]);
3658        assert!(info.is_none(), "Should not find 'nonexistent' field");
3659
3660        // Test non-existent nested field
3661        let info = get_field_info_from_schema(schema_source, &["logging", "nonexistent"]);
3662        assert!(
3663            info.is_none(),
3664            "Should not find 'logging.nonexistent' field"
3665        );
3666
3667        // Test @seq type - field inside sequence element
3668        let seq_schema = r#"meta { id test }
3669schema {
3670    @ @object{
3671        /// List of specifications
3672        specs @seq(@object{
3673            /// Name of the spec
3674            name @string
3675            /// Prefix for annotations
3676            prefix @string
3677        })
3678    }
3679}"#;
3680
3681        // Hovering on a field inside a sequence element (path includes index)
3682        // Path is ["specs", "0", "name"] where "0" is the sequence index
3683        let info = get_field_info_from_schema(seq_schema, &["specs", "0", "name"]);
3684        assert!(
3685            info.is_some(),
3686            "Should find 'specs[0].name' field inside @seq element"
3687        );
3688        let info = info.unwrap();
3689        assert_eq!(info.type_str, "@string");
3690        assert_eq!(info.doc_comment, Some("Name of the spec".to_string()));
3691
3692        // Test nested @seq with type reference
3693        let nested_seq_schema = r#"meta { id test }
3694schema {
3695    @ @object{
3696        specs @seq(@Spec)
3697    }
3698    Spec @object{
3699        name @string
3700        impls @seq(@Impl)
3701    }
3702    Impl @object{
3703        /// Implementation name
3704        name @string
3705        include @seq(@string)
3706    }
3707}"#;
3708
3709        // Field in first-level seq element
3710        let info = get_field_info_from_schema(nested_seq_schema, &["specs", "0", "name"]);
3711        assert!(info.is_some(), "Should find 'specs[0].name'");
3712        assert_eq!(info.unwrap().type_str, "@string");
3713
3714        // Field in nested seq element (specs[0].impls[0].name)
3715        let info =
3716            get_field_info_from_schema(nested_seq_schema, &["specs", "0", "impls", "1", "name"]);
3717        assert!(info.is_some(), "Should find 'specs[0].impls[1].name'");
3718        let info = info.unwrap();
3719        assert_eq!(info.type_str, "@string");
3720        assert_eq!(info.doc_comment, Some("Implementation name".to_string()));
3721
3722        // Test @optional(@seq(...))
3723        let optional_seq_schema = r#"meta { id test }
3724schema {
3725    @ @object{
3726        items @optional(@seq(@object{
3727            value @int
3728        }))
3729    }
3730}"#;
3731
3732        let info = get_field_info_from_schema(optional_seq_schema, &["items", "0", "value"]);
3733        assert!(
3734            info.is_some(),
3735            "Should find 'items[0].value' through @optional(@seq(...))"
3736        );
3737        assert_eq!(info.unwrap().type_str, "@int");
3738
3739        // Test @seq inside @map: @map(@string @seq(@Item))
3740        let seq_in_map_schema = r#"meta { id test }
3741schema {
3742    @ @object{
3743        groups @map(@string @seq(@Item))
3744    }
3745    Item @object{
3746        id @int
3747        label @string
3748    }
3749}"#;
3750
3751        // Map key returns the value type (@seq(@Item))
3752        let info = get_field_info_from_schema(seq_in_map_schema, &["groups", "mygroup"]);
3753        assert!(info.is_some(), "Should find map key 'groups.mygroup'");
3754        assert_eq!(info.unwrap().type_str, "@seq(@Item)");
3755
3756        // Field inside seq element inside map value
3757        let info =
3758            get_field_info_from_schema(seq_in_map_schema, &["groups", "mygroup", "0", "label"]);
3759        assert!(
3760            info.is_some(),
3761            "Should find 'groups.mygroup[0].label' through @map -> @seq"
3762        );
3763        assert_eq!(info.unwrap().type_str, "@string");
3764
3765        // Test @map inside @seq: @seq(@map(@string @Value))
3766        let map_in_seq_schema = r#"meta { id test }
3767schema {
3768    @ @object{
3769        records @seq(@map(@string @Value))
3770    }
3771    Value @object{
3772        data @string
3773    }
3774}"#;
3775
3776        // Map inside seq element
3777        let info = get_field_info_from_schema(map_in_seq_schema, &["records", "0", "somekey"]);
3778        assert!(
3779            info.is_some(),
3780            "Should find 'records[0].somekey' through @seq -> @map"
3781        );
3782        assert_eq!(info.unwrap().type_str, "@Value");
3783
3784        // Field inside map value inside seq element
3785        let info =
3786            get_field_info_from_schema(map_in_seq_schema, &["records", "0", "somekey", "data"]);
3787        assert!(
3788            info.is_some(),
3789            "Should find 'records[0].somekey.data' through @seq -> @map -> @object"
3790        );
3791        assert_eq!(info.unwrap().type_str, "@string");
3792
3793        // Test @default with @seq: @default([] @seq(@Item))
3794        let default_seq_schema = r#"meta { id test }
3795schema {
3796    @ @object{
3797        tags @default([] @seq(@object{
3798            name @string
3799        }))
3800    }
3801}"#;
3802
3803        let info = get_field_info_from_schema(default_seq_schema, &["tags", "0", "name"]);
3804        assert!(
3805            info.is_some(),
3806            "Should find 'tags[0].name' through @default([] @seq(...))"
3807        );
3808        assert_eq!(info.unwrap().type_str, "@string");
3809
3810        // Test nested sequences: @seq(@seq(@Item))
3811        let nested_seq_seq_schema = r#"meta { id test }
3812schema {
3813    @ @object{
3814        matrix @seq(@seq(@Cell))
3815    }
3816    Cell @object{
3817        value @int
3818    }
3819}"#;
3820
3821        // First level - hovering on index returns None (we skip indices to get element type)
3822        let _info = get_field_info_from_schema(nested_seq_seq_schema, &["matrix", "0"]);
3823
3824        // Second level - inside the inner seq
3825        let info =
3826            get_field_info_from_schema(nested_seq_seq_schema, &["matrix", "0", "1", "value"]);
3827        assert!(
3828            info.is_some(),
3829            "Should find 'matrix[0][1].value' through nested @seq"
3830        );
3831        assert_eq!(info.unwrap().type_str, "@int");
3832
3833        // Test @seq with @optional element: @seq(@optional(@Item))
3834        let seq_optional_element_schema = r#"meta { id test }
3835schema {
3836    @ @object{
3837        maybe_items @seq(@optional(@Item))
3838    }
3839    Item @object{
3840        name @string
3841    }
3842}"#;
3843
3844        let info =
3845            get_field_info_from_schema(seq_optional_element_schema, &["maybe_items", "0", "name"]);
3846        assert!(
3847            info.is_some(),
3848            "Should find 'maybe_items[0].name' through @seq(@optional(@Item))"
3849        );
3850        assert_eq!(info.unwrap().type_str, "@string");
3851    }
3852
3853    #[test]
3854    fn test_doc_comments_in_schema() {
3855        let schema_source = r#"meta {
3856    id test
3857}
3858schema {
3859    @ @object{
3860        /// The server name
3861        name @string
3862    }
3863}"#;
3864
3865        // First, let's verify the tree structure
3866        let tree = styx_tree::parse(schema_source).unwrap();
3867        let obj = tree.as_object().unwrap();
3868
3869        // Find the schema entry
3870        let schema_entry = obj
3871            .entries
3872            .iter()
3873            .find(|e| e.key.as_str() == Some("schema"))
3874            .unwrap();
3875        let schema_obj = schema_entry.value.as_object().unwrap();
3876
3877        // Find the @ entry (unit key)
3878        let unit_entry = schema_obj.entries.iter().find(|e| e.key.is_unit()).unwrap();
3879
3880        // The value is @object{...} - check if it's an object with entries
3881        println!("unit_entry.value tag: {:?}", unit_entry.value.tag);
3882        println!(
3883            "unit_entry.value payload: {:?}",
3884            unit_entry.value.payload.is_some()
3885        );
3886
3887        // Navigate into the @object
3888        if let Some(inner_obj) = unit_entry.value.as_object() {
3889            for entry in &inner_obj.entries {
3890                println!(
3891                    "Inner entry: key={:?}, tag={:?}, doc={:?}",
3892                    entry.key.as_str(),
3893                    entry.key.tag_name(),
3894                    entry.doc_comment
3895                );
3896            }
3897        }
3898
3899        // Now test via our function
3900        let info = get_field_info_from_schema(schema_source, &["name"]);
3901        assert!(info.is_some(), "Should find 'name' field");
3902        let info = info.unwrap();
3903        assert_eq!(
3904            info.doc_comment,
3905            Some("The server name".to_string()),
3906            "Doc comment should be extracted"
3907        );
3908    }
3909
3910    #[test]
3911    fn test_get_completion_fields_at_path() {
3912        // Schema with nested objects
3913        let schema_source = r#"meta { id test }
3914schema {
3915    @ @object{
3916        content @string
3917        output @string
3918        syntax_highlight @optional(@object{
3919            light_theme @string
3920            dark_theme @string
3921        })
3922    }
3923}"#;
3924
3925        // At root level, should get root fields
3926        let root_fields = get_schema_fields_from_source_at_path(schema_source, &[]);
3927        let root_names: Vec<_> = root_fields.iter().map(|(n, _)| n.as_str()).collect();
3928        assert!(
3929            root_names.contains(&"content"),
3930            "Root should have 'content', got: {:?}",
3931            root_names
3932        );
3933        assert!(root_names.contains(&"output"), "Root should have 'output'");
3934        assert!(
3935            root_names.contains(&"syntax_highlight"),
3936            "Root should have 'syntax_highlight'"
3937        );
3938        assert!(
3939            !root_names.contains(&"light_theme"),
3940            "Root should NOT have 'light_theme' (it's nested)"
3941        );
3942
3943        // Inside syntax_highlight, should get its fields
3944        // Debug: trace the schema structure
3945        let tree = styx_tree::parse(schema_source).unwrap();
3946        let obj = tree.as_object().unwrap();
3947        let schema_entry = obj
3948            .entries
3949            .iter()
3950            .find(|e| e.key.as_str() == Some("schema"))
3951            .unwrap();
3952        tracing::debug!(
3953            "schema_entry.value.tag: {:?}",
3954            schema_entry.value.tag_name()
3955        );
3956
3957        // Look for syntax_highlight in the schema
3958        if let Some(inner_obj) = extract_object_from_value(&schema_entry.value) {
3959            for entry in &inner_obj.entries {
3960                tracing::debug!(
3961                    key = ?entry.key.as_str(),
3962                    tag = ?entry.key.tag_name(),
3963                    is_unit = entry.key.is_unit(),
3964                    "L1 entry"
3965                );
3966
3967                // If unit entry, go deeper
3968                if entry.key.is_unit() {
3969                    tracing::debug!(tag = ?entry.value.tag_name(), "unit value");
3970                    if let Some(l2_obj) = extract_object_from_value(&entry.value) {
3971                        for l2_entry in &l2_obj.entries {
3972                            tracing::debug!(
3973                                key = ?l2_entry.key.as_str(),
3974                                tag = ?l2_entry.key.tag_name(),
3975                                "L2 entry"
3976                            );
3977                            if l2_entry.key.as_str() == Some("syntax_highlight") {
3978                                tracing::debug!("found syntax_highlight!");
3979                                tracing::debug!(tag = ?l2_entry.value.tag_name(), "value");
3980                                let unwrapped = unwrap_type_wrappers(&l2_entry.value);
3981                                tracing::debug!(tag = ?unwrapped.tag_name(), "unwrapped");
3982                            }
3983                        }
3984                    }
3985                }
3986            }
3987        }
3988
3989        let nested_fields =
3990            get_schema_fields_from_source_at_path(schema_source, &["syntax_highlight".to_string()]);
3991        let nested_names: Vec<_> = nested_fields.iter().map(|(n, _)| n.as_str()).collect();
3992        assert!(
3993            nested_names.contains(&"light_theme"),
3994            "syntax_highlight should have 'light_theme', got: {:?}",
3995            nested_names
3996        );
3997        assert!(
3998            nested_names.contains(&"dark_theme"),
3999            "syntax_highlight should have 'dark_theme'"
4000        );
4001        assert!(
4002            !nested_names.contains(&"content"),
4003            "syntax_highlight should NOT have 'content' (it's at root)"
4004        );
4005    }
4006
4007    #[test]
4008    fn test_find_object_context_at_cursor() {
4009        // Document with cursor inside nested object
4010        let content = "syntax_highlight {\n    light_theme foo\n    \n}";
4011        //                                                    ^ cursor here (line 2, after light_theme)
4012        let tree = styx_tree::parse(content).unwrap();
4013
4014        // Find offset for the empty line inside the object
4015        let cursor_offset = content.find("    \n}").unwrap() + 4; // just before the newline
4016
4017        let ctx = find_object_at_offset(&tree, cursor_offset);
4018        assert!(ctx.is_some(), "Should find object context at cursor");
4019        let ctx = ctx.unwrap();
4020        assert_eq!(
4021            ctx.path,
4022            vec!["syntax_highlight".to_string()],
4023            "Path should be ['syntax_highlight'], got: {:?}",
4024            ctx.path
4025        );
4026    }
4027
4028    #[test]
4029    fn test_hover_with_catchall_key_schema() {
4030        // Schema where root is @object{@string @Decl} - a map with typed catch-all keys
4031        // This is used by dibs-queries where any string key maps to a @Decl
4032        let schema_source = r#"meta {id test}
4033schema {
4034    Decl @enum{
4035        /// A query declaration.
4036        query @Query
4037    }
4038    Query @object{
4039        /// Source table to query from.
4040        from @optional(@string)
4041        /// Filter conditions.
4042        where @optional(@object{@string @string})
4043    }
4044    @ @object{@string @Decl}
4045}"#;
4046
4047        // Hovering on "AllProducts" in a document like:
4048        //   AllProducts @query{from product}
4049        // Should return type info: @Decl
4050        let info = get_field_info_from_schema(schema_source, &["AllProducts"]);
4051        assert!(
4052            info.is_some(),
4053            "Should find field info for dynamic key 'AllProducts' in catch-all schema"
4054        );
4055        let info = info.unwrap();
4056        // The type should be @Decl (the value type of the catch-all)
4057        assert!(
4058            info.type_str.contains("Decl"),
4059            "Type should reference Decl, got: {}",
4060            info.type_str
4061        );
4062
4063        // Hovering on nested field "from" inside a query:
4064        //   AllProducts @query{from product}
4065        // Path would be ["AllProducts", "from"]
4066        let info = get_field_info_from_schema(schema_source, &["AllProducts", "from"]);
4067        assert!(
4068            info.is_some(),
4069            "Should find field info for 'from' inside a query"
4070        );
4071        let info = info.unwrap();
4072        assert!(
4073            info.type_str.contains("string"),
4074            "Type of 'from' should be string, got: {}",
4075            info.type_str
4076        );
4077    }
4078
4079    /// Test helper: parse content with `⏐` as cursor position marker,
4080    /// return (content_without_marker, cursor_offset)
4081    fn parse_cursor(input: &str) -> (String, usize) {
4082        let cursor_pos = input
4083            .find('⏐')
4084            .expect("test input must contain ⏐ cursor marker");
4085        let content = input.replace('⏐', "");
4086        (content, cursor_pos)
4087    }
4088
4089    #[test]
4090    fn test_nesting_depth_at_root() {
4091        // Cursor at root level, outside any braces
4092        let (content, offset) = parse_cursor("⏐name value");
4093        let parse = styx_cst::parse(&content);
4094        assert_eq!(find_nesting_depth_cst(&parse, offset), 0);
4095    }
4096
4097    #[test]
4098    fn test_nesting_depth_after_root_entry() {
4099        // Cursor at root level after an entry
4100        let (content, offset) = parse_cursor("name value\n⏐");
4101        let parse = styx_cst::parse(&content);
4102        assert_eq!(find_nesting_depth_cst(&parse, offset), 0);
4103    }
4104
4105    #[test]
4106    fn test_nesting_depth_inside_object() {
4107        // Cursor inside a top-level object
4108        let (content, offset) = parse_cursor("server {\n    ⏐\n}");
4109        let parse = styx_cst::parse(&content);
4110        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4111    }
4112
4113    #[test]
4114    fn test_nesting_depth_inside_object_with_content() {
4115        // Cursor inside object that has content
4116        let (content, offset) = parse_cursor("server {\n    host localhost\n    ⏐\n}");
4117        let parse = styx_cst::parse(&content);
4118        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4119    }
4120
4121    #[test]
4122    fn test_nesting_depth_nested_object() {
4123        // Cursor inside a nested object (depth 2)
4124        let (content, offset) = parse_cursor("server {\n    tls {\n        ⏐\n    }\n}");
4125        let parse = styx_cst::parse(&content);
4126        assert_eq!(find_nesting_depth_cst(&parse, offset), 2);
4127    }
4128
4129    #[test]
4130    fn test_nesting_depth_deeply_nested() {
4131        // Cursor at depth 3
4132        let (content, offset) =
4133            parse_cursor("a {\n    b {\n        c {\n            ⏐\n        }\n    }\n}");
4134        let parse = styx_cst::parse(&content);
4135        assert_eq!(find_nesting_depth_cst(&parse, offset), 3);
4136    }
4137
4138    #[test]
4139    fn test_nesting_depth_inside_sequence() {
4140        // Cursor inside a sequence
4141        let (content, offset) = parse_cursor("items (\n    ⏐\n)");
4142        let parse = styx_cst::parse(&content);
4143        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4144    }
4145
4146    #[test]
4147    fn test_nesting_depth_object_inside_sequence() {
4148        // Cursor inside an object that's inside a sequence
4149        let (content, offset) = parse_cursor("items (\n    {\n        ⏐\n    }\n)");
4150        let parse = styx_cst::parse(&content);
4151        assert_eq!(find_nesting_depth_cst(&parse, offset), 2);
4152    }
4153
4154    #[test]
4155    fn test_nesting_depth_sequence_inside_object() {
4156        // Cursor inside a sequence that's inside an object
4157        let (content, offset) = parse_cursor("server {\n    ports (\n        ⏐\n    )\n}");
4158        let parse = styx_cst::parse(&content);
4159        assert_eq!(find_nesting_depth_cst(&parse, offset), 2);
4160    }
4161
4162    #[test]
4163    fn test_nesting_depth_between_siblings() {
4164        // Cursor between two sibling entries at root
4165        let (content, offset) = parse_cursor("first {\n    a 1\n}\n⏐\nsecond {\n    b 2\n}");
4166        let parse = styx_cst::parse(&content);
4167        assert_eq!(find_nesting_depth_cst(&parse, offset), 0);
4168    }
4169
4170    #[test]
4171    fn test_nesting_depth_just_after_open_brace() {
4172        // Cursor right after opening brace
4173        let (content, offset) = parse_cursor("server {⏐}");
4174        let parse = styx_cst::parse(&content);
4175        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4176    }
4177
4178    #[test]
4179    fn test_nesting_depth_just_before_close_brace() {
4180        // Cursor right before closing brace
4181        let (content, offset) = parse_cursor("server {\n    host localhost\n⏐}");
4182        let parse = styx_cst::parse(&content);
4183        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4184    }
4185
4186    #[test]
4187    fn test_nesting_depth_inline_object() {
4188        // Cursor inside inline object
4189        let (content, offset) = parse_cursor("server {host localhost, ⏐}");
4190        let parse = styx_cst::parse(&content);
4191        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4192    }
4193
4194    #[test]
4195    fn test_nesting_depth_tagged_object() {
4196        // Cursor inside a tagged object like @query{...}
4197        let (content, offset) = parse_cursor("AllProducts @query{\n    ⏐\n}");
4198        let parse = styx_cst::parse(&content);
4199        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4200    }
4201
4202    #[test]
4203    fn test_nesting_depth_nested_tagged_objects() {
4204        // Cursor inside nested tagged objects
4205        let (content, offset) =
4206            parse_cursor("AllProducts @query{\n    select {\n        ⏐\n    }\n}");
4207        let parse = styx_cst::parse(&content);
4208        assert_eq!(find_nesting_depth_cst(&parse, offset), 2);
4209    }
4210
4211    // === Edge case tests ===
4212
4213    #[test]
4214    fn test_nesting_depth_empty_object() {
4215        // Cursor inside empty object
4216        let (content, offset) = parse_cursor("empty {⏐}");
4217        let parse = styx_cst::parse(&content);
4218        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4219    }
4220
4221    #[test]
4222    fn test_nesting_depth_empty_sequence() {
4223        // Cursor inside empty sequence
4224        let (content, offset) = parse_cursor("empty (⏐)");
4225        let parse = styx_cst::parse(&content);
4226        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4227    }
4228
4229    #[test]
4230    fn test_nesting_depth_after_closing_brace() {
4231        // Cursor right after closing brace (back to root)
4232        let (content, offset) = parse_cursor("server { host localhost }⏐");
4233        let parse = styx_cst::parse(&content);
4234        assert_eq!(find_nesting_depth_cst(&parse, offset), 0);
4235    }
4236
4237    #[test]
4238    fn test_nesting_depth_empty_document() {
4239        // Empty document
4240        let (content, offset) = parse_cursor("⏐");
4241        let parse = styx_cst::parse(&content);
4242        assert_eq!(find_nesting_depth_cst(&parse, offset), 0);
4243    }
4244
4245    #[test]
4246    fn test_nesting_depth_whitespace_only() {
4247        // Document with only whitespace
4248        let (content, offset) = parse_cursor("   ⏐   ");
4249        let parse = styx_cst::parse(&content);
4250        assert_eq!(find_nesting_depth_cst(&parse, offset), 0);
4251    }
4252
4253    #[test]
4254    fn test_nesting_depth_multiline_sequence_elements() {
4255        // Cursor between sequence elements
4256        let (content, offset) = parse_cursor("items (\n    a\n    ⏐\n    b\n)");
4257        let parse = styx_cst::parse(&content);
4258        assert_eq!(find_nesting_depth_cst(&parse, offset), 1);
4259    }
4260
4261    #[test]
4262    fn test_nesting_depth_complex_dibs_like() {
4263        // Real-world-like dibs query structure
4264        let (content, offset) = parse_cursor(
4265            r#"AllProducts @query{
4266    from product
4267    where {deleted_at @null}
4268    select {
4269        id
4270        handle
42714272    }
4273}"#,
4274        );
4275        let parse = styx_cst::parse(&content);
4276        assert_eq!(find_nesting_depth_cst(&parse, offset), 2);
4277    }
4278
4279    #[test]
4280    fn test_nesting_depth_inside_where_clause() {
4281        // Cursor inside where clause object
4282        let (content, offset) = parse_cursor(
4283            r#"Query @query{
4284    where {
4285        status "published"
42864287    }
4288}"#,
4289        );
4290        let parse = styx_cst::parse(&content);
4291        assert_eq!(find_nesting_depth_cst(&parse, offset), 2);
4292    }
4293
4294    #[test]
4295    fn test_parse_error_diagnostics() {
4296        // Document with "too many atoms" error - CST should capture this
4297        let content = "foo {a b c}";
4298        let parsed = styx_cst::parse(content);
4299
4300        // The CST layer should report an error
4301        assert!(
4302            !parsed.errors().is_empty(),
4303            "CST should report a parse error for 'foo {{a b c}}'"
4304        );
4305
4306        // The error message should mention the issue
4307        let error = &parsed.errors()[0];
4308        assert!(
4309            error.message.contains("unexpected atom"),
4310            "Error message should mention 'unexpected atom': {}",
4311            error.message
4312        );
4313    }
4314
4315    #[test]
4316    fn test_compute_diagnostics_reports_parse_errors() {
4317        // Document with "too many atoms" error
4318        let content = "foo {a b c}";
4319        let parsed = styx_cst::parse(content);
4320
4321        // Create a fake server to call compute_diagnostics
4322        // We need to verify that parse errors are converted to LSP diagnostics
4323        let _uri = tower_lsp::lsp_types::Url::parse("file:///test.styx").unwrap();
4324
4325        // Note: styx_tree::parse fails on this input, so tree is None
4326        let tree = styx_tree::parse(content).ok();
4327        assert!(tree.is_none(), "tree parse should fail for invalid input");
4328
4329        // Manually compute diagnostics (mimicking what the server does)
4330        let mut diagnostics = Vec::new();
4331
4332        // Phase 1: Parse errors - this is what publish_diagnostics does
4333        for error in parsed.errors() {
4334            let range = tower_lsp::lsp_types::Range {
4335                start: offset_to_position(content, error.offset as usize),
4336                end: offset_to_position(content, error.offset as usize + 1),
4337            };
4338
4339            diagnostics.push(tower_lsp::lsp_types::Diagnostic {
4340                range,
4341                severity: Some(tower_lsp::lsp_types::DiagnosticSeverity::ERROR),
4342                code: None,
4343                code_description: None,
4344                source: Some("styx".to_string()),
4345                message: error.message.clone(),
4346                related_information: None,
4347                tags: None,
4348                data: None,
4349            });
4350        }
4351
4352        // Verify we got a diagnostic
4353        assert!(
4354            !diagnostics.is_empty(),
4355            "Should have at least one diagnostic for parse error"
4356        );
4357
4358        // Verify the diagnostic message
4359        assert!(
4360            diagnostics[0].message.contains("unexpected atom"),
4361            "Diagnostic message should mention the error: {}",
4362            diagnostics[0].message
4363        );
4364    }
4365}