Skip to main content

php_lsp/backend/
server.rs

1#![allow(unused_imports)]
2
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use arc_swap::ArcSwap;
7
8use tower_lsp::jsonrpc::Result;
9use tower_lsp::lsp_types::notification::Progress as ProgressNotification;
10use tower_lsp::lsp_types::request::WorkDoneProgressCreate;
11use tower_lsp::lsp_types::*;
12use tower_lsp::{Client, LanguageServer, async_trait};
13
14use php_ast::{
15    ClassMember, ClassMemberKind, EnumMember, EnumMemberKind, ExprKind, NamespaceBody, Stmt,
16    StmtKind,
17};
18
19use crate::ast::{ParsedDoc, str_offset};
20use crate::autoload::Psr4Map;
21use crate::completion::{CompletionCtx, filtered_completions_at};
22use crate::config::LspConfig;
23use crate::document_store::DocumentStore;
24use crate::file_rename::{use_edits_for_delete, use_edits_for_rename};
25use crate::hover::{
26    class_hover_from_index, docs_for_symbol_from_index, hover_info_with_maps,
27    signature_for_symbol_from_index,
28};
29use crate::open_files::{OpenFiles, compute_open_file_diagnostics};
30use crate::panic_guard::{guard_async, guard_async_result};
31use crate::phpstorm_meta::PhpStormMeta;
32use crate::symbols::{
33    document_symbols, resolve_workspace_symbol, workspace_symbols_from_workspace,
34};
35use crate::use_import::{build_use_import_edit, find_fqn_for_class};
36use crate::util::{fqn_short_name, word_at_position};
37use crate::workspace_scan::{scan_workspace, send_refresh_requests};
38
39use crate::actions::extract_action::extract_variable_actions;
40use crate::actions::extract_constant_action::extract_constant_actions;
41use crate::actions::extract_method_action::extract_method_actions;
42use crate::actions::generate_action::{
43    generate_constructor_actions, generate_getters_setters_actions,
44};
45use crate::actions::implement_action::implement_missing_actions;
46use crate::actions::inline_action::inline_variable_actions;
47use crate::actions::phpdoc_action::phpdoc_actions;
48use crate::actions::promote_action::promote_constructor_actions;
49use crate::actions::type_action::add_return_type_actions;
50
51use crate::navigation::call_hierarchy::{
52    incoming_calls, outgoing_calls_indexed, prepare_call_hierarchy_indexed,
53};
54use crate::navigation::declaration::{goto_declaration, goto_declaration_from_index};
55use crate::navigation::definition::{
56    find_declaration_range, find_method_in_class_hierarchy, find_method_range_in_class,
57    goto_definition,
58};
59use crate::navigation::implementation::{
60    find_implementations, find_implementations_from_workspace,
61};
62use crate::navigation::moniker::moniker_at;
63use crate::navigation::references::{
64    SymbolKind, find_constructor_references, find_references, find_references_with_target,
65};
66use crate::navigation::type_definition::{goto_type_definition, goto_type_definition_from_index};
67use crate::navigation::type_hierarchy::{
68    prepare_type_hierarchy_from_workspace, subtypes_of_from_workspace, supertypes_of_from_workspace,
69};
70
71use crate::analysis::code_lens::code_lenses;
72use crate::analysis::diagnostics::{
73    merge_file_diagnostics, parse_document, parse_document_no_diags,
74};
75use crate::analysis::document_highlight::document_highlights;
76use crate::analysis::inlay_hints::inlay_hints;
77use crate::analysis::inline_value::inline_values_in_range;
78use crate::analysis::semantic_tokens::{
79    compute_token_delta, legend, semantic_tokens, semantic_tokens_range, token_hash,
80};
81
82use crate::editing::document_link::document_links;
83use crate::editing::folding::folding_ranges;
84use crate::editing::formatting::{format_document, format_range};
85use crate::editing::on_type_format::on_type_format;
86use crate::editing::organize_imports::organize_imports_action;
87use crate::editing::rename::{prepare_rename, rename, rename_property, rename_variable};
88use crate::editing::selection_range::selection_ranges;
89use crate::editing::signature_help::signature_help;
90
91use super::helpers::{
92    DEFERRED_ACTION_TAGS, class_name_at_construct_decl, cursor_is_on_constant_decl,
93    cursor_is_on_method_decl, cursor_is_on_property_decl, defer_actions, is_after_arrow,
94    php_file_op, position_to_byte_offset, promoted_property_at_cursor, range_within, run_phpunit,
95    symbol_kind_at,
96};
97use super::{
98    Backend, IndexReadyNotification, build_mir_symbol, compute_dependent_publishes_owned,
99    compute_diagnostic_result_id, resolve_reference_symbol,
100};
101
102#[async_trait]
103impl LanguageServer for Backend {
104    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
105        // Collect all workspace roots. Prefer workspace_folders (multi-root) over
106        // the deprecated root_uri (single root).
107        {
108            let mut roots: Vec<PathBuf> = params
109                .workspace_folders
110                .as_deref()
111                .unwrap_or(&[])
112                .iter()
113                .filter_map(|f| f.uri.to_file_path().ok())
114                .collect();
115            if roots.is_empty()
116                && let Some(path) = params.root_uri.as_ref().and_then(|u| u.to_file_path().ok())
117            {
118                roots.push(path);
119            }
120            self.root_paths.store(Arc::new(roots));
121        }
122
123        {
124            let opts = params.initialization_options.as_ref();
125            let roots = self.root_paths.load_full();
126            let file_cfg = crate::autoload::load_project_config_json(&roots);
127
128            if matches!(file_cfg, Some(serde_json::Value::Null)) {
129                self.client
130                    .log_message(
131                        tower_lsp::lsp_types::MessageType::WARNING,
132                        "php-lsp: .php-lsp.json contains invalid JSON — ignoring",
133                    )
134                    .await;
135            }
136
137            if let Some(serde_json::Value::Object(ref obj)) = file_cfg
138                && let Some(ver) = obj.get("phpVersion").and_then(|v| v.as_str())
139                && !crate::autoload::is_valid_php_version(ver)
140            {
141                self.client
142                    .log_message(
143                        tower_lsp::lsp_types::MessageType::WARNING,
144                        format!(
145                            "php-lsp: .php-lsp.json unsupported phpVersion {ver:?} — valid values: {}",
146                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
147                        ),
148                    )
149                    .await;
150            }
151
152            if let Some(ver) = opts
153                .and_then(|o| o.get("phpVersion"))
154                .and_then(|v| v.as_str())
155                && !crate::autoload::is_valid_php_version(ver)
156            {
157                self.client
158                    .log_message(
159                        tower_lsp::lsp_types::MessageType::WARNING,
160                        format!(
161                            "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
162                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
163                        ),
164                    )
165                    .await;
166            }
167
168            // Merge: file config is the base; editor initializationOptions override per-key.
169            // excludePaths arrays are concatenated rather than replaced.
170            let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
171            let merged = LspConfig::merge_project_configs(file_obj, opts);
172            let mut cfg = LspConfig::from_value(&merged);
173
174            // PSR-4 loading and PHP version resolution both involve blocking I/O
175            // (filesystem reads, and potentially spawning `php --version`). Run
176            // them concurrently on the blocking thread pool so initialize responds
177            // in max(psr4_time, version_time) rather than psr4_time + version_time.
178            let roots_for_psr4 = (*roots).clone();
179            let roots_for_ver = (*roots).clone();
180            let explicit_version = cfg.php_version.clone();
181            let (psr4_result, ver_result) = tokio::join!(
182                tokio::task::spawn_blocking(move || {
183                    let mut merged = Psr4Map::empty();
184                    for root in &roots_for_psr4 {
185                        merged.extend(Psr4Map::load(root));
186                    }
187                    merged
188                }),
189                tokio::task::spawn_blocking(move || {
190                    crate::autoload::resolve_php_version_from_roots(
191                        &roots_for_ver,
192                        explicit_version.as_deref(),
193                    )
194                }),
195            );
196            if let Ok(psr4) = psr4_result {
197                self.psr4.store(Arc::new(psr4));
198            }
199            let (ver, source) =
200                ver_result.unwrap_or_else(|_| (crate::autoload::PHP_8_5.to_string(), "default"));
201            self.client
202                .log_message(
203                    tower_lsp::lsp_types::MessageType::INFO,
204                    format!("php-lsp: using PHP {ver} ({source})"),
205                )
206                .await;
207            let ver = if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
208                let clamped = crate::autoload::clamp_php_version(&ver);
209                self.client
210                    .show_message(
211                        tower_lsp::lsp_types::MessageType::WARNING,
212                        format!(
213                            "php-lsp: detected PHP {ver} is outside the supported range \
214                                 ({}); using PHP {clamped} for analysis",
215                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
216                        ),
217                    )
218                    .await;
219                clamped.to_string()
220            } else {
221                ver
222            };
223            cfg.php_version = Some(ver.clone());
224            if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
225                self.docs.set_php_version(pv);
226            }
227            self.config.store(Arc::new(cfg));
228        }
229
230        let feat = self.config.load().features.clone();
231        Ok(InitializeResult {
232            capabilities: ServerCapabilities {
233                text_document_sync: Some(TextDocumentSyncCapability::Options(
234                    TextDocumentSyncOptions {
235                        open_close: Some(true),
236                        change: Some(TextDocumentSyncKind::INCREMENTAL),
237                        will_save: Some(true),
238                        will_save_wait_until: Some(true),
239                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
240                            include_text: Some(false),
241                        })),
242                    },
243                )),
244                completion_provider: feat.completion.then(|| CompletionOptions {
245                    trigger_characters: Some(vec![
246                        "$".to_string(),
247                        ">".to_string(),
248                        ":".to_string(),
249                        "(".to_string(),
250                        "[".to_string(),
251                    ]),
252                    resolve_provider: Some(true),
253                    ..Default::default()
254                }),
255                hover_provider: feat.hover.then_some(HoverProviderCapability::Simple(true)),
256                definition_provider: feat.definition.then_some(OneOf::Left(true)),
257                references_provider: feat.references.then_some(OneOf::Left(true)),
258                document_symbol_provider: feat.document_symbols.then_some(OneOf::Left(true)),
259                workspace_symbol_provider: feat.workspace_symbols.then(|| {
260                    OneOf::Right(WorkspaceSymbolOptions {
261                        resolve_provider: Some(true),
262                        work_done_progress_options: Default::default(),
263                    })
264                }),
265                rename_provider: feat.rename.then(|| {
266                    OneOf::Right(RenameOptions {
267                        prepare_provider: Some(true),
268                        work_done_progress_options: Default::default(),
269                    })
270                }),
271                signature_help_provider: feat.signature_help.then(|| SignatureHelpOptions {
272                    trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
273                    retrigger_characters: None,
274                    work_done_progress_options: Default::default(),
275                }),
276                inlay_hint_provider: feat.inlay_hints.then(|| {
277                    OneOf::Right(InlayHintServerCapabilities::Options(InlayHintOptions {
278                        resolve_provider: Some(true),
279                        work_done_progress_options: Default::default(),
280                    }))
281                }),
282                folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
283                semantic_tokens_provider: feat.semantic_tokens.then(|| {
284                    SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
285                        legend: legend(),
286                        full: Some(SemanticTokensFullOptions::Delta { delta: Some(true) }),
287                        range: Some(true),
288                        ..Default::default()
289                    })
290                }),
291                selection_range_provider: feat
292                    .selection_range
293                    .then_some(SelectionRangeProviderCapability::Simple(true)),
294                call_hierarchy_provider: feat
295                    .call_hierarchy
296                    .then_some(CallHierarchyServerCapability::Simple(true)),
297                document_highlight_provider: feat.document_highlight.then_some(OneOf::Left(true)),
298                implementation_provider: feat
299                    .implementation
300                    .then_some(ImplementationProviderCapability::Simple(true)),
301                code_action_provider: feat.code_action.then(|| {
302                    CodeActionProviderCapability::Options(CodeActionOptions {
303                        resolve_provider: Some(true),
304                        ..Default::default()
305                    })
306                }),
307                declaration_provider: feat
308                    .declaration
309                    .then_some(DeclarationCapability::Simple(true)),
310                type_definition_provider: feat
311                    .type_definition
312                    .then_some(TypeDefinitionProviderCapability::Simple(true)),
313                code_lens_provider: feat.code_lens.then_some(CodeLensOptions {
314                    resolve_provider: Some(true),
315                }),
316                document_formatting_provider: feat.formatting.then_some(OneOf::Left(true)),
317                document_range_formatting_provider: feat
318                    .range_formatting
319                    .then_some(OneOf::Left(true)),
320                document_on_type_formatting_provider: feat.on_type_formatting.then(|| {
321                    DocumentOnTypeFormattingOptions {
322                        first_trigger_character: "}".to_string(),
323                        more_trigger_character: Some(vec!["\n".to_string()]),
324                    }
325                }),
326                document_link_provider: feat.document_link.then(|| DocumentLinkOptions {
327                    resolve_provider: Some(true),
328                    work_done_progress_options: Default::default(),
329                }),
330                execute_command_provider: Some(ExecuteCommandOptions {
331                    commands: vec!["php-lsp.runTest".to_string()],
332                    work_done_progress_options: Default::default(),
333                }),
334                diagnostic_provider: Some(DiagnosticServerCapabilities::Options(
335                    DiagnosticOptions {
336                        identifier: None,
337                        inter_file_dependencies: true,
338                        workspace_diagnostics: true,
339                        work_done_progress_options: Default::default(),
340                    },
341                )),
342                workspace: Some(WorkspaceServerCapabilities {
343                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
344                        supported: Some(true),
345                        change_notifications: Some(OneOf::Left(true)),
346                    }),
347                    file_operations: Some(WorkspaceFileOperationsServerCapabilities {
348                        will_rename: Some(php_file_op()),
349                        did_rename: Some(php_file_op()),
350                        will_create: Some(php_file_op()),
351                        did_create: Some(php_file_op()),
352                        will_delete: Some(php_file_op()),
353                        did_delete: Some(php_file_op()),
354                    }),
355                }),
356                linked_editing_range_provider: feat
357                    .linked_editing_range
358                    .then_some(LinkedEditingRangeServerCapabilities::Simple(true)),
359                moniker_provider: Some(OneOf::Left(true)),
360                inline_value_provider: feat.inline_values.then(|| {
361                    OneOf::Right(InlineValueServerCapabilities::Options(InlineValueOptions {
362                        work_done_progress_options: Default::default(),
363                    }))
364                }),
365                ..Default::default()
366            },
367            ..Default::default()
368        })
369    }
370
371    async fn initialized(&self, _params: InitializedParams) {
372        // Register dynamic capabilities: file watcher + type hierarchy
373        let php_selector = serde_json::json!([{"language": "php"}]);
374        let registrations = vec![
375            Registration {
376                id: "php-lsp-file-watcher".to_string(),
377                method: "workspace/didChangeWatchedFiles".to_string(),
378                register_options: Some(serde_json::json!({
379                    "watchers": [{"globPattern": "**/*.php"}]
380                })),
381            },
382            // Type hierarchy has no static ServerCapabilities field in lsp-types 0.94,
383            // so register it dynamically here.
384            Registration {
385                id: "php-lsp-type-hierarchy".to_string(),
386                method: "textDocument/prepareTypeHierarchy".to_string(),
387                register_options: Some(serde_json::json!({"documentSelector": php_selector})),
388            },
389            Registration {
390                id: "php-lsp-config-change".to_string(),
391                method: "workspace/didChangeConfiguration".to_string(),
392                register_options: Some(serde_json::json!({"section": "php-lsp"})),
393            },
394        ];
395        self.client.register_capability(registrations).await.ok();
396
397        let roots: Vec<PathBuf> = (**self.root_paths.load()).clone();
398        if !roots.is_empty() {
399            {
400                let mut merged = Psr4Map::empty();
401                for root in &roots {
402                    merged.extend(Psr4Map::load(root));
403                }
404                self.psr4.store(Arc::new(merged));
405            }
406            self.meta.store(Arc::new(PhpStormMeta::load(&roots[0])));
407
408            let token = NumberOrString::String("php-lsp/indexing".to_string());
409            self.client
410                .send_request::<WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
411                    token: token.clone(),
412                })
413                .await
414                .ok();
415
416            // Background-warm the mir AnalysisSession so the first did_open
417            // doesn't pay ~80ms of stub loading.  Fired before the scan so
418            // warm-up and file I/O overlap on separate blocking threads.
419            let warm_docs = Arc::clone(&self.docs);
420            tokio::task::spawn_blocking(move || {
421                let php_version = warm_docs.workspace_php_version();
422                warm_docs.analysis_session(php_version);
423            });
424
425            let docs = Arc::clone(&self.docs);
426            let open_files = self.open_files.clone();
427            let client = self.client.clone();
428            let (exclude_paths, include_paths, max_indexed_files) = {
429                let cfg = self.config.load();
430                let mut exclude = cfg.exclude_paths.clone();
431                // Lazy vendor: by default we skip `vendor/` during the eager
432                // scan and rely on PSR-4 resolution (`psr4_goto`) to index
433                // vendor files on demand. Users with workspaces small enough
434                // to want full-vendor symbol search can opt in via
435                // `indexVendor: true`.
436                if !cfg.index_vendor && !exclude.iter().any(|p| p == "vendor" || p == "vendor/") {
437                    exclude.push("vendor/".to_string());
438                }
439                (exclude, cfg.include_paths.clone(), cfg.max_indexed_files)
440            };
441            tokio::spawn(async move {
442                client
443                    .send_notification::<ProgressNotification>(ProgressParams {
444                        token: token.clone(),
445                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(
446                            WorkDoneProgressBegin {
447                                title: "php-lsp: indexing workspace".to_string(),
448                                cancellable: Some(false),
449                                message: None,
450                                percentage: None,
451                            },
452                        )),
453                    })
454                    .await;
455
456                let mut total = 0usize;
457                let mut session_cache_set = false;
458                for root in roots {
459                    // Phase K2b: open the on-disk cache for this root. If the
460                    // system has no usable cache dir (weird XDG env, sandboxed
461                    // runner, read-only home), `new` returns None and every
462                    // per-file `cache.as_ref()` guard below no-ops — scan still
463                    // runs, just without persistence.
464                    let cache = crate::cache::WorkspaceCache::new(&root);
465                    // Wire the first available cache directory into the
466                    // AnalysisSession builder so stub-parse results survive
467                    // server restarts.
468                    if !session_cache_set && let Some(ref c) = cache {
469                        let session_dir = c.cache_dir().join("session");
470                        docs.set_session_cache_dir(session_dir);
471                        session_cache_set = true;
472                    }
473                    total += scan_workspace(
474                        root,
475                        Arc::clone(&docs),
476                        open_files.clone(),
477                        cache,
478                        &exclude_paths,
479                        &include_paths,
480                        max_indexed_files,
481                    )
482                    .await;
483                }
484
485                client
486                    .send_notification::<ProgressNotification>(ProgressParams {
487                        token,
488                        value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(
489                            WorkDoneProgressEnd {
490                                message: Some(format!("Indexed {total} files")),
491                            },
492                        )),
493                    })
494                    .await;
495
496                client
497                    .log_message(
498                        MessageType::INFO,
499                        format!("php-lsp: indexed {total} workspace files"),
500                    )
501                    .await;
502
503                // Ask clients to re-request tokens/lenses/hints/diagnostics now
504                // that the index is populated. Without this, editors that opened
505                // files before indexing finished would show stale information.
506                send_refresh_requests(&client).await;
507
508                // Phase D: reference index is lazy. `textDocument/references`
509                // drives `symbol_refs(ws, key)` on demand; salsa memoizes the
510                // per-file `file_refs` across requests. Invalidation is
511                // automatic on edits.
512                //
513                // Phase L: warm the memo in the background so the first real
514                // reference lookup doesn't pay the full-workspace walk.
515                // `symbol_refs(ws, <any key>)` iterates every file's
516                // `file_refs` to build its result — even with a sentinel key
517                // that matches nothing, the per-file walk runs and populates
518                // salsa's memo. Fire-and-forget: a reference request that
519                // arrives mid-warmup just retries through
520                // `snapshot_query`'s `salsa::Cancelled` handling.
521                let salsa_docs = Arc::clone(&docs);
522                drop(docs);
523                client.send_notification::<IndexReadyNotification>(()).await;
524                // Pre-warm salsa cache in the background so subsequent workspace
525                // requests don't pay the full parse cost on the first hit.
526                // Fire-and-forget: a request arriving mid-warmup retries via
527                // snapshot_query's salsa::Cancelled handling.
528                drop(tokio::task::spawn_blocking(move || {
529                    salsa_docs.get_workspace_index_salsa();
530                }));
531            });
532        }
533
534        self.client
535            .log_message(MessageType::INFO, "php-lsp ready")
536            .await;
537    }
538
539    async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
540        // Pull the current configuration from the client rather than parsing the
541        // (often-null) params.settings, which not all clients populate.
542        let items = vec![ConfigurationItem {
543            scope_uri: None,
544            section: Some("php-lsp".to_string()),
545        }];
546        if let Ok(values) = self.client.configuration(items).await
547            && let Some(value) = values.into_iter().next()
548        {
549            let roots = self.root_paths.load_full();
550
551            // Re-read .php-lsp.json so a user who edits the file and then
552            // triggers a configuration reload picks up the latest values.
553            let file_cfg = crate::autoload::load_project_config_json(&roots);
554
555            if let Some(ver) = value.get("phpVersion").and_then(|v| v.as_str())
556                && !crate::autoload::is_valid_php_version(ver)
557            {
558                self.client
559                    .log_message(
560                        tower_lsp::lsp_types::MessageType::WARNING,
561                        format!(
562                            "php-lsp: unsupported phpVersion {ver:?} — valid values: {}",
563                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
564                        ),
565                    )
566                    .await;
567            }
568
569            let file_obj = file_cfg.as_ref().filter(|v| v.is_object());
570            let merged = LspConfig::merge_project_configs(file_obj, Some(&value));
571            let mut cfg = LspConfig::from_value(&merged);
572
573            // Resolve the PHP version and log what was chosen and why.
574            let (ver, source) = self.resolve_php_version(cfg.php_version.as_deref());
575            self.client
576                .log_message(
577                    tower_lsp::lsp_types::MessageType::INFO,
578                    format!("php-lsp: using PHP {ver} ({source})"),
579                )
580                .await;
581            // Clamp unsupported versions to the nearest supported one and warn.
582            let ver = if source != "set by editor" && !crate::autoload::is_valid_php_version(&ver) {
583                let clamped = crate::autoload::clamp_php_version(&ver);
584                self.client
585                    .show_message(
586                        tower_lsp::lsp_types::MessageType::WARNING,
587                        format!(
588                            "php-lsp: detected PHP {ver} is outside the supported range ({}); \
589                             using PHP {clamped} for analysis",
590                            crate::autoload::SUPPORTED_PHP_VERSIONS.join(", ")
591                        ),
592                    )
593                    .await;
594                clamped.to_string()
595            } else {
596                ver
597            };
598            cfg.php_version = Some(ver.clone());
599            if let Ok(pv) = ver.parse::<mir_analyzer::PhpVersion>() {
600                self.docs.set_php_version(pv);
601            }
602            self.config.store(Arc::new(cfg));
603            send_refresh_requests(&self.client).await;
604        }
605    }
606
607    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
608        // Remove folders from our tracked roots.
609        {
610            let mut roots = (**self.root_paths.load()).clone();
611            for removed in &params.event.removed {
612                if let Ok(path) = removed.uri.to_file_path() {
613                    roots.retain(|r| r != &path);
614                }
615            }
616            self.root_paths.store(Arc::new(roots));
617        }
618
619        // Add new folders and kick off background scans for each.
620        let (exclude_paths, include_paths, max_indexed_files) = {
621            let cfg = self.config.load();
622            (
623                cfg.exclude_paths.clone(),
624                cfg.include_paths.clone(),
625                cfg.max_indexed_files,
626            )
627        };
628        for added in &params.event.added {
629            if let Ok(path) = added.uri.to_file_path() {
630                let is_new = {
631                    let mut roots = (**self.root_paths.load()).clone();
632                    if !roots.contains(&path) {
633                        roots.push(path.clone());
634                        self.root_paths.store(Arc::new(roots));
635                        true
636                    } else {
637                        false
638                    }
639                };
640                if is_new {
641                    let docs = Arc::clone(&self.docs);
642                    let open_files = self.open_files.clone();
643                    let ex = exclude_paths.clone();
644                    let ip = include_paths.clone();
645                    let path_clone = path.clone();
646                    let client = self.client.clone();
647                    tokio::spawn(async move {
648                        let cache = crate::cache::WorkspaceCache::new(&path_clone);
649                        scan_workspace(
650                            path_clone,
651                            docs,
652                            open_files,
653                            cache,
654                            &ex,
655                            &ip,
656                            max_indexed_files,
657                        )
658                        .await;
659                        send_refresh_requests(&client).await;
660                    });
661                }
662            }
663        }
664    }
665
666    async fn shutdown(&self) -> Result<()> {
667        Ok(())
668    }
669
670    #[tracing::instrument(skip_all)]
671    async fn did_open(&self, params: DidOpenTextDocumentParams) {
672        guard_async("did_open", async move {
673            let uri = params.text_document.uri;
674            let text = params.text_document.text;
675
676            // Store text immediately so other features work while parsing.
677            // This also mirrors the new text into salsa, so the codebase query
678            // sees it when semantic_diagnostics runs below.
679            self.set_open_text(uri.clone(), text.clone());
680
681            let docs_for_spawn = Arc::clone(&self.docs);
682            let diag_cfg = self.config.load().diagnostics.clone();
683
684            // Phase I: semantic analysis on the blocking pool (CPU-bound, hundreds
685            // of ms on cold files).  We skip the redundant standalone parse_document
686            // call that used to precede this — get_semantic_issues_salsa already
687            // parses via salsa internally and caches the ParsedDoc.  Parse errors
688            // are read from that cached doc after the blocking task completes.
689            let uri_sem = uri.clone();
690            let sem_issues = tokio::task::spawn_blocking(move || {
691                docs_for_spawn.get_semantic_issues_salsa(&uri_sem)
692            })
693            .await
694            .unwrap_or(None);
695
696            // Extract parse errors from the salsa-cached ParsedDoc.  On the fast
697            // path this is a lock-free DashMap lookup — no re-parse.
698            let parse_diags = self
699                .docs
700                .get_doc_salsa(&uri)
701                .map(|doc| crate::analysis::diagnostics::diagnostics_from_doc(&doc))
702                .unwrap_or_default();
703
704            self.set_parse_diagnostics(&uri, parse_diags.clone());
705            let semantic = sem_issues
706                .map(|issues| {
707                    crate::semantic_diagnostics::issues_to_diagnostics(&issues, &uri, &diag_cfg)
708                })
709                .unwrap_or_default();
710            let all_diags = merge_file_diagnostics(parse_diags, semantic);
711            // Publish for the opened file FIRST — see did_change for why ordering matters.
712            self.client
713                .publish_diagnostics(uri.clone(), all_diags, None)
714                .await;
715
716            // Cross-file republish via the session's parallel re-analysis API.
717            // Only files whose Pass-2 actually changed appear in the result —
718            // we don't blast every open file with a publish like the old loop.
719            let dependents = self.compute_dependent_publishes(&uri, &diag_cfg).await;
720            for (dep_uri, dep_diags) in dependents {
721                self.client
722                    .publish_diagnostics(dep_uri, dep_diags, None)
723                    .await;
724            }
725        })
726        .await
727    }
728
729    #[tracing::instrument(skip_all)]
730    async fn did_change(&self, params: DidChangeTextDocumentParams) {
731        guard_async("did_change", async move {
732            let uri = params.text_document.uri;
733            // Incremental sync: apply changes in order to the live buffer.
734            // Each ranged change refers to the document state produced by the
735            // previous one; a change without a range is a full-document
736            // replacement (clients may still send those under INCREMENTAL).
737            let mut updated: Option<String> = None;
738            for change in params.content_changes {
739                match change.range {
740                    None => updated = Some(change.text),
741                    Some(range) => {
742                        let mut cur = match updated.take() {
743                            Some(t) => t,
744                            None => self.get_open_text(&uri).unwrap_or_default(),
745                        };
746                        crate::util::apply_content_change(&mut cur, range, &change.text);
747                        updated = Some(cur);
748                    }
749                }
750            }
751            let Some(text) = updated else { return };
752
753            // Store text immediately and capture the version token.
754            // Features (completion, hover, …) see the new text instantly while
755            // the parse runs in the background.
756            let version = self.set_open_text(uri.clone(), text.clone());
757
758            let docs = Arc::clone(&self.docs);
759            let open_files = self.open_files.clone();
760            let client = self.client.clone();
761            let diag_cfg = self.config.load().diagnostics.clone();
762            tokio::spawn(async move {
763                // 100 ms debounce: if another edit arrives before we parse,
764                // the version gate in Backend below will discard this result.
765                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
766
767                let (_doc, diagnostics) =
768                    tokio::task::spawn_blocking(move || parse_document(&text))
769                        .await
770                        .unwrap_or_else(|_| (ParsedDoc::default(), vec![]));
771
772                // Only apply if no newer edit arrived while we were parsing.
773                // Backend-level gate replaces the old `apply_parse` version check.
774                if open_files.current_version(&uri) == Some(version) {
775                    open_files.set_parse_diagnostics(&uri, diagnostics.clone());
776
777                    // Phase I: the salsa `semantic_issues` walk is synchronous
778                    // and CPU-bound on a cold file — run it on the blocking
779                    // pool so the async runtime stays responsive. Returns the
780                    // full diagnostic bundle (semantic + dup-decl + deprecated
781                    // calls), all computed off-thread.
782                    let docs_sem = Arc::clone(&docs);
783                    let uri_sem = uri.clone();
784                    let diag_cfg_sem = diag_cfg.clone();
785                    let extra_sem = tokio::task::spawn_blocking(move || {
786                        docs_sem
787                            .get_semantic_issues_salsa(&uri_sem)
788                            .map(|issues| {
789                                crate::semantic_diagnostics::issues_to_diagnostics(
790                                    &issues,
791                                    &uri_sem,
792                                    &diag_cfg_sem,
793                                )
794                            })
795                            .unwrap_or_default()
796                    })
797                    .await
798                    .unwrap_or_default();
799
800                    let all_diags = merge_file_diagnostics(diagnostics, extra_sem);
801                    // Publish for the changed file FIRST. Test harnesses (and
802                    // some clients) consume publishDiagnostics for unrelated
803                    // URIs while waiting for one specific URI; reversing this
804                    // order would silently swallow the changed file's publish.
805                    client
806                        .publish_diagnostics(uri.clone(), all_diags, None)
807                        .await;
808
809                    // Cross-file republish via the session's parallel
810                    // re-analysis API. Only files whose Pass-2 changed are
811                    // returned — the old loop blasted every open file.
812                    //
813                    // Race window: if `other` is being edited concurrently,
814                    // its own debounced did_change will still fire a republish,
815                    // so any briefly-stale publish here self-corrects within
816                    // ~100 ms.
817                    let dependents = compute_dependent_publishes_owned(
818                        Arc::clone(&docs),
819                        open_files.clone(),
820                        uri.clone(),
821                        diag_cfg.clone(),
822                    )
823                    .await;
824                    for (dep_uri, dep_diags) in dependents {
825                        client.publish_diagnostics(dep_uri, dep_diags, None).await;
826                    }
827                }
828            });
829        })
830        .await
831    }
832
833    async fn did_close(&self, params: DidCloseTextDocumentParams) {
834        let uri = params.text_document.uri;
835        self.close_open_file(&uri);
836        // Clear editor diagnostics; the file stays indexed for cross-file features
837        self.client.publish_diagnostics(uri, vec![], None).await;
838    }
839
840    async fn will_save(&self, _params: WillSaveTextDocumentParams) {}
841
842    async fn will_save_wait_until(
843        &self,
844        params: WillSaveTextDocumentParams,
845    ) -> Result<Option<Vec<TextEdit>>> {
846        let source = self
847            .get_open_text(&params.text_document.uri)
848            .unwrap_or_default();
849        Ok(format_document(&source))
850    }
851
852    async fn did_save(&self, params: DidSaveTextDocumentParams) {
853        let uri = params.text_document.uri;
854        // Re-publish diagnostics on save so editors that defer diagnostics
855        // until save (rather than on every keystroke) see up-to-date results.
856        // Must include semantic diagnostics — publishDiagnostics replaces the
857        // prior set entirely, so omitting them would clear errors the editor
858        // showed after the last did_change.
859        let diag_cfg = self.config.load().diagnostics.clone();
860        let all = compute_open_file_diagnostics(&self.docs, &self.open_files, &uri, &diag_cfg);
861        self.client.publish_diagnostics(uri, all, None).await;
862    }
863
864    async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
865        for change in params.changes {
866            match change.typ {
867                FileChangeType::CREATED | FileChangeType::CHANGED => {
868                    if let Ok(path) = change.uri.to_file_path()
869                        && let Ok(text) = tokio::fs::read_to_string(&path).await
870                    {
871                        // Salsa path: index_from_doc mirrors the new text into
872                        // the SourceFile input. On the next codebase() call,
873                        // salsa re-runs file_definitions for this file and the
874                        // aggregator re-folds — no manual remove/collect/finalize.
875                        let doc = parse_document_no_diags(&text);
876                        self.index_from_doc_if_not_open(change.uri.clone(), &doc);
877                    }
878                }
879                FileChangeType::DELETED => {
880                    self.docs.remove(&change.uri);
881                }
882                _ => {}
883            }
884        }
885        // File changes may affect cross-file features — refresh all live editors.
886        send_refresh_requests(&self.client).await;
887    }
888
889    #[tracing::instrument(skip_all)]
890    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
891        guard_async_result("completion", async move {
892            let uri = &params.text_document_position.text_document.uri;
893            let position = params.text_document_position.position;
894            let source = self.get_open_text(uri).unwrap_or_default();
895            let doc = match self.get_doc(uri) {
896                Some(d) => d,
897                None => return Ok(Some(CompletionResponse::Array(vec![]))),
898            };
899            let other_docs: Vec<Arc<ParsedDoc>> = self
900                .docs
901                .other_docs(uri, &self.open_urls())
902                .into_iter()
903                .map(|(_, d)| d)
904                .collect();
905            let trigger = params
906                .context
907                .as_ref()
908                .and_then(|c| c.trigger_character.as_deref());
909            let meta_loaded = self.meta.load();
910            let meta_opt = if meta_loaded.is_empty() {
911                None
912            } else {
913                Some(&**meta_loaded)
914            };
915            let imports = self.file_imports(uri);
916            let wi = self.workspace_index_async().await;
917            let docs_for_lookup = Arc::clone(&self.docs);
918            let find_class_doc_fn = move |name: &str| -> Option<Arc<ParsedDoc>> {
919                let cr = *wi.classes_by_name.get(name)?.first()?;
920                let (uri, _) = wi.at(cr)?;
921                docs_for_lookup.get_doc_salsa(uri)
922            };
923            let analysis = self.cached_analysis_async(uri).await;
924            // Cross-request TypeMap cache: rebuilt only when the document text
925            // (or PHPStorm meta) changes, instead of one full AST walk per
926            // completion request.
927            let docs_for_tm = Arc::clone(&self.docs);
928            let doc_for_tm = Arc::clone(&doc);
929            let uri_for_tm = uri.clone();
930            let get_type_map =
931                move || docs_for_tm.cached_type_map(&uri_for_tm, &doc_for_tm, meta_opt);
932            let ctx = CompletionCtx {
933                source: Some(&source),
934                position: Some(position),
935                meta: meta_opt,
936                doc_uri: Some(uri),
937                file_imports: Some(&imports),
938                find_class_doc: Some(&find_class_doc_fn),
939                analysis: analysis.as_deref(),
940                type_map: Some(&get_type_map),
941            };
942            Ok(Some(CompletionResponse::Array(filtered_completions_at(
943                &doc,
944                &other_docs,
945                trigger,
946                &ctx,
947            ))))
948        })
949        .await
950    }
951
952    async fn completion_resolve(&self, mut item: CompletionItem) -> Result<CompletionItem> {
953        if item.documentation.is_some() && item.detail.is_some() {
954            return Ok(item);
955        }
956        // Strip trailing ':' from named-argument labels (e.g. "param:") before lookup.
957        let name = item.label.trim_end_matches(':');
958        let all_indexes = self.docs.all_indexes();
959        if item.detail.is_none()
960            && let Some(sig) = signature_for_symbol_from_index(name, &all_indexes)
961        {
962            item.detail = Some(sig);
963        }
964        if item.documentation.is_none()
965            && let Some(md) = docs_for_symbol_from_index(name, &all_indexes)
966        {
967            item.documentation = Some(Documentation::MarkupContent(MarkupContent {
968                kind: MarkupKind::Markdown,
969                value: md,
970            }));
971        }
972        Ok(item)
973    }
974
975    async fn goto_definition(
976        &self,
977        params: GotoDefinitionParams,
978    ) -> Result<Option<GotoDefinitionResponse>> {
979        guard_async_result("goto_definition", async move {
980            let uri = &params.text_document_position_params.text_document.uri;
981            let position = params.text_document_position_params.position;
982            let source = self.get_open_text(uri).unwrap_or_default();
983            let doc = match self.get_doc(uri) {
984                Some(d) => d,
985                None => return Ok(None),
986            };
987            // mir-backed method dispatch: use FileAnalysis::symbol_at to resolve
988            // the declaring class before touching the in-file AST walk.  This
989            // respects `insteadof` conflict resolution (which the AST walk ignores
990            // because it returns the first declaration it encounters by name).
991            if let Some(word) = crate::util::word_at_position(&source, position)
992                && !word.starts_with('$')
993            {
994                let analysis = self.cached_analysis_async(uri).await;
995                let resolved_class = analysis.as_deref().and_then(|a| {
996                    let off = crate::util::word_range_at(&source, position)
997                        .map(|r| doc.view().byte_of_position(r.start))?;
998                    let sym = a.symbol_at(off)?;
999                    match &sym.kind {
1000                        mir_analyzer::ReferenceKind::MethodCall { class, .. }
1001                        | mir_analyzer::ReferenceKind::StaticCall { class, .. } => {
1002                            Some(fqn_short_name(class).to_string())
1003                        }
1004                        _ => None,
1005                    }
1006                });
1007                if let Some(cls) = resolved_class {
1008                    let all_indexes = self.docs.all_indexes();
1009                    if let Some(loc) = find_method_in_class_hierarchy(&cls, &word, &all_indexes) {
1010                        let refined = self
1011                            .docs
1012                            .get_doc_salsa(&loc.uri)
1013                            .and_then(|d| {
1014                                // Try class-specific lookup first: needed when the same
1015                                // method name exists in multiple classes in the same file
1016                                // (e.g. insteadof — both A::hello and B::hello present).
1017                                // Fall back to global name search for cases where the mir
1018                                // class is the receiver type, not the declaring type in
1019                                // the target file (e.g. @mixin, interface calls).
1020                                let range = find_method_range_in_class(&d, &cls, &word)
1021                                    .or_else(|| find_declaration_range(d.source(), &d, &word));
1022                                range.map(|range| Location {
1023                                    uri: loc.uri.clone(),
1024                                    range,
1025                                })
1026                            })
1027                            .unwrap_or(loc);
1028                        return Ok(Some(GotoDefinitionResponse::Scalar(refined)));
1029                    }
1030                }
1031            }
1032
1033            // Search current file's ParsedDoc (fast), then fall back to index search.
1034            if let Some(loc) = goto_definition(uri, &source, &doc, &[], position) {
1035                return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1036            }
1037            // Receiver-aware method dispatch: `$var->method()` must jump to the
1038            // method defined in `$var`'s class hierarchy, not the first `method`
1039            // found in any indexed file (which would return a wrong class).
1040            if let Some(line_text) = source.lines().nth(position.line as usize)
1041                && let Some(word) = crate::util::word_at_position(&source, position)
1042                && let Some(receiver) = crate::hover::extract_receiver_var_before_cursor(
1043                    line_text,
1044                    position.character as usize,
1045                )
1046            {
1047                let class_name = if receiver == "$this" {
1048                    crate::type_map::enclosing_class_at(&source, &doc, position)
1049                } else {
1050                    let tm = crate::type_map::TypeMap::from_doc_at_position(&doc, None, position);
1051                    tm.get(&receiver).map(|s| s.to_string())
1052                };
1053                if let Some(cls) = class_name {
1054                    let first_cls = cls.split('|').next().unwrap_or(&cls).to_owned();
1055                    let all_indexes = self.docs.all_indexes();
1056                    if let Some(loc) =
1057                        find_method_in_class_hierarchy(&first_cls, &word, &all_indexes)
1058                    {
1059                        let refined = self
1060                            .docs
1061                            .get_doc_salsa(&loc.uri)
1062                            .and_then(|doc| {
1063                                find_declaration_range(doc.source(), &doc, &word).map(|range| {
1064                                    Location {
1065                                        uri: loc.uri.clone(),
1066                                        range,
1067                                    }
1068                                })
1069                            })
1070                            .unwrap_or(loc);
1071                        return Ok(Some(GotoDefinitionResponse::Scalar(refined)));
1072                    }
1073                }
1074            }
1075
1076            // Cross-file: O(1) lookup in the salsa-memoized aggregate's
1077            // `decls_by_name` map (no disk I/O for background files). The
1078            // current file is excluded — it was already searched above with
1079            // accurate AST ranges.
1080            let wi = self.workspace_index_async().await;
1081            if let Some(word) = crate::util::word_at_position(&source, position)
1082                && let Some(loc) = wi.find_declaration(&word, Some(uri))
1083            {
1084                let refined = self
1085                    .docs
1086                    .get_doc_salsa(&loc.uri)
1087                    .and_then(|doc| {
1088                        find_declaration_range(doc.source(), &doc, &word).map(|range| Location {
1089                            uri: loc.uri.clone(),
1090                            range,
1091                        })
1092                    })
1093                    .unwrap_or(loc);
1094                return Ok(Some(GotoDefinitionResponse::Scalar(refined)));
1095            }
1096
1097            // PSR-4 fallback: handles FQN and aliased-namespace references such as
1098            // PHP 8 attributes (`#[ORM\Column]` with `use Doctrine\ORM\Mapping as ORM`).
1099            if let Some(word) = word_at_position(&source, position)
1100                && word.contains('\\')
1101            {
1102                // Expand alias prefix: `ORM\Column` → `Doctrine\ORM\Mapping\Column`
1103                let imports = crate::navigation::references::collect_class_imports(&doc);
1104                let expanded = expand_alias_prefix(&word, &imports);
1105                if let Some(loc) = self.psr4_goto(&expanded).await {
1106                    return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1107                }
1108            }
1109
1110            Ok(None)
1111        })
1112        .await
1113    }
1114
1115    async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
1116        guard_async_result("references", async move {
1117            // Yield before any work so that a queued $/cancelRequest notification
1118            // can be processed by tower-lsp's cancellation layer before we start
1119            // the synchronous reference scan.  Without this, fast runtimes (Linux
1120            // CI) complete the entire handler without an await point, making
1121            // cancellation impossible.
1122            tokio::task::yield_now().await;
1123            let uri = &params.text_document_position.text_document.uri;
1124            let position = params.text_document_position.position;
1125            let source = self.get_open_text(uri).unwrap_or_default();
1126            let word = match word_at_position(&source, position) {
1127                Some(w) => w,
1128                None => return Ok(None),
1129            };
1130            let include_declaration = params.context.include_declaration;
1131
1132            // Special case: cursor on a class's `__construct` declaration — its
1133            // call sites are `new OwningClass(...)`, so name-only matching would
1134            // otherwise return every class's constructor declaration.
1135            if word == "__construct"
1136                && let Some(doc) = self.get_doc(uri)
1137                && let Some(class_name) =
1138                    class_name_at_construct_decl(doc.source(), &doc.program().stmts, position)
1139            {
1140                let locations = self.construct_references(
1141                    uri,
1142                    &source,
1143                    position,
1144                    &class_name,
1145                    include_declaration,
1146                );
1147                return Ok((!locations.is_empty()).then_some(locations));
1148            }
1149
1150            let doc_opt = self.get_doc(uri);
1151            let (word, kind, constant_owner) =
1152                resolve_reference_symbol(doc_opt.as_ref(), &source, position, word);
1153            let all_docs = self.docs.all_docs_for_scan();
1154            let target_fqn = self.resolve_reference_target_fqn(
1155                uri,
1156                doc_opt.as_ref(),
1157                &word,
1158                kind,
1159                position,
1160                constant_owner,
1161            );
1162
1163            // Ensure all workspace files are ingested before querying the
1164            // session — it only sees files that have been opened, so
1165            // background-indexed files would otherwise be invisible to
1166            // `references_to`.
1167            if matches!(kind, Some(SymbolKind::Method)) {
1168                // Full-workspace mir Pass 1 + Pass 2 — by far the heaviest
1169                // synchronous call in any handler. Run it on the blocking pool
1170                // so it doesn't stall the executor; a JoinError (panic) falls
1171                // through and the session simply sees fewer files.
1172                let docs = Arc::clone(&self.docs);
1173                let _ = tokio::task::spawn_blocking(move || docs.ensure_all_files_ingested()).await;
1174            }
1175            let owner_short: Option<String> = if matches!(kind, Some(SymbolKind::Method)) {
1176                target_fqn
1177                    .as_deref()
1178                    .map(|fqn| fqn_short_name(fqn.trim_start_matches('\\')).to_string())
1179            } else {
1180                None
1181            };
1182
1183            let session_method_refs = self.session_method_references(
1184                &word,
1185                kind,
1186                target_fqn.as_deref(),
1187                owner_short.as_deref(),
1188            );
1189
1190            let mut locations = if let Some(session_locs) =
1191                session_method_refs.filter(|l| !l.is_empty())
1192            {
1193                // Session results are the authoritative call-site source for
1194                // methods. Push the cursor's own method-name span as the decl so
1195                // `include_declaration=true` still surfaces it — derived from the
1196                // identifier under the cursor (not the raw cursor position) so a
1197                // mid-identifier cursor doesn't shift the span right.
1198                let mut combined = session_locs;
1199                if include_declaration {
1200                    let range =
1201                        crate::util::word_range_at(&source, position).unwrap_or_else(|| Range {
1202                            start: position,
1203                            end: Position {
1204                                line: position.line,
1205                                character: position.character + word.len() as u32,
1206                            },
1207                        });
1208                    combined.push(Location {
1209                        uri: uri.clone(),
1210                        range,
1211                    });
1212                    crate::references::dedup_ref_locations(&mut combined);
1213                }
1214                combined
1215            } else {
1216                match target_fqn.as_deref() {
1217                    Some(t) => {
1218                        find_references_with_target(&word, &all_docs, include_declaration, kind, t)
1219                    }
1220                    None => find_references(&word, &all_docs, include_declaration, kind),
1221                }
1222            };
1223
1224            // For Class / Function kinds: AST walker is authoritative; augment
1225            // with session refs to catch type-resolved sites the walker misses.
1226            // Property is excluded: mir's property spans include `$` for static
1227            // accesses (`Foo::$prop`), which is off-by-one vs the AST walker's
1228            // sigil-stripped spans, defeating dedup and producing duplicates.
1229            if !matches!(kind, Some(SymbolKind::Method) | Some(SymbolKind::Property))
1230                && let Some(sym) = build_mir_symbol(&word, kind, target_fqn.as_deref())
1231            {
1232                let extra = self.docs.session_references_to(&sym);
1233                if !extra.is_empty() {
1234                    let mut seen: std::collections::HashSet<(String, u32, u32, u32)> = locations
1235                        .iter()
1236                        .map(crate::references::ref_location_key)
1237                        .collect();
1238                    for loc in extra
1239                        .into_iter()
1240                        .filter_map(crate::references::session_tuple_to_location)
1241                    {
1242                        if seen.insert(crate::references::ref_location_key(&loc)) {
1243                            locations.push(loc);
1244                        }
1245                    }
1246                }
1247            }
1248
1249            Ok((!locations.is_empty()).then_some(locations))
1250        })
1251        .await
1252    }
1253
1254    async fn prepare_rename(
1255        &self,
1256        params: TextDocumentPositionParams,
1257    ) -> Result<Option<PrepareRenameResponse>> {
1258        let uri = &params.text_document.uri;
1259        let source = self.get_open_text(uri).unwrap_or_default();
1260        Ok(prepare_rename(&source, params.position).map(PrepareRenameResponse::Range))
1261    }
1262
1263    async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
1264        let uri = &params.text_document_position.text_document.uri;
1265        let position = params.text_document_position.position;
1266        let source = self.get_open_text(uri).unwrap_or_default();
1267        let word = match word_at_position(&source, position) {
1268            Some(w) => w,
1269            None => return Ok(None),
1270        };
1271        if word.starts_with('$') {
1272            let doc = match self.get_doc(uri) {
1273                Some(d) => d,
1274                None => return Ok(None),
1275            };
1276            Ok(Some(rename_variable(
1277                &word,
1278                &params.new_name,
1279                uri,
1280                &doc,
1281                position,
1282            )))
1283        } else if is_after_arrow(&source, position) {
1284            let all_docs = self.docs.all_docs_for_scan();
1285            Ok(Some(rename_property(&word, &params.new_name, &all_docs)))
1286        } else {
1287            let all_docs = self.docs.all_docs_for_scan();
1288            let doc_opt = self.get_doc(uri);
1289            let target_fqn: Option<String> = doc_opt.as_ref().map(|doc| {
1290                let imports = self.file_imports(uri);
1291                crate::navigation::moniker::resolve_fqn(doc, &word, &imports)
1292            });
1293            Ok(Some(rename(
1294                &word,
1295                &params.new_name,
1296                &all_docs,
1297                target_fqn.as_deref(),
1298            )))
1299        }
1300    }
1301
1302    async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
1303        let uri = &params.text_document_position_params.text_document.uri;
1304        let position = params.text_document_position_params.position;
1305        let source = self.get_open_text(uri).unwrap_or_default();
1306        let doc = match self.get_doc(uri) {
1307            Some(d) => d,
1308            None => return Ok(None),
1309        };
1310        let all_indexes = self.docs.all_indexes();
1311        Ok(signature_help(&source, &doc, position, &all_indexes))
1312    }
1313
1314    #[tracing::instrument(skip_all)]
1315    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
1316        guard_async_result("hover", async move {
1317            let uri = &params.text_document_position_params.text_document.uri;
1318            let position = params.text_document_position_params.position;
1319            let source = self.get_open_text(uri).unwrap_or_default();
1320            let doc = match self.get_doc(uri) {
1321                Some(d) => d,
1322                None => return Ok(None),
1323            };
1324            let other_docs = self.docs.other_docs(uri, &self.open_urls());
1325            let other_maps = self.docs.other_symbol_maps(uri, &self.open_urls());
1326            let analysis = self.cached_analysis_async(uri).await;
1327            let result = hover_info_with_maps(
1328                &source,
1329                &doc,
1330                analysis.as_deref(),
1331                position,
1332                &other_docs,
1333                &other_maps,
1334            );
1335            if result.is_some() {
1336                return Ok(result);
1337            }
1338            // Fallback: look up the word in the workspace index so class names in
1339            // extends clauses and parameter types resolve even when their defining
1340            // file is never opened.  Also try the alias-resolved name so that
1341            // `use Foo as Bar` works even when Foo is only in the index.
1342            if let Some(word) = crate::util::word_at_position(&source, position) {
1343                let wi = self.workspace_index_async().await;
1344                // Try the literal word first.
1345                if let Some(h) = class_hover_from_index(&word, &wi.files) {
1346                    return Ok(Some(h));
1347                }
1348                // Try alias resolution.
1349                if let Some(resolved) = crate::hover::resolve_use_alias(&doc.program().stmts, &word)
1350                    && let Some(h) = class_hover_from_index(&resolved, &wi.files)
1351                {
1352                    return Ok(Some(h));
1353                }
1354            }
1355            Ok(None)
1356        })
1357        .await
1358    }
1359
1360    async fn document_symbol(
1361        &self,
1362        params: DocumentSymbolParams,
1363    ) -> Result<Option<DocumentSymbolResponse>> {
1364        let uri = &params.text_document.uri;
1365        let doc = match self.get_doc(uri) {
1366            Some(d) => d,
1367            None => return Ok(None),
1368        };
1369        Ok(Some(DocumentSymbolResponse::Nested(document_symbols(
1370            doc.source(),
1371            &doc,
1372        ))))
1373    }
1374
1375    async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
1376        let uri = &params.text_document.uri;
1377        let doc = match self.get_doc(uri) {
1378            Some(d) => d,
1379            None => return Ok(None),
1380        };
1381        let ranges = folding_ranges(doc.source(), &doc);
1382        Ok(if ranges.is_empty() {
1383            None
1384        } else {
1385            Some(ranges)
1386        })
1387    }
1388
1389    async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
1390        let uri = &params.text_document.uri;
1391        let doc = match self.get_doc(uri) {
1392            Some(d) => d,
1393            None => return Ok(None),
1394        };
1395        let analysis = self.cached_analysis_async(uri).await;
1396        let wi = self.workspace_index_async().await;
1397        Ok(Some(inlay_hints(
1398            doc.source(),
1399            &doc,
1400            analysis.as_deref(),
1401            params.range,
1402            &wi.files,
1403        )))
1404    }
1405
1406    async fn inlay_hint_resolve(&self, mut item: InlayHint) -> Result<InlayHint> {
1407        if item.tooltip.is_some() {
1408            return Ok(item);
1409        }
1410        let func_name = item
1411            .data
1412            .as_ref()
1413            .and_then(|d| d.get("php_lsp_fn"))
1414            .and_then(|v| v.as_str())
1415            .map(str::to_string);
1416        if let Some(name) = func_name {
1417            let all_indexes = self.docs.all_indexes();
1418            if let Some(md) = docs_for_symbol_from_index(&name, &all_indexes) {
1419                item.tooltip = Some(InlayHintTooltip::MarkupContent(MarkupContent {
1420                    kind: MarkupKind::Markdown,
1421                    value: md,
1422                }));
1423            }
1424        }
1425        Ok(item)
1426    }
1427
1428    async fn symbol(
1429        &self,
1430        params: WorkspaceSymbolParams,
1431    ) -> Result<Option<Vec<SymbolInformation>>> {
1432        // Phase J: read through the salsa-memoized aggregate so repeated
1433        // workspace-symbol queries (every keystroke in the picker) share the
1434        // same `Arc` until a file changes.
1435        let wi = self.workspace_index_async().await;
1436        let results = workspace_symbols_from_workspace(&params.query, &wi);
1437        Ok(Some(results))
1438    }
1439
1440    async fn symbol_resolve(&self, params: WorkspaceSymbol) -> Result<WorkspaceSymbol> {
1441        // For resolve, we need the full range from the ParsedDoc of open files.
1442        let docs = self.docs.docs_for(&self.open_urls());
1443        Ok(resolve_workspace_symbol(params, &docs))
1444    }
1445
1446    #[tracing::instrument(skip_all)]
1447    async fn semantic_tokens_full(
1448        &self,
1449        params: SemanticTokensParams,
1450    ) -> Result<Option<SemanticTokensResult>> {
1451        guard_async_result("semantic_tokens_full", async move {
1452            let uri = &params.text_document.uri;
1453            let doc = match self.get_doc(uri) {
1454                Some(d) => d,
1455                None => {
1456                    return Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1457                        result_id: None,
1458                        data: vec![],
1459                    })));
1460                }
1461            };
1462            let tokens = semantic_tokens(doc.source(), &doc);
1463            let result_id = token_hash(&tokens);
1464            let tokens_arc = Arc::new(tokens);
1465            self.docs
1466                .store_token_cache(uri, result_id.clone(), Arc::clone(&tokens_arc));
1467            let data = Arc::try_unwrap(tokens_arc).unwrap_or_else(|arc| (*arc).clone());
1468            Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
1469                result_id: Some(result_id),
1470                data,
1471            })))
1472        })
1473        .await
1474    }
1475
1476    async fn semantic_tokens_range(
1477        &self,
1478        params: SemanticTokensRangeParams,
1479    ) -> Result<Option<SemanticTokensRangeResult>> {
1480        let uri = &params.text_document.uri;
1481        let doc = match self.get_doc(uri) {
1482            Some(d) => d,
1483            None => {
1484                return Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1485                    result_id: None,
1486                    data: vec![],
1487                })));
1488            }
1489        };
1490        let tokens = semantic_tokens_range(doc.source(), &doc, params.range);
1491        Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
1492            result_id: None,
1493            data: tokens,
1494        })))
1495    }
1496
1497    async fn semantic_tokens_full_delta(
1498        &self,
1499        params: SemanticTokensDeltaParams,
1500    ) -> Result<Option<SemanticTokensFullDeltaResult>> {
1501        let uri = &params.text_document.uri;
1502        let doc = match self.get_doc(uri) {
1503            Some(d) => d,
1504            None => return Ok(None),
1505        };
1506
1507        let new_tokens = Arc::new(semantic_tokens(doc.source(), &doc));
1508        let new_result_id = token_hash(&new_tokens);
1509        let prev_id = &params.previous_result_id;
1510
1511        let result = match self.docs.get_token_cache(uri, prev_id) {
1512            Some(old_tokens) => {
1513                let edits = compute_token_delta(&old_tokens, &new_tokens);
1514                SemanticTokensFullDeltaResult::TokensDelta(SemanticTokensDelta {
1515                    result_id: Some(new_result_id.clone()),
1516                    edits,
1517                })
1518            }
1519            // Unknown previous result — fall back to full tokens
1520            None => SemanticTokensFullDeltaResult::Tokens(SemanticTokens {
1521                result_id: Some(new_result_id.clone()),
1522                data: (*new_tokens).clone(),
1523            }),
1524        };
1525
1526        self.docs.store_token_cache(uri, new_result_id, new_tokens);
1527        Ok(Some(result))
1528    }
1529
1530    async fn selection_range(
1531        &self,
1532        params: SelectionRangeParams,
1533    ) -> Result<Option<Vec<SelectionRange>>> {
1534        let uri = &params.text_document.uri;
1535        let doc = match self.get_doc(uri) {
1536            Some(d) => d,
1537            None => return Ok(None),
1538        };
1539        let ranges = selection_ranges(&doc, &params.positions);
1540        Ok(if ranges.is_empty() {
1541            None
1542        } else {
1543            Some(ranges)
1544        })
1545    }
1546
1547    async fn prepare_call_hierarchy(
1548        &self,
1549        params: CallHierarchyPrepareParams,
1550    ) -> Result<Option<Vec<CallHierarchyItem>>> {
1551        let uri = &params.text_document_position_params.text_document.uri;
1552        let position = params.text_document_position_params.position;
1553        let source = self.get_open_text(uri).unwrap_or_default();
1554        let word = match word_at_position(&source, position) {
1555            Some(w) => w,
1556            None => return Ok(None),
1557        };
1558        // O(matches) lookup via the aggregate's `decls_by_name` map instead
1559        // of scanning every workspace doc.
1560        let wi = self.workspace_index_async().await;
1561        let docs = Arc::clone(&self.docs);
1562        let get_doc = move |u: &Url| docs.get_doc_salsa(u);
1563        Ok(prepare_call_hierarchy_indexed(&word, &wi, &get_doc).map(|item| vec![item]))
1564    }
1565
1566    async fn incoming_calls(
1567        &self,
1568        params: CallHierarchyIncomingCallsParams,
1569    ) -> Result<Option<Vec<CallHierarchyIncomingCall>>> {
1570        // Genuinely needs every doc (call sites are body-level, not indexed);
1571        // run the workspace scan on the blocking pool.
1572        let docs = Arc::clone(&self.docs);
1573        let item = params.item;
1574        let calls = tokio::task::spawn_blocking(move || {
1575            let all_docs = docs.all_docs_for_scan();
1576            incoming_calls(&item, &all_docs)
1577        })
1578        .await
1579        .unwrap_or_default();
1580        Ok(if calls.is_empty() { None } else { Some(calls) })
1581    }
1582
1583    async fn outgoing_calls(
1584        &self,
1585        params: CallHierarchyOutgoingCallsParams,
1586    ) -> Result<Option<Vec<CallHierarchyOutgoingCall>>> {
1587        // Per-callee declaration lookups go through `decls_by_name` — the old
1588        // path re-scanned the whole workspace once per distinct callee.
1589        let wi = self.workspace_index_async().await;
1590        let docs = Arc::clone(&self.docs);
1591        let item = params.item;
1592        let calls = tokio::task::spawn_blocking(move || {
1593            let get_doc = |u: &Url| docs.get_doc_salsa(u);
1594            outgoing_calls_indexed(&item, &wi, &get_doc)
1595        })
1596        .await
1597        .unwrap_or_default();
1598        Ok(if calls.is_empty() { None } else { Some(calls) })
1599    }
1600
1601    async fn document_highlight(
1602        &self,
1603        params: DocumentHighlightParams,
1604    ) -> Result<Option<Vec<DocumentHighlight>>> {
1605        let uri = &params.text_document_position_params.text_document.uri;
1606        let position = params.text_document_position_params.position;
1607        let source = self.get_open_text(uri).unwrap_or_default();
1608        let doc = match self.get_doc(uri) {
1609            Some(d) => d,
1610            None => return Ok(None),
1611        };
1612        let highlights = document_highlights(&source, &doc, position);
1613        Ok(if highlights.is_empty() {
1614            None
1615        } else {
1616            Some(highlights)
1617        })
1618    }
1619
1620    async fn linked_editing_range(
1621        &self,
1622        params: LinkedEditingRangeParams,
1623    ) -> Result<Option<LinkedEditingRanges>> {
1624        let uri = &params.text_document_position_params.text_document.uri;
1625        let position = params.text_document_position_params.position;
1626        let source = self.get_open_text(uri).unwrap_or_default();
1627        let doc = match self.get_doc(uri) {
1628            Some(d) => d,
1629            None => return Ok(None),
1630        };
1631        // Need the word at the cursor to know if this is a variable rename
1632        // (`$foo`) — the wordPattern we send back must require/forbid `$`
1633        // accordingly so that linked-mode typing produces valid PHP.
1634        let word = match crate::util::word_at_position(&source, position) {
1635            Some(w) => w,
1636            None => return Ok(None),
1637        };
1638        let is_variable = word.starts_with('$');
1639        let cursor_word_range = match crate::util::word_range_at(&source, position) {
1640            Some(r) => r,
1641            None => return Ok(None),
1642        };
1643
1644        // Reuse document_highlights: every occurrence of the symbol is a linked range.
1645        let highlights = document_highlights(&source, &doc, position);
1646        if highlights.is_empty() {
1647            return Ok(None);
1648        }
1649
1650        // Bail when the cursor's word isn't itself one of the highlight
1651        // ranges. `document_highlights` resolves the cursor to a word and
1652        // walks the AST for occurrences of that name; if the cursor sits in
1653        // a comment or string literal that happens to share a word with a
1654        // real identifier, the AST occurrences would still come back and
1655        // entering linked-edit mode would silently mirror unrelated ranges.
1656        // Comparing against `word_range_at` (rather than a contains check)
1657        // also accepts the half-open right boundary — a common cursor
1658        // position right after typing the name.
1659        if !highlights.iter().any(|h| h.range == cursor_word_range) {
1660            return Ok(None);
1661        }
1662
1663        // Scope class-member rewrites so that two unrelated classes sharing
1664        // a method/property/const name aren't linked together — but keep
1665        // legitimate call sites at module scope (`$obj->bar()` outside any
1666        // class). The rule: drop highlights that fall inside *another*
1667        // class than the cursor's. Highlights inside the cursor's class
1668        // and at module scope (outside every class) are preserved.
1669        // Class declarations themselves (cursor on the class header) stay
1670        // global so renaming a class spans the whole file.
1671        let scope_to_class = !is_variable
1672            && crate::type_map::enclosing_class_at(&source, &doc, position).as_deref()
1673                != Some(word.as_str());
1674        let other_class_ranges: Vec<Range> = if scope_to_class {
1675            let cursor_class = crate::type_map::enclosing_class_range_at(&doc, position);
1676            crate::type_map::collect_all_class_ranges(&doc)
1677                .into_iter()
1678                .filter(|r| Some(*r) != cursor_class)
1679                .collect()
1680        } else {
1681            Vec::new()
1682        };
1683        let ranges: Vec<Range> = highlights
1684            .into_iter()
1685            .map(|h| h.range)
1686            .filter(|r| !other_class_ranges.iter().any(|ocr| range_within(*r, *ocr)))
1687            .collect();
1688        if ranges.is_empty() {
1689            return Ok(None);
1690        }
1691
1692        // Variables include the leading `$` in their range, so the pattern
1693        // must require it; for everything else (class/function/method names)
1694        // a `$` would produce invalid PHP. The Unicode range covers the
1695        // full BMP so that PHP identifiers using non-Latin alphabets
1696        // (CJK, Cyrillic, Greek, …) round-trip through linked-mode
1697        // typing rather than being rejected by the regex.
1698        let word_pattern = if is_variable {
1699            r"\$[a-zA-Z_\u00A0-\uFFFF][a-zA-Z0-9_\u00A0-\uFFFF]*".to_string()
1700        } else {
1701            r"[a-zA-Z_\u00A0-\uFFFF][a-zA-Z0-9_\u00A0-\uFFFF]*".to_string()
1702        };
1703        Ok(Some(LinkedEditingRanges {
1704            ranges,
1705            word_pattern: Some(word_pattern),
1706        }))
1707    }
1708
1709    async fn goto_implementation(
1710        &self,
1711        params: tower_lsp::lsp_types::request::GotoImplementationParams,
1712    ) -> Result<Option<tower_lsp::lsp_types::request::GotoImplementationResponse>> {
1713        let uri = &params.text_document_position_params.text_document.uri;
1714        let position = params.text_document_position_params.position;
1715        let source = self.get_open_text(uri).unwrap_or_default();
1716        let imports = self.file_imports(uri);
1717        let raw_word = crate::util::word_at_position(&source, position).unwrap_or_default();
1718        // `word_at_position` includes `\` as a word character, so the cursor on
1719        // a use-statement import (`use A\B\Foo`) returns the full qualified name.
1720        // Split to recover the short name and treat the rest as the FQN so the
1721        // workspace index lookup (keyed by short name) still finds subtypes.
1722        let (word, fqn_owned): (String, Option<String>) = if raw_word.contains('\\') {
1723            let short = raw_word
1724                .rsplit('\\')
1725                .next()
1726                .unwrap_or(&raw_word)
1727                .to_string();
1728            let full = raw_word.trim_start_matches('\\').to_string();
1729            (short, Some(full))
1730        } else {
1731            let fqn = imports.get(&raw_word).cloned();
1732            (raw_word, fqn)
1733        };
1734        let fqn = fqn_owned.as_deref();
1735        // First pass: open-file ParsedDocs give accurate character positions.
1736        let open_docs = self.docs.docs_for(&self.open_urls());
1737        let mut locs = find_implementations(&word, fqn, &open_docs);
1738        if locs.is_empty() {
1739            // Second pass: background files via the salsa-memoized workspace
1740            // aggregate's `subtypes_of` reverse map (line-only positions).
1741            let wi = self.workspace_index_async().await;
1742            locs = find_implementations_from_workspace(&word, fqn, &wi);
1743        }
1744        if locs.is_empty() {
1745            Ok(None)
1746        } else {
1747            Ok(Some(GotoDefinitionResponse::Array(locs)))
1748        }
1749    }
1750
1751    async fn goto_declaration(
1752        &self,
1753        params: tower_lsp::lsp_types::request::GotoDeclarationParams,
1754    ) -> Result<Option<tower_lsp::lsp_types::request::GotoDeclarationResponse>> {
1755        let uri = &params.text_document_position_params.text_document.uri;
1756        let position = params.text_document_position_params.position;
1757        let source = self.get_open_text(uri).unwrap_or_default();
1758        // First pass: open-file ParsedDocs give accurate character positions.
1759        let open_docs = self.docs.docs_for(&self.open_urls());
1760        if let Some(loc) = goto_declaration(&source, &open_docs, position) {
1761            return Ok(Some(GotoDefinitionResponse::Scalar(loc)));
1762        }
1763        // Second pass: background files via FileIndex (line-only positions).
1764        let all_indexes = self.docs.all_indexes();
1765        Ok(goto_declaration_from_index(&source, &all_indexes, position)
1766            .map(GotoDefinitionResponse::Scalar))
1767    }
1768
1769    async fn goto_type_definition(
1770        &self,
1771        params: tower_lsp::lsp_types::request::GotoTypeDefinitionParams,
1772    ) -> Result<Option<tower_lsp::lsp_types::request::GotoTypeDefinitionResponse>> {
1773        let uri = &params.text_document_position_params.text_document.uri;
1774        let position = params.text_document_position_params.position;
1775        let source = self.get_open_text(uri).unwrap_or_default();
1776        let doc = match self.get_doc(uri) {
1777            Some(d) => d,
1778            None => return Ok(None),
1779        };
1780        let analysis = self.cached_analysis_async(uri).await;
1781        // First pass: open-file ParsedDocs give accurate character positions.
1782        let open_docs = self.docs.docs_for(&self.open_urls());
1783        let mut results =
1784            goto_type_definition(&source, &doc, analysis.as_deref(), &open_docs, position);
1785
1786        // If no results from first pass, try background files via FileIndex (line-only positions).
1787        if results.is_empty() {
1788            let all_indexes = self.docs.all_indexes();
1789            results = goto_type_definition_from_index(
1790                &source,
1791                &doc,
1792                analysis.as_deref(),
1793                &all_indexes,
1794                position,
1795            );
1796        }
1797
1798        // Format response: scalar for single result, array for multiple, none for empty
1799        let response = match results.len() {
1800            0 => None,
1801            1 => Some(GotoDefinitionResponse::Scalar(
1802                results.into_iter().next().unwrap(),
1803            )),
1804            _ => Some(GotoDefinitionResponse::Array(results)),
1805        };
1806        Ok(response)
1807    }
1808
1809    async fn prepare_type_hierarchy(
1810        &self,
1811        params: TypeHierarchyPrepareParams,
1812    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1813        let uri = &params.text_document_position_params.text_document.uri;
1814        let position = params.text_document_position_params.position;
1815        let source = self.get_open_text(uri).unwrap_or_default();
1816        // Phase J: use the salsa-memoized aggregate's `classes_by_name` map.
1817        let wi = self.workspace_index_async().await;
1818        Ok(prepare_type_hierarchy_from_workspace(&source, &wi, position).map(|item| vec![item]))
1819    }
1820
1821    async fn supertypes(
1822        &self,
1823        params: TypeHierarchySupertypesParams,
1824    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1825        // Phase J: resolve parents via the aggregate's `classes_by_name` map.
1826        let wi = self.workspace_index_async().await;
1827        let result = supertypes_of_from_workspace(&params.item, &wi);
1828        Ok(if result.is_empty() {
1829            None
1830        } else {
1831            Some(result)
1832        })
1833    }
1834
1835    async fn subtypes(
1836        &self,
1837        params: TypeHierarchySubtypesParams,
1838    ) -> Result<Option<Vec<TypeHierarchyItem>>> {
1839        // Phase J: O(matches) lookup via the aggregate's `subtypes_of` map.
1840        let wi = self.workspace_index_async().await;
1841        let result = subtypes_of_from_workspace(&params.item, &wi);
1842        Ok(if result.is_empty() {
1843            None
1844        } else {
1845            Some(result)
1846        })
1847    }
1848
1849    async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
1850        let uri = &params.text_document.uri;
1851        let doc = match self.get_doc(uri) {
1852            Some(d) => d,
1853            None => return Ok(None),
1854        };
1855        // Reference-count lenses scan every doc per declaration; run the
1856        // whole computation on the blocking pool.
1857        let docs = Arc::clone(&self.docs);
1858        let uri_owned = uri.clone();
1859        let lenses = tokio::task::spawn_blocking(move || {
1860            let all_docs = docs.all_docs_for_scan();
1861            code_lenses(&uri_owned, &doc, &all_docs)
1862        })
1863        .await
1864        .unwrap_or_default();
1865        Ok(if lenses.is_empty() {
1866            None
1867        } else {
1868            Some(lenses)
1869        })
1870    }
1871
1872    async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
1873        // Lenses are fully populated by code_lens; nothing to add.
1874        Ok(params)
1875    }
1876
1877    async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
1878        let uri = &params.text_document.uri;
1879        let doc = match self.get_doc(uri) {
1880            Some(d) => d,
1881            None => return Ok(None),
1882        };
1883        let links = document_links(uri, &doc, doc.source());
1884        Ok(if links.is_empty() { None } else { Some(links) })
1885    }
1886
1887    async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
1888        // Links already carry their target URI; nothing to add.
1889        Ok(params)
1890    }
1891
1892    async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
1893        let uri = &params.text_document.uri;
1894        let source = self.get_open_text(uri).unwrap_or_default();
1895        Ok(format_document(&source))
1896    }
1897
1898    async fn range_formatting(
1899        &self,
1900        params: DocumentRangeFormattingParams,
1901    ) -> Result<Option<Vec<TextEdit>>> {
1902        let uri = &params.text_document.uri;
1903        let source = self.get_open_text(uri).unwrap_or_default();
1904        Ok(format_range(&source, params.range))
1905    }
1906
1907    async fn on_type_formatting(
1908        &self,
1909        params: DocumentOnTypeFormattingParams,
1910    ) -> Result<Option<Vec<TextEdit>>> {
1911        let uri = &params.text_document_position.text_document.uri;
1912        let source = self.get_open_text(uri).unwrap_or_default();
1913        let edits = on_type_format(
1914            &source,
1915            params.text_document_position.position,
1916            &params.ch,
1917            &params.options,
1918        );
1919        Ok(if edits.is_empty() { None } else { Some(edits) })
1920    }
1921
1922    async fn execute_command(
1923        &self,
1924        params: ExecuteCommandParams,
1925    ) -> Result<Option<serde_json::Value>> {
1926        match params.command.as_str() {
1927            "php-lsp.runTest" => {
1928                // Arguments: [uri_string, "ClassName::methodName"]
1929                let file_uri = params
1930                    .arguments
1931                    .first()
1932                    .and_then(|v| v.as_str())
1933                    .and_then(|s| Url::parse(s).ok());
1934                let filter = params
1935                    .arguments
1936                    .get(1)
1937                    .and_then(|v| v.as_str())
1938                    .unwrap_or("")
1939                    .to_string();
1940
1941                let root = self.root_paths.load().first().cloned();
1942                let client = self.client.clone();
1943
1944                tokio::spawn(async move {
1945                    run_phpunit(&client, &filter, root.as_deref(), file_uri.as_ref()).await;
1946                });
1947
1948                Ok(None)
1949            }
1950            _ => Ok(None),
1951        }
1952    }
1953
1954    async fn will_rename_files(&self, params: RenameFilesParams) -> Result<Option<WorkspaceEdit>> {
1955        let psr4 = self.psr4.load();
1956        let all_docs = self.docs.all_docs_for_scan();
1957        let mut merged_changes: std::collections::HashMap<
1958            tower_lsp::lsp_types::Url,
1959            Vec<tower_lsp::lsp_types::TextEdit>,
1960        > = std::collections::HashMap::new();
1961
1962        for file_rename in &params.files {
1963            let old_path = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri)
1964                .ok()
1965                .and_then(|u| u.to_file_path().ok());
1966            let new_path = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
1967                .ok()
1968                .and_then(|u| u.to_file_path().ok());
1969
1970            let (Some(old_path), Some(new_path)) = (old_path, new_path) else {
1971                continue;
1972            };
1973
1974            let old_fqn = psr4.file_to_fqn(&old_path);
1975            let new_fqn = psr4.file_to_fqn(&new_path);
1976
1977            let (Some(old_fqn), Some(new_fqn)) = (old_fqn, new_fqn) else {
1978                continue;
1979            };
1980
1981            let edit = use_edits_for_rename(&old_fqn, &new_fqn, &all_docs);
1982            if let Some(changes) = edit.changes {
1983                for (uri, edits) in changes {
1984                    merged_changes.entry(uri).or_default().extend(edits);
1985                }
1986            }
1987        }
1988
1989        Ok(if merged_changes.is_empty() {
1990            None
1991        } else {
1992            Some(WorkspaceEdit {
1993                changes: Some(merged_changes),
1994                ..Default::default()
1995            })
1996        })
1997    }
1998
1999    async fn did_rename_files(&self, params: RenameFilesParams) {
2000        for file_rename in &params.files {
2001            // Drop the old URI from the index
2002            if let Ok(old_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.old_uri) {
2003                self.docs.remove(&old_uri);
2004            }
2005            // Index the file at its new location
2006            if let Ok(new_uri) = tower_lsp::lsp_types::Url::parse(&file_rename.new_uri)
2007                && let Ok(path) = new_uri.to_file_path()
2008                && let Ok(text) = tokio::fs::read_to_string(&path).await
2009            {
2010                self.index_if_not_open(new_uri, &text);
2011            }
2012        }
2013    }
2014
2015    // ── File-create notifications ────────────────────────────────────────────
2016
2017    async fn will_create_files(&self, params: CreateFilesParams) -> Result<Option<WorkspaceEdit>> {
2018        let psr4 = self.psr4.load();
2019        let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2020            std::collections::HashMap::new();
2021
2022        for file in &params.files {
2023            let Ok(uri) = Url::parse(&file.uri) else {
2024                continue;
2025            };
2026            // Check the extension from the URI path so this works on Windows
2027            // where to_file_path() fails for drive-less URIs (e.g. file:///foo.php).
2028            if !uri.path().ends_with(".php") {
2029                continue;
2030            }
2031
2032            let stub = if let Ok(path) = uri.to_file_path()
2033                && let Some(fqn) = psr4.file_to_fqn(&path)
2034            {
2035                let (ns, class_name) = match fqn.rfind('\\') {
2036                    Some(pos) => (&fqn[..pos], &fqn[pos + 1..]),
2037                    None => ("", fqn.as_str()),
2038                };
2039                if ns.is_empty() {
2040                    format!("<?php\n\ndeclare(strict_types=1);\n\nclass {class_name}\n{{\n}}\n")
2041                } else {
2042                    format!(
2043                        "<?php\n\ndeclare(strict_types=1);\n\nnamespace {ns};\n\nclass {class_name}\n{{\n}}\n"
2044                    )
2045                }
2046            } else {
2047                "<?php\n\n".to_string()
2048            };
2049
2050            changes.insert(
2051                uri,
2052                vec![TextEdit {
2053                    range: Range {
2054                        start: Position {
2055                            line: 0,
2056                            character: 0,
2057                        },
2058                        end: Position {
2059                            line: 0,
2060                            character: 0,
2061                        },
2062                    },
2063                    new_text: stub,
2064                }],
2065            );
2066        }
2067
2068        Ok(if changes.is_empty() {
2069            None
2070        } else {
2071            Some(WorkspaceEdit {
2072                changes: Some(changes),
2073                ..Default::default()
2074            })
2075        })
2076    }
2077
2078    async fn did_create_files(&self, params: CreateFilesParams) {
2079        for file in &params.files {
2080            if let Ok(uri) = Url::parse(&file.uri)
2081                && let Ok(path) = uri.to_file_path()
2082                && let Ok(text) = tokio::fs::read_to_string(&path).await
2083            {
2084                self.index_if_not_open(uri, &text);
2085            }
2086        }
2087        send_refresh_requests(&self.client).await;
2088    }
2089
2090    // ── File-delete notifications ────────────────────────────────────────────
2091
2092    /// Before a file is deleted, return workspace edits that remove every
2093    /// `use` import referencing its PSR-4 class name.
2094    async fn will_delete_files(&self, params: DeleteFilesParams) -> Result<Option<WorkspaceEdit>> {
2095        let psr4 = self.psr4.load();
2096        let all_docs = self.docs.all_docs_for_scan();
2097        let mut merged_changes: std::collections::HashMap<Url, Vec<TextEdit>> =
2098            std::collections::HashMap::new();
2099
2100        for file in &params.files {
2101            let path = Url::parse(&file.uri)
2102                .ok()
2103                .and_then(|u| u.to_file_path().ok());
2104            let Some(path) = path else { continue };
2105            let Some(fqn) = psr4.file_to_fqn(&path) else {
2106                continue;
2107            };
2108
2109            let edit = use_edits_for_delete(&fqn, &all_docs);
2110            if let Some(changes) = edit.changes {
2111                for (uri, edits) in changes {
2112                    merged_changes.entry(uri).or_default().extend(edits);
2113                }
2114            }
2115        }
2116
2117        Ok(if merged_changes.is_empty() {
2118            None
2119        } else {
2120            Some(WorkspaceEdit {
2121                changes: Some(merged_changes),
2122                ..Default::default()
2123            })
2124        })
2125    }
2126
2127    async fn did_delete_files(&self, params: DeleteFilesParams) {
2128        for file in &params.files {
2129            if let Ok(uri) = Url::parse(&file.uri) {
2130                self.docs.remove(&uri);
2131                // Clear diagnostics for the now-deleted file.
2132                self.client.publish_diagnostics(uri, vec![], None).await;
2133            }
2134        }
2135        send_refresh_requests(&self.client).await;
2136    }
2137
2138    // ── Moniker ──────────────────────────────────────────────────────────────
2139
2140    async fn moniker(&self, params: MonikerParams) -> Result<Option<Vec<Moniker>>> {
2141        let uri = &params.text_document_position_params.text_document.uri;
2142        let position = params.text_document_position_params.position;
2143        let source = self.get_open_text(uri).unwrap_or_default();
2144        let doc = match self.get_doc(uri) {
2145            Some(d) => d,
2146            None => return Ok(None),
2147        };
2148        let imports = self.file_imports(uri);
2149        Ok(moniker_at(&source, &doc, position, &imports).map(|m| vec![m]))
2150    }
2151
2152    // ── Inline values ────────────────────────────────────────────────────────
2153
2154    async fn inline_value(&self, params: InlineValueParams) -> Result<Option<Vec<InlineValue>>> {
2155        let uri = &params.text_document.uri;
2156        let source = self.get_open_text(uri).unwrap_or_default();
2157        let values = inline_values_in_range(&source, params.range);
2158        Ok(if values.is_empty() {
2159            None
2160        } else {
2161            Some(values)
2162        })
2163    }
2164
2165    async fn diagnostic(
2166        &self,
2167        params: DocumentDiagnosticParams,
2168    ) -> Result<DocumentDiagnosticReportResult> {
2169        let uri = &params.text_document.uri;
2170        let parse_diags = self.get_parse_diagnostics(uri).unwrap_or_default();
2171        let _doc = match self.get_doc(uri) {
2172            Some(d) => d,
2173            None => {
2174                // Even if document not fully indexed, compute result_id for parse diagnostics
2175                let _version = self
2176                    .open_files
2177                    .all_with_diagnostics()
2178                    .iter()
2179                    .find(|(u, _, _)| u == uri)
2180                    .and_then(|(_, _, v)| *v)
2181                    .unwrap_or(1);
2182                let result_id = compute_diagnostic_result_id(&parse_diags, uri.as_str());
2183                return Ok(DocumentDiagnosticReportResult::Report(
2184                    DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2185                        related_documents: None,
2186                        full_document_diagnostic_report: FullDocumentDiagnosticReport {
2187                            result_id: Some(result_id),
2188                            items: parse_diags,
2189                        },
2190                    }),
2191                ));
2192            }
2193        };
2194        let (diag_cfg, php_version) = {
2195            let cfg = self.config.load();
2196            (cfg.diagnostics.clone(), cfg.php_version.clone())
2197        };
2198        // Note: php_version could be used for version-specific diagnostics in the future
2199        let _ = php_version;
2200
2201        // Phase I: salsa Pass-2 is CPU-bound; run off the async executor.
2202        let docs = Arc::clone(&self.docs);
2203        let uri_owned = uri.clone();
2204        let diag_cfg_sem = diag_cfg.clone();
2205        let sem_diags = tokio::task::spawn_blocking(move || {
2206            docs.get_semantic_issues_salsa(&uri_owned)
2207                .map(|issues| {
2208                    crate::semantic_diagnostics::issues_to_diagnostics(
2209                        &issues,
2210                        &uri_owned,
2211                        &diag_cfg_sem,
2212                    )
2213                })
2214                .unwrap_or_default()
2215        })
2216        .await
2217        .map_err(|e| {
2218            use std::borrow::Cow;
2219            tower_lsp::jsonrpc::Error {
2220                code: tower_lsp::jsonrpc::ErrorCode::InternalError,
2221                message: Cow::Owned(format!("diagnostic analysis failed: {}", e)),
2222                data: None,
2223            }
2224        })?;
2225
2226        let items = merge_file_diagnostics(parse_diags, sem_diags);
2227
2228        // Generate stable result_id for caching
2229        let _version = self
2230            .open_files
2231            .all_with_diagnostics()
2232            .iter()
2233            .find(|(u, _, _)| u == uri)
2234            .and_then(|(_, _, v)| *v)
2235            .unwrap_or(1);
2236        let result_id = compute_diagnostic_result_id(&items, uri.as_str());
2237
2238        Ok(DocumentDiagnosticReportResult::Report(
2239            DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
2240                related_documents: None,
2241                full_document_diagnostic_report: FullDocumentDiagnosticReport {
2242                    result_id: Some(result_id),
2243                    items,
2244                },
2245            }),
2246        ))
2247    }
2248
2249    async fn workspace_diagnostic(
2250        &self,
2251        params: WorkspaceDiagnosticParams,
2252    ) -> Result<WorkspaceDiagnosticReportResult> {
2253        let all_parse_diags = self.all_open_files_with_diagnostics();
2254        let (diag_cfg, php_version) = {
2255            let cfg = self.config.load();
2256            (cfg.diagnostics.clone(), cfg.php_version.clone())
2257        };
2258
2259        // Note: php_version could be used for version-specific diagnostics in the future
2260        let _ = php_version;
2261
2262        // Build a URI→result_id lookup from the client's cached state.
2263        // Per LSP §3.17.7: files present in this map with a matching result_id
2264        // should return Unchanged; all others return Full.
2265        // Duplicate URIs: last-wins (HashMap collect). Clients shouldn't send duplicates,
2266        // but if they do the last entry wins — safe and simple.
2267        let previous_map: std::collections::HashMap<Url, String> = params
2268            .previous_result_ids
2269            .into_iter()
2270            .map(|p| (p.uri, p.value))
2271            .collect();
2272
2273        // Phase I: each file's semantic issues flow through the salsa
2274        // `semantic_issues` query. The memo is shared with `did_open` /
2275        // `did_change` / `document_diagnostic` / `code_action`, so repeated
2276        // workspace-diagnostic pulls reuse prior analysis. The first pull on
2277        // a cold workspace still walks every file's `StatementsAnalyzer` —
2278        // run the whole sweep on the blocking pool so the async runtime
2279        // stays responsive.
2280        let docs = Arc::clone(&self.docs);
2281        let diag_cfg_sweep = diag_cfg.clone();
2282        let items = tokio::task::spawn_blocking(move || {
2283            all_parse_diags
2284                .into_iter()
2285                .map(|(uri, parse_diags, version)| {
2286                    let sem_diags = docs
2287                        .get_semantic_issues_salsa(&uri)
2288                        .map(|issues| {
2289                            crate::semantic_diagnostics::issues_to_diagnostics(
2290                                &issues,
2291                                &uri,
2292                                &diag_cfg_sweep,
2293                            )
2294                        })
2295                        .unwrap_or_default();
2296                    let all_diags = merge_file_diagnostics(parse_diags, sem_diags);
2297
2298                    let result_id = compute_diagnostic_result_id(&all_diags, uri.as_str());
2299
2300                    // Per LSP §3.17.7: return Unchanged only when the client already has
2301                    // this exact result_id cached for this URI; otherwise return Full.
2302                    if previous_map.get(&uri) == Some(&result_id) {
2303                        WorkspaceDocumentDiagnosticReport::Unchanged(
2304                            WorkspaceUnchangedDocumentDiagnosticReport {
2305                                uri,
2306                                version,
2307                                unchanged_document_diagnostic_report:
2308                                    UnchangedDocumentDiagnosticReport { result_id },
2309                            },
2310                        )
2311                    } else {
2312                        WorkspaceDocumentDiagnosticReport::Full(
2313                            WorkspaceFullDocumentDiagnosticReport {
2314                                uri,
2315                                version,
2316                                full_document_diagnostic_report: FullDocumentDiagnosticReport {
2317                                    result_id: Some(result_id),
2318                                    items: all_diags,
2319                                },
2320                            },
2321                        )
2322                    }
2323                })
2324                .collect::<Vec<_>>()
2325        })
2326        .await
2327        .map_err(|e| {
2328            use std::borrow::Cow;
2329            tower_lsp::jsonrpc::Error {
2330                code: tower_lsp::jsonrpc::ErrorCode::InternalError,
2331                message: Cow::Owned(format!("workspace_diagnostic analysis failed: {}", e)),
2332                data: None,
2333            }
2334        })?;
2335
2336        Ok(WorkspaceDiagnosticReportResult::Report(
2337            WorkspaceDiagnosticReport { items },
2338        ))
2339    }
2340
2341    async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
2342        let uri = &params.text_document.uri;
2343        let source = self.get_open_text(uri).unwrap_or_default();
2344        let doc = match self.get_doc(uri) {
2345            Some(d) => d,
2346            None => return Ok(None),
2347        };
2348        let other_docs = self.docs.other_docs(uri, &self.open_urls());
2349
2350        // Phase I: read semantic issues through the salsa query. The result
2351        // is memoized across did_open/did_change/document_diagnostic, so
2352        // code_action usually hits the memo instead of rerunning analysis.
2353        // On a memo miss (e.g. code-action fires before did_open finishes),
2354        // the analyzer runs — park that on the blocking pool so the async
2355        // runtime doesn't stall.
2356        let diag_cfg = self.config.load().diagnostics.clone();
2357        let docs_sem = Arc::clone(&self.docs);
2358        let uri_sem = uri.clone();
2359        let diag_cfg_sem = diag_cfg.clone();
2360        let sem_diags = tokio::task::spawn_blocking(move || {
2361            docs_sem
2362                .get_semantic_issues_salsa(&uri_sem)
2363                .map(|issues| {
2364                    crate::semantic_diagnostics::issues_to_diagnostics(
2365                        &issues,
2366                        &uri_sem,
2367                        &diag_cfg_sem,
2368                    )
2369                })
2370                .unwrap_or_default()
2371        })
2372        .await
2373        .unwrap_or_default();
2374
2375        // Build "Add use import" code actions for undefined class names in range
2376        let mut actions: Vec<CodeActionOrCommand> = Vec::new();
2377        for diag in &sem_diags {
2378            if diag.code != Some(NumberOrString::String("UndefinedClass".to_string())) {
2379                continue;
2380            }
2381            // Only act on diagnostics within the requested range
2382            if diag.range.start.line < params.range.start.line
2383                || diag.range.start.line > params.range.end.line
2384            {
2385                continue;
2386            }
2387            // Message format: "Class {name} does not exist"
2388            let class_name = diag
2389                .message
2390                .strip_prefix("Class ")
2391                .and_then(|s| s.strip_suffix(" does not exist"))
2392                .unwrap_or("")
2393                .trim();
2394            if class_name.is_empty() {
2395                continue;
2396            }
2397
2398            // Find a class with this short name in other indexed documents
2399            for (_other_uri, other_doc) in &other_docs {
2400                if let Some(fqn) = find_fqn_for_class(other_doc, class_name) {
2401                    let edit = build_use_import_edit(&source, uri, &fqn);
2402                    let action = CodeAction {
2403                        title: format!("Add use {fqn}"),
2404                        kind: Some(CodeActionKind::QUICKFIX),
2405                        edit: Some(edit),
2406                        diagnostics: Some(vec![diag.clone()]),
2407                        ..Default::default()
2408                    };
2409                    actions.push(CodeActionOrCommand::CodeAction(action));
2410                    break; // one action per undefined symbol
2411                }
2412            }
2413        }
2414
2415        // Defer edit computation to code_action_resolve so the menu renders
2416        // instantly; the client fetches the full edit only for the selected item.
2417        for tag in DEFERRED_ACTION_TAGS {
2418            actions.extend(defer_actions(
2419                self.generate_deferred_actions(tag, &source, &doc, params.range, uri),
2420                tag,
2421                uri,
2422                params.range,
2423            ));
2424        }
2425
2426        // Extract variable: cheap, keep eager.
2427        actions.extend(extract_variable_actions(&source, params.range, uri));
2428        actions.extend(extract_method_actions(&source, &doc, params.range, uri));
2429        actions.extend(extract_constant_actions(&source, params.range, uri));
2430        // Inline variable: inverse of extract variable.
2431        actions.extend(inline_variable_actions(&source, params.range, uri));
2432        // Organize imports: sort and remove unused use statements.
2433        if let Some(action) = organize_imports_action(&source, uri) {
2434            actions.push(action);
2435        }
2436
2437        Ok(if actions.is_empty() {
2438            None
2439        } else {
2440            Some(actions)
2441        })
2442    }
2443
2444    async fn code_action_resolve(&self, item: CodeAction) -> Result<CodeAction> {
2445        let data = match &item.data {
2446            Some(d) => d.clone(),
2447            None => return Ok(item),
2448        };
2449        let kind_tag = match data.get("php_lsp_resolve").and_then(|v| v.as_str()) {
2450            Some(k) => k.to_string(),
2451            None => return Ok(item),
2452        };
2453        let uri: Url = match data
2454            .get("uri")
2455            .and_then(|v| v.as_str())
2456            .and_then(|s| Url::parse(s).ok())
2457        {
2458            Some(u) => u,
2459            None => return Ok(item),
2460        };
2461        let range: Range = match data
2462            .get("range")
2463            .and_then(|v| serde_json::from_value(v.clone()).ok())
2464        {
2465            Some(r) => r,
2466            None => return Ok(item),
2467        };
2468
2469        let source = self.get_open_text(&uri).unwrap_or_default();
2470        let doc = match self.get_doc(&uri) {
2471            Some(d) => d,
2472            None => return Ok(item),
2473        };
2474
2475        let candidates = self.generate_deferred_actions(&kind_tag, &source, &doc, range, &uri);
2476
2477        // Find the action whose title matches and return it fully resolved.
2478        for candidate in candidates {
2479            if let CodeActionOrCommand::CodeAction(ca) = candidate
2480                && ca.title == item.title
2481            {
2482                return Ok(ca);
2483            }
2484        }
2485
2486        Ok(item)
2487    }
2488}
2489
2490/// Expand an aliased namespace prefix in `word` using the file's import map.
2491///
2492/// `use Doctrine\ORM\Mapping as ORM;` + `ORM\Column` → `Doctrine\ORM\Mapping\Column`
2493///
2494/// Only the first segment is checked against the alias map; if no alias matches
2495/// the word is returned unchanged (including already-FQN words like `\Foo\Bar`).
2496fn expand_alias_prefix(word: &str, imports: &std::collections::HashMap<String, String>) -> String {
2497    if let Some((first, rest)) = word.split_once('\\')
2498        && let Some(ns_prefix) = imports.get(first)
2499    {
2500        return format!("{}\\{}", ns_prefix, rest);
2501    }
2502    word.to_string()
2503}