Skip to main content

lexd_lsp/
server.rs

1//! Main language server implementation
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use crate::extension_dispatch::{
8    dispatch_code_action as ext_dispatch_code_action,
9    dispatch_completion as ext_dispatch_completion, dispatch_hover as ext_dispatch_hover,
10    LspExtensionState,
11};
12use crate::features::commands::{self, execute_command};
13use crate::features::document_links::collect_document_links;
14use crate::features::document_symbols::{collect_document_symbols, LexDocumentSymbol};
15use crate::features::extract::{self, ExtractError};
16use crate::features::folding_ranges::{folding_ranges as collect_folding_ranges, LexFoldingRange};
17use crate::features::formatting::{self, LineRange as FormattingLineRange, TextEditSpan};
18use crate::features::go_to_definition::goto_definition;
19use crate::features::hover::{hover as compute_hover, HoverResult};
20use crate::features::references::find_references;
21use crate::features::semantic_tokens::{
22    collect_semantic_tokens, LexSemanticToken, SEMANTIC_TOKEN_KINDS,
23};
24use clapfig::{Boundary, Clapfig, SearchPath};
25use lex_analysis::completion::{completion_items, CompletionCandidate, CompletionWorkspace};
26use lex_analysis::diagnostics::{
27    analyze as analyze_diagnostics, apply_rules, AnalysisDiagnostic, DiagnosticKind,
28};
29use lex_babel::formats::lex::formatting_rules::FormattingRules;
30use lex_babel::templates::{
31    build_asset_snippet, build_verbatim_snippet, AssetSnippetRequest, VerbatimSnippetRequest,
32};
33use lex_config::{
34    collect_extension_diagnostic_rules, LabelsConfig, LexConfig, LoadedLexConfig, CONFIG_FILE_NAME,
35    DIAGNOSTICS_RULES_PATH,
36};
37use lex_core::lex::ast::links::{DocumentLink as AstDocumentLink, LinkType};
38use lex_core::lex::ast::range::SourceLocation;
39use lex_core::lex::ast::{Document, Position as AstPosition, Range as AstRange};
40use lex_core::lex::builtins as lex_builtins;
41use lex_core::lex::includes::{resolve_from_source, FsLoader, IncludeError, ResolveConfig};
42use lex_core::lex::parsing;
43use lex_extension_host::registry::Registry;
44use lex_lsp_core::prepare_paste::{
45    prepare_paste as prepare_paste_transform, PasteMode, PreparePasteParams, PreparePasteResult,
46};
47use serde_json::{json, Value};
48use tokio::sync::RwLock;
49use tower_lsp::async_trait;
50use tower_lsp::jsonrpc::{Error, Result};
51use tower_lsp::lsp_types::{
52    CodeActionParams, CodeActionProviderCapability, CodeActionResponse, CompletionItem,
53    CompletionOptions, CompletionParams, CompletionResponse, DidChangeConfigurationParams,
54    DidChangeWorkspaceFoldersParams, DocumentFormattingParams, DocumentLink, DocumentLinkOptions,
55    DocumentLinkParams, DocumentRangeFormattingParams, DocumentSymbol, DocumentSymbolParams,
56    DocumentSymbolResponse, ExecuteCommandOptions, ExecuteCommandParams, FoldingRange,
57    FoldingRangeParams, FoldingRangeProviderCapability, GotoDefinitionParams,
58    GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
59    InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind,
60    OneOf, Position, Range, ReferenceParams, SemanticToken, SemanticTokenType, SemanticTokens,
61    SemanticTokensFullOptions, SemanticTokensLegend, SemanticTokensOptions, SemanticTokensParams,
62    SemanticTokensResult, ServerCapabilities, ServerInfo, TextDocumentItem,
63    TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Url, WorkDoneProgressOptions,
64    WorkspaceFoldersServerCapabilities,
65};
66use tower_lsp::Client;
67
68use tower_lsp::lsp_types::Diagnostic;
69
70use tower_lsp::lsp_types::MessageType;
71
72#[async_trait]
73pub trait LspClient:
74    crate::trust_prompt::LspTrustRequester + Send + Sync + Clone + 'static
75{
76    async fn publish_diagnostics(&self, uri: Url, diags: Vec<Diagnostic>, version: Option<i32>);
77    async fn show_message(&self, typ: MessageType, message: String);
78}
79
80#[async_trait]
81impl LspClient for Client {
82    async fn publish_diagnostics(&self, uri: Url, diags: Vec<Diagnostic>, version: Option<i32>) {
83        self.publish_diagnostics(uri, diags, version).await;
84    }
85
86    async fn show_message(&self, typ: MessageType, message: String) {
87        self.show_message(typ, message).await;
88    }
89}
90
91pub trait FeatureProvider: Send + Sync + 'static {
92    fn semantic_tokens(&self, document: &Document) -> Vec<LexSemanticToken>;
93    fn document_symbols(&self, document: &Document) -> Vec<LexDocumentSymbol>;
94    fn folding_ranges(&self, document: &Document) -> Vec<LexFoldingRange>;
95    fn hover(&self, document: &Document, position: AstPosition) -> Option<HoverResult>;
96    fn goto_definition(&self, document: &Document, position: AstPosition) -> Vec<AstRange>;
97    fn references(
98        &self,
99        document: &Document,
100        position: AstPosition,
101        include_declaration: bool,
102    ) -> Vec<AstRange>;
103    fn document_links(&self, document: &Document) -> Vec<AstDocumentLink>;
104    fn format_document(
105        &self,
106        document: &Document,
107        source: &str,
108        rules: Option<FormattingRules>,
109    ) -> Vec<TextEditSpan>;
110    fn format_range(
111        &self,
112        document: &Document,
113        source: &str,
114        range: FormattingLineRange,
115        rules: Option<FormattingRules>,
116    ) -> Vec<TextEditSpan>;
117    fn completion(
118        &self,
119        document: &Document,
120        position: AstPosition,
121        current_line: Option<&str>,
122        workspace: Option<&CompletionWorkspace>,
123        trigger_char: Option<&str>,
124    ) -> Vec<CompletionCandidate>;
125    fn execute_command(&self, command: &str, arguments: &[Value]) -> Result<Option<Value>>;
126}
127
128#[derive(Default)]
129pub struct DefaultFeatureProvider;
130
131impl DefaultFeatureProvider {
132    pub fn new() -> Self {
133        Self
134    }
135}
136
137#[async_trait]
138impl FeatureProvider for DefaultFeatureProvider {
139    fn semantic_tokens(&self, document: &Document) -> Vec<LexSemanticToken> {
140        collect_semantic_tokens(document)
141    }
142
143    fn document_symbols(&self, document: &Document) -> Vec<LexDocumentSymbol> {
144        collect_document_symbols(document)
145    }
146
147    fn folding_ranges(&self, document: &Document) -> Vec<LexFoldingRange> {
148        collect_folding_ranges(document)
149    }
150
151    fn hover(&self, document: &Document, position: AstPosition) -> Option<HoverResult> {
152        compute_hover(document, position)
153    }
154
155    fn goto_definition(&self, document: &Document, position: AstPosition) -> Vec<AstRange> {
156        goto_definition(document, position)
157    }
158
159    fn references(
160        &self,
161        document: &Document,
162        position: AstPosition,
163        include_declaration: bool,
164    ) -> Vec<AstRange> {
165        find_references(document, position, include_declaration)
166    }
167
168    fn document_links(&self, document: &Document) -> Vec<AstDocumentLink> {
169        collect_document_links(document)
170    }
171
172    fn format_document(
173        &self,
174        document: &Document,
175        source: &str,
176        rules: Option<FormattingRules>,
177    ) -> Vec<TextEditSpan> {
178        formatting::format_document(document, source, rules)
179    }
180
181    fn format_range(
182        &self,
183        document: &Document,
184        source: &str,
185        range: FormattingLineRange,
186        rules: Option<FormattingRules>,
187    ) -> Vec<TextEditSpan> {
188        formatting::format_range(document, source, range, rules)
189    }
190
191    fn completion(
192        &self,
193        document: &Document,
194        position: AstPosition,
195        current_line: Option<&str>,
196        workspace: Option<&CompletionWorkspace>,
197        trigger_char: Option<&str>,
198    ) -> Vec<CompletionCandidate> {
199        completion_items(document, position, current_line, workspace, trigger_char)
200    }
201
202    fn execute_command(&self, command: &str, arguments: &[Value]) -> Result<Option<Value>> {
203        execute_command(command, arguments)
204    }
205}
206
207#[derive(Clone)]
208struct DocumentEntry {
209    document: Arc<Document>,
210    text: Arc<String>,
211}
212
213#[derive(Default)]
214struct DocumentStore {
215    entries: RwLock<HashMap<Url, Option<DocumentEntry>>>,
216}
217
218impl DocumentStore {
219    async fn upsert(&self, uri: Url, text: String) -> Option<DocumentEntry> {
220        // Permissive parse: `doc.*` and unknown `lex.*` labels — which
221        // strict-mode `NormalizeLabels` rejects as parse errors — flow
222        // through into the AST instead so the analysis stage can
223        // surface them as in-place diagnostics. PR 4 of #584 wires up
224        // the diagnostic surface; without permissive parse here, a
225        // single forbidden label would blank out every LSP feature
226        // for the document.
227        let parsed = match parsing::parse_document_permissive(&text) {
228            Ok(document) => Some(DocumentEntry {
229                document: Arc::new(document),
230                text: Arc::new(text),
231            }),
232            Err(_) => None,
233        };
234        self.entries.write().await.insert(uri, parsed.clone());
235        parsed
236    }
237
238    async fn get(&self, uri: &Url) -> Option<DocumentEntry> {
239        self.entries.read().await.get(uri).cloned().flatten()
240    }
241
242    async fn remove(&self, uri: &Url) {
243        self.entries.write().await.remove(uri);
244    }
245}
246
247fn document_directory_from_uri(uri: &Url) -> Option<PathBuf> {
248    uri.to_file_path()
249        .ok()
250        .and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
251}
252
253fn indent_level_from_position(
254    entry: &DocumentEntry,
255    position: &Position,
256    rules: &FormattingRules,
257) -> usize {
258    let indent_unit = rules.indent_string.as_str();
259    if indent_unit.is_empty() {
260        return 0;
261    }
262    let indent_len = indent_unit.len();
263    let line = entry.text.lines().nth(position.line as usize).unwrap_or("");
264    let prefix: String = line.chars().take(position.character as usize).collect();
265    let mut level = 0;
266    let mut remainder = prefix.as_str();
267    while remainder.starts_with(indent_unit) {
268        level += 1;
269        remainder = &remainder[indent_len..];
270    }
271    level
272}
273
274fn semantic_tokens_legend() -> SemanticTokensLegend {
275    SemanticTokensLegend {
276        token_types: SEMANTIC_TOKEN_KINDS
277            .iter()
278            .map(|kind| SemanticTokenType::new(kind.as_str()))
279            .collect(),
280        token_modifiers: Vec::new(),
281    }
282}
283
284pub struct LexLanguageServer<C = Client, P = DefaultFeatureProvider> {
285    client: C,
286    documents: DocumentStore,
287    features: Arc<P>,
288    workspace_roots: RwLock<Vec<PathBuf>>,
289    config: RwLock<LoadedLexConfig>,
290    /// Extension registry + boot diagnostics, lazily populated on first
291    /// extension-aware request (hover/completion/code_action). Held for
292    /// the lifetime of the workspace; rebuilt when workspace folders
293    /// change. `None` when the LSP is running outside any workspace
294    /// (e.g. a single untitled buffer) — extension dispatch is a no-op
295    /// in that case, and built-in providers handle every request.
296    extension: RwLock<Option<Arc<LspExtensionState>>>,
297    /// Serializes concurrent calls to [`Self::extension_state`] so the
298    /// first request to land at boot does the work and every other
299    /// request waits for that single boot to finish — instead of all
300    /// of them racing into `spawn_blocking` and producing N parallel
301    /// schema reads, N parallel subprocess spawns, and N parallel
302    /// `lex/trustRequest` prompts to the editor. Naturally happens
303    /// when N requests arrive on file-open (semantic tokens + hover +
304    /// document symbols + folding + …).
305    extension_init: tokio::sync::Mutex<()>,
306}
307
308impl LexLanguageServer<Client, DefaultFeatureProvider> {
309    pub fn new(client: Client) -> Self {
310        Self::with_features(client, Arc::new(DefaultFeatureProvider::new()))
311    }
312}
313
314impl<C, P> LexLanguageServer<C, P>
315where
316    C: LspClient,
317    P: FeatureProvider,
318{
319    pub fn with_features(client: C, features: Arc<P>) -> Self {
320        let config = load_config(None);
321        Self {
322            client,
323            documents: DocumentStore::default(),
324            features,
325            workspace_roots: RwLock::new(Vec::new()),
326            config: RwLock::new(config),
327            extension: RwLock::new(None),
328            extension_init: tokio::sync::Mutex::new(()),
329        }
330    }
331
332    /// Lazily boot the extension registry against the current workspace
333    /// root + config. Idempotent: once built, returns the cached state.
334    /// Returns `None` when no workspace is set (e.g. single-file mode);
335    /// extension dispatch is a no-op without a workspace anchor.
336    ///
337    /// Concurrency: the first request to land on a fresh workspace
338    /// takes the `extension_init` mutex and runs the boot; every
339    /// other concurrent request blocks on the mutex, then re-checks
340    /// the cache and reuses what the first one produced. Without
341    /// this serialization, an open-file event that fires several
342    /// providers at once (hover, completion, semantic tokens, folding,
343    /// document-symbols, …) would launch N parallel boots, with N
344    /// concurrent reads of the schema directory, N concurrent
345    /// subprocess spawns, and N `lex/trustRequest` prompts to the
346    /// editor. The mutex keeps the observable side effects to one
347    /// prompt and one set of spawns.
348    async fn extension_state(&self) -> Option<Arc<LspExtensionState>> {
349        // Fast path: already booted, no lock needed.
350        if let Some(s) = self.extension.read().await.clone() {
351            return Some(s);
352        }
353
354        // Slow path: serialize boot. Hold the init lock for the whole
355        // boot so the second-arriving request waits for the first.
356        let _guard = self.extension_init.lock().await;
357
358        // Re-check after acquiring the init lock — another task may
359        // have completed boot while we were waiting on the mutex.
360        if let Some(s) = self.extension.read().await.clone() {
361            return Some(s);
362        }
363
364        let workspace_root = {
365            let roots = self.workspace_roots.read().await;
366            roots.first().cloned()?
367        };
368        let labels_config = LabelsConfig {
369            namespaces: self.config.read().await.config.labels.clone(),
370        };
371
372        // boot_registry does synchronous filesystem IO (schema load,
373        // trust store open) and may attempt to spawn subprocess
374        // handlers — a few hundred milliseconds in the worst case. Run
375        // it on the blocking thread pool so the tokio runtime keeps
376        // serving other LSP requests while boot runs.
377        //
378        // The trust prompt handler bridges sync→async via
379        // `Handle::block_on` — safe because we're on a blocking-pool
380        // thread, not a runtime worker.
381        let workspace_root_owned = workspace_root.clone();
382        let trust_requester = std::sync::Arc::new(self.client.clone());
383        let runtime_handle = tokio::runtime::Handle::current();
384        let outcome = match tokio::task::spawn_blocking(move || {
385            lex_fmt::boot_registry(lex_fmt::ExtensionSetup {
386                workspace_root: workspace_root_owned.as_path(),
387                labels_config: &labels_config,
388                // The LSP server has no `--ext-schema` flag; only
389                // `[labels]` entries from `lex.toml` register schemas
390                // in this surface.
391                ext_schemas: &[],
392                // `enable_handlers` is irrelevant on the Lsp surface —
393                // that flag is the CLI shortcut for the trust-prompt
394                // path. The LSP consults the trust store + prompt
395                // handler directly.
396                enable_handlers: false,
397                surface_override: Some(lex_extension_host::Surface::LspSession),
398                // Forwards `lex/trustRequest` to the editor and awaits
399                // the user's decision. Already-pinned decisions in
400                // `<workspace>/.lex/trust.json` short-circuit before
401                // the prompt fires.
402                trust_prompt: Box::new(crate::trust_prompt::LspPromptHandler::new(
403                    trust_requester,
404                    runtime_handle,
405                )),
406                // Reports `lexd-lsp`'s version to subprocess handlers
407                // in their initialize handshake — what handlers expect
408                // to see, not the `lex-fmt` boot crate's version.
409                host_version: env!("CARGO_PKG_VERSION"),
410            })
411        })
412        .await
413        {
414            Ok(outcome) => outcome,
415            Err(_) => {
416                // Blocking task panicked or was cancelled. Skip
417                // extension boot for this session; the next request
418                // will retry.
419                return None;
420            }
421        };
422
423        // Surface boot diagnostics to the editor before we cache the
424        // state. Per-namespace failures (resolver errors, denied
425        // subprocess handlers, schema load problems) are stored on
426        // the outcome but the user has no way to see them otherwise
427        // — pre-validation diagnostics are attached to documents,
428        // but boot diagnostics aren't. `window/showMessage` is the
429        // right surface for one-shot status that's not tied to a
430        // specific document range.
431        for diag in &outcome.diagnostics {
432            let prefix = match &diag.namespace {
433                Some(ns) => format!("lex extension `{ns}`: "),
434                None => "lex extensions: ".to_string(),
435            };
436            self.client
437                .show_message(MessageType::WARNING, format!("{prefix}{}", diag.message))
438                .await;
439        }
440
441        // Cross-check `[diagnostics.rules]` extension entries against the
442        // freshly-booted registry. A `<namespace>.<code>` rule whose
443        // namespace is registered but doesn't declare the code is a dead
444        // letter — it retunes nothing. Surface each as a warning so the
445        // misspelling is visible; like boot diagnostics, it's session
446        // status with no document range to attach to. Unregistered
447        // namespaces pass silently (the user may install the extension
448        // later), so this never fires for staged-ahead rules.
449        // Collect findings under the lock, then drop it before awaiting
450        // any `show_message` — holding the config read lock across the
451        // network await could starve a concurrent config write.
452        let rule_findings = {
453            let cfg = self.config.read().await;
454            lex_fmt::validate_extension_diagnostic_rules(
455                &cfg.extension_diagnostic_rules,
456                &outcome.registry,
457            )
458        };
459        for finding in rule_findings {
460            self.client
461                .show_message(MessageType::WARNING, finding.message)
462                .await;
463        }
464
465        let state = Arc::new(LspExtensionState::from(outcome));
466        *self.extension.write().await = Some(state.clone());
467        Some(state)
468    }
469
470    /// Discard the cached extension registry. Called when workspace
471    /// folders change so the next request picks up the new root +
472    /// config.
473    async fn invalidate_extension_state(&self) {
474        *self.extension.write().await = None;
475    }
476
477    async fn parse_and_store(&self, uri: Url, text: String) {
478        // Try include resolution first when the document has an on-disk
479        // path. If resolution succeeds, the resolved (merged) tree is what
480        // we store and analyze; downstream features (semantic tokens,
481        // hover, goto) see the post-include AST. If resolution fails, we
482        // fall back to a plain parse so the rest of the LSP keeps working,
483        // and surface the include error as a diagnostic at the include
484        // site.
485        let include_diags = self.resolve_and_upsert(&uri, &text).await;
486
487        let mut diagnostics: Vec<Diagnostic> = include_diags;
488        if let Some(entry) = self.documents.get(&uri).await {
489            let mut analysis_diags = analyze_diagnostics(&entry.document);
490            let cfg = self.config.read().await;
491            apply_rules(&mut analysis_diags, |code| {
492                cfg.lookup_diagnostic_rule(code).cloned()
493            });
494            drop(cfg);
495            diagnostics.extend(analysis_diags.into_iter().map(to_lsp_diagnostic));
496        }
497
498        self.client
499            .publish_diagnostics(uri, diagnostics, None)
500            .await;
501    }
502
503    /// Drives include resolution (when the URI is a file path) for
504    /// *diagnostic* purposes only. Always stores the **unresolved**
505    /// parse under `uri`; that's what every LSP feature
506    /// (semantic tokens, hover, goto-definition, document symbols) sees.
507    ///
508    /// Why not store the merged tree: nodes spliced in from included
509    /// files carry Ranges in the *included file's* coordinate space —
510    /// `range.start.line == 0` means "line 0 of chapter.lex", not
511    /// "line 0 of the host buffer." Serving those ranges back as if
512    /// they were positions in the host URI's text would highlight the
513    /// wrong tokens, send goto-definition to the wrong spot, etc. Until
514    /// we have an origin-path-aware location-mapping layer (PR 9+),
515    /// the safe behavior is to use the merged tree only to decide
516    /// whether resolution succeeded, and emit diagnostics if it didn't.
517    ///
518    /// Returns include-related diagnostics: empty on success or when
519    /// the document doesn't use includes at all; one diagnostic
520    /// pointing at the include site (or document head as fallback) on
521    /// resolver failure.
522    async fn resolve_and_upsert(&self, uri: &Url, text: &str) -> Vec<Diagnostic> {
523        // Standard parse goes in regardless — this is the tree every
524        // LSP feature works against.
525        self.documents.upsert(uri.clone(), text.to_string()).await;
526
527        // Fast path: no `lex.include` literal in source, nothing to
528        // resolve, nothing to diagnose. Avoids per-keystroke resolver
529        // work for documents that don't use the feature, and prevents
530        // the resolver's `ParseFailed` from firing as a spurious
531        // include diagnostic for ordinary parse errors.
532        if !text.contains("lex.include") {
533            return Vec::new();
534        }
535
536        let path = match uri.to_file_path() {
537            Ok(p) => p,
538            // Untitled / non-file URIs (e.g. `untitled:Untitled-1`)
539            // can't anchor relative include paths.
540            Err(_) => return Vec::new(),
541        };
542
543        // Canonicalize the entry path so it lives in the same absolute-
544        // path space as `inc_root` (`absolutize_path` calls
545        // `Path::canonicalize` which follows symlinks — important on
546        // macOS where /var → /private/var). Without this, host_dir
547        // (path.parent()) and inc_root differ by symlink resolution and
548        // every lookup fails the root-escape prefix check.
549        let path = absolutize_path(&path);
550
551        let cfg = self.config.read().await;
552        let inc_root = inc_root_for(&path, &cfg.config);
553        let max_depth = cfg.config.includes.max_depth;
554        let max_total_includes = cfg.config.includes.max_total_includes;
555        let max_file_size = cfg.config.includes.max_file_size;
556        drop(cfg);
557
558        let resolve_config = ResolveConfig {
559            root: inc_root.clone(),
560            max_depth,
561            max_total_includes,
562        };
563        let loader = FsLoader::new(inc_root).with_max_file_size(max_file_size);
564        let registry = Registry::new();
565        if let Err(e) = lex_builtins::register_into(
566            &registry,
567            std::sync::Arc::new(loader),
568            resolve_config.clone(),
569        ) {
570            return vec![registry_setup_diagnostic(&e.to_string())];
571        }
572
573        match resolve_from_source(text, Some(path), &resolve_config, &registry) {
574            Ok(_doc) => {
575                // Resolution succeeded. We *don't* store the merged
576                // tree — see fn-level docstring. The resolver was run
577                // only to surface errors; the tree itself is dropped.
578                Vec::new()
579            }
580            Err(err) => vec![include_error_to_diagnostic(&err)],
581        }
582    }
583
584    async fn document_entry(&self, uri: &Url) -> Option<DocumentEntry> {
585        self.documents.get(uri).await
586    }
587
588    /// Resolve a `lex.include` annotation at `position` to a Location
589    /// pointing at the target file. Returns `None` when the cursor isn't
590    /// on a `lex.include`, when the URI has no on-disk anchor (untitled
591    /// buffers), when the include has no `src=` parameter, when the
592    /// path resolves outside the include root, **or when the target
593    /// file does not exist on disk**. The last guard avoids navigating
594    /// the editor to a non-existent path — the user gets the
595    /// `include-not-found` diagnostic from PR 8 instead, which surfaces
596    /// the underlying problem clearly. The Location range is the file
597    /// head (line 0, column 0) — cross-file goto-def lands the user at
598    /// the top of the target.
599    async fn goto_for_include(
600        &self,
601        uri: &Url,
602        document: &Document,
603        position: AstPosition,
604    ) -> Option<Location> {
605        let annotation = lex_analysis::utils::find_annotation_at_position(document, position)?;
606        if !annotation.is_include() {
607            return None;
608        }
609        let src = annotation.include_src()?;
610
611        let host_path = absolutize_path(&uri.to_file_path().ok()?);
612        let cfg = self.config.read().await;
613        let inc_root = inc_root_for(&host_path, &cfg.config);
614        drop(cfg);
615
616        let target = lex_core::lex::includes::resolve_file_reference(
617            &src,
618            Some(host_path.as_path()),
619            inc_root.as_path(),
620        )
621        .ok()?;
622        // Existence check: don't send the editor to nowhere.
623        // `resolve_file_reference` is filesystem-free (lexical only),
624        // so the path it returns may not exist. The PR 8 diagnostic
625        // already surfaces missing-target errors; goto-def returning
626        // None here lets the editor render its native "no definition
627        // found" UX instead of opening a phantom buffer.
628        if !target.is_file() {
629            return None;
630        }
631        let target_uri = Url::from_file_path(&target).ok()?;
632        Some(Location {
633            uri: target_uri,
634            range: head_range(),
635        })
636    }
637
638    /// Build a hover preview for a `lex.include` annotation at `position`.
639    /// The preview shows the target file's first two non-blank lines
640    /// (no AST parsing — just the raw text) — enough to confirm the
641    /// include points where the author thinks. Returns `None` when the
642    /// cursor isn't on a `lex.include`, the URI has no on-disk anchor,
643    /// or the target can't be loaded.
644    async fn hover_for_include(
645        &self,
646        uri: &Url,
647        document: &Document,
648        position: AstPosition,
649    ) -> Option<Hover> {
650        let annotation = lex_analysis::utils::find_annotation_at_position(document, position)?;
651        if !annotation.is_include() {
652            return None;
653        }
654        let src = annotation.include_src()?;
655
656        let host_path = absolutize_path(&uri.to_file_path().ok()?);
657        let cfg = self.config.read().await;
658        let inc_root = inc_root_for(&host_path, &cfg.config);
659        drop(cfg);
660
661        let target = lex_core::lex::includes::resolve_file_reference(
662            &src,
663            Some(host_path.as_path()),
664            inc_root.as_path(),
665        )
666        .ok()?;
667
668        let loader = FsLoader::new(inc_root.clone());
669        let loaded = lex_core::lex::includes::Loader::load(&loader, target.as_path()).ok()?;
670        let preview = include_preview_markdown(&src, &target, &loaded.source);
671
672        Some(Hover {
673            contents: HoverContents::Markup(MarkupContent {
674                kind: MarkupKind::Markdown,
675                value: preview,
676            }),
677            range: Some(to_lsp_range(annotation.header_location())),
678        })
679    }
680
681    async fn document(&self, uri: &Url) -> Option<Arc<Document>> {
682        self.document_entry(uri).await.map(|entry| entry.document)
683    }
684
685    #[allow(deprecated)]
686    async fn update_workspace_roots(&self, params: &InitializeParams) {
687        let mut roots = Vec::new();
688
689        if let Some(folders) = params.workspace_folders.as_ref() {
690            for folder in folders {
691                if let Ok(path) = folder.uri.to_file_path() {
692                    roots.push(path);
693                }
694            }
695        }
696
697        if roots.is_empty() {
698            if let Some(root_uri) = params.root_uri.as_ref() {
699                if let Ok(path) = root_uri.to_file_path() {
700                    roots.push(path);
701                }
702            } else if let Some(root_path) = params.root_path.as_ref() {
703                roots.push(PathBuf::from(root_path));
704            } else if let Ok(current_dir) = std::env::current_dir() {
705                roots.push(current_dir);
706            }
707        }
708
709        *self.workspace_roots.write().await = roots;
710    }
711
712    async fn workspace_context_for_uri(&self, uri: &Url) -> Option<CompletionWorkspace> {
713        let document_path = uri.to_file_path().ok()?;
714        let roots = self.workspace_roots.read().await;
715        let project_root = best_matching_root(&roots, &document_path)
716            .or_else(|| document_directory_from_uri(uri))
717            .or_else(|| document_path.parent().map(|path| path.to_path_buf()))
718            .unwrap_or_else(|| document_path.clone());
719
720        Some(CompletionWorkspace {
721            project_root,
722            document_path,
723        })
724    }
725
726    /// Build formatting rules from stored config, with per-request LSP overrides on top.
727    async fn resolve_formatting_rules(&self, options: &FormattingOptions) -> FormattingRules {
728        let config = self.config.read().await;
729        let mut rules = FormattingRules::from(&config.config.formatting.rules);
730
731        // Layer per-request LSP overrides (editors can send lex.* properties)
732        apply_formatting_overrides(&mut rules, options);
733
734        rules
735    }
736}
737
738/// Load a [`LoadedLexConfig`] via clapfig, searching from an optional
739/// workspace root. The wrapper carries both the typed [`LexConfig`] and
740/// the side-channel map of extension-emitted diagnostic rules captured
741/// from `[diagnostics.rules]` via the `on_unknown_key` callback.
742fn load_config(workspace_root: Option<&Path>) -> LoadedLexConfig {
743    let mut search_paths = vec![SearchPath::Platform];
744    if let Some(root) = workspace_root {
745        search_paths.push(SearchPath::Path(root.to_path_buf()));
746    } else {
747        search_paths.push(SearchPath::Ancestors(Boundary::Marker(".git")));
748        search_paths.push(SearchPath::Cwd);
749    }
750    load_with(search_paths, false).unwrap_or_else(|_| {
751        // Fall back to compiled defaults if config loading fails.
752        load_with(vec![], true).expect("compiled defaults must load")
753    })
754}
755
756fn load_with(
757    search_paths: Vec<SearchPath>,
758    no_env: bool,
759) -> std::result::Result<LoadedLexConfig, clapfig::ClapfigError> {
760    let mut builder = Clapfig::schema_builder::<LexConfig>()
761        .app_name("lex")
762        .file_name(CONFIG_FILE_NAME)
763        .search_paths(search_paths)
764        .accept_dotted_extension_keys_in(
765            DIAGNOSTICS_RULES_PATH,
766            clapfig::UnknownKeyDecision::Collect,
767        );
768    if no_env {
769        builder = builder.no_env();
770    }
771    let (config, unknowns) = builder.load_with_unknowns()?;
772    Ok(LoadedLexConfig {
773        config,
774        extension_diagnostic_rules: collect_extension_diagnostic_rules(unknowns),
775    })
776}
777
778fn best_matching_root(roots: &[PathBuf], document_path: &Path) -> Option<PathBuf> {
779    roots
780        .iter()
781        .filter(|root| document_path.starts_with(root))
782        .max_by_key(|root| root.components().count())
783        .cloned()
784}
785
786fn to_lsp_position(position: &AstPosition) -> Position {
787    Position::new(position.line as u32, position.column as u32)
788}
789
790fn to_lsp_range(range: &AstRange) -> Range {
791    Range {
792        start: to_lsp_position(&range.start),
793        end: to_lsp_position(&range.end),
794    }
795}
796
797fn to_lsp_location(uri: &Url, range: &AstRange) -> Location {
798    Location {
799        uri: uri.clone(),
800        range: to_lsp_range(range),
801    }
802}
803
804fn spans_to_text_edits(text: &str, spans: Vec<TextEditSpan>) -> Vec<TextEdit> {
805    if spans.is_empty() {
806        return Vec::new();
807    }
808    let locator = SourceLocation::new(text);
809    spans
810        .into_iter()
811        .map(|span| TextEdit {
812            range: Range {
813                start: to_lsp_position(&locator.byte_to_position(span.start)),
814                end: to_lsp_position(&locator.byte_to_position(span.end)),
815            },
816            new_text: span.new_text,
817        })
818        .collect()
819}
820
821fn to_formatting_line_range(range: &Range) -> FormattingLineRange {
822    let start = range.start.line as usize;
823    let mut end = range.end.line as usize;
824    if range.end.character > 0 || end == start {
825        end += 1;
826    }
827    FormattingLineRange { start, end }
828}
829
830use lsp_types::{FormattingOptions, FormattingProperty};
831
832/// Apply per-request LSP overrides onto existing formatting rules.
833///
834/// Clients can pass custom Lex formatting options through the `properties` field
835/// of FormattingOptions. Supported keys (all under "lex." prefix):
836/// - lex.session_blank_lines_before
837/// - lex.session_blank_lines_after
838/// - lex.normalize_seq_markers
839/// - lex.unordered_seq_marker
840/// - lex.max_blank_lines
841/// - lex.indent_string
842/// - lex.preserve_trailing_blanks
843/// - lex.normalize_verbatim_markers
844fn apply_formatting_overrides(rules: &mut FormattingRules, options: &FormattingOptions) {
845    for (key, value) in &options.properties {
846        match key.as_str() {
847            "lex.session_blank_lines_before" => {
848                if let FormattingProperty::Number(n) = value {
849                    rules.session_blank_lines_before = (*n).max(0) as usize;
850                }
851            }
852            "lex.session_blank_lines_after" => {
853                if let FormattingProperty::Number(n) = value {
854                    rules.session_blank_lines_after = (*n).max(0) as usize;
855                }
856            }
857            "lex.normalize_seq_markers" => {
858                if let FormattingProperty::Bool(b) = value {
859                    rules.normalize_seq_markers = *b;
860                }
861            }
862            "lex.unordered_seq_marker" => {
863                if let FormattingProperty::String(s) = value {
864                    if let Some(c) = s.chars().next() {
865                        rules.unordered_seq_marker = c;
866                    }
867                }
868            }
869            "lex.max_blank_lines" => {
870                if let FormattingProperty::Number(n) = value {
871                    rules.max_blank_lines = (*n).max(0) as usize;
872                }
873            }
874            "lex.indent_string" => {
875                if let FormattingProperty::String(s) = value {
876                    rules.indent_string = s.clone();
877                }
878            }
879            "lex.preserve_trailing_blanks" => {
880                if let FormattingProperty::Bool(b) = value {
881                    rules.preserve_trailing_blanks = *b;
882                }
883            }
884            "lex.normalize_verbatim_markers" => {
885                if let FormattingProperty::Bool(b) = value {
886                    rules.normalize_verbatim_markers = *b;
887                }
888            }
889            _ => {}
890        }
891    }
892}
893
894fn from_lsp_position(position: Position) -> AstPosition {
895    AstPosition::new(position.line as usize, position.character as usize)
896}
897
898fn encode_semantic_tokens(tokens: &[LexSemanticToken], text: &str) -> Vec<SemanticToken> {
899    let line_offsets = compute_line_offsets(text);
900    let mut data = Vec::new();
901    let mut prev_line = 0u32;
902    let mut prev_start = 0u32;
903
904    for token in tokens {
905        let token_type_index = SEMANTIC_TOKEN_KINDS
906            .iter()
907            .position(|kind| *kind == token.kind)
908            .unwrap_or(0) as u32;
909        for (line, start, length) in split_token_on_lines(token, text, &line_offsets) {
910            if length == 0 {
911                continue;
912            }
913            let delta_line = line.saturating_sub(prev_line);
914            let delta_start = if delta_line == 0 {
915                start.saturating_sub(prev_start)
916            } else {
917                start
918            };
919            data.push(SemanticToken {
920                delta_line,
921                delta_start,
922                length,
923                token_type: token_type_index,
924                token_modifiers_bitset: 0,
925            });
926            prev_line = line;
927            prev_start = start;
928        }
929    }
930
931    data
932}
933
934fn compute_line_offsets(text: &str) -> Vec<usize> {
935    let mut offsets = vec![0];
936    for (idx, ch) in text.char_indices() {
937        if ch == '\n' {
938            offsets.push(idx + ch.len_utf8());
939        }
940    }
941    offsets
942}
943
944/// Expand a semantic token range into single-line segments.
945///
946/// The LSP wire format encodes tokens as delta positions relative to the previous token
947/// and disallows spanning multiple lines, so every multi-line range must be broken into
948/// per-line slices before encoding.
949fn split_token_on_lines(
950    token: &LexSemanticToken,
951    text: &str,
952    line_offsets: &[usize],
953) -> Vec<(u32, u32, u32)> {
954    let span = &token.range.span;
955    if span.start > text.len() || span.end > text.len() {
956        // Defensive: skip tokens whose byte span exceeds the source text.
957        // This can happen when the parser produces out-of-bounds ranges.
958        return Vec::new();
959    }
960    let slice = &text[span.clone()];
961    let mut segments = Vec::new();
962    let mut current_line = token.range.start.line as u32;
963    let mut segment_start = 0;
964    let base_offset = token.range.span.start;
965
966    for (idx, ch) in slice.char_indices() {
967        if ch == '\n' {
968            if idx > segment_start {
969                let length = (idx - segment_start) as u32;
970                let absolute_start = base_offset + segment_start;
971                let line_offset = line_offsets
972                    .get(current_line as usize)
973                    .copied()
974                    .unwrap_or(0);
975                let start_col = (absolute_start.saturating_sub(line_offset)) as u32;
976                segments.push((current_line, start_col, length));
977            }
978            current_line += 1;
979            segment_start = idx + ch.len_utf8();
980        }
981    }
982
983    if slice.len() > segment_start {
984        let length = (slice.len() - segment_start) as u32;
985        let absolute_start = base_offset + segment_start;
986        let line_offset = line_offsets
987            .get(current_line as usize)
988            .copied()
989            .unwrap_or(0);
990        let start_col = (absolute_start.saturating_sub(line_offset)) as u32;
991        segments.push((current_line, start_col, length));
992    }
993
994    segments
995}
996
997#[allow(deprecated)]
998fn to_document_symbol(symbol: &LexDocumentSymbol) -> DocumentSymbol {
999    DocumentSymbol {
1000        name: symbol.name.clone(),
1001        detail: symbol.detail.clone(),
1002        kind: symbol.kind,
1003        deprecated: None,
1004        range: to_lsp_range(&symbol.range),
1005        selection_range: to_lsp_range(&symbol.selection_range),
1006        children: if symbol.children.is_empty() {
1007            None
1008        } else {
1009            Some(symbol.children.iter().map(to_document_symbol).collect())
1010        },
1011        tags: None,
1012    }
1013}
1014
1015fn to_lsp_folding_range(range: &LexFoldingRange) -> FoldingRange {
1016    FoldingRange {
1017        start_line: range.start_line,
1018        start_character: range.start_character,
1019        end_line: range.end_line,
1020        end_character: range.end_character,
1021        kind: range.kind.clone(),
1022        collapsed_text: None,
1023    }
1024}
1025
1026fn to_lsp_completion_item(candidate: &CompletionCandidate) -> CompletionItem {
1027    CompletionItem {
1028        label: candidate.label.clone(),
1029        kind: Some(candidate.kind),
1030        detail: candidate.detail.clone(),
1031        insert_text: candidate.insert_text.clone(),
1032        ..Default::default()
1033    }
1034}
1035
1036fn build_document_link(uri: &Url, link: &AstDocumentLink) -> Option<DocumentLink> {
1037    let target = link_target_uri(uri, link)?;
1038    Some(DocumentLink {
1039        range: to_lsp_range(&link.range),
1040        target: Some(target),
1041        tooltip: None,
1042        data: None,
1043    })
1044}
1045
1046fn link_target_uri(document_uri: &Url, link: &AstDocumentLink) -> Option<Url> {
1047    match link.link_type {
1048        LinkType::Url => Url::parse(&link.target).ok(),
1049        LinkType::File | LinkType::VerbatimSrc => {
1050            resolve_file_like_target(document_uri, &link.target)
1051        }
1052    }
1053}
1054
1055fn resolve_file_like_target(document_uri: &Url, target: &str) -> Option<Url> {
1056    if target.is_empty() {
1057        return None;
1058    }
1059    let path = Path::new(target);
1060    if path.is_absolute() {
1061        return Url::from_file_path(path).ok();
1062    }
1063    if document_uri.scheme() == "file" {
1064        let mut base = document_uri.to_file_path().ok()?;
1065        base.pop();
1066        base.push(target);
1067        Url::from_file_path(base).ok()
1068    } else {
1069        parent_directory_uri(document_uri).join(target).ok()
1070    }
1071}
1072
1073fn parent_directory_uri(uri: &Url) -> Url {
1074    let mut base = uri.clone();
1075    let mut path = base.path().to_string();
1076    if let Some(idx) = path.rfind('/') {
1077        path.truncate(idx + 1);
1078    } else {
1079        path.push('/');
1080    }
1081    base.set_path(&path);
1082    base.set_query(None);
1083    base.set_fragment(None);
1084    base
1085}
1086
1087#[async_trait]
1088impl<C, P> tower_lsp::LanguageServer for LexLanguageServer<C, P>
1089where
1090    C: LspClient,
1091    P: FeatureProvider,
1092{
1093    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
1094        self.update_workspace_roots(&params).await;
1095
1096        // Reload config now that we know the workspace root
1097        {
1098            let roots = self.workspace_roots.read().await;
1099            let root = roots.first().map(|p| p.as_path());
1100            *self.config.write().await = load_config(root);
1101        }
1102
1103        let capabilities = ServerCapabilities {
1104            text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
1105            hover_provider: Some(HoverProviderCapability::Simple(true)),
1106            document_symbol_provider: Some(OneOf::Left(true)),
1107            folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
1108            definition_provider: Some(OneOf::Left(true)),
1109            references_provider: Some(OneOf::Left(true)),
1110            document_link_provider: Some(DocumentLinkOptions {
1111                work_done_progress_options: WorkDoneProgressOptions::default(),
1112                resolve_provider: Some(false),
1113            }),
1114            code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
1115            completion_provider: Some(CompletionOptions {
1116                resolve_provider: Some(false),
1117                trigger_characters: Some(vec![
1118                    "[".to_string(),
1119                    ":".to_string(),
1120                    "=".to_string(),
1121                    "@".to_string(),
1122                ]),
1123                work_done_progress_options: WorkDoneProgressOptions::default(),
1124                all_commit_characters: None,
1125                ..Default::default()
1126            }),
1127            document_formatting_provider: Some(OneOf::Left(true)),
1128            document_range_formatting_provider: Some(OneOf::Left(true)),
1129            semantic_tokens_provider: Some(
1130                lsp_types::SemanticTokensServerCapabilities::SemanticTokensOptions(
1131                    SemanticTokensOptions {
1132                        work_done_progress_options: WorkDoneProgressOptions::default(),
1133                        legend: semantic_tokens_legend(),
1134                        range: None,
1135                        full: Some(SemanticTokensFullOptions::Bool(true)),
1136                    },
1137                ),
1138            ),
1139            execute_command_provider: Some(ExecuteCommandOptions {
1140                commands: vec![
1141                    commands::COMMAND_ECHO.to_string(),
1142                    commands::COMMAND_IMPORT.to_string(),
1143                    commands::COMMAND_EXPORT.to_string(),
1144                    commands::COMMAND_NEXT_ANNOTATION.to_string(),
1145                    commands::COMMAND_PREVIOUS_ANNOTATION.to_string(),
1146                    commands::COMMAND_RESOLVE_ANNOTATION.to_string(),
1147                    commands::COMMAND_TOGGLE_ANNOTATIONS.to_string(),
1148                    commands::COMMAND_INSERT_ASSET.to_string(),
1149                    commands::COMMAND_INSERT_VERBATIM.to_string(),
1150                    commands::COMMAND_FOOTNOTES_REORDER.to_string(),
1151                    commands::COMMAND_TABLE_FORMAT.to_string(),
1152                    commands::COMMAND_TABLE_NEXT_CELL.to_string(),
1153                    commands::COMMAND_TABLE_PREVIOUS_CELL.to_string(),
1154                    commands::COMMAND_FORMATS_LIST.to_string(),
1155                    commands::COMMAND_EXTRACT_TO_INCLUDE.to_string(),
1156                ],
1157                work_done_progress_options: WorkDoneProgressOptions::default(),
1158            }),
1159            workspace: Some(lsp_types::WorkspaceServerCapabilities {
1160                workspace_folders: Some(WorkspaceFoldersServerCapabilities {
1161                    supported: Some(true),
1162                    change_notifications: Some(OneOf::Left(true)),
1163                }),
1164                file_operations: None,
1165            }),
1166            // Advertise the custom `lex/preparePaste` request under
1167            // `experimental` so editors enable paste interception only against a
1168            // server that implements smart paste (comms#73 §5); a server without
1169            // this flag falls back to native paste.
1170            experimental: Some(json!({ "lexPreparePaste": true })),
1171            ..ServerCapabilities::default()
1172        };
1173
1174        Ok(InitializeResult {
1175            capabilities,
1176            server_info: Some(ServerInfo {
1177                name: "lexd-lsp".to_string(),
1178                version: Some(env!("CARGO_PKG_VERSION").to_string()),
1179            }),
1180        })
1181    }
1182
1183    async fn initialized(&self, _: InitializedParams) {}
1184
1185    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
1186        let mut roots = self.workspace_roots.write().await;
1187
1188        // Remove old folders
1189        for removed in &params.event.removed {
1190            if let Ok(path) = removed.uri.to_file_path() {
1191                roots.retain(|r| r != &path);
1192            }
1193        }
1194
1195        // Add new folders
1196        for added in &params.event.added {
1197            if let Ok(path) = added.uri.to_file_path() {
1198                if !roots.contains(&path) {
1199                    roots.push(path);
1200                }
1201            }
1202        }
1203
1204        // Reload config from the first (primary) root
1205        drop(roots);
1206        let roots = self.workspace_roots.read().await;
1207        let root = roots.first().map(|p| p.as_path());
1208        *self.config.write().await = load_config(root);
1209
1210        // Workspace shape changed — drop the cached extension registry
1211        // so the next request rebuilds it against the new root + config.
1212        self.invalidate_extension_state().await;
1213    }
1214
1215    async fn shutdown(&self) -> Result<()> {
1216        Ok(())
1217    }
1218
1219    async fn did_open(&self, params: lsp_types::DidOpenTextDocumentParams) {
1220        let TextDocumentItem { uri, text, .. } = params.text_document;
1221        self.parse_and_store(uri, text).await;
1222    }
1223
1224    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
1225        // Reload config from disk (e.g. .lex.toml changed)
1226        {
1227            let roots = self.workspace_roots.read().await;
1228            let root = roots.first().map(|p| p.as_path());
1229            *self.config.write().await = load_config(root);
1230        }
1231
1232        // Config changed — `[labels]` may have grown / shrunk; drop the
1233        // cached extension registry so the next request rebuilds it.
1234        self.invalidate_extension_state().await;
1235
1236        // Re-check all documents with new settings
1237        let uris: Vec<Url> = self
1238            .documents
1239            .entries
1240            .read()
1241            .await
1242            .keys()
1243            .cloned()
1244            .collect();
1245
1246        for uri in uris {
1247            if let Some(entry) = self.documents.get(&uri).await {
1248                self.parse_and_store(uri, entry.text.to_string()).await;
1249            }
1250        }
1251    }
1252    async fn did_change(&self, params: lsp_types::DidChangeTextDocumentParams) {
1253        if let Some(change) = params.content_changes.into_iter().last() {
1254            self.parse_and_store(params.text_document.uri, change.text)
1255                .await;
1256        }
1257    }
1258
1259    async fn did_close(&self, params: lsp_types::DidCloseTextDocumentParams) {
1260        self.documents.remove(&params.text_document.uri).await;
1261    }
1262
1263    async fn semantic_tokens_full(
1264        &self,
1265        params: SemanticTokensParams,
1266    ) -> Result<Option<SemanticTokensResult>> {
1267        if let Some(entry) = self.document_entry(&params.text_document.uri).await {
1268            let DocumentEntry { document, text } = entry;
1269            let tokens = self.features.semantic_tokens(&document);
1270            let data = encode_semantic_tokens(&tokens, text.as_str());
1271            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1272                result_id: None,
1273                data,
1274            })))
1275        } else {
1276            Ok(None)
1277        }
1278    }
1279
1280    async fn document_symbol(
1281        &self,
1282        params: DocumentSymbolParams,
1283    ) -> Result<Option<DocumentSymbolResponse>> {
1284        if let Some(document) = self.document(&params.text_document.uri).await {
1285            let symbols = self.features.document_symbols(&document);
1286            let converted: Vec<DocumentSymbol> = symbols.iter().map(to_document_symbol).collect();
1287            Ok(Some(DocumentSymbolResponse::Nested(converted)))
1288        } else {
1289            Ok(None)
1290        }
1291    }
1292
1293    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1294        let uri = &params.text_document_position_params.text_document.uri;
1295        if let Some(document) = self.document(uri).await {
1296            let position = from_lsp_position(params.text_document_position_params.position);
1297
1298            // Include-aware short-circuit: if the cursor is on a
1299            // `lex.include` annotation, render a preview of the
1300            // target file's title + first paragraph instead of falling
1301            // through to the generic hover. This is the editor UX win
1302            // — author can peek the chapter without navigating away.
1303            if let Some(hover) = self.hover_for_include(uri, &document, position).await {
1304                return Ok(Some(hover));
1305            }
1306
1307            // Extension dispatch: ask any registered third-party
1308            // namespace's handler for hover content at this position.
1309            // Takes precedence over the built-in hover when it returns
1310            // Some — the handler authored the label, it knows the most
1311            // about what to show.
1312            if let Some(state) = self.extension_state().await {
1313                if let Some(hover) =
1314                    ext_dispatch_hover(&document, position, state.registry.as_ref())
1315                {
1316                    return Ok(Some(hover));
1317                }
1318            }
1319
1320            if let Some(result) = self.features.hover(&document, position) {
1321                return Ok(Some(Hover {
1322                    contents: HoverContents::Markup(MarkupContent {
1323                        kind: MarkupKind::Markdown,
1324                        value: result.contents,
1325                    }),
1326                    range: Some(to_lsp_range(&result.range)),
1327                }));
1328            }
1329        }
1330        Ok(None)
1331    }
1332
1333    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1334        if let Some(document) = self.document(&params.text_document.uri).await {
1335            let ranges = self.features.folding_ranges(&document);
1336            Ok(Some(ranges.iter().map(to_lsp_folding_range).collect()))
1337        } else {
1338            Ok(None)
1339        }
1340    }
1341
1342    async fn goto_definition(
1343        &self,
1344        params: GotoDefinitionParams,
1345    ) -> Result<Option<GotoDefinitionResponse>> {
1346        let uri = params.text_document_position_params.text_document.uri;
1347        if let Some(document) = self.document(&uri).await {
1348            let position = from_lsp_position(params.text_document_position_params.position);
1349
1350            // Include-aware short-circuit: if cursor is on a
1351            // `lex.include` annotation, jump to the target file rather
1352            // than running the in-document goto logic (which only
1353            // returns Ranges, can't cross files).
1354            if let Some(loc) = self.goto_for_include(&uri, &document, position).await {
1355                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1356            }
1357
1358            let ranges = self.features.goto_definition(&document, position);
1359            if ranges.is_empty() {
1360                Ok(None)
1361            } else {
1362                let locations: Vec<Location> = ranges
1363                    .iter()
1364                    .map(|range| to_lsp_location(&uri, range))
1365                    .collect();
1366                Ok(Some(GotoDefinitionResponse::Array(locations)))
1367            }
1368        } else {
1369            Ok(None)
1370        }
1371    }
1372
1373    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1374        let uri = params.text_document_position.text_document.uri;
1375        if let Some(document) = self.document(&uri).await {
1376            let position = from_lsp_position(params.text_document_position.position);
1377            let include_declaration = params.context.include_declaration;
1378            let ranges = self
1379                .features
1380                .references(&document, position, include_declaration);
1381            if ranges.is_empty() {
1382                Ok(None)
1383            } else {
1384                Ok(Some(
1385                    ranges
1386                        .iter()
1387                        .map(|range| to_lsp_location(&uri, range))
1388                        .collect(),
1389                ))
1390            }
1391        } else {
1392            Ok(None)
1393        }
1394    }
1395
1396    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1397        let uri = params.text_document.uri;
1398        if let Some(document) = self.document(&uri).await {
1399            let links = self.features.document_links(&document);
1400            let resolved: Vec<DocumentLink> = links
1401                .iter()
1402                .filter_map(|link| build_document_link(&uri, link))
1403                .collect();
1404            Ok(Some(resolved))
1405        } else {
1406            Ok(None)
1407        }
1408    }
1409
1410    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1411        let uri = params.text_document.uri;
1412        if let Some(entry) = self.document_entry(&uri).await {
1413            let DocumentEntry { document, text } = entry;
1414            let rules = self.resolve_formatting_rules(&params.options).await;
1415            let edits = self
1416                .features
1417                .format_document(&document, text.as_str(), Some(rules));
1418            Ok(Some(spans_to_text_edits(text.as_str(), edits)))
1419        } else {
1420            Ok(None)
1421        }
1422    }
1423
1424    async fn range_formatting(
1425        &self,
1426        params: DocumentRangeFormattingParams,
1427    ) -> Result<Option<Vec<TextEdit>>> {
1428        let uri = params.text_document.uri;
1429        if let Some(entry) = self.document_entry(&uri).await {
1430            let DocumentEntry { document, text } = entry;
1431            let line_range = to_formatting_line_range(&params.range);
1432            let rules = self.resolve_formatting_rules(&params.options).await;
1433            let edits =
1434                self.features
1435                    .format_range(&document, text.as_str(), line_range, Some(rules));
1436            Ok(Some(spans_to_text_edits(text.as_str(), edits)))
1437        } else {
1438            Ok(None)
1439        }
1440    }
1441
1442    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
1443        let uri = params.text_document_position.text_document.uri;
1444        if let Some(entry) = self.document_entry(&uri).await {
1445            let DocumentEntry { document, text } = entry;
1446            let position = from_lsp_position(params.text_document_position.position);
1447            let workspace = self.workspace_context_for_uri(&uri).await;
1448
1449            // Extract trigger character from context
1450            let trigger_char = params
1451                .context
1452                .as_ref()
1453                .and_then(|ctx| ctx.trigger_character.as_deref());
1454
1455            // Extract current line text for resilient parsing (e.g. "::" without following newline)
1456            let current_line = text.lines().nth(position.line);
1457
1458            let candidates = self.features.completion(
1459                &document,
1460                position,
1461                current_line,
1462                workspace.as_ref(),
1463                trigger_char,
1464            );
1465            let mut items: Vec<CompletionItem> =
1466                candidates.iter().map(to_lsp_completion_item).collect();
1467
1468            // Extension dispatch: append handler-supplied completions.
1469            // Additive — the built-in items still appear so the user
1470            // doesn't lose access to footnote/reference/snippet
1471            // completions when an extension is loaded.
1472            if let Some(state) = self.extension_state().await {
1473                items.extend(ext_dispatch_completion(
1474                    &document,
1475                    position,
1476                    state.registry.as_ref(),
1477                ));
1478            }
1479
1480            Ok(Some(CompletionResponse::Array(items)))
1481        } else {
1482            Ok(None)
1483        }
1484    }
1485
1486    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
1487        let mut actions = Vec::new();
1488
1489        let document_uri = params.text_document.uri.clone();
1490        if let Some(entry) = self.documents.get(&document_uri).await {
1491            let lex_actions = crate::features::available_actions::compute_actions(
1492                &entry.document,
1493                &entry.text,
1494                &params,
1495            );
1496            for action in lex_actions {
1497                actions.push(tower_lsp::lsp_types::CodeActionOrCommand::CodeAction(
1498                    action,
1499                ));
1500            }
1501
1502            // Extension dispatch: append handler-supplied code actions
1503            // for the labelled node under the request's selection. The
1504            // request's range start is the position we use to locate
1505            // the labelled node — `compute_actions` already operates
1506            // off the same anchor.
1507            if let Some(state) = self.extension_state().await {
1508                let start = from_lsp_position(params.range.start);
1509                for action in ext_dispatch_code_action(
1510                    &entry.document,
1511                    start,
1512                    &document_uri,
1513                    state.registry.as_ref(),
1514                ) {
1515                    actions.push(tower_lsp::lsp_types::CodeActionOrCommand::CodeAction(
1516                        action,
1517                    ));
1518                }
1519            }
1520        }
1521
1522        if actions.is_empty() {
1523            Ok(None)
1524        } else {
1525            Ok(Some(actions))
1526        }
1527    }
1528
1529    async fn execute_command(&self, params: ExecuteCommandParams) -> Result<Option<Value>> {
1530        let command = params.command.as_str();
1531        match command {
1532            commands::COMMAND_NEXT_ANNOTATION | commands::COMMAND_PREVIOUS_ANNOTATION => {
1533                let uri_str = params.arguments.first().and_then(|v| v.as_str());
1534                let pos_val = params.arguments.get(1);
1535
1536                if let (Some(uri_str), Some(pos_val)) = (uri_str, pos_val) {
1537                    if let Ok(uri) = Url::parse(uri_str) {
1538                        if let Ok(position) = serde_json::from_value::<Position>(pos_val.clone()) {
1539                            if let Some(document) = self.document(&uri).await {
1540                                let ast_pos = from_lsp_position(position);
1541                                let navigation = if command == commands::COMMAND_NEXT_ANNOTATION {
1542                                    lex_analysis::annotations::next_annotation(&document, ast_pos)
1543                                } else {
1544                                    lex_analysis::annotations::previous_annotation(
1545                                        &document, ast_pos,
1546                                    )
1547                                };
1548
1549                                if let Some(result) = navigation {
1550                                    let location = to_lsp_location(&uri, &result.header);
1551                                    return Ok(Some(
1552                                        serde_json::to_value(location)
1553                                            .map_err(|_| Error::internal_error())?,
1554                                    ));
1555                                }
1556                            }
1557                        }
1558                    }
1559                }
1560                Ok(None)
1561            }
1562            commands::COMMAND_RESOLVE_ANNOTATION | commands::COMMAND_TOGGLE_ANNOTATIONS => {
1563                let uri_str = params.arguments.first().and_then(|v| v.as_str());
1564                let pos_val = params.arguments.get(1);
1565
1566                if let (Some(uri_str), Some(pos_val)) = (uri_str, pos_val) {
1567                    if let Ok(uri) = Url::parse(uri_str) {
1568                        if let Ok(position) = serde_json::from_value::<Position>(pos_val.clone()) {
1569                            if let Some(document) = self.document(&uri).await {
1570                                let ast_pos = from_lsp_position(position);
1571                                let _resolved = command == commands::COMMAND_RESOLVE_ANNOTATION;
1572
1573                                // For toggle, we need to check current status, but lex-analysis toggle takes a boolean "resolved".
1574                                // Wait, lex-analysis toggle_annotation_resolution takes "resolved: bool".
1575                                // If we want to toggle, we need to know current state.
1576                                // But the command name "toggle_annotations" implies switching.
1577                                // Let's check lex-analysis signature again.
1578                                // toggle_annotation_resolution(doc, pos, resolved) -> Option<Edit>
1579                                // It sets status=resolved if resolved=true, removes it if false.
1580                                // So "resolve" command should pass true.
1581                                // "toggle" command needs to check if it's currently resolved and flip it.
1582
1583                                let target_state =
1584                                    if command == commands::COMMAND_RESOLVE_ANNOTATION {
1585                                        true
1586                                    } else {
1587                                        // Check if currently resolved
1588                                        if let Some(annotation) =
1589                                            lex_analysis::utils::find_annotation_at_position(
1590                                                &document, ast_pos,
1591                                            )
1592                                        {
1593                                            let is_resolved =
1594                                                annotation.data.parameters.iter().any(|p| {
1595                                                    p.key == "status" && p.value == "resolved"
1596                                                });
1597                                            !is_resolved
1598                                        } else {
1599                                            return Ok(None);
1600                                        }
1601                                    };
1602
1603                                if let Some(edit) =
1604                                    lex_analysis::annotations::toggle_annotation_resolution(
1605                                        &document,
1606                                        ast_pos,
1607                                        target_state,
1608                                    )
1609                                {
1610                                    let text_edit = TextEdit {
1611                                        range: to_lsp_range(&edit.range),
1612                                        new_text: edit.new_text,
1613                                    };
1614                                    let mut changes = HashMap::new();
1615                                    changes.insert(uri, vec![text_edit]);
1616                                    let workspace_edit = tower_lsp::lsp_types::WorkspaceEdit {
1617                                        changes: Some(changes),
1618                                        ..Default::default()
1619                                    };
1620                                    return Ok(Some(
1621                                        serde_json::to_value(workspace_edit)
1622                                            .map_err(|_| Error::internal_error())?,
1623                                    ));
1624                                }
1625                            }
1626                        }
1627                    }
1628                }
1629                Ok(None)
1630            }
1631            commands::COMMAND_INSERT_ASSET => {
1632                let uri_str = params.arguments.first().and_then(|v| v.as_str());
1633                let pos_val = params.arguments.get(1);
1634                let path_val = params.arguments.get(2).and_then(|v| v.as_str());
1635
1636                if let (Some(uri_str), Some(pos_val), Some(path)) = (uri_str, pos_val, path_val) {
1637                    if let Ok(uri) = Url::parse(uri_str) {
1638                        if let Ok(position) = serde_json::from_value::<Position>(pos_val.clone()) {
1639                            let file_path = PathBuf::from(path);
1640                            let rules = FormattingRules::default();
1641                            let entry = self.document_entry(&uri).await;
1642                            let indent_level = entry
1643                                .as_ref()
1644                                .map(|entry| indent_level_from_position(entry, &position, &rules))
1645                                .unwrap_or(0);
1646                            let document_directory = document_directory_from_uri(&uri);
1647                            let snippet = {
1648                                let request = AssetSnippetRequest {
1649                                    asset_path: file_path.as_path(),
1650                                    document_directory: document_directory.as_deref(),
1651                                    formatting: &rules,
1652                                    indent_level,
1653                                };
1654                                build_asset_snippet(&request)
1655                            };
1656
1657                            return Ok(Some(json!({
1658                                "text": snippet.text,
1659                                "cursorOffset": snippet.cursor_offset,
1660                            })));
1661                        }
1662                    }
1663                }
1664                Ok(None)
1665            }
1666            commands::COMMAND_INSERT_VERBATIM => {
1667                let uri_str = params.arguments.first().and_then(|v| v.as_str());
1668                let pos_val = params.arguments.get(1);
1669                let path_val = params.arguments.get(2).and_then(|v| v.as_str());
1670
1671                if let (Some(uri_str), Some(pos_val), Some(path)) = (uri_str, pos_val, path_val) {
1672                    if let Ok(uri) = Url::parse(uri_str) {
1673                        if let Ok(position) = serde_json::from_value::<Position>(pos_val.clone()) {
1674                            let file_path = PathBuf::from(path);
1675                            let rules = FormattingRules::default();
1676                            let entry = self.document_entry(&uri).await;
1677                            let indent_level = entry
1678                                .as_ref()
1679                                .map(|entry| indent_level_from_position(entry, &position, &rules))
1680                                .unwrap_or(0);
1681                            let document_directory = document_directory_from_uri(&uri);
1682                            let snippet_result = {
1683                                let mut request =
1684                                    VerbatimSnippetRequest::new(file_path.as_path(), &rules);
1685                                request.document_directory = document_directory.as_deref();
1686                                request.indent_level = indent_level;
1687                                build_verbatim_snippet(&request)
1688                            };
1689
1690                            match snippet_result {
1691                                Ok(snippet) => {
1692                                    return Ok(Some(json!({
1693                                        "text": snippet.text,
1694                                        "cursorOffset": snippet.cursor_offset,
1695                                    })));
1696                                }
1697                                Err(err) => {
1698                                    return Err(Error::invalid_params(format!(
1699                                        "Failed to insert verbatim block: {err}"
1700                                    )));
1701                                }
1702                            }
1703                        }
1704                    }
1705                }
1706                Ok(None)
1707            }
1708            commands::COMMAND_EXTRACT_TO_INCLUDE => {
1709                self.handle_extract_to_include(&params.arguments).await
1710            }
1711            _ => self
1712                .features
1713                .execute_command(&params.command, &params.arguments),
1714        }
1715    }
1716}
1717
1718impl<C, P> LexLanguageServer<C, P>
1719where
1720    C: LspClient,
1721    P: FeatureProvider,
1722{
1723    /// Handler for `lex.extractToInclude`. Args are **positional**:
1724    /// `[uri: string, range: lsp.Range, src: string]`.
1725    ///
1726    /// Returns a `WorkspaceEdit` JSON value the editor applies atomically
1727    /// (file creation + selection replacement). All validation failures
1728    /// surface as `invalid_params` errors carrying the
1729    /// [`ExtractError::message`] string for direct display.
1730    async fn handle_extract_to_include(&self, arguments: &[Value]) -> Result<Option<Value>> {
1731        let uri_str = arguments
1732            .first()
1733            .and_then(|v| v.as_str())
1734            .ok_or_else(|| Error::invalid_params("Missing 'uri' argument"))?;
1735        let range_val = arguments
1736            .get(1)
1737            .ok_or_else(|| Error::invalid_params("Missing 'range' argument"))?;
1738        let src = arguments
1739            .get(2)
1740            .and_then(|v| v.as_str())
1741            .ok_or_else(|| Error::invalid_params("Missing 'src' argument"))?;
1742
1743        let uri = Url::parse(uri_str)
1744            .map_err(|_| Error::invalid_params(format!("Invalid uri: {uri_str}")))?;
1745        let range: Range = serde_json::from_value(range_val.clone())
1746            .map_err(|e| Error::invalid_params(format!("Invalid range: {e}")))?;
1747
1748        let host_path = uri
1749            .to_file_path()
1750            .map_err(|_| Error::invalid_params(ExtractError::InvalidHostUri.message()))?;
1751        let host_path = absolutize_path(&host_path);
1752
1753        let entry = self
1754            .document_entry(&uri)
1755            .await
1756            .ok_or_else(|| Error::invalid_params("Document not open in the server"))?;
1757
1758        let selection_text = slice_text_by_range(&entry.text, range)
1759            .ok_or_else(|| Error::invalid_params("Selection range out of bounds"))?;
1760
1761        let host_indent = range.start.character as usize;
1762
1763        let cfg = self.config.read().await;
1764        let inc_root = inc_root_for(&host_path, &cfg.config);
1765        drop(cfg);
1766
1767        let edit = extract::build_extract_workspace_edit(
1768            &uri,
1769            &host_path,
1770            range,
1771            &selection_text,
1772            host_indent,
1773            src,
1774            &inc_root,
1775        )
1776        .map_err(|e| Error::invalid_params(e.message()))?;
1777
1778        Ok(Some(
1779            serde_json::to_value(edit).map_err(|_| Error::internal_error())?,
1780        ))
1781    }
1782
1783    /// Handler for the custom `lex/preparePaste` request (smart paste,
1784    /// comms#73). The editor sends the document identity, the range the paste
1785    /// replaces, and the raw clipboard text; the server reuses the
1786    /// already-parsed buffer state to classify the paste and re-anchor the
1787    /// clipboard to the caret's structural context, returning the text to
1788    /// splice across the range plus the [`PasteMode`] it applied.
1789    ///
1790    /// Pure with respect to document state: it reads the parse, computes a
1791    /// string, and mutates nothing. When the document is not open in the
1792    /// server (no parse to consult), the response echoes the clipboard back in
1793    /// `re-anchor` mode — a safe no-op so the editor still completes the paste.
1794    pub async fn prepare_paste(&self, params: PreparePasteParams) -> Result<PreparePasteResult> {
1795        let Some(entry) = self.document_entry(&params.text_document.uri).await else {
1796            // No parsed buffer to consult — hand the clipboard back unchanged
1797            // rather than fail; the editor applies it as an ordinary paste.
1798            return Ok(PreparePasteResult {
1799                text: params.pasted_text,
1800                mode: PasteMode::Reanchor,
1801            });
1802        };
1803
1804        Ok(prepare_paste_transform(
1805            &entry.document,
1806            &entry.text,
1807            params.range,
1808            &params.pasted_text,
1809        ))
1810    }
1811}
1812
1813/// Slice `text` by an LSP `Range`. Returns `None` when the range falls
1814/// outside the document or splits a multi-byte character.
1815///
1816/// `character` is treated as a **UTF-8 byte offset**, following the
1817/// crate's position-encoding convention: lex-core's
1818/// `SourceLocation::byte_to_position` computes
1819/// `column = byte_offset - line_start`, and `to_lsp_position` forwards
1820/// that value to LSP as-is. Using char offsets here would mis-slice any
1821/// selection containing multi-byte characters. See the crate-level
1822/// "Position Encoding" docs for the full convention (and its one known
1823/// straggler).
1824fn slice_text_by_range(text: &str, range: Range) -> Option<String> {
1825    let start_line = range.start.line as usize;
1826    let end_line = range.end.line as usize;
1827    let start_col = range.start.character as usize;
1828    let end_col = range.end.character as usize;
1829    if start_line > end_line || (start_line == end_line && start_col > end_col) {
1830        return None;
1831    }
1832
1833    let lines: Vec<&str> = text.split_inclusive('\n').collect();
1834    if end_line >= lines.len() && !(end_line == lines.len() && end_col == 0) {
1835        return None;
1836    }
1837
1838    let mut out = String::new();
1839    for (i, line) in lines.iter().enumerate() {
1840        if i < start_line || i > end_line {
1841            continue;
1842        }
1843        let line_bytes = line.as_bytes();
1844        let from = if i == start_line { start_col } else { 0 };
1845        let to = if i == end_line {
1846            end_col
1847        } else {
1848            line_bytes.len()
1849        };
1850        if from > line_bytes.len() || to > line_bytes.len() {
1851            return None;
1852        }
1853        // Reject ranges that cut a UTF-8 character in half rather than
1854        // returning a string with replacement characters.
1855        if !line.is_char_boundary(from) || !line.is_char_boundary(to) {
1856            return None;
1857        }
1858        out.push_str(&line[from..to]);
1859    }
1860    Some(out)
1861}
1862
1863/// Compute the include-resolution root for an entry document.
1864///
1865/// Order:
1866/// 1. `[includes].root` from `LexConfig` if set.
1867/// 2. Directory of the nearest `.lex.toml` walking upward from the
1868///    entry document's directory.
1869/// 3. The entry document's own directory.
1870///
1871/// Always returns an absolute, lexically-normalized path so the
1872/// resolver's root-escape prefix check is sound.
1873fn inc_root_for(entry_path: &Path, cfg: &LexConfig) -> PathBuf {
1874    let raw = if let Some(root) = cfg.includes.root.as_ref() {
1875        PathBuf::from(root)
1876    } else {
1877        let start = entry_path
1878            .parent()
1879            .map(Path::to_path_buf)
1880            .unwrap_or_else(|| PathBuf::from("."));
1881        find_nearest_config_dir(&start).unwrap_or(start)
1882    };
1883    absolutize_path(&raw)
1884}
1885
1886/// Walk upward from `start` looking for a directory that contains
1887/// `.lex.toml`. Returns that directory, or `None` if we hit the
1888/// filesystem root without finding one.
1889fn find_nearest_config_dir(start: &Path) -> Option<PathBuf> {
1890    let mut cur: PathBuf = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
1891    loop {
1892        if cur.join(CONFIG_FILE_NAME).is_file() {
1893            return Some(cur);
1894        }
1895        if !cur.pop() {
1896            return None;
1897        }
1898    }
1899}
1900
1901/// Best-effort absolutize: try `Path::canonicalize` first (handles
1902/// symlinks + resolves `..` against the real filesystem), falling back
1903/// to `current_dir().join(path)` if the path doesn't exist on disk.
1904/// Always returns an absolute path; `ResolveConfig::root` requires one
1905/// for the root-escape prefix check to be sound.
1906fn absolutize_path(p: &Path) -> PathBuf {
1907    if let Ok(canon) = p.canonicalize() {
1908        return canon;
1909    }
1910    if p.is_absolute() {
1911        return p.to_path_buf();
1912    }
1913    std::env::current_dir()
1914        .map(|cwd| cwd.join(p))
1915        .unwrap_or_else(|_| p.to_path_buf())
1916}
1917
1918/// Map an [`IncludeError`] to an LSP [`Diagnostic`].
1919///
1920/// The diagnostic's range points at the offending `lex.include`
1921/// annotation when the error carries one (Cycle, DepthExceeded,
1922/// NotFound, ContainerPolicy, MissingSrc, TotalIncludesExceeded,
1923/// FileTooLarge); otherwise it falls back to the document head
1924/// (line 0, column 0) so the user at least sees something in the
1925/// editor's diagnostics panel.
1926fn include_error_to_diagnostic(err: &IncludeError) -> Diagnostic {
1927    let (range, code, message) = match err {
1928        IncludeError::Cycle { include_site, .. } => {
1929            (to_lsp_range(include_site), "include-cycle", err.to_string())
1930        }
1931        IncludeError::DepthExceeded { include_site, .. } => (
1932            to_lsp_range(include_site),
1933            "include-depth-exceeded",
1934            err.to_string(),
1935        ),
1936        IncludeError::RootEscape { .. } => (head_range(), "include-root-escape", err.to_string()),
1937        IncludeError::AbsolutePath { .. } => {
1938            (head_range(), "include-absolute-path", err.to_string())
1939        }
1940        IncludeError::NotFound { include_site, .. } => (
1941            to_lsp_range(include_site),
1942            "include-not-found",
1943            err.to_string(),
1944        ),
1945        IncludeError::ParseFailed { .. } => (head_range(), "include-parse-failed", err.to_string()),
1946        IncludeError::ContainerPolicy { include_site, .. } => (
1947            to_lsp_range(include_site),
1948            "include-container-policy",
1949            err.to_string(),
1950        ),
1951        IncludeError::LoaderIo { .. } => (head_range(), "include-loader-io", err.to_string()),
1952        IncludeError::MissingSrc { include_site } => (
1953            to_lsp_range(include_site),
1954            "include-missing-src",
1955            err.to_string(),
1956        ),
1957        IncludeError::TotalIncludesExceeded { include_site, .. } => (
1958            to_lsp_range(include_site),
1959            "include-total-exceeded",
1960            err.to_string(),
1961        ),
1962        IncludeError::FileTooLarge { include_site, .. } => (
1963            to_lsp_range(include_site),
1964            "include-file-too-large",
1965            err.to_string(),
1966        ),
1967        IncludeError::HandlerFailed { include_site, .. } => (
1968            to_lsp_range(include_site),
1969            "include-handler-failed",
1970            err.to_string(),
1971        ),
1972    };
1973    Diagnostic {
1974        range,
1975        severity: Some(tower_lsp::lsp_types::DiagnosticSeverity::ERROR),
1976        code: Some(tower_lsp::lsp_types::NumberOrString::String(
1977            code.to_string(),
1978        )),
1979        code_description: None,
1980        source: Some("lex".to_string()),
1981        message,
1982        related_information: None,
1983        tags: None,
1984        data: None,
1985    }
1986}
1987
1988/// Synthesize a document-head diagnostic when registry registration
1989/// fails (e.g., another path of the LSP already registered the `lex`
1990/// namespace and we collided). This should never happen in practice
1991/// — we build a fresh `Registry` per resolve call — but the path is
1992/// here so a future regression surfaces an editor diagnostic rather
1993/// than a silent panic.
1994fn registry_setup_diagnostic(message: &str) -> Diagnostic {
1995    Diagnostic {
1996        range: head_range(),
1997        severity: Some(tower_lsp::lsp_types::DiagnosticSeverity::ERROR),
1998        code: Some(tower_lsp::lsp_types::NumberOrString::String(
1999            "include-registry-setup".to_string(),
2000        )),
2001        code_description: None,
2002        source: Some("lex".to_string()),
2003        message: format!("could not configure include resolver: {message}"),
2004        related_information: None,
2005        tags: None,
2006        data: None,
2007    }
2008}
2009
2010fn head_range() -> Range {
2011    Range {
2012        start: Position::new(0, 0),
2013        end: Position::new(0, 0),
2014    }
2015}
2016
2017/// Build the markdown body for an include hover. Shows the source path
2018/// from the annotation, the resolved on-disk path, and a small content
2019/// preview consisting of the first two non-blank lines of the target
2020/// (no AST parsing — just raw text). Designed to fit in a hover popup,
2021/// not to replace opening the file.
2022///
2023/// Uses a four-backtick code fence so a triple-backtick that happens to
2024/// appear in a previewed line (e.g., a markdown verbatim block) does
2025/// not terminate the fence early and corrupt the rendered hover.
2026fn include_preview_markdown(src: &str, target: &Path, target_source: &str) -> String {
2027    let mut out = String::new();
2028    out.push_str(&format!("**`lex.include`** → `{src}`\n\n"));
2029    out.push_str(&format!("Resolved: `{}`\n\n", target.display()));
2030
2031    let preview_lines: Vec<&str> = target_source
2032        .lines()
2033        .map(|l| l.trim_end())
2034        .filter(|l| !l.is_empty())
2035        .take(2)
2036        .collect();
2037    if preview_lines.is_empty() {
2038        out.push_str("_(empty file)_");
2039    } else {
2040        out.push_str("````lex\n");
2041        for line in &preview_lines {
2042            out.push_str(line);
2043            out.push('\n');
2044        }
2045        out.push_str("````");
2046    }
2047    out
2048}
2049
2050fn to_lsp_diagnostic(diag: AnalysisDiagnostic) -> Diagnostic {
2051    use lex_analysis::diagnostics::DiagnosticSeverity as AS;
2052    let severity = match diag.severity {
2053        AS::Error => tower_lsp::lsp_types::DiagnosticSeverity::ERROR,
2054        AS::Warning => tower_lsp::lsp_types::DiagnosticSeverity::WARNING,
2055        AS::Info => tower_lsp::lsp_types::DiagnosticSeverity::INFORMATION,
2056        AS::Hint => tower_lsp::lsp_types::DiagnosticSeverity::HINT,
2057    };
2058
2059    let code = diag.kind.code().into_owned();
2060
2061    let source = match &diag.kind {
2062        DiagnosticKind::Handler { namespace, .. } => format!("lex:{namespace}"),
2063        _ => "lex".to_string(),
2064    };
2065
2066    Diagnostic {
2067        range: to_lsp_range(&diag.range),
2068        severity: Some(severity),
2069        code: Some(tower_lsp::lsp_types::NumberOrString::String(code)),
2070        code_description: None,
2071        source: Some(source),
2072        message: diag.message,
2073        related_information: None,
2074        tags: None,
2075        data: None,
2076    }
2077}
2078
2079#[cfg(test)]
2080mod tests {
2081    use super::*;
2082    use crate::features::semantic_tokens::LexSemanticTokenKind;
2083    use lex_analysis::test_support::sample_source;
2084    use serde::Deserialize;
2085    use std::fs;
2086    use std::sync::atomic::{AtomicUsize, Ordering};
2087    use std::sync::Mutex;
2088    use tempfile::tempdir;
2089    use tower_lsp::lsp_types::{
2090        CompletionItemKind, DidOpenTextDocumentParams, DocumentFormattingParams,
2091        DocumentLinkParams, DocumentRangeFormattingParams, DocumentSymbolParams, FoldingRangeKind,
2092        FoldingRangeParams, FormattingOptions, GotoDefinitionParams, HoverParams, Position, Range,
2093        ReferenceContext, ReferenceParams, SemanticTokensParams, SymbolKind,
2094        TextDocumentIdentifier, TextDocumentItem, TextDocumentPositionParams,
2095    };
2096    use tower_lsp::LanguageServer;
2097
2098    #[derive(Clone, Default)]
2099    struct NoopClient;
2100    #[async_trait]
2101    impl LspClient for NoopClient {
2102        async fn publish_diagnostics(&self, _: Url, _: Vec<Diagnostic>, _: Option<i32>) {}
2103        async fn show_message(&self, _: MessageType, _: String) {}
2104    }
2105    #[async_trait]
2106    impl crate::trust_prompt::LspTrustRequester for NoopClient {
2107        async fn send_trust_request(
2108            &self,
2109            _: crate::trust_prompt::TrustRequestParams,
2110        ) -> tower_lsp::jsonrpc::Result<crate::trust_prompt::TrustResponse> {
2111            // Tests don't exercise the trust prompt path; deny so a
2112            // boot path that does reach the prompt has predictable
2113            // behavior.
2114            Ok(crate::trust_prompt::TrustResponse {
2115                decision: "denied".into(),
2116                reason: Some("test client".into()),
2117            })
2118        }
2119    }
2120
2121    #[derive(Default)]
2122    struct MockFeatureProvider {
2123        semantic_tokens_called: AtomicUsize,
2124        document_symbols_called: AtomicUsize,
2125        hover_called: AtomicUsize,
2126        folding_called: AtomicUsize,
2127        last_hover_position: Mutex<Option<AstPosition>>,
2128        definition_called: AtomicUsize,
2129        references_called: AtomicUsize,
2130        document_links_called: AtomicUsize,
2131        last_references_include: Mutex<Option<bool>>,
2132        formatting_called: AtomicUsize,
2133        range_formatting_called: AtomicUsize,
2134        completion_called: AtomicUsize,
2135        execute_command_called: AtomicUsize,
2136    }
2137
2138    impl FeatureProvider for MockFeatureProvider {
2139        fn semantic_tokens(&self, _: &Document) -> Vec<LexSemanticToken> {
2140            self.semantic_tokens_called.fetch_add(1, Ordering::SeqCst);
2141            vec![LexSemanticToken {
2142                kind: LexSemanticTokenKind::DocumentTitle,
2143                range: AstRange::new(0..5, AstPosition::new(0, 0), AstPosition::new(0, 5)),
2144            }]
2145        }
2146
2147        fn document_symbols(&self, _: &Document) -> Vec<LexDocumentSymbol> {
2148            self.document_symbols_called.fetch_add(1, Ordering::SeqCst);
2149            vec![LexDocumentSymbol {
2150                name: "symbol".into(),
2151                detail: None,
2152                kind: SymbolKind::FILE,
2153                range: AstRange::new(0..5, AstPosition::new(0, 0), AstPosition::new(0, 5)),
2154                selection_range: AstRange::new(
2155                    0..5,
2156                    AstPosition::new(0, 0),
2157                    AstPosition::new(0, 5),
2158                ),
2159                children: Vec::new(),
2160            }]
2161        }
2162
2163        fn folding_ranges(&self, _: &Document) -> Vec<LexFoldingRange> {
2164            self.folding_called.fetch_add(1, Ordering::SeqCst);
2165            vec![LexFoldingRange {
2166                start_line: 0,
2167                start_character: Some(0),
2168                end_line: 1,
2169                end_character: Some(0),
2170                kind: Some(FoldingRangeKind::Region),
2171            }]
2172        }
2173
2174        fn hover(&self, _: &Document, position: AstPosition) -> Option<HoverResult> {
2175            self.hover_called.fetch_add(1, Ordering::SeqCst);
2176            *self.last_hover_position.lock().unwrap() = Some(position);
2177            Some(HoverResult {
2178                range: AstRange::new(0..5, AstPosition::new(0, 0), AstPosition::new(0, 5)),
2179                contents: "hover".into(),
2180            })
2181        }
2182
2183        fn goto_definition(&self, _: &Document, _: AstPosition) -> Vec<AstRange> {
2184            self.definition_called.fetch_add(1, Ordering::SeqCst);
2185            vec![AstRange::new(
2186                0..5,
2187                AstPosition::new(0, 0),
2188                AstPosition::new(0, 5),
2189            )]
2190        }
2191
2192        fn references(
2193            &self,
2194            _: &Document,
2195            _: AstPosition,
2196            include_declaration: bool,
2197        ) -> Vec<AstRange> {
2198            self.references_called.fetch_add(1, Ordering::SeqCst);
2199            *self.last_references_include.lock().unwrap() = Some(include_declaration);
2200            vec![AstRange::new(
2201                0..5,
2202                AstPosition::new(0, 0),
2203                AstPosition::new(0, 5),
2204            )]
2205        }
2206
2207        fn document_links(&self, _: &Document) -> Vec<AstDocumentLink> {
2208            self.document_links_called.fetch_add(1, Ordering::SeqCst);
2209            vec![AstDocumentLink::new(
2210                AstRange::new(0..5, AstPosition::new(0, 0), AstPosition::new(0, 5)),
2211                "https://example.com".to_string(),
2212                LinkType::Url,
2213            )]
2214        }
2215
2216        fn format_document(
2217            &self,
2218            _: &Document,
2219            _: &str,
2220            _: Option<FormattingRules>,
2221        ) -> Vec<TextEditSpan> {
2222            self.formatting_called.fetch_add(1, Ordering::SeqCst);
2223            vec![TextEditSpan {
2224                start: 0,
2225                end: 0,
2226                new_text: "formatted".into(),
2227            }]
2228        }
2229
2230        fn format_range(
2231            &self,
2232            _: &Document,
2233            _: &str,
2234            _: FormattingLineRange,
2235            _: Option<FormattingRules>,
2236        ) -> Vec<TextEditSpan> {
2237            self.range_formatting_called.fetch_add(1, Ordering::SeqCst);
2238            vec![TextEditSpan {
2239                start: 0,
2240                end: 0,
2241                new_text: "range".into(),
2242            }]
2243        }
2244
2245        fn completion(
2246            &self,
2247            _: &Document,
2248            _: AstPosition,
2249            _: Option<&str>,
2250            _: Option<&CompletionWorkspace>,
2251            _: Option<&str>,
2252        ) -> Vec<CompletionCandidate> {
2253            self.completion_called.fetch_add(1, Ordering::SeqCst);
2254            vec![CompletionCandidate {
2255                label: "completion".into(),
2256                detail: None,
2257                kind: CompletionItemKind::TEXT,
2258                insert_text: None,
2259            }]
2260        }
2261
2262        fn execute_command(&self, command: &str, _: &[Value]) -> Result<Option<Value>> {
2263            self.execute_command_called.fetch_add(1, Ordering::SeqCst);
2264            if command == "test.command" {
2265                Ok(Some(Value::String("executed".into())))
2266            } else {
2267                Ok(None)
2268            }
2269        }
2270    }
2271
2272    fn sample_uri() -> Url {
2273        Url::parse("file:///sample.lex").unwrap()
2274    }
2275
2276    fn sample_text() -> String {
2277        sample_source().to_string()
2278    }
2279
2280    fn offset_to_position(source: &str, offset: usize) -> AstPosition {
2281        let mut line = 0;
2282        let mut line_start = 0;
2283        for (idx, ch) in source.char_indices() {
2284            if idx >= offset {
2285                break;
2286            }
2287            if ch == '\n' {
2288                line += 1;
2289                line_start = idx + ch.len_utf8();
2290            }
2291        }
2292        AstPosition::new(line, offset - line_start)
2293    }
2294
2295    fn range_for_snippet(snippet: &str) -> AstRange {
2296        let source = sample_source();
2297        let start = source
2298            .find(snippet)
2299            .unwrap_or_else(|| panic!("snippet not found: {snippet}"));
2300        let end = start + snippet.len();
2301        let start_pos = offset_to_position(source, start);
2302        let end_pos = offset_to_position(source, end);
2303        AstRange::new(start..end, start_pos, end_pos)
2304    }
2305
2306    async fn open_sample_document(server: &LexLanguageServer<NoopClient, MockFeatureProvider>) {
2307        let uri = sample_uri();
2308        server
2309            .did_open(DidOpenTextDocumentParams {
2310                text_document: TextDocumentItem {
2311                    uri,
2312                    language_id: "lex".into(),
2313                    version: 1,
2314                    text: sample_text(),
2315                },
2316            })
2317            .await;
2318    }
2319
2320    #[test]
2321    fn encode_semantic_tokens_splits_multi_line_ranges() {
2322        let snippet = "    CLI Example:\n        lex build\n        lex serve";
2323        let range = range_for_snippet(snippet);
2324        let tokens = vec![LexSemanticToken {
2325            kind: LexSemanticTokenKind::DocumentTitle,
2326            range,
2327        }];
2328        let source = sample_source();
2329        let encoded = encode_semantic_tokens(&tokens, source);
2330        assert_eq!(encoded.len(), 3);
2331        let snippet_offset = source
2332            .find(snippet)
2333            .expect("snippet not found in sample document");
2334        let mut cursor = 0;
2335        let lines: Vec<&str> = snippet.split('\n').collect();
2336        let mut expected_positions = Vec::new();
2337        for (idx, line) in lines.iter().enumerate() {
2338            let offset = snippet_offset + cursor;
2339            expected_positions.push(offset_to_position(source, offset));
2340            cursor += line.len();
2341            if idx < lines.len() - 1 {
2342                cursor += 1; // account for newline
2343            }
2344        }
2345        let mut absolute_positions = Vec::new();
2346        let mut line = 0u32;
2347        let mut column = 0u32;
2348        for token in &encoded {
2349            line += token.delta_line;
2350            let start = if token.delta_line == 0 {
2351                column + token.delta_start
2352            } else {
2353                token.delta_start
2354            };
2355            column = start;
2356            absolute_positions.push((line, start));
2357        }
2358        for (actual, expected) in absolute_positions.iter().zip(expected_positions.iter()) {
2359            assert_eq!(actual.0, expected.line as u32);
2360            assert_eq!(actual.1, expected.column as u32);
2361        }
2362        let expected_len: usize = snippet.lines().map(|line| line.len()).sum();
2363        let actual_len: usize = encoded.iter().map(|token| token.length as usize).sum();
2364        assert_eq!(actual_len, expected_len);
2365    }
2366
2367    #[tokio::test]
2368    async fn semantic_tokens_call_feature_layer() {
2369        let provider = Arc::new(MockFeatureProvider::default());
2370        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2371        open_sample_document(&server).await;
2372
2373        let result = server
2374            .semantic_tokens_full(SemanticTokensParams {
2375                text_document: TextDocumentIdentifier { uri: sample_uri() },
2376                work_done_progress_params: Default::default(),
2377                partial_result_params: Default::default(),
2378            })
2379            .await
2380            .unwrap()
2381            .unwrap();
2382
2383        assert_eq!(provider.semantic_tokens_called.load(Ordering::SeqCst), 1);
2384        let data_len = match result {
2385            SemanticTokensResult::Tokens(tokens) => tokens.data.len(),
2386            SemanticTokensResult::Partial(partial) => partial.data.len(),
2387        };
2388        assert!(data_len > 0);
2389    }
2390
2391    #[tokio::test]
2392    async fn document_symbols_call_feature_layer() {
2393        let provider = Arc::new(MockFeatureProvider::default());
2394        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2395        open_sample_document(&server).await;
2396
2397        let response = server
2398            .document_symbol(DocumentSymbolParams {
2399                text_document: TextDocumentIdentifier { uri: sample_uri() },
2400                work_done_progress_params: Default::default(),
2401                partial_result_params: Default::default(),
2402            })
2403            .await
2404            .unwrap()
2405            .unwrap();
2406
2407        match response {
2408            DocumentSymbolResponse::Nested(symbols) => assert!(!symbols.is_empty()),
2409            _ => panic!("unexpected symbol response"),
2410        }
2411        assert_eq!(provider.document_symbols_called.load(Ordering::SeqCst), 1);
2412    }
2413
2414    #[tokio::test]
2415    async fn hover_uses_feature_provider_position() {
2416        let provider = Arc::new(MockFeatureProvider::default());
2417        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2418        open_sample_document(&server).await;
2419
2420        let hover = server
2421            .hover(HoverParams {
2422                text_document_position_params: TextDocumentPositionParams {
2423                    text_document: TextDocumentIdentifier { uri: sample_uri() },
2424                    position: Position::new(0, 0),
2425                },
2426                work_done_progress_params: Default::default(),
2427            })
2428            .await
2429            .unwrap()
2430            .unwrap();
2431
2432        assert!(matches!(hover.contents, HoverContents::Markup(_)));
2433        assert_eq!(provider.hover_called.load(Ordering::SeqCst), 1);
2434        let stored = provider.last_hover_position.lock().unwrap().unwrap();
2435        assert_eq!(stored.line, 0);
2436        assert_eq!(stored.column, 0);
2437    }
2438
2439    #[tokio::test]
2440    async fn folding_range_uses_feature_provider() {
2441        let provider = Arc::new(MockFeatureProvider::default());
2442        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2443        open_sample_document(&server).await;
2444
2445        let ranges = server
2446            .folding_range(FoldingRangeParams {
2447                text_document: TextDocumentIdentifier { uri: sample_uri() },
2448                work_done_progress_params: Default::default(),
2449                partial_result_params: Default::default(),
2450            })
2451            .await
2452            .unwrap()
2453            .unwrap();
2454
2455        assert_eq!(provider.folding_called.load(Ordering::SeqCst), 1);
2456        assert_eq!(ranges.len(), 1);
2457    }
2458
2459    #[tokio::test]
2460    async fn goto_definition_uses_feature_provider() {
2461        let provider = Arc::new(MockFeatureProvider::default());
2462        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2463        open_sample_document(&server).await;
2464
2465        let response = server
2466            .goto_definition(GotoDefinitionParams {
2467                text_document_position_params: TextDocumentPositionParams {
2468                    text_document: TextDocumentIdentifier { uri: sample_uri() },
2469                    position: Position::new(0, 0),
2470                },
2471                work_done_progress_params: Default::default(),
2472                partial_result_params: Default::default(),
2473            })
2474            .await
2475            .unwrap()
2476            .unwrap();
2477
2478        assert_eq!(provider.definition_called.load(Ordering::SeqCst), 1);
2479        match response {
2480            GotoDefinitionResponse::Array(locations) => assert_eq!(locations.len(), 1),
2481            _ => panic!("unexpected goto definition response"),
2482        }
2483    }
2484
2485    #[derive(Deserialize)]
2486    struct SnippetResponse {
2487        text: String,
2488        #[serde(rename = "cursorOffset")]
2489        cursor_offset: usize,
2490    }
2491
2492    #[tokio::test]
2493    async fn execute_insert_commands() {
2494        let provider = Arc::new(MockFeatureProvider::default());
2495        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2496        open_sample_document(&server).await;
2497
2498        let temp_dir = tempdir().unwrap();
2499        let asset_file = temp_dir.path().join("diagram.png");
2500        fs::write(&asset_file, [0u8, 159u8, 146u8, 150u8]).unwrap();
2501
2502        let params = ExecuteCommandParams {
2503            command: commands::COMMAND_INSERT_ASSET.to_string(),
2504            arguments: vec![
2505                serde_json::to_value(sample_uri().to_string()).unwrap(),
2506                serde_json::to_value(Position::new(0, 0)).unwrap(),
2507                serde_json::to_value(asset_file.to_string_lossy()).unwrap(),
2508            ],
2509            work_done_progress_params: Default::default(),
2510        };
2511        let result = server.execute_command(params).await.unwrap();
2512        let snippet: SnippetResponse = serde_json::from_value(result.unwrap()).unwrap();
2513        assert!(snippet.text.contains(":: image"));
2514        assert!(snippet.text.contains(asset_file.to_string_lossy().as_ref()));
2515
2516        let verbatim_file = temp_dir.path().join("example.py");
2517        fs::write(&verbatim_file, "print('hi')\n").unwrap();
2518
2519        let params = ExecuteCommandParams {
2520            command: commands::COMMAND_INSERT_VERBATIM.to_string(),
2521            arguments: vec![
2522                serde_json::to_value(sample_uri().to_string()).unwrap(),
2523                serde_json::to_value(Position::new(0, 0)).unwrap(),
2524                serde_json::to_value(verbatim_file.to_string_lossy()).unwrap(),
2525            ],
2526            work_done_progress_params: Default::default(),
2527        };
2528        let result = server.execute_command(params).await.unwrap();
2529        let snippet: SnippetResponse = serde_json::from_value(result.unwrap()).unwrap();
2530        assert!(snippet.text.contains(":: python"));
2531        assert!(snippet.text.contains("print('hi')"));
2532        assert_eq!(snippet.cursor_offset, 0);
2533    }
2534
2535    #[tokio::test]
2536    async fn execute_annotation_navigation_commands() {
2537        let provider = Arc::new(MockFeatureProvider::default());
2538        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2539        let uri = Url::parse("file:///annotations.lex").unwrap();
2540        let text = ":: note ::\n    First\n::\n\n:: note ::\n    Second\n::\n";
2541        server
2542            .did_open(DidOpenTextDocumentParams {
2543                text_document: TextDocumentItem {
2544                    uri: uri.clone(),
2545                    language_id: "lex".into(),
2546                    version: 1,
2547                    text: text.to_string(),
2548                },
2549            })
2550            .await;
2551
2552        let next_params = ExecuteCommandParams {
2553            command: commands::COMMAND_NEXT_ANNOTATION.to_string(),
2554            arguments: vec![
2555                serde_json::to_value(uri.to_string()).unwrap(),
2556                serde_json::to_value(Position::new(0, 0)).unwrap(),
2557            ],
2558            work_done_progress_params: Default::default(),
2559        };
2560        let next_location: Location =
2561            serde_json::from_value(server.execute_command(next_params).await.unwrap().unwrap())
2562                .unwrap();
2563        assert_eq!(next_location.range.start.line, 0);
2564
2565        let previous_params = ExecuteCommandParams {
2566            command: commands::COMMAND_PREVIOUS_ANNOTATION.to_string(),
2567            arguments: vec![
2568                serde_json::to_value(uri.to_string()).unwrap(),
2569                serde_json::to_value(Position::new(0, 0)).unwrap(),
2570            ],
2571            work_done_progress_params: Default::default(),
2572        };
2573        let previous_location: Location = serde_json::from_value(
2574            server
2575                .execute_command(previous_params)
2576                .await
2577                .unwrap()
2578                .unwrap(),
2579        )
2580        .unwrap();
2581        assert_eq!(previous_location.range.start.line, 4);
2582
2583        let resolve_params = ExecuteCommandParams {
2584            command: commands::COMMAND_RESOLVE_ANNOTATION.to_string(),
2585            arguments: vec![
2586                serde_json::to_value(uri.to_string()).unwrap(),
2587                serde_json::to_value(Position::new(0, 0)).unwrap(),
2588            ],
2589            work_done_progress_params: Default::default(),
2590        };
2591        let edit_value = server
2592            .execute_command(resolve_params)
2593            .await
2594            .unwrap()
2595            .unwrap();
2596        let workspace_edit: tower_lsp::lsp_types::WorkspaceEdit =
2597            serde_json::from_value(edit_value).unwrap();
2598        let changes = workspace_edit.changes.expect("workspace edit changes");
2599        let edits = changes.get(&uri).expect("edits for document");
2600        assert_eq!(edits[0].new_text, ":: note status=resolved ::");
2601    }
2602
2603    #[tokio::test]
2604    async fn references_use_feature_provider() {
2605        let provider = Arc::new(MockFeatureProvider::default());
2606        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2607        open_sample_document(&server).await;
2608
2609        let result = server
2610            .references(ReferenceParams {
2611                text_document_position: TextDocumentPositionParams {
2612                    text_document: TextDocumentIdentifier { uri: sample_uri() },
2613                    position: Position::new(0, 0),
2614                },
2615                context: ReferenceContext {
2616                    include_declaration: true,
2617                },
2618                work_done_progress_params: Default::default(),
2619                partial_result_params: Default::default(),
2620            })
2621            .await
2622            .unwrap()
2623            .unwrap();
2624
2625        assert_eq!(provider.references_called.load(Ordering::SeqCst), 1);
2626        assert_eq!(result.len(), 1);
2627        assert_eq!(
2628            *provider.last_references_include.lock().unwrap(),
2629            Some(true)
2630        );
2631    }
2632
2633    #[tokio::test]
2634    async fn document_links_use_feature_provider() {
2635        let provider = Arc::new(MockFeatureProvider::default());
2636        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2637        open_sample_document(&server).await;
2638
2639        let links = server
2640            .document_link(DocumentLinkParams {
2641                text_document: TextDocumentIdentifier { uri: sample_uri() },
2642                work_done_progress_params: Default::default(),
2643                partial_result_params: Default::default(),
2644            })
2645            .await
2646            .unwrap()
2647            .unwrap();
2648
2649        assert_eq!(provider.document_links_called.load(Ordering::SeqCst), 1);
2650        assert_eq!(links.len(), 1);
2651        assert_eq!(
2652            links[0].target.as_ref().map(|url| url.as_str()),
2653            Some("https://example.com/")
2654        );
2655    }
2656
2657    #[tokio::test]
2658    async fn formatting_uses_feature_provider() {
2659        let provider = Arc::new(MockFeatureProvider::default());
2660        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2661        open_sample_document(&server).await;
2662
2663        let edits = server
2664            .formatting(DocumentFormattingParams {
2665                text_document: TextDocumentIdentifier { uri: sample_uri() },
2666                options: FormattingOptions::default(),
2667                work_done_progress_params: Default::default(),
2668            })
2669            .await
2670            .unwrap()
2671            .unwrap();
2672
2673        assert_eq!(provider.formatting_called.load(Ordering::SeqCst), 1);
2674        assert_eq!(edits.len(), 1);
2675        assert_eq!(edits[0].new_text, "formatted");
2676    }
2677
2678    #[tokio::test]
2679    async fn range_formatting_uses_feature_provider() {
2680        let provider = Arc::new(MockFeatureProvider::default());
2681        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2682        open_sample_document(&server).await;
2683
2684        let edits = server
2685            .range_formatting(DocumentRangeFormattingParams {
2686                text_document: TextDocumentIdentifier { uri: sample_uri() },
2687                range: Range {
2688                    start: Position::new(0, 0),
2689                    end: Position::new(0, 0),
2690                },
2691                options: FormattingOptions::default(),
2692                work_done_progress_params: Default::default(),
2693            })
2694            .await
2695            .unwrap()
2696            .unwrap();
2697
2698        assert_eq!(provider.range_formatting_called.load(Ordering::SeqCst), 1);
2699        assert_eq!(edits.len(), 1);
2700        assert_eq!(edits[0].new_text, "range");
2701    }
2702
2703    #[tokio::test]
2704    async fn semantic_tokens_returns_none_when_document_missing() {
2705        let provider = Arc::new(MockFeatureProvider::default());
2706        let server = LexLanguageServer::with_features(NoopClient, provider);
2707
2708        let result = server
2709            .semantic_tokens_full(SemanticTokensParams {
2710                text_document: TextDocumentIdentifier { uri: sample_uri() },
2711                work_done_progress_params: Default::default(),
2712                partial_result_params: Default::default(),
2713            })
2714            .await
2715            .unwrap();
2716
2717        assert!(result.is_none());
2718    }
2719
2720    #[tokio::test]
2721    async fn execute_command_uses_feature_provider() {
2722        let provider = Arc::new(MockFeatureProvider::default());
2723        let server = LexLanguageServer::with_features(NoopClient, provider.clone());
2724
2725        let result = server
2726            .execute_command(ExecuteCommandParams {
2727                command: "test.command".into(),
2728                arguments: vec![],
2729                work_done_progress_params: Default::default(),
2730            })
2731            .await
2732            .unwrap()
2733            .unwrap();
2734
2735        assert_eq!(provider.execute_command_called.load(Ordering::SeqCst), 1);
2736        assert_eq!(result, Value::String("executed".into()));
2737    }
2738
2739    #[tokio::test]
2740    async fn hover_returns_none_without_document_entry() {
2741        let provider = Arc::new(MockFeatureProvider::default());
2742        let server = LexLanguageServer::with_features(NoopClient, provider);
2743
2744        let hover = server
2745            .hover(HoverParams {
2746                text_document_position_params: TextDocumentPositionParams {
2747                    text_document: TextDocumentIdentifier { uri: sample_uri() },
2748                    position: Position::new(0, 0),
2749                },
2750                work_done_progress_params: Default::default(),
2751            })
2752            .await
2753            .unwrap();
2754
2755        assert!(hover.is_none());
2756    }
2757
2758    #[test]
2759    fn apply_formatting_overrides_noop_without_lex_properties() {
2760        let options = FormattingOptions {
2761            tab_size: 4,
2762            insert_spaces: true,
2763            properties: Default::default(),
2764            trim_trailing_whitespace: None,
2765            insert_final_newline: None,
2766            trim_final_newlines: None,
2767        };
2768        let mut rules = FormattingRules::default();
2769        let original = rules.clone();
2770        apply_formatting_overrides(&mut rules, &options);
2771        assert_eq!(rules.indent_string, original.indent_string);
2772        assert_eq!(rules.max_blank_lines, original.max_blank_lines);
2773    }
2774
2775    #[test]
2776    fn apply_formatting_overrides_applies_lex_properties() {
2777        use std::collections::HashMap;
2778
2779        let mut properties = HashMap::new();
2780        properties.insert(
2781            "lex.indent_string".to_string(),
2782            FormattingProperty::String("  ".to_string()),
2783        );
2784        properties.insert(
2785            "lex.max_blank_lines".to_string(),
2786            FormattingProperty::Number(3),
2787        );
2788        properties.insert(
2789            "lex.normalize_seq_markers".to_string(),
2790            FormattingProperty::Bool(false),
2791        );
2792        properties.insert(
2793            "lex.unordered_seq_marker".to_string(),
2794            FormattingProperty::String("*".to_string()),
2795        );
2796
2797        let options = FormattingOptions {
2798            tab_size: 4,
2799            insert_spaces: true,
2800            properties,
2801            trim_trailing_whitespace: None,
2802            insert_final_newline: None,
2803            trim_final_newlines: None,
2804        };
2805
2806        let mut rules = FormattingRules::default();
2807        apply_formatting_overrides(&mut rules, &options);
2808        assert_eq!(rules.indent_string, "  ");
2809        assert_eq!(rules.max_blank_lines, 3);
2810        assert!(!rules.normalize_seq_markers);
2811        assert_eq!(rules.unordered_seq_marker, '*');
2812    }
2813
2814    #[tokio::test]
2815    async fn did_change_workspace_folders_adds_roots() {
2816        let provider = Arc::new(MockFeatureProvider::default());
2817        let server = LexLanguageServer::with_features(NoopClient, provider);
2818
2819        // Start with one root via initialize
2820        server
2821            .initialize(InitializeParams {
2822                root_uri: Some(Url::from_file_path("/initial").unwrap()),
2823                ..Default::default()
2824            })
2825            .await
2826            .unwrap();
2827
2828        assert_eq!(server.workspace_roots.read().await.len(), 1);
2829
2830        // Add a workspace folder
2831        server
2832            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
2833                event: lsp_types::WorkspaceFoldersChangeEvent {
2834                    added: vec![lsp_types::WorkspaceFolder {
2835                        uri: Url::from_file_path("/added").unwrap(),
2836                        name: "added".to_string(),
2837                    }],
2838                    removed: vec![],
2839                },
2840            })
2841            .await;
2842
2843        let roots = server.workspace_roots.read().await;
2844        assert_eq!(roots.len(), 2);
2845        assert_eq!(roots[1], PathBuf::from("/added"));
2846    }
2847
2848    #[tokio::test]
2849    async fn did_change_workspace_folders_removes_roots() {
2850        let provider = Arc::new(MockFeatureProvider::default());
2851        let server = LexLanguageServer::with_features(NoopClient, provider);
2852
2853        server
2854            .initialize(InitializeParams {
2855                root_uri: Some(Url::from_file_path("/initial").unwrap()),
2856                ..Default::default()
2857            })
2858            .await
2859            .unwrap();
2860
2861        // Add a folder then remove the initial one
2862        server
2863            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
2864                event: lsp_types::WorkspaceFoldersChangeEvent {
2865                    added: vec![lsp_types::WorkspaceFolder {
2866                        uri: Url::from_file_path("/new-root").unwrap(),
2867                        name: "new-root".to_string(),
2868                    }],
2869                    removed: vec![lsp_types::WorkspaceFolder {
2870                        uri: Url::from_file_path("/initial").unwrap(),
2871                        name: "initial".to_string(),
2872                    }],
2873                },
2874            })
2875            .await;
2876
2877        let roots = server.workspace_roots.read().await;
2878        assert_eq!(roots.len(), 1);
2879        assert_eq!(roots[0], PathBuf::from("/new-root"));
2880    }
2881
2882    #[tokio::test]
2883    async fn did_change_workspace_folders_does_not_duplicate() {
2884        let provider = Arc::new(MockFeatureProvider::default());
2885        let server = LexLanguageServer::with_features(NoopClient, provider);
2886
2887        server
2888            .initialize(InitializeParams {
2889                root_uri: Some(Url::from_file_path("/root").unwrap()),
2890                ..Default::default()
2891            })
2892            .await
2893            .unwrap();
2894
2895        // Try to add the same folder that already exists
2896        server
2897            .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
2898                event: lsp_types::WorkspaceFoldersChangeEvent {
2899                    added: vec![lsp_types::WorkspaceFolder {
2900                        uri: Url::from_file_path("/root").unwrap(),
2901                        name: "root".to_string(),
2902                    }],
2903                    removed: vec![],
2904                },
2905            })
2906            .await;
2907
2908        assert_eq!(server.workspace_roots.read().await.len(), 1);
2909    }
2910
2911    #[tokio::test]
2912    async fn initialize_advertises_workspace_folder_support() {
2913        let provider = Arc::new(MockFeatureProvider::default());
2914        let server = LexLanguageServer::with_features(NoopClient, provider);
2915
2916        let result = server
2917            .initialize(InitializeParams::default())
2918            .await
2919            .unwrap();
2920
2921        let workspace = result
2922            .capabilities
2923            .workspace
2924            .expect("workspace capabilities");
2925        let folders = workspace
2926            .workspace_folders
2927            .expect("workspace folder support");
2928        assert_eq!(folders.supported, Some(true));
2929        assert_eq!(folders.change_notifications, Some(OneOf::Left(true)));
2930    }
2931
2932    // ========================================================================
2933    // Include resolution integration (PR 8)
2934    // ========================================================================
2935    //
2936    // These tests use a CapturingClient that records every
2937    // publish_diagnostics call so assertions can inspect the diagnostic
2938    // payload directly. Test sources are written to a TempDir so the
2939    // FsLoader is exercised end-to-end (no MemoryLoader bypass).
2940
2941    type DiagnosticLog = Arc<Mutex<Vec<(Url, Vec<Diagnostic>)>>>;
2942
2943    #[derive(Clone, Default)]
2944    struct CapturingClient {
2945        last_diagnostics: DiagnosticLog,
2946    }
2947
2948    #[async_trait]
2949    impl LspClient for CapturingClient {
2950        async fn publish_diagnostics(&self, uri: Url, diags: Vec<Diagnostic>, _: Option<i32>) {
2951            self.last_diagnostics.lock().unwrap().push((uri, diags));
2952        }
2953        async fn show_message(&self, _: MessageType, _: String) {}
2954    }
2955    #[async_trait]
2956    impl crate::trust_prompt::LspTrustRequester for CapturingClient {
2957        async fn send_trust_request(
2958            &self,
2959            _: crate::trust_prompt::TrustRequestParams,
2960        ) -> tower_lsp::jsonrpc::Result<crate::trust_prompt::TrustResponse> {
2961            // Tests run with no `[labels]` block; the prompt path is
2962            // not exercised.
2963            Ok(crate::trust_prompt::TrustResponse {
2964                decision: "denied".into(),
2965                reason: Some("test client".into()),
2966            })
2967        }
2968    }
2969
2970    impl CapturingClient {
2971        fn diagnostics_for(&self, uri: &Url) -> Vec<Diagnostic> {
2972            self.last_diagnostics
2973                .lock()
2974                .unwrap()
2975                .iter()
2976                .rev()
2977                .find(|(u, _)| u == uri)
2978                .map(|(_, d)| d.clone())
2979                .unwrap_or_default()
2980        }
2981    }
2982
2983    /// Build a temp directory with the given `(relpath, contents)` files,
2984    /// open the entry file via the LSP, and return (server, capturing client,
2985    /// entry uri, temp dir). The TempDir is returned so the caller keeps it
2986    /// alive for the duration of the test (drop = cleanup).
2987    async fn open_in_tempdir(
2988        files: &[(&str, &str)],
2989        entry: &str,
2990    ) -> (
2991        LexLanguageServer<CapturingClient, DefaultFeatureProvider>,
2992        CapturingClient,
2993        Url,
2994        tempfile::TempDir,
2995    ) {
2996        let dir = tempdir().expect("tempdir");
2997        for (rel, contents) in files {
2998            let path = dir.path().join(rel);
2999            if let Some(parent) = path.parent() {
3000                std::fs::create_dir_all(parent).expect("mkdir -p");
3001            }
3002            std::fs::write(&path, contents).expect("write fixture");
3003        }
3004        let entry_path = dir.path().join(entry);
3005        let entry_text = std::fs::read_to_string(&entry_path).expect("read entry");
3006        let uri = Url::from_file_path(&entry_path).expect("file uri");
3007
3008        let client = CapturingClient::default();
3009        let server = LexLanguageServer::with_features(
3010            client.clone(),
3011            Arc::new(DefaultFeatureProvider::new()),
3012        );
3013
3014        server
3015            .did_open(DidOpenTextDocumentParams {
3016                text_document: TextDocumentItem {
3017                    uri: uri.clone(),
3018                    language_id: "lex".into(),
3019                    version: 1,
3020                    text: entry_text,
3021                },
3022            })
3023            .await;
3024
3025        (server, client, uri, dir)
3026    }
3027
3028    fn has_diag_with_code(diags: &[Diagnostic], code: &str) -> bool {
3029        diags.iter().any(|d| {
3030            matches!(
3031                &d.code,
3032                Some(tower_lsp::lsp_types::NumberOrString::String(c)) if c == code
3033            )
3034        })
3035    }
3036
3037    #[tokio::test]
3038    async fn includes_did_open_resolves_and_publishes_no_include_diagnostic() {
3039        let (_server, client, uri, _dir) = open_in_tempdir(
3040            &[
3041                (
3042                    "main.lex",
3043                    "1. Host\n\n    :: lex.include src=\"chapter.lex\" ::\n",
3044                ),
3045                ("chapter.lex", "1.1 Chapter\n\n    Body of chapter.\n"),
3046            ],
3047            "main.lex",
3048        )
3049        .await;
3050
3051        let diags = client.diagnostics_for(&uri);
3052        assert!(
3053            !diags.iter().any(|d| matches!(
3054                &d.code,
3055                Some(tower_lsp::lsp_types::NumberOrString::String(c)) if c.starts_with("include-")
3056            )),
3057            "successful include resolution should produce no include-* diagnostics, got {diags:?}"
3058        );
3059    }
3060
3061    #[tokio::test]
3062    async fn includes_missing_target_emits_diagnostic_with_path() {
3063        // The include sits on line 0, column 0 — flat fixture so the
3064        // diagnostic should pin to that exact location, not the
3065        // document head fallback (which would also be (0,0)–(0,0); the
3066        // distinction the test cares about is "did the resolver wire
3067        // annotation.location through to the diagnostic at all").
3068        let (_server, client, uri, _dir) = open_in_tempdir(
3069            &[("main.lex", ":: lex.include src=\"missing.lex\" ::\n")],
3070            "main.lex",
3071        )
3072        .await;
3073
3074        let diags = client.diagnostics_for(&uri);
3075        assert!(
3076            has_diag_with_code(&diags, "include-not-found"),
3077            "missing include should surface include-not-found, got {diags:?}"
3078        );
3079        assert!(
3080            diags.iter().any(|d| d.message.contains("missing.lex")),
3081            "diagnostic should name the missing file, got {diags:?}"
3082        );
3083        // The diagnostic must span more than a single point at (0,0).
3084        // The default `head_range()` fallback was (0,0)–(0,0), a
3085        // zero-width point — vscode renders nothing useful for that.
3086        // After wiring annotation.location through, the range covers
3087        // the annotation text.
3088        let not_found = diags
3089            .iter()
3090            .find(|d| {
3091                matches!(
3092                    &d.code,
3093                    Some(tower_lsp::lsp_types::NumberOrString::String(c)) if c == "include-not-found"
3094                )
3095            })
3096            .expect("not-found diag");
3097        let r = &not_found.range;
3098        assert!(
3099            r.end.line > r.start.line || r.end.character > r.start.character,
3100            "include-not-found should span the annotation, not collapse to a point; got {r:?}",
3101        );
3102    }
3103
3104    #[tokio::test]
3105    async fn includes_cycle_emits_diagnostic_pointing_at_include_site() {
3106        let (_server, client, uri, _dir) = open_in_tempdir(
3107            &[
3108                ("main.lex", ":: lex.include src=\"a.lex\" ::\n"),
3109                ("a.lex", ":: lex.include src=\"b.lex\" ::\n"),
3110                ("b.lex", ":: lex.include src=\"a.lex\" ::\n"),
3111            ],
3112            "main.lex",
3113        )
3114        .await;
3115
3116        let diags = client.diagnostics_for(&uri);
3117        assert!(
3118            has_diag_with_code(&diags, "include-cycle"),
3119            "cycle should surface include-cycle, got {diags:?}"
3120        );
3121        // The Cycle variant carries an include_site Range — the
3122        // diagnostic should point at it (not at the document head).
3123        let cycle = diags
3124            .iter()
3125            .find(|d| {
3126                matches!(
3127                    &d.code,
3128                    Some(tower_lsp::lsp_types::NumberOrString::String(c)) if c == "include-cycle"
3129                )
3130            })
3131            .expect("cycle diag");
3132        // The site is in main.lex line 0 (the only include there).
3133        assert_eq!(cycle.range.start.line, 0);
3134    }
3135
3136    #[tokio::test]
3137    async fn includes_root_escape_emits_diagnostic() {
3138        let (_server, client, uri, _dir) = open_in_tempdir(
3139            &[(
3140                "main.lex",
3141                "1. Host\n\n    :: lex.include src=\"../../etc/passwd\" ::\n",
3142            )],
3143            "main.lex",
3144        )
3145        .await;
3146
3147        let diags = client.diagnostics_for(&uri);
3148        assert!(
3149            has_diag_with_code(&diags, "include-root-escape"),
3150            "root escape should surface include-root-escape, got {diags:?}"
3151        );
3152    }
3153
3154    #[tokio::test]
3155    async fn includes_stored_tree_remains_unresolved_so_positions_match_host_buffer() {
3156        // The stored Document MUST be the unresolved parse of the host
3157        // buffer. Storing the merged tree would mix in nodes whose
3158        // Range.{start,end,span} reference the *included file's*
3159        // coordinate space, so semantic-token / hover / goto positions
3160        // served back to the editor would point at the wrong text.
3161        // (The merged tree is computed for diagnostic purposes only —
3162        // resolver errors get surfaced — and then dropped.)
3163        let (server, _client, uri, _dir) = open_in_tempdir(
3164            &[
3165                ("main.lex", ":: lex.include src=\"chapter.lex\" ::\n"),
3166                (
3167                    "chapter.lex",
3168                    "1. Spliced Chapter\n\n    Body content here.\n",
3169                ),
3170            ],
3171            "main.lex",
3172        )
3173        .await;
3174
3175        let entry = server.document_entry(&uri).await.expect("entry stored");
3176        // Walk to find the session title — "1. Spliced Chapter" should
3177        // NOT be present in the host buffer's parse (it lives in the
3178        // included file).
3179        use lex_core::lex::ast::elements::content_item::ContentItem;
3180        let titles: Vec<String> = entry
3181            .document
3182            .root
3183            .children
3184            .iter()
3185            .filter_map(|i| match i {
3186                ContentItem::Session(s) => Some(s.title.as_string().to_string()),
3187                _ => None,
3188            })
3189            .collect();
3190        assert!(
3191            !titles.iter().any(|t| t == "1. Spliced Chapter"),
3192            "spliced chapter must NOT be in the stored host tree (its Ranges \
3193             would point at the wrong buffer); got titles {titles:?}"
3194        );
3195    }
3196
3197    // ------------------------------------------------------------------
3198    // Goto-def + hover for `lex.include` annotations (PR 9)
3199    // ------------------------------------------------------------------
3200
3201    /// Build a `GotoDefinitionParams` pointing at a given (line, char)
3202    /// inside `uri` — small helper to keep tests short.
3203    fn goto_at(uri: &Url, line: u32, character: u32) -> GotoDefinitionParams {
3204        GotoDefinitionParams {
3205            text_document_position_params: TextDocumentPositionParams {
3206                text_document: TextDocumentIdentifier { uri: uri.clone() },
3207                position: Position { line, character },
3208            },
3209            work_done_progress_params: Default::default(),
3210            partial_result_params: Default::default(),
3211        }
3212    }
3213
3214    fn hover_at(uri: &Url, line: u32, character: u32) -> HoverParams {
3215        HoverParams {
3216            text_document_position_params: TextDocumentPositionParams {
3217                text_document: TextDocumentIdentifier { uri: uri.clone() },
3218                position: Position { line, character },
3219            },
3220            work_done_progress_params: Default::default(),
3221        }
3222    }
3223
3224    #[tokio::test]
3225    async fn goto_definition_on_include_returns_target_file_location() {
3226        let (server, _client, uri, dir) = open_in_tempdir(
3227            &[
3228                ("main.lex", ":: lex.include src=\"chapter.lex\" ::\n"),
3229                ("chapter.lex", "1. Chapter\n\n    Body.\n"),
3230            ],
3231            "main.lex",
3232        )
3233        .await;
3234
3235        // Cursor on the `lex.include` annotation header (line 0).
3236        let response = server.goto_definition(goto_at(&uri, 0, 5)).await.unwrap();
3237        let location = match response {
3238            Some(GotoDefinitionResponse::Scalar(loc)) => loc,
3239            other => panic!("expected scalar Location, got {other:?}"),
3240        };
3241
3242        // Target URI must point at chapter.lex (canonicalized via the
3243        // same absolutize_path the resolver uses).
3244        let expected = Url::from_file_path(absolutize_path(&dir.path().join("chapter.lex")))
3245            .expect("file uri");
3246        assert_eq!(location.uri, expected);
3247        // Range is the file head — cross-file goto-def lands at top-of-file.
3248        assert_eq!(location.range.start.line, 0);
3249        assert_eq!(location.range.start.character, 0);
3250    }
3251
3252    #[tokio::test]
3253    async fn goto_definition_off_include_falls_through_to_normal_logic() {
3254        // Cursor on a paragraph (NOT an include) — the include-aware
3255        // short-circuit must not fire, so the response comes from the
3256        // normal in-document goto path. With no references at this
3257        // position, that's None.
3258        let (server, _client, uri, _dir) = open_in_tempdir(
3259            &[("main.lex", "1. Chapter\n\n    Just a paragraph.\n")],
3260            "main.lex",
3261        )
3262        .await;
3263        let response = server.goto_definition(goto_at(&uri, 2, 8)).await.unwrap();
3264        assert!(
3265            response.is_none(),
3266            "non-include cursor should fall through, got {response:?}"
3267        );
3268    }
3269
3270    #[tokio::test]
3271    async fn hover_on_include_returns_preview_of_target_file() {
3272        let (server, _client, uri, _dir) = open_in_tempdir(
3273            &[
3274                ("main.lex", ":: lex.include src=\"chapter.lex\" ::\n"),
3275                ("chapter.lex", "1. Chapter\n\n    Body line.\n"),
3276            ],
3277            "main.lex",
3278        )
3279        .await;
3280
3281        let hover = server
3282            .hover(hover_at(&uri, 0, 5))
3283            .await
3284            .unwrap()
3285            .expect("hover");
3286        let body = match hover.contents {
3287            HoverContents::Markup(m) => m.value,
3288            other => panic!("expected markup hover, got {other:?}"),
3289        };
3290        // Mentions the src parameter and the resolved path.
3291        assert!(
3292            body.contains("chapter.lex"),
3293            "hover should name target: {body}"
3294        );
3295        // Includes a preview chunk from the file content.
3296        assert!(
3297            body.contains("1. Chapter"),
3298            "hover should preview content: {body}"
3299        );
3300    }
3301
3302    #[tokio::test]
3303    async fn hover_off_include_falls_through_to_normal_hover() {
3304        // The default feature provider's hover is a no-op for plain
3305        // text positions, so we just check that the include-specific
3306        // path didn't fire and produce a phantom hover.
3307        let (server, _client, uri, _dir) = open_in_tempdir(
3308            &[("main.lex", "1. Chapter\n\n    Just text.\n")],
3309            "main.lex",
3310        )
3311        .await;
3312        let hover = server.hover(hover_at(&uri, 2, 8)).await.unwrap();
3313        if let Some(h) = hover {
3314            // If something does come back, it must NOT be the include
3315            // preview (which always mentions "lex.include").
3316            let body = match h.contents {
3317                HoverContents::Markup(m) => m.value,
3318                _ => String::new(),
3319            };
3320            assert!(
3321                !body.contains("lex.include"),
3322                "non-include cursor must not get include preview, got {body}"
3323            );
3324        }
3325    }
3326
3327    #[tokio::test]
3328    async fn goto_definition_on_include_with_missing_target_returns_none() {
3329        // A broken include (target file doesn't exist on disk) — goto-def
3330        // returns None so the editor renders its native "no definition
3331        // found" UX. The user already gets the missing-target signal via
3332        // the PR 8 `include-not-found` diagnostic; we don't want to also
3333        // navigate them to a phantom buffer.
3334        let (server, _client, uri, _dir) = open_in_tempdir(
3335            &[("main.lex", ":: lex.include src=\"missing.lex\" ::\n")],
3336            "main.lex",
3337        )
3338        .await;
3339        let response = server.goto_definition(goto_at(&uri, 0, 5)).await.unwrap();
3340        assert!(
3341            response.is_none(),
3342            "goto-def must return None for missing targets, got {response:?}"
3343        );
3344    }
3345
3346    // ========================================================================
3347    // lex.extractToInclude — end-to-end via executeCommand (lex#497)
3348    // ========================================================================
3349
3350    #[tokio::test]
3351    async fn extract_to_include_returns_workspace_edit_with_create_and_replace() {
3352        let host_text = "Doc\n===\n\n1. Section\n\n    Some content.\n    More content.\n";
3353        let (server, _client, uri, dir) =
3354            open_in_tempdir(&[("main.lex", host_text)], "main.lex").await;
3355
3356        // Select the indented body of the section — lines 5–6, column 4–end.
3357        let range = Range::new(Position::new(5, 4), Position::new(7, 0));
3358        let result = server
3359            .execute_command(ExecuteCommandParams {
3360                command: commands::COMMAND_EXTRACT_TO_INCLUDE.to_string(),
3361                arguments: vec![
3362                    Value::String(uri.to_string()),
3363                    serde_json::to_value(range).unwrap(),
3364                    Value::String("section-body.lex".to_string()),
3365                ],
3366                work_done_progress_params: Default::default(),
3367            })
3368            .await
3369            .unwrap()
3370            .expect("extract command should return WorkspaceEdit");
3371
3372        let edit: tower_lsp::lsp_types::WorkspaceEdit = serde_json::from_value(result).unwrap();
3373        let ops = match edit.document_changes.unwrap() {
3374            tower_lsp::lsp_types::DocumentChanges::Operations(ops) => ops,
3375            _ => panic!("expected operations"),
3376        };
3377        assert_eq!(ops.len(), 3, "create + target-content + host-replace");
3378
3379        // First op: create target.
3380        match &ops[0] {
3381            tower_lsp::lsp_types::DocumentChangeOperation::Op(
3382                tower_lsp::lsp_types::ResourceOp::Create(c),
3383            ) => {
3384                assert!(c.uri.path().ends_with("section-body.lex"));
3385            }
3386            other => panic!("expected CreateFile, got {other:?}"),
3387        }
3388
3389        // Second op writes the indent-shifted selection into the target.
3390        let target_text = match &ops[1] {
3391            tower_lsp::lsp_types::DocumentChangeOperation::Edit(e) => match &e.edits[0] {
3392                OneOf::Left(t) => t.new_text.clone(),
3393                _ => panic!("unexpected edit shape"),
3394            },
3395            _ => panic!("expected TextDocumentEdit for target"),
3396        };
3397        assert!(
3398            target_text.contains("Some content.") && target_text.contains("More content."),
3399            "target should hold the extracted body, got: {target_text:?}"
3400        );
3401        // Indent-shifted: should start at column 0.
3402        assert!(
3403            target_text.starts_with("Some content."),
3404            "expected indent shift to drop leading 4 spaces, got: {target_text:?}"
3405        );
3406
3407        // Third op replaces the host range with `:: lex.include ::` at indent 4.
3408        let host_replace = match &ops[2] {
3409            tower_lsp::lsp_types::DocumentChangeOperation::Edit(e) => match &e.edits[0] {
3410                OneOf::Left(t) => t.new_text.clone(),
3411                _ => panic!("unexpected edit shape"),
3412            },
3413            _ => panic!("expected TextDocumentEdit for host"),
3414        };
3415        assert_eq!(
3416            host_replace,
3417            "    :: lex.include src=\"section-body.lex\" ::"
3418        );
3419
3420        // Keep dir alive until end of test.
3421        drop(dir);
3422    }
3423
3424    #[tokio::test]
3425    async fn extract_to_include_surfaces_validation_errors_as_invalid_params() {
3426        let host_text = "Doc\n===\n\n    Body text.\n";
3427        let (server, _client, uri, _dir) =
3428            open_in_tempdir(&[("main.lex", host_text)], "main.lex").await;
3429
3430        let range = Range::new(Position::new(3, 4), Position::new(4, 0));
3431        let err = server
3432            .execute_command(ExecuteCommandParams {
3433                command: commands::COMMAND_EXTRACT_TO_INCLUDE.to_string(),
3434                arguments: vec![
3435                    Value::String(uri.to_string()),
3436                    serde_json::to_value(range).unwrap(),
3437                    Value::String("https://elsewhere/foo.lex".to_string()),
3438                ],
3439                work_done_progress_params: Default::default(),
3440            })
3441            .await
3442            .unwrap_err();
3443        assert!(
3444            err.message.contains("URL"),
3445            "expected URL-scheme error message, got: {}",
3446            err.message
3447        );
3448    }
3449
3450    #[tokio::test]
3451    async fn extract_to_include_capability_advertises_command() {
3452        let provider = Arc::new(MockFeatureProvider::default());
3453        let server = LexLanguageServer::with_features(NoopClient, provider);
3454        let init = server
3455            .initialize(InitializeParams::default())
3456            .await
3457            .unwrap();
3458        let advertised = init
3459            .capabilities
3460            .execute_command_provider
3461            .expect("execute_command_provider")
3462            .commands;
3463        assert!(
3464            advertised.contains(&commands::COMMAND_EXTRACT_TO_INCLUDE.to_string()),
3465            "extractToInclude must be in advertised commands, got: {advertised:?}"
3466        );
3467    }
3468
3469    /// `slice_text_by_range` treats each endpoint's `Position.character`
3470    /// (`range.start.character` / `range.end.character`) as a UTF-8 byte
3471    /// offset, matching lex-core's `SourceLocation::byte_to_position`
3472    /// (which sets `column = byte_offset - line_start`). Char-based
3473    /// slicing would mis-slice selections containing multi-byte chars;
3474    /// this test pins the byte semantics.
3475    #[test]
3476    fn slice_text_by_range_uses_utf8_byte_offsets() {
3477        let text = "café\nrestaurant\n";
3478        // The é is 2 UTF-8 bytes, so "café" occupies bytes 0..5.
3479        let range = Range::new(Position::new(0, 0), Position::new(0, 5));
3480        assert_eq!(slice_text_by_range(text, range).as_deref(), Some("café"));
3481
3482        // Mid-character byte offset (between the two bytes of é) is rejected.
3483        let bad = Range::new(Position::new(0, 0), Position::new(0, 4));
3484        assert!(slice_text_by_range(text, bad).is_none());
3485
3486        // Multi-line slice with non-ASCII in the source.
3487        let multi = Range::new(Position::new(0, 0), Position::new(1, 10));
3488        assert_eq!(
3489            slice_text_by_range(text, multi).as_deref(),
3490            Some("café\nrestaurant")
3491        );
3492    }
3493
3494    #[tokio::test]
3495    async fn includes_untitled_uri_skips_resolution_without_error() {
3496        // Untitled URIs (no on-disk anchor) can't drive include
3497        // resolution. The server must handle these gracefully — no
3498        // panics, no spurious include diagnostics.
3499        let client = CapturingClient::default();
3500        let server = LexLanguageServer::with_features(
3501            client.clone(),
3502            Arc::new(DefaultFeatureProvider::new()),
3503        );
3504        let uri: Url = "untitled:Untitled-1".parse().unwrap();
3505        server
3506            .did_open(DidOpenTextDocumentParams {
3507                text_document: TextDocumentItem {
3508                    uri: uri.clone(),
3509                    language_id: "lex".into(),
3510                    version: 1,
3511                    text: "1. Host\n\n    Some content.\n".to_string(),
3512                },
3513            })
3514            .await;
3515
3516        let diags = client.diagnostics_for(&uri);
3517        assert!(
3518            !diags.iter().any(|d| matches!(
3519                &d.code,
3520                Some(tower_lsp::lsp_types::NumberOrString::String(c)) if c.starts_with("include-")
3521            )),
3522            "untitled URIs should produce no include-* diagnostics, got {diags:?}"
3523        );
3524    }
3525
3526    // ========================================================================
3527    // Smart paste — `lex/preparePaste` request wiring (comms#73).
3528    //
3529    // The transform itself is exhaustively table-tested in
3530    // `lex_lsp_core::prepare_paste`; these tests cover the server-side wiring:
3531    // capability advertisement, document-store lookup, and the missing-buffer
3532    // fallback.
3533    // ========================================================================
3534
3535    #[tokio::test]
3536    async fn initialize_advertises_prepare_paste_capability() {
3537        let provider = Arc::new(MockFeatureProvider::default());
3538        let server = LexLanguageServer::with_features(NoopClient, provider);
3539
3540        let result = server
3541            .initialize(InitializeParams::default())
3542            .await
3543            .unwrap();
3544
3545        let experimental = result
3546            .capabilities
3547            .experimental
3548            .expect("experimental capabilities advertised");
3549        assert_eq!(experimental["lexPreparePaste"], serde_json::json!(true));
3550    }
3551
3552    #[tokio::test]
3553    async fn prepare_paste_reanchors_against_open_buffer() {
3554        let provider = Arc::new(MockFeatureProvider::default());
3555        let server = LexLanguageServer::with_features(NoopClient, provider);
3556        let uri = Url::from_file_path("/tmp/paste.lex").unwrap();
3557
3558        server
3559            .did_open(DidOpenTextDocumentParams {
3560                text_document: TextDocumentItem {
3561                    uri: uri.clone(),
3562                    language_id: "lex".into(),
3563                    version: 1,
3564                    text: "Top:\n\n    existing\n\n".to_string(),
3565                },
3566            })
3567            .await;
3568
3569        // Fresh blank line 3, inside the session (content indent 4). Paste a
3570        // column-zero two-line block; both lines re-anchor to indent 4.
3571        let result = server
3572            .prepare_paste(PreparePasteParams {
3573                text_document: TextDocumentIdentifier { uri },
3574                range: Range {
3575                    start: Position::new(3, 0),
3576                    end: Position::new(3, 0),
3577                },
3578                pasted_text: "first\n    second\n".to_string(),
3579            })
3580            .await
3581            .unwrap();
3582
3583        assert_eq!(result.mode, PasteMode::Reanchor);
3584        assert_eq!(result.text, "    first\n        second\n");
3585    }
3586
3587    #[tokio::test]
3588    async fn prepare_paste_passes_through_verbatim() {
3589        let provider = Arc::new(MockFeatureProvider::default());
3590        let server = LexLanguageServer::with_features(NoopClient, provider);
3591        let uri = Url::from_file_path("/tmp/verb.lex").unwrap();
3592
3593        server
3594            .did_open(DidOpenTextDocumentParams {
3595                text_document: TextDocumentItem {
3596                    uri: uri.clone(),
3597                    language_id: "lex".into(),
3598                    version: 1,
3599                    text: "Code:\n    line one\n    line two\n:: text ::\n".to_string(),
3600                },
3601            })
3602            .await;
3603
3604        let pasted = "  weird\n      indent\n".to_string();
3605        let result = server
3606            .prepare_paste(PreparePasteParams {
3607                text_document: TextDocumentIdentifier { uri },
3608                range: Range {
3609                    start: Position::new(1, 8),
3610                    end: Position::new(1, 8),
3611                },
3612                pasted_text: pasted.clone(),
3613            })
3614            .await
3615            .unwrap();
3616
3617        assert_eq!(result.mode, PasteMode::PassthroughVerbatim);
3618        assert_eq!(result.text, pasted);
3619    }
3620
3621    #[tokio::test]
3622    async fn prepare_paste_unopened_buffer_echoes_clipboard() {
3623        let provider = Arc::new(MockFeatureProvider::default());
3624        let server = LexLanguageServer::with_features(NoopClient, provider);
3625        let uri = Url::from_file_path("/tmp/never-opened.lex").unwrap();
3626
3627        let pasted = "anything\n    here\n".to_string();
3628        let result = server
3629            .prepare_paste(PreparePasteParams {
3630                text_document: TextDocumentIdentifier { uri },
3631                range: Range {
3632                    start: Position::new(0, 0),
3633                    end: Position::new(0, 0),
3634                },
3635                pasted_text: pasted.clone(),
3636            })
3637            .await
3638            .unwrap();
3639
3640        // No parse to consult — clipboard echoed back unchanged so the editor
3641        // still completes the paste.
3642        assert_eq!(result.text, pasted);
3643    }
3644}