Skip to main content

deps_lsp/handlers/
completion.rs

1//! Completion handler implementation.
2//!
3//! Delegates to ecosystem-specific completion logic.
4
5use crate::config::DepsConfig;
6use crate::document::{ServerState, ensure_document_loaded};
7use deps_core::EcosystemId;
8use deps_core::completion::COMPLETION_SEARCH_TIMEOUT;
9use deps_core::{
10    is_safe_maven_coordinate_segment, is_safe_package_name, is_safe_registry_url,
11    is_safe_version_string, lsp_helpers::warn_rejected_value,
12};
13use std::sync::Arc;
14use tokio::sync::RwLock;
15use tower_lsp_server::Client;
16use tower_lsp_server::ls_types::{
17    CompletionItem, CompletionItemKind, CompletionList, CompletionParams, CompletionResponse,
18    InsertTextFormat,
19};
20
21// Completion is keystroke-driven and must stay responsive, so registry-backed
22// completion work gets its own short timeout instead of sharing the 30s HTTP client
23// timeout used elsewhere ([`COMPLETION_SEARCH_TIMEOUT`]).
24//
25// Shared with `deps_core::completion` (rather than kept local) because
26// registry-backed completion paths that retry internally on failure (e.g.
27// `deps-maven`'s `search_typed`, #274) must size their own retry budget against
28// this same value — see `deps_core::completion::COMPLETION_SEARCH_TIMEOUT`'s doc.
29
30/// Handles completion requests.
31///
32/// Delegates to the appropriate ecosystem implementation based on the document type.
33/// Falls back to text-based completion when TOML parsing fails (user is still typing).
34pub async fn handle_completion(
35    state: Arc<ServerState>,
36    params: CompletionParams,
37    client: Client,
38    config: Arc<RwLock<DepsConfig>>,
39) -> Option<CompletionResponse> {
40    let uri = &params.text_document_position.text_document.uri;
41    let position = params.text_document_position.position;
42
43    tracing::info!(
44        "completion request: uri={:?}, line={}, character={}",
45        uri,
46        position.line,
47        position.character
48    );
49
50    // Snapshot before any document lookup, matching hover.rs/diagnostics.rs's ordering —
51    // this acquires the config RwLock before the DashMap shard guard, never the reverse.
52    let freshness = { config.read().await.freshness.to_settings() };
53
54    // Resolved once, from the URI alone via `get_for_uri` (the same routing
55    // `handle_document_open` uses), rather than from the loaded document's
56    // `ecosystem_id` — that would only be available *after* the document-load and
57    // document-lookup early returns below. `is_some_and` (not `?`) so an
58    // unrecognized URI falls through to `false` (matching every ecosystem's
59    // default) rather than short-circuiting this function.
60    let package_search_is_incomplete = state
61        .ecosystem_registry
62        .get_for_uri(uri)
63        .is_some_and(|e| e.package_search_is_incomplete());
64
65    // Shared by the document-load and document-lookup early returns below, so
66    // both report `isIncomplete` consistently for an ecosystem whose package-name
67    // search (the only kind of completion either path could otherwise have
68    // produced, via `fallback_completion`) may be a truncated view of a larger
69    // candidate set (#419 S1) — `None` would serialize as LSP `null`, which
70    // carries no `isIncomplete` and leaves the client with nothing to invalidate
71    // on the next keystroke.
72    let context_less_response = || {
73        if package_search_is_incomplete {
74            Some(CompletionResponse::List(CompletionList {
75                is_incomplete: true,
76                items: vec![],
77            }))
78        } else {
79            None
80        }
81    };
82
83    // Check if document is loaded, if not try to load with short timeout
84    // Completion is latency-critical, so we use a 200ms timeout
85    if state.get_document(uri).is_none() {
86        tracing::info!("completion: document not loaded, loading from disk");
87
88        // Try to load with short timeout (200ms)
89        let load_result = tokio::time::timeout(
90            std::time::Duration::from_millis(200),
91            ensure_document_loaded(uri, Arc::clone(&state), client.clone(), Arc::clone(&config)),
92        )
93        .await;
94
95        match load_result {
96            Ok(true) => {
97                // Document loaded successfully, continue with completion
98                tracing::debug!("completion: document loaded successfully");
99            }
100            Ok(false) | Err(_) => {
101                // Load failed or timed out, return empty completions
102                tracing::warn!("completion: document load failed or timed out");
103                return context_less_response();
104            }
105        }
106    }
107
108    // Own everything needed from the document in a single shard acquisition, then
109    // release the `Ref` immediately: two separate acquisitions (one for `content`, a
110    // later one for `parse_result`) would let a concurrent `didChange` land in
111    // between, pairing a `parse_result` with `content` from a different document
112    // revision — `generate_completions` correlates the two (e.g. `extract_prefix`
113    // slicing `content` at a range taken from `parse_result`), so a torn pair risks
114    // wrong or out-of-bounds-guarded-empty completions (#319 review).
115    // `with_document` makes releasing the guard structural rather than a convention
116    // to remember (#333).
117    let Some((ecosystem_id, ecosystem_kind, content, parse_result)) =
118        state.with_document(uri, |doc| {
119            (
120                doc.ecosystem_id(),
121                doc.ecosystem,
122                doc.content.clone(),
123                doc.parse_result_arc(),
124            )
125        })
126    else {
127        tracing::warn!("completion: document not found: {:?}", uri);
128        return context_less_response();
129    };
130
131    tracing::info!(
132        "completion: ecosystem={}, has_parse_result={}",
133        ecosystem_id,
134        parse_result.is_some()
135    );
136
137    // Try parse_result first, fallback to text-based detection. `is_incomplete` is
138    // the per-call signal `generate_completions` computed for the actual completion
139    // context it served (#427). Whenever `fallback_completion` actually runs — the
140    // primary result was empty, or there was no `parse_result` to call
141    // `generate_completions` with at all — it is OR'd with
142    // `ecosystem.package_search_is_incomplete()`: `fallback_completion` always
143    // performs a raw package-name search via `Registry::search` regardless of the
144    // primary context, so it inherits the primary's `is_incomplete` only by
145    // coincidence, not because the two searches share a completeness signal.
146    let (items, is_incomplete) = if let Some(parse_result) = parse_result {
147        match state.ecosystem_registry.get(ecosystem_id) {
148            Some(ecosystem) => {
149                // The DashMap shard `Ref` was already dropped above, before this
150                // timeout-bound await: the search can run for up to
151                // `COMPLETION_SEARCH_TIMEOUT`, and holding the guard that long would
152                // block a concurrent `documents.get_mut` on the same shard for the
153                // duration (#319).
154                let completion_result = tokio::time::timeout(
155                    COMPLETION_SEARCH_TIMEOUT,
156                    ecosystem.generate_completions(
157                        parse_result.as_ref(),
158                        position,
159                        &content,
160                        freshness,
161                    ),
162                )
163                .await;
164
165                match completion_result {
166                    // Ecosystem returned no completions: try fallback, since this
167                    // handles the case where the user is typing a NEW package name.
168                    Ok(completions) if completions.items.is_empty() => {
169                        tracing::info!("completion: ecosystem returned empty, trying fallback");
170                        let fallback_items =
171                            fallback_completion(&state, ecosystem_kind, position, &content).await;
172                        (
173                            fallback_items,
174                            completions.is_incomplete || ecosystem.package_search_is_incomplete(),
175                        )
176                    }
177                    Ok(completions) => (completions.items, completions.is_incomplete),
178                    // Timed out, not genuinely empty: the registry is slow right now,
179                    // so a fallback search against the same registry would likely
180                    // time out too. Skip it instead of doubling the worst-case
181                    // latency.
182                    Err(_) => {
183                        tracing::warn!(
184                            "completion: generate_completions timed out after \
185                             {}s, skipping fallback search",
186                            COMPLETION_SEARCH_TIMEOUT.as_secs()
187                        );
188                        (vec![], false)
189                    }
190                }
191            }
192            None => {
193                tracing::warn!("completion: ecosystem not found for id: {ecosystem_id}");
194                (vec![], false)
195            }
196        }
197    } else {
198        // Fallback: detect context from raw text. No `parse_result` means
199        // `generate_completions` was never called, so `package_search_is_incomplete`
200        // (already resolved for this URI's ecosystem above) is the only signal
201        // available — matches the every-`didChange`-with-a-parse-failure case
202        // (`document/lifecycle.rs`'s `new_without_parse_result`), exactly the
203        // mid-typing state for a new package name.
204        (
205            fallback_completion(&state, ecosystem_kind, position, &content).await,
206            package_search_is_incomplete,
207        )
208    };
209
210    tracing::info!("completion: returning {} items", items.len());
211
212    if is_incomplete {
213        // Must still be a `List` when `items` is empty: `None` serializes as LSP
214        // `null`, which carries no `isIncomplete` and leaves the client with
215        // nothing to invalidate on the next keystroke (#419 C1) — this is the
216        // cold-start-returns-empty case PyPI's search index relies on.
217        Some(CompletionResponse::List(CompletionList {
218            is_incomplete: true,
219            items,
220        }))
221    } else if items.is_empty() {
222        None
223    } else {
224        Some(CompletionResponse::Array(items))
225    }
226}
227
228/// Fallback completion when document parsing fails.
229///
230/// Detects dependencies sections from raw text and provides package name suggestions.
231async fn fallback_completion(
232    state: &ServerState,
233    ecosystem_kind: EcosystemId,
234    position: tower_lsp_server::ls_types::Position,
235    content: &str,
236) -> Vec<CompletionItem> {
237    tracing::info!(
238        "fallback_completion: starting for ecosystem={}",
239        ecosystem_kind
240    );
241
242    // Get the current line
243    let line = match content.lines().nth(position.line as usize) {
244        Some(l) => l,
245        None => {
246            tracing::info!("fallback_completion: line {} not found", position.line);
247            return vec![];
248        }
249    };
250
251    tracing::info!("fallback_completion: line content = {:?}", line);
252
253    if !is_in_dependencies_section(content, position.line as usize, ecosystem_kind) {
254        tracing::info!("fallback_completion: not in dependencies section");
255        return vec![];
256    }
257
258    // Extract what user has typed (from start of line to cursor)
259    let prefix = extract_prefix(line, position.character, ecosystem_kind);
260
261    tracing::info!("fallback_completion: prefix = {:?}", prefix);
262
263    // If it looks like a package name (letters, no = sign, at least 2 chars).
264    // Count Unicode scalar values, not bytes: a single multi-byte character
265    // (e.g. one CJK character) must not satisfy the "at least 2 chars" intent.
266    if prefix.is_empty() || prefix.contains('=') || prefix.chars().count() < 2 {
267        tracing::info!("fallback_completion: prefix rejected (empty, contains =, or < 2 chars)");
268        return vec![];
269    }
270
271    // Get ecosystem and search for packages
272    let ecosystem = match state.ecosystem_registry.get(ecosystem_kind.id()) {
273        Some(e) => e,
274        None => return vec![],
275    };
276
277    let registry = ecosystem.registry();
278
279    // Search for packages matching the prefix
280    search_packages(registry.as_ref(), ecosystem_kind, prefix).await
281}
282
283/// Extracts what the user has typed on `line` up to the cursor (`character`), trimmed
284/// of whitespace.
285///
286/// For JSON manifests (package.json, composer.json) a quote can survive on either
287/// side: a leading `"` when the cursor sits before the closing quote of a still-typed
288/// key, or a trailing `"` when the cursor sits right after a closing quote (e.g. an
289/// editor auto-closed it, or the user retyped it). Either would otherwise reach the
290/// registry as part of the search query and suppress exact matches. PyPI's
291/// `pyproject.toml` entries (`"pytes` inside `dependencies = [...]`) carry the same
292/// surviving-quote shape, just as a TOML array element rather than a JSON key — see
293/// [`uses_toml_string_array_values`].
294///
295/// For XML manifests (`pom.xml`) an opening tag survives on the left instead (cursor
296/// inside `<artifactId>gua`) — stripped so the extracted text matches what the
297/// ecosystem's own primary completion path (e.g. `MavenEcosystem::detect_xml_context`)
298/// searches for at the same cursor position. Without this, the raw-text fallback path
299/// searches the registry for markup-polluted text instead of the real prefix, and (#282
300/// C1) a per-query dedup/cache mechanism keyed on the search string never recognizes
301/// the fallback's call as a repeat of the primary path's call for the same prefix.
302fn extract_prefix(line: &str, character: u32, ecosystem_kind: EcosystemId) -> &str {
303    let prefix_end =
304        deps_core::completion::utf16_to_byte_offset(line, character).unwrap_or(line.len());
305    let prefix = line[..prefix_end].trim();
306    if uses_json_quoted_keys(ecosystem_kind) || uses_toml_string_array_values(ecosystem_kind) {
307        prefix.trim_matches('"')
308    } else if uses_xml_tag_values(ecosystem_kind) {
309        strip_leading_xml_tag(prefix)
310    } else {
311        prefix
312    }
313}
314
315/// Whether `ecosystem_kind`'s manifest wraps a completable value in an XML open tag on
316/// the same line (`<artifactId>gua`), so [`extract_prefix`] must strip that tag.
317///
318/// Exhaustively matched, like [`uses_json_quoted_keys`], so a future XML-manifest
319/// ecosystem forces a decision here instead of silently leaking tag markup into a
320/// registry search query. NuGet is XML too but correctly `false`: its dependencies are
321/// attribute-valued (`<PackageReference Include="..." Version="..." />`), not tag-value
322/// wrapped like Maven's, and its `is_in_dependencies_section` arm is already `false`
323/// (see that function's doc), so it never reaches `extract_prefix` regardless.
324const fn uses_xml_tag_values(ecosystem_kind: EcosystemId) -> bool {
325    match ecosystem_kind {
326        EcosystemId::Maven => true,
327        EcosystemId::Npm
328        | EcosystemId::Composer
329        | EcosystemId::Cargo
330        | EcosystemId::Pypi
331        | EcosystemId::Go
332        | EcosystemId::Dart
333        | EcosystemId::Gradle
334        | EcosystemId::Swift
335        | EcosystemId::NuGet
336        | EcosystemId::Bundler
337        | EcosystemId::Deno
338        | EcosystemId::GithubActions
339        | EcosystemId::GitlabCi => false,
340    }
341}
342
343/// Strips everything up to and including the *last* `>` in `prefix` (`<artifactId>gua`
344/// -> `gua`); returns `prefix` unchanged if it contains no `>` at all (e.g. the tag is
345/// not yet closed, as when the user is still typing the tag name itself).
346///
347/// Scans for the last `>`, not the first, to mirror `MavenEcosystem::
348/// detect_xml_context`'s own `rfind`-based tag lookup (`crates/deps-maven/src/
349/// ecosystem.rs`): that function locates the closest opening tag *before the cursor*,
350/// which is the last one on the line, not the first. A first-`>` version of this
351/// function diverges from it whenever more than one tag precedes the cursor on a line
352/// (`<groupId>com.google.guava</groupId><artifactId>gua` — the first `>` sits inside
353/// `<groupId>`, well short of the real value), or when the cursor sits right after a
354/// closing tag (`<artifactId>guava</artifactId>` with the cursor at the end: the last
355/// `>` is the line's very last character, correctly yielding an empty string — matching
356/// `detect_xml_context`'s own "no context" outcome for that position, since its
357/// `between.contains("</")` guard rejects it too).
358fn strip_leading_xml_tag(prefix: &str) -> &str {
359    prefix.rfind('>').map_or(prefix, |gt| &prefix[gt + 1..])
360}
361
362/// Whether `ecosystem_kind`'s manifest keys are typed as JSON string literals
363/// (package.json, composer.json), and so can carry a stray quote into [`extract_prefix`].
364///
365/// Exhaustively matched, like [`is_in_dependencies_section`], so a future JSON-manifest
366/// ecosystem forces a decision here instead of silently keeping a stray quote.
367///
368/// `Deno` is deliberately `false` despite `deno.json` being JSON, unlike npm/Composer:
369/// the npm analogy doesn't hold here because the completable text at a package-name
370/// position in `deno.json` is the JSON *value* (the `jsr:`/`npm:` specifier string), not
371/// the *key* (the import alias) — `extract_prefix`'s whole-line-to-cursor-then-trim
372/// approach only strips a stray quote correctly for a key-position completion, so
373/// applying it to Deno would leak the alias and colon into the fallback search query.
374///
375/// Fallback (raw-text) completion does still *run* for Deno — `is_in_dependencies_section`
376/// returns `true` inside `imports`, same as any other JSON ecosystem — but it is harmless:
377/// the raw line-start-to-cursor text `extract_prefix` produces is always preceded by the
378/// alias key, colon and opening quote in real JSON (`"@std/fs": "jsr:@std/f`), so it can
379/// never coincide with a bare `jsr:`/`npm:` prefix. `DenoRegistry::search` (`deps-deno`)
380/// therefore always takes its scheme-less `None => Ok(vec![])` arm for this path, so the
381/// fallback query is effectively a no-op rather than a source of garbage results — it is
382/// the primary `detect_completion_context`-based path
383/// (`DenoEcosystem::generate_completions`) that does the real work.
384const fn uses_json_quoted_keys(ecosystem_kind: EcosystemId) -> bool {
385    match ecosystem_kind {
386        EcosystemId::Npm | EcosystemId::Composer => true,
387        EcosystemId::Cargo
388        | EcosystemId::Pypi
389        | EcosystemId::Go
390        | EcosystemId::Dart
391        | EcosystemId::Maven
392        | EcosystemId::Gradle
393        | EcosystemId::Swift
394        | EcosystemId::NuGet
395        | EcosystemId::Bundler
396        | EcosystemId::Deno
397        | EcosystemId::GithubActions
398        | EcosystemId::GitlabCi => false,
399    }
400}
401
402/// Whether `ecosystem_kind`'s manifest completes a value positioned as a TOML
403/// string-array *element* (`"pytes` inside `dependencies = [...]`), so
404/// [`extract_prefix`] must strip a surviving quote the same way it does for
405/// [`uses_json_quoted_keys`]'s JSON object-key shape.
406///
407/// PyPI only for now: PEP 621's `dependencies`/`optional-dependencies` entries are
408/// the only raw-text-detected (see [`is_in_dependencies_section`]) dependency shape
409/// in this file that is a bare TOML array of strings. Cargo also flows through
410/// `is_in_toml_dependencies`, but its dependency shape is a *key* (`name =
411/// "version"`), not an array element, so it must stay `false` here — a `true`
412/// value would incorrectly strip a quote from a still-typed Cargo key.
413const fn uses_toml_string_array_values(ecosystem_kind: EcosystemId) -> bool {
414    match ecosystem_kind {
415        EcosystemId::Pypi => true,
416        EcosystemId::Cargo
417        | EcosystemId::Npm
418        | EcosystemId::Composer
419        | EcosystemId::Go
420        | EcosystemId::Dart
421        | EcosystemId::Maven
422        | EcosystemId::Gradle
423        | EcosystemId::Swift
424        | EcosystemId::NuGet
425        | EcosystemId::Bundler
426        | EcosystemId::Deno
427        | EcosystemId::GithubActions
428        | EcosystemId::GitlabCi => false,
429    }
430}
431
432/// Checks if a line is inside a dependencies section.
433///
434/// Dispatches to a per-ecosystem raw-text heuristic. Matching on [`EcosystemId`]
435/// rather than the raw ecosystem id string makes this exhaustive: adding a new
436/// ecosystem forces a decision here instead of silently disabling section-aware
437/// completion for it (see issue #118).
438fn is_in_dependencies_section(
439    content: &str,
440    line_number: usize,
441    ecosystem_id: EcosystemId,
442) -> bool {
443    match ecosystem_id {
444        EcosystemId::Cargo => is_in_toml_dependencies(content, line_number),
445        // PyPI's PEP 621 primary dependency list is a `dependencies = [...]` array
446        // under `[project]`, not a section header like Cargo's `[dependencies]` —
447        // `is_in_toml_dependencies` alone never matches it (see
448        // `is_in_pypi_project_dependencies_array`'s doc). `[project.optional-
449        // dependencies]` groups, by contrast, ARE a real header and stay covered by
450        // `is_in_toml_dependencies`.
451        EcosystemId::Pypi => {
452            is_in_toml_dependencies(content, line_number)
453                || is_in_pypi_project_dependencies_array(content, line_number)
454        }
455        EcosystemId::Npm => is_in_json_dependencies(
456            content,
457            line_number,
458            &[
459                "dependencies",
460                "devDependencies",
461                "peerDependencies",
462                "optionalDependencies",
463            ],
464        ),
465        EcosystemId::Composer => {
466            is_in_json_dependencies(content, line_number, &["require", "require-dev"])
467        }
468        EcosystemId::Maven => is_in_xml_tag_section(content, line_number, "dependencies"),
469        EcosystemId::Go => is_in_go_require(content, line_number),
470        EcosystemId::Dart => is_in_yaml_dependencies(content, line_number),
471        // TODO(#118 follow-up): Gemfile has no delimited dependencies section —
472        // `gem "name"` calls are valid anywhere at the top level or inside
473        // `group ... do ... end` blocks, so there is no raw-text boundary to detect.
474        // `false` matches the pre-fix behavior (fallback completion disabled) rather
475        // than `true`: `fallback_completion` fires on every keystroke where the
476        // ecosystem's own completion is empty, using the *whole trimmed line* as the
477        // search query (not a token), so a permissive `true` here would fire a live
478        // registry search on unrelated text and insert results with unrelated syntax.
479        EcosystemId::Bundler => false,
480        // TODO(#118 follow-up): Package.swift dependencies are `.package(...)` calls
481        // matched anywhere in the file by the real parser (not confined to the
482        // `dependencies: [...]` array), so there is no reliable raw-text section
483        // boundary here either. See the Bundler arm above for why this is `false`.
484        EcosystemId::Swift => false,
485        // TODO(#118 follow-up): Gradle spans five manifest formats (TOML version
486        // catalog, Groovy DSL, Kotlin DSL) with no raw-text section marker shared
487        // across all of them. See the Bundler arm above for why this is `false`.
488        EcosystemId::Gradle => false,
489        // TODO(#118 follow-up): NuGet spans three schemas: csproj/Directory.Packages
490        // .props nest PackageReference/PackageVersion in `<ItemGroup>`, while
491        // packages.config lists `<package>` elements directly under its root with no
492        // such wrapper. See the Bundler arm above for why this is `false`.
493        EcosystemId::NuGet => false,
494        EcosystemId::Deno => is_in_json_dependencies(content, line_number, &["imports"]),
495        // A `uses:` step key can appear at any nesting depth in a workflow file
496        // (`jobs.*.steps[].uses`, `jobs.<id>.uses`), so unlike the other YAML/TOML/
497        // JSON ecosystems above there is no enclosing section header to track —
498        // the target line itself is the only signal needed.
499        EcosystemId::GithubActions => is_github_actions_uses_line(content, line_number),
500        // `deps-gitlab-ci` never supports `PackageName` completion at all (spec
501        // NFR-002 — no cheap GitLab search endpoint), so the raw-text fallback this
502        // function drives is never reached for it either. `false` matches the
503        // Bundler/Swift/Gradle/NuGet arms above.
504        EcosystemId::GitlabCi => false,
505    }
506}
507
508/// Whether line `line_number` of `content` is a workflow `uses:` step key
509/// (`uses: owner/repo@ref` or, as a sequence item, `- uses: owner/repo@ref`).
510fn is_github_actions_uses_line(content: &str, line_number: usize) -> bool {
511    content
512        .lines()
513        .nth(line_number)
514        .map(str::trim_start)
515        .is_some_and(|trimmed| trimmed.starts_with("uses:") || trimmed.starts_with("- uses:"))
516}
517
518/// Checks if a line is inside a TOML dependencies section.
519///
520/// Looks for `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]` sections
521/// in Cargo.toml or `[project.dependencies]` in pyproject.toml.
522fn is_in_toml_dependencies(content: &str, line_number: usize) -> bool {
523    // Walk backwards from current line to find the most recent section header
524    // Collect lines up to target, then iterate backwards
525    let lines: Vec<_> = content.lines().enumerate().take(line_number + 1).collect();
526
527    for (_, line) in lines.iter().rev() {
528        let line = line.trim();
529
530        // Check if this is a section header
531        if line.starts_with('[') && line.ends_with(']') {
532            // Check if it's a dependencies section
533            return line == "[dependencies]"
534                || line == "[dev-dependencies]"
535                || line == "[build-dependencies]"
536                || line == "[workspace.dependencies]"
537                || line == "[project.dependencies]"
538                || line == "[project.optional-dependencies]"
539                || line.starts_with("[target.")
540                    && (line.contains(".dependencies]")
541                        || line.contains(".dev-dependencies]")
542                        || line.contains(".build-dependencies]"));
543        }
544    }
545
546    false
547}
548
549/// Checks if a line is inside PEP 621's `dependencies = [...]` array under the
550/// `[project]` table.
551///
552/// Unlike `[dependencies]`/`[project.optional-dependencies]`, PEP 621's primary
553/// dependency list is a *value* (an array assigned to the `dependencies` key), not
554/// a section header — no real `pyproject.toml` ever writes a literal
555/// `[project.dependencies]` header — so it needs its own bracket-depth scan rather
556/// than `is_in_toml_dependencies`'s header-string match.
557///
558/// The bracket-depth counter has no string awareness, so an unbalanced `[`/`]`
559/// inside a still-typed extras spec (`"uvicorn[stan`) or a comment would otherwise
560/// desync it permanently. TOML forbids a table header inside an array value, so a
561/// bare `[...]` header line (checked on every line, not just outside the array) is
562/// used as an unambiguous resync point regardless of the counter's state.
563fn is_in_pypi_project_dependencies_array(content: &str, line_number: usize) -> bool {
564    let mut in_project = false;
565    let mut in_array = false;
566    let mut depth: i32 = 0;
567
568    for (i, line) in content.lines().enumerate() {
569        if i > line_number {
570            break;
571        }
572        let trimmed = strip_trailing_toml_comment(line.trim());
573
574        if trimmed.starts_with('[') && trimmed.ends_with(']') {
575            in_array = false;
576            in_project = trimmed == "[project]";
577            continue;
578        }
579
580        if !in_array && in_project && is_dependencies_array_start(trimmed) {
581            in_array = true;
582            depth = 0;
583        }
584
585        if in_array {
586            if i == line_number {
587                return true;
588            }
589            for ch in trimmed.chars() {
590                match ch {
591                    '[' => depth += 1,
592                    ']' => depth -= 1,
593                    _ => {}
594                }
595            }
596            if depth <= 0 {
597                in_array = false;
598            }
599        }
600    }
601
602    false
603}
604
605/// Strips a trailing TOML comment (`# ...`) from `line`, ignoring a `#` that
606/// appears inside a quoted string.
607///
608/// Naive like this file's other hand-rolled raw-text scanners (e.g.
609/// `is_in_json_dependencies`'s brace counting): does not handle a `\"` escape
610/// inside a double-quoted string, which would end the string one character too
611/// early. Good enough for the fallback-completion heuristic this feeds.
612fn strip_trailing_toml_comment(line: &str) -> &str {
613    let mut in_string: Option<char> = None;
614    for (idx, ch) in line.char_indices() {
615        match in_string {
616            Some(quote) if ch == quote => in_string = None,
617            Some(_) => {}
618            None if ch == '"' || ch == '\'' => in_string = Some(ch),
619            None if ch == '#' => return line[..idx].trim_end(),
620            None => {}
621        }
622    }
623    line
624}
625
626/// Whether `trimmed` opens the `dependencies = [...]` array (`dependencies = [` or
627/// the single-line `dependencies = [...]`), used by
628/// [`is_in_pypi_project_dependencies_array`].
629fn is_dependencies_array_start(trimmed: &str) -> bool {
630    trimmed
631        .strip_prefix("dependencies")
632        .map(str::trim_start)
633        .and_then(|rest| rest.strip_prefix('='))
634        .is_some_and(|rest| rest.trim_start().starts_with('['))
635}
636
637/// Checks if a line is inside a JSON dependencies-like section.
638///
639/// Looks for `"{key}": {` for any of the given `keys`, e.g. `dependencies` /
640/// `devDependencies` in package.json, or `require` / `require-dev` in composer.json.
641fn is_in_json_dependencies(content: &str, line_number: usize, keys: &[&str]) -> bool {
642    let mut in_dependencies = false;
643    let mut brace_depth = 0;
644    // Build each `"{key}":` needle once per call rather than once per line.
645    let needles: Vec<String> = keys.iter().map(|key| format!("\"{key}\":")).collect();
646
647    for (i, line) in content.lines().enumerate() {
648        // Early exit: stop if we've passed the target line
649        if i > line_number {
650            break;
651        }
652
653        let trimmed = line.trim();
654
655        // Check if we're entering a dependencies-like section
656        if trimmed.starts_with('"')
657            && needles
658                .iter()
659                .any(|needle| trimmed.contains(needle.as_str()))
660        {
661            in_dependencies = true;
662            brace_depth = 0;
663        }
664
665        // Track brace depth when in dependencies section
666        if in_dependencies {
667            for ch in trimmed.chars() {
668                match ch {
669                    '{' => brace_depth += 1,
670                    '}' => {
671                        brace_depth -= 1;
672                        // If we've closed the dependencies section
673                        if brace_depth <= 0 {
674                            in_dependencies = false;
675                        }
676                    }
677                    _ => {}
678                }
679            }
680
681            // If we're at the target line and inside dependencies section with depth > 0
682            if i == line_number && in_dependencies && brace_depth > 0 {
683                return true;
684            }
685        }
686    }
687
688    false
689}
690
691/// Checks if a line is inside an XML `<tag>...</tag>` element.
692///
693/// Tracks nested open/close tag counts (ignoring attributes and self-closing tags) to
694/// find whether the target line falls within any occurrence of the element, e.g.
695/// `<dependencies>` in pom.xml (including nested inside `<dependencyManagement>`).
696fn is_in_xml_tag_section(content: &str, line_number: usize, tag: &str) -> bool {
697    let open_prefix = format!("<{tag}");
698    let close = format!("</{tag}>");
699    let mut depth: usize = 0;
700
701    for (i, line) in content.lines().enumerate() {
702        if i > line_number {
703            break;
704        }
705
706        let opens_here = count_open_tags(line, &open_prefix);
707        depth += opens_here;
708        // A line with an opening tag counts as "inside" even if the same line also
709        // closes it (`<dependencies></dependencies>`), consistent with the target
710        // line being the header itself in `is_in_toml_dependencies`.
711        if i == line_number && opens_here > 0 {
712            return true;
713        }
714
715        depth = depth.saturating_sub(line.matches(close.as_str()).count());
716        if i == line_number && depth > 0 {
717            return true;
718        }
719    }
720
721    false
722}
723
724/// Counts real `<{open_prefix}...>` tag occurrences on `line`, i.e. `open_prefix`
725/// followed by `>` or whitespace (an attribute) rather than more tag-name characters
726/// (so `<dependencies` doesn't also match a longer, unrelated tag name).
727fn count_open_tags(line: &str, open_prefix: &str) -> usize {
728    let mut count = 0;
729    let mut search_from = 0;
730
731    while let Some(rel_idx) = line[search_from..].find(open_prefix) {
732        let idx = search_from + rel_idx;
733        let after = &line[idx + open_prefix.len()..];
734        if after.starts_with('>') || after.starts_with(char::is_whitespace) {
735            count += 1;
736        }
737        search_from = idx + open_prefix.len();
738    }
739
740    count
741}
742
743/// Checks if a line is inside a go.mod `require` directive.
744///
745/// Handles both the single-line form (`require module version`) and the
746/// parenthesized block form (`require (` ... `)`).
747fn is_in_go_require(content: &str, line_number: usize) -> bool {
748    let mut in_require_block = false;
749
750    for (i, line) in content.lines().enumerate() {
751        if i > line_number {
752            break;
753        }
754
755        let trimmed = line.trim();
756        let is_block_start = trimmed
757            .strip_prefix("require")
758            .is_some_and(|rest| rest.trim_start().starts_with('('));
759
760        if is_block_start {
761            in_require_block = true;
762        } else if in_require_block && trimmed.starts_with(')') {
763            in_require_block = false;
764        }
765
766        if i == line_number {
767            return in_require_block || is_block_start || trimmed.starts_with("require ");
768        }
769    }
770
771    false
772}
773
774/// Checks if a line is inside a pubspec.yaml dependency section.
775///
776/// Dart's `dependencies`, `dev_dependencies`, and `dependency_overrides` keys are
777/// top-level (unindented) YAML mappings; their entries stay part of the section until
778/// the next unindented key starts a new one.
779fn is_in_yaml_dependencies(content: &str, line_number: usize) -> bool {
780    const SECTION_KEYS: &[&str] = &[
781        "dependencies:",
782        "dev_dependencies:",
783        "dependency_overrides:",
784    ];
785    let mut in_dependencies = false;
786
787    for (i, line) in content.lines().enumerate() {
788        if i > line_number {
789            break;
790        }
791
792        let trimmed = line.trim_start();
793        if trimmed.is_empty() || trimmed.starts_with('#') {
794            continue;
795        }
796
797        // Top-level (unindented) key: starts a new section, or leaves the current one.
798        if trimmed.len() == line.len() {
799            in_dependencies = SECTION_KEYS.iter().any(|key| trimmed.starts_with(key));
800        }
801    }
802
803    in_dependencies
804}
805
806/// Searches for packages and returns completion items.
807///
808/// Bounded by [`deps_core::completion::COMPLETION_SEARCH_TIMEOUT`] as a direct, in-place timeout (not a
809/// detached `tokio::spawn`): this keeps the search cancellable by the LSP server's own
810/// `$/cancelRequest` handling, which wraps the whole request future and aborts it on
811/// cancellation — a detached task would sit outside that abort and keep the request's
812/// registry connection open regardless.
813async fn search_packages(
814    registry: &dyn deps_core::Registry,
815    ecosystem_id: EcosystemId,
816    query: &str,
817) -> Vec<CompletionItem> {
818    tracing::info!(
819        "search_packages: query={:?}, ecosystem={}",
820        query,
821        ecosystem_id
822    );
823
824    let results =
825        match tokio::time::timeout(COMPLETION_SEARCH_TIMEOUT, registry.search(query, 50)).await {
826            Ok(Ok(r)) => {
827                tracing::info!("search_packages: found {} results", r.len());
828                r
829            }
830            Ok(Err(e)) => {
831                tracing::warn!("search_packages: search failed: {}", e);
832                return vec![];
833            }
834            Err(_) => {
835                tracing::warn!(
836                    "search_packages: timed out after {}s",
837                    COMPLETION_SEARCH_TIMEOUT.as_secs()
838                );
839                return vec![];
840            }
841        };
842
843    // Convert search results to completion items
844    results
845        .iter()
846        .filter_map(|metadata| create_package_completion_item(metadata.as_ref(), ecosystem_id))
847        .collect()
848}
849
850/// Creates a completion item for a package.
851///
852/// The insert text mirrors each ecosystem's manifest syntax, exhaustively matched on
853/// [`EcosystemId`] so a new ecosystem must supply its own snippet instead of silently
854/// inheriting Cargo's `name = "version"` TOML syntax (see issue #118).
855///
856/// Returns `None` when a value this function interpolates into `insert_text` fails its
857/// allowlist — `latest` against [`is_safe_version_string`] (whenever non-empty; several
858/// arms legitimately omit the version clause when it's empty, so an empty `latest` is not
859/// itself unsafe), a Maven `groupId`/`artifactId` against
860/// [`is_safe_maven_coordinate_segment`], and a Swift repository URL against
861/// [`is_safe_registry_url`]. `metadata` comes straight from a registry search response, so
862/// a malicious/compromised registry must not be able to write structural
863/// characters into the manifest this text is inserted into.
864fn create_package_completion_item(
865    metadata: &dyn deps_core::Metadata,
866    ecosystem_id: EcosystemId,
867) -> Option<CompletionItem> {
868    let name = metadata.name();
869    let latest = metadata.latest_version().as_str();
870    let description = metadata.description();
871
872    if !is_safe_package_name(name.as_str()) {
873        warn_rejected_value(
874            "is_safe_package_name",
875            "package name completion item",
876            name.as_str(),
877        );
878        return None;
879    }
880
881    if !latest.is_empty() && !is_safe_version_string(latest) {
882        warn_rejected_value(
883            "is_safe_version_string",
884            "package name completion item",
885            latest,
886        );
887        return None;
888    }
889
890    let insert_text = match ecosystem_id {
891        // The key is quoted, not bare: a bare TOML key containing `.` (allowed by
892        // `is_safe_package_name` for Cargo crate names) expands into a nested table
893        // instead of a dependency entry — quoting closes that dotted-key injection.
894        EcosystemId::Cargo => format!("\"{name}\" = \"{latest}\""),
895        // Both real PEP 621 shapes (`dependencies = [...]` and an
896        // `[project.optional-dependencies]` group) are TOML string-array elements,
897        // not a key=value table entry like Cargo's — the surrounding quotes already
898        // exist in the manifest (or the user is still typing them), matching the
899        // bare-name insert `build_package_completion` already uses for PyPI at the
900        // same cursor position on the primary (parsed) completion path.
901        EcosystemId::Pypi => name.to_string(),
902        EcosystemId::Npm | EcosystemId::Composer => format!("\"{name}\": \"^{latest}\""),
903        EcosystemId::Go => format!("{name} {latest}"),
904        // The key is quoted: an unquoted YAML plain scalar can't start with `@`
905        // (allowed by `is_safe_package_name` for npm/Deno-shaped names), which would
906        // otherwise emit invalid YAML instead of a dependency entry.
907        EcosystemId::Dart => format!("\"{name}\": ^{latest}"),
908        EcosystemId::Maven => {
909            // The predicate rejects `:` by design (see its doc comment), so it must
910            // validate each half of the coordinate after splitting, never the joined
911            // `name`.
912            let (group_id, artifact_id) = match name.as_str().split_once(':') {
913                Some((group_id, artifact_id)) => (Some(group_id), artifact_id),
914                None => (None, name.as_str()),
915            };
916            if !is_safe_maven_coordinate_segment(artifact_id) {
917                warn_rejected_value(
918                    "is_safe_maven_coordinate_segment",
919                    "maven package name completion item",
920                    artifact_id,
921                );
922                return None;
923            }
924            if let Some(g) = group_id
925                && !is_safe_maven_coordinate_segment(g)
926            {
927                warn_rejected_value(
928                    "is_safe_maven_coordinate_segment",
929                    "maven package name completion item",
930                    g,
931                );
932                return None;
933            }
934            group_id.map_or_else(
935                || format!("<artifactId>{artifact_id}</artifactId><version>{latest}</version>"),
936                |group_id| format!(
937                    "<groupId>{group_id}</groupId><artifactId>{artifact_id}</artifactId><version>{latest}</version>"
938                ),
939            )
940        }
941        EcosystemId::Gradle => format!("implementation(\"{name}:{latest}\")"),
942        EcosystemId::Swift => {
943            let url = metadata
944                .repository()
945                .map_or_else(|| format!("https://github.com/{name}"), str::to_string);
946            if !is_safe_registry_url(&url) {
947                warn_rejected_value(
948                    "is_safe_registry_url",
949                    "swift package name completion item",
950                    &url,
951                );
952                return None;
953            }
954            if latest.is_empty() {
955                format!(".package(url: \"{url}\")")
956            } else {
957                format!(".package(url: \"{url}\", from: \"{latest}\")")
958            }
959        }
960        EcosystemId::NuGet => {
961            format!("<PackageReference Include=\"{name}\" Version=\"{latest}\" />")
962        }
963        EcosystemId::Bundler => format!("gem \"{name}\", \"~> {latest}\""),
964        EcosystemId::Deno => {
965            // D11: the alias key is conventionally the bare name (scheme stripped); the
966            // value is the full scheme-qualified specifier.
967            let bare = name
968                .as_str()
969                .split_once(':')
970                .map_or(name.as_str(), |(_, rest)| rest);
971            // N5: an empty `latest` (a JSR search hit with no `latestVersion`) must not
972            // insert a dangling `@^` with nothing after it — mirrors the Swift arm's
973            // `latest.is_empty()` guard above.
974            if latest.is_empty() {
975                format!("\"{bare}\": \"{name}\"")
976            } else {
977                format!("\"{bare}\": \"{name}@^{latest}\"")
978            }
979        }
980        // GHA's `search()` always returns `Ok(vec![])` (MVP scope — see
981        // `deps-github-actions`'s registry docs), so this arm is unreachable in
982        // practice today; it exists only to keep the match exhaustive (#118) and to
983        // behave correctly if package-name search is ever added. The `owner/repo`
984        // shape check is inlined rather than calling
985        // `deps_github_actions::is_valid_github_identity` — that dependency is
986        // feature-gated, while this match (on `EcosystemId`, not a per-feature type)
987        // is compiled unconditionally.
988        EcosystemId::GithubActions => {
989            if !name.as_str().contains('/') {
990                warn_rejected_value(
991                    "owner/repo shape",
992                    "github actions package name completion item",
993                    name.as_str(),
994                );
995                return None;
996            }
997            if latest.is_empty() {
998                name.to_string()
999            } else {
1000                format!("{name}@{latest}")
1001            }
1002        }
1003        // `deps-gitlab-ci`'s `search()` always returns `Ok(vec![])` (spec NFR-002 — no
1004        // cheap GitLab search endpoint under the rate-limit budget), so this arm is
1005        // unreachable in practice; it exists only to keep the match exhaustive (#118).
1006        // GitLab CI has two structurally different include forms (`project:`+`ref:` vs.
1007        // `component:` `name@ref`), so there is no single insertable snippet shape —
1008        // this mirrors the GitHub Actions arm's bare `name`/`name@version` fallback.
1009        EcosystemId::GitlabCi => {
1010            if latest.is_empty() {
1011                name.to_string()
1012            } else {
1013                format!("{name}@{latest}")
1014            }
1015        }
1016    };
1017
1018    // Build detail text
1019    let detail = if latest.is_empty() {
1020        None
1021    } else {
1022        Some(format!("Latest: {latest}"))
1023    };
1024
1025    Some(CompletionItem {
1026        label: name.to_string(),
1027        kind: Some(CompletionItemKind::MODULE),
1028        detail,
1029        documentation: description
1030            .map(|d| tower_lsp_server::ls_types::Documentation::String(d.into())),
1031        insert_text: Some(insert_text),
1032        insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
1033        ..Default::default()
1034    })
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040    use crate::document::DocumentState;
1041    use crate::test_utils::test_helpers::create_test_client_and_config;
1042    use tower_lsp_server::ls_types::{
1043        Position, TextDocumentIdentifier, TextDocumentPositionParams,
1044    };
1045
1046    /// Builds a `ServerState` whose `id`/`manifest_filename` ecosystem entry is
1047    /// overridden to route registry search through `registry`, so `fallback_completion`
1048    /// tests can observe (or forbid) a search call without hitting the network.
1049    fn mock_ecosystem_state(
1050        id: &'static str,
1051        manifest_filename: &'static str,
1052        registry: Arc<dyn deps_core::Registry>,
1053    ) -> ServerState {
1054        use deps_core::{
1055            DiagnosticMessages, DiagnosticPolicy, Ecosystem, EcosystemFormatter, OsvNaming,
1056            PackageNaming, PackageRendering, ParseResult, RequirementResolution, SourcePolicy,
1057        };
1058        use std::any::Any;
1059        use tower_lsp_server::ls_types::Uri;
1060
1061        struct MockFormatter;
1062        impl PackageNaming for MockFormatter {}
1063
1064        impl PackageRendering for MockFormatter {
1065            fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
1066                version.to_string()
1067            }
1068
1069            fn package_url(&self, name: &deps_core::PackageName) -> String {
1070                format!("https://example.com/{name}")
1071            }
1072        }
1073
1074        impl RequirementResolution for MockFormatter {}
1075
1076        impl DiagnosticMessages for MockFormatter {}
1077
1078        impl DiagnosticPolicy for MockFormatter {}
1079
1080        impl SourcePolicy for MockFormatter {}
1081
1082        impl OsvNaming for MockFormatter {}
1083
1084        struct MockEcosystem {
1085            id: &'static str,
1086            manifest_filename: &'static str,
1087            registry: Arc<dyn deps_core::Registry>,
1088        }
1089        impl deps_core::ecosystem::private::Sealed for MockEcosystem {}
1090        impl Ecosystem for MockEcosystem {
1091            fn id(&self) -> &'static str {
1092                self.id
1093            }
1094            fn display_name(&self) -> &'static str {
1095                self.id
1096            }
1097            fn manifest_filenames(&self) -> &[&'static str] {
1098                std::slice::from_ref(&self.manifest_filename)
1099            }
1100            fn parse_manifest<'a>(
1101                &'a self,
1102                _content: &'a str,
1103                _uri: &'a Uri,
1104            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Box<dyn ParseResult>>>
1105            {
1106                Box::pin(async move { unimplemented!() })
1107            }
1108            fn registry(&self) -> Arc<dyn deps_core::Registry> {
1109                Arc::clone(&self.registry)
1110            }
1111            fn formatter(&self) -> &dyn EcosystemFormatter {
1112                &MockFormatter
1113            }
1114            fn generate_completions<'a>(
1115                &'a self,
1116                _parse_result: &'a dyn ParseResult,
1117                _position: tower_lsp_server::ls_types::Position,
1118                _content: &'a str,
1119                _freshness: deps_core::FreshnessSettings,
1120            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
1121            {
1122                Box::pin(async move { unimplemented!() })
1123            }
1124            fn as_any(&self) -> &dyn Any {
1125                self
1126            }
1127        }
1128
1129        let state = ServerState::new();
1130        state.ecosystem_registry.register(Arc::new(MockEcosystem {
1131            id,
1132            manifest_filename,
1133            registry,
1134        }));
1135        state
1136    }
1137
1138    /// Builds a `ServerState` whose `"cargo"` ecosystem entry is overridden to route
1139    /// registry search through `registry`, so `fallback_completion` tests can observe
1140    /// (or forbid) a search call without hitting the network.
1141    fn mock_cargo_state(registry: Arc<dyn deps_core::Registry>) -> ServerState {
1142        mock_ecosystem_state("cargo", "Cargo.toml", registry)
1143    }
1144
1145    /// Same as [`mock_cargo_state`], but for the `"maven"` ecosystem.
1146    fn mock_maven_state(registry: Arc<dyn deps_core::Registry>) -> ServerState {
1147        mock_ecosystem_state("maven", "pom.xml", registry)
1148    }
1149
1150    /// Same as [`mock_cargo_state`], but for the `"pypi"` ecosystem.
1151    fn mock_pypi_state(registry: Arc<dyn deps_core::Registry>) -> ServerState {
1152        mock_ecosystem_state("pypi", "pyproject.toml", registry)
1153    }
1154
1155    #[tokio::test]
1156    async fn test_completion_returns_empty_for_missing_document() {
1157        let state = Arc::new(ServerState::new());
1158        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1159
1160        let params = CompletionParams {
1161            text_document_position: TextDocumentPositionParams {
1162                text_document: TextDocumentIdentifier { uri },
1163                position: Position::new(0, 0),
1164            },
1165            work_done_progress_params: Default::default(),
1166            partial_result_params: Default::default(),
1167            context: None,
1168        };
1169
1170        let (client, config) = create_test_client_and_config();
1171        let result = handle_completion(state, params, client, config).await;
1172        // With cold start support, missing documents trigger background load and
1173        // return empty completions for the first request, collapsing to `None`.
1174        assert!(result.is_none());
1175    }
1176
1177    /// #419 S1 regression, still required after #427: the document-not-loaded/
1178    /// load-failed early return never reaches `generate_completions` — there is no
1179    /// completion context yet to compute a precise per-call `is_incomplete` from
1180    /// (see [`Completions`](deps_core::completion::Completions)) — but it must still
1181    /// report `isIncomplete: true` for an ecosystem whose package-name search (the
1182    /// only kind `fallback_completion` could otherwise have produced) is truncated,
1183    /// via [`Ecosystem::package_search_is_incomplete`]. `None` serializes as LSP
1184    /// `null`, which carries no `isIncomplete` and would leave the client with
1185    /// nothing to invalidate on the next keystroke.
1186    #[tokio::test]
1187    async fn test_completion_missing_document_reports_incomplete_for_flagged_ecosystem() {
1188        use deps_core::completion::Completions;
1189        use deps_core::ecosystem::private::Sealed;
1190        use deps_core::{
1191            DiagnosticMessages, DiagnosticPolicy, Ecosystem, EcosystemFormatter, Metadata,
1192            OsvNaming, PackageNaming, PackageRendering, ParseResult, Registry,
1193            RequirementResolution, SourcePolicy, Version,
1194        };
1195        use std::any::Any;
1196        use tower_lsp_server::ls_types::Uri;
1197
1198        struct NoopRegistry;
1199        impl Registry for NoopRegistry {
1200            fn get_versions<'a>(
1201                &'a self,
1202                _name: &'a deps_core::PackageName,
1203            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1204            {
1205                Box::pin(async move { Ok(vec![]) })
1206            }
1207            fn get_latest_matching<'a>(
1208                &'a self,
1209                _name: &'a deps_core::PackageName,
1210                _req: &'a deps_core::VersionReq,
1211            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1212            {
1213                Box::pin(async move { Ok(None) })
1214            }
1215            fn search<'a>(
1216                &'a self,
1217                _query: &'a str,
1218                _limit: usize,
1219            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1220            {
1221                Box::pin(async move { Ok(vec![]) })
1222            }
1223            fn as_any(&self) -> &dyn Any {
1224                self
1225            }
1226        }
1227
1228        struct NoopFormatter;
1229        impl PackageNaming for NoopFormatter {}
1230
1231        impl PackageRendering for NoopFormatter {
1232            fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
1233                version.to_string()
1234            }
1235
1236            fn package_url(&self, name: &deps_core::PackageName) -> String {
1237                format!("https://example.com/{name}")
1238            }
1239        }
1240
1241        impl RequirementResolution for NoopFormatter {}
1242
1243        impl DiagnosticMessages for NoopFormatter {}
1244
1245        impl DiagnosticPolicy for NoopFormatter {}
1246
1247        impl SourcePolicy for NoopFormatter {}
1248
1249        impl OsvNaming for NoopFormatter {}
1250
1251        /// Stands in for `PypiEcosystem`: overrides `package_search_is_incomplete`
1252        /// the same way, and `generate_completions` is deliberately `unimplemented!()`
1253        /// since this test never lets it run.
1254        struct IncompleteEcosystem;
1255        impl Sealed for IncompleteEcosystem {}
1256        impl Ecosystem for IncompleteEcosystem {
1257            fn id(&self) -> &'static str {
1258                "cargo"
1259            }
1260            fn display_name(&self) -> &'static str {
1261                "cargo"
1262            }
1263            fn manifest_filenames(&self) -> &[&'static str] {
1264                &["Cargo.toml"]
1265            }
1266            fn parse_manifest<'a>(
1267                &'a self,
1268                _content: &'a str,
1269                _uri: &'a Uri,
1270            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Box<dyn ParseResult>>>
1271            {
1272                Box::pin(async move { unimplemented!() })
1273            }
1274            fn registry(&self) -> Arc<dyn Registry> {
1275                Arc::new(NoopRegistry)
1276            }
1277            fn formatter(&self) -> &dyn EcosystemFormatter {
1278                &NoopFormatter
1279            }
1280            fn package_search_is_incomplete(&self) -> bool {
1281                true
1282            }
1283            fn generate_completions<'a>(
1284                &'a self,
1285                _parse_result: &'a dyn ParseResult,
1286                _position: tower_lsp_server::ls_types::Position,
1287                _content: &'a str,
1288                _freshness: deps_core::FreshnessSettings,
1289            ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
1290                Box::pin(async move { unimplemented!() })
1291            }
1292            fn as_any(&self) -> &dyn Any {
1293                self
1294            }
1295        }
1296
1297        let state = Arc::new(ServerState::new());
1298        state
1299            .ecosystem_registry
1300            .register(Arc::new(IncompleteEcosystem));
1301        // Deliberately never inserted into `state.documents` — the document-load
1302        // path below must time out/fail against a nonexistent file, exactly the
1303        // `test_completion_returns_empty_for_missing_document` shape.
1304        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1305
1306        let params = CompletionParams {
1307            text_document_position: TextDocumentPositionParams {
1308                text_document: TextDocumentIdentifier { uri },
1309                position: Position::new(0, 0),
1310            },
1311            work_done_progress_params: Default::default(),
1312            partial_result_params: Default::default(),
1313            context: None,
1314        };
1315
1316        let (client, config) = create_test_client_and_config();
1317        let result = handle_completion(state, params, client, config).await;
1318        match result {
1319            Some(CompletionResponse::List(list)) => {
1320                assert!(list.is_incomplete);
1321                assert!(list.items.is_empty());
1322            }
1323            other => panic!("expected List{{is_incomplete:true, items:[]}}, got {other:?}"),
1324        }
1325    }
1326
1327    #[tokio::test]
1328    async fn test_completion_delegates_to_ecosystem() {
1329        let state = Arc::new(ServerState::new());
1330        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1331
1332        let content = "[dependencies]\nserde = \"1.0\"".to_string();
1333
1334        // Parse the manifest to get a proper parse result
1335        let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
1336        let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
1337
1338        let doc = DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
1339        state.update_document(uri.clone(), doc);
1340
1341        let params = CompletionParams {
1342            text_document_position: TextDocumentPositionParams {
1343                text_document: TextDocumentIdentifier { uri },
1344                position: Position::new(1, 9),
1345            },
1346            work_done_progress_params: Default::default(),
1347            partial_result_params: Default::default(),
1348            context: None,
1349        };
1350
1351        // Should return Some or None based on ecosystem implementation
1352        // We don't test the actual completions here as that's ecosystem-specific
1353        let (client, config) = create_test_client_and_config();
1354        let _result = handle_completion(state, params, client, config).await;
1355        // Just verify it doesn't panic - actual completion logic is in ecosystem
1356    }
1357
1358    /// #319 liveness regression: `handle_completion` must release the DashMap shard
1359    /// `Ref` on the document *before* entering the `COMPLETION_SEARCH_TIMEOUT`-bounded
1360    /// await, so a concurrent `documents.get_mut` on the same URI (e.g. a `didChange`)
1361    /// is never blocked behind an in-flight (or stuck) registry-backed search.
1362    ///
1363    /// `BlockingEcosystem::generate_completions` waits on a `Barrier` before blocking
1364    /// forever (`std::future::pending`), standing in for a registry call that never
1365    /// returns — the worst case for a shard `Ref` held across the search. The test
1366    /// only proceeds to race the writer once that future has demonstrably started
1367    /// executing (via the barrier), which — pre-fix — would still be *after* the old
1368    /// code's `let doc = state.get_document(uri)?;` acquisition but *before* its
1369    /// `drop(doc)`, since that drop ran only once the whole timeout resolved. A
1370    /// concurrent write racing here would previously deadlock against the `parking_lot`
1371    /// shard guard for the life of the (never-resolving) search; post-fix it must
1372    /// complete almost immediately, since the `Ref` was already dropped before the
1373    /// search was ever awaited.
1374    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1375    async fn test_concurrent_document_write_not_blocked_by_in_flight_completion_search() {
1376        use crate::test_utils::blocking_ecosystem::{
1377            BlockingEcosystem, BlockingHook, MockParseResult,
1378        };
1379        use deps_core::ParseResult;
1380        use tokio::sync::Barrier;
1381
1382        let state = Arc::new(ServerState::new());
1383        let started = Arc::new(Barrier::new(2));
1384        state
1385            .ecosystem_registry
1386            .register(Arc::new(BlockingEcosystem {
1387                started: Arc::clone(&started),
1388                hook: BlockingHook::Completions,
1389            }));
1390
1391        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1392        let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
1393        let parse_result: Box<dyn ParseResult> = Box::new(MockParseResult { uri: uri.clone() });
1394        let doc = DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
1395        state.update_document(uri.clone(), doc);
1396
1397        let (client, config) = create_test_client_and_config();
1398
1399        let completion_task = tokio::spawn({
1400            let state = Arc::clone(&state);
1401            let uri = uri.clone();
1402            async move {
1403                let params = CompletionParams {
1404                    text_document_position: TextDocumentPositionParams {
1405                        text_document: TextDocumentIdentifier { uri },
1406                        position: Position::new(1, 9),
1407                    },
1408                    work_done_progress_params: Default::default(),
1409                    partial_result_params: Default::default(),
1410                    context: None,
1411                };
1412                handle_completion(state, params, client, config).await
1413            }
1414        });
1415
1416        // Block until `generate_completions` has actually started executing — i.e.
1417        // `handle_completion` has reached (and is now inside) the
1418        // `COMPLETION_SEARCH_TIMEOUT`-bounded await — before racing the writer below.
1419        started.wait().await;
1420
1421        // Spawned onto its own task (rather than awaited inline) deliberately:
1422        // `DashMap::get_mut` blocks the OS thread synchronously on a `parking_lot`
1423        // lock, with no `.await` point of its own. Wrapping that blocking call
1424        // directly in `tokio::time::timeout` would not work — a `Future::poll` that
1425        // never returns can't be preempted by a sibling timer that only fires between
1426        // polls. Spawning it gives the *join* a real async yield point, so the
1427        // `timeout` below can race against it and fire even while the spawned task
1428        // sits blocked on the shard lock.
1429        let write_task = tokio::spawn({
1430            let state = Arc::clone(&state);
1431            let uri = uri.clone();
1432            async move {
1433                state.documents.get_mut(&uri).unwrap().set_loading();
1434            }
1435        });
1436        let write_result =
1437            tokio::time::timeout(std::time::Duration::from_millis(500), write_task).await;
1438
1439        completion_task.abort();
1440
1441        assert!(
1442            write_result.is_ok(),
1443            "#319 regression: a concurrent documents.get_mut on the same URI must not \
1444             block on an in-flight completion search — the DashMap shard Ref must be \
1445             dropped before the COMPLETION_SEARCH_TIMEOUT-bounded await, not after it"
1446        );
1447    }
1448
1449    /// Issue #227 tester gap: `build_version_completion`'s `label_details`
1450    /// present/absent-when-`freshness.enabled`-toggles behavior is already unit-tested
1451    /// directly in `deps_core::completion` — this test covers the piece that isn't: that
1452    /// `handle_completion` (`completion.rs:47`) re-reads `config.freshness` on *every*
1453    /// call, so a `workspace/didChangeConfiguration`-driven config update (simulated here
1454    /// by writing directly to the shared `Arc<RwLock<DepsConfig>>`, exactly what
1455    /// `Backend::did_change_configuration` does) changes completion's age-suffix presence
1456    /// on the very next request, with no server restart and no re-opening the document.
1457    #[tokio::test]
1458    async fn test_completion_freshness_enabled_live_reload_changes_label_details_on_next_request() {
1459        use deps_core::ecosystem::private::Sealed;
1460        use deps_core::{
1461            Dependency, DiagnosticMessages, DiagnosticPolicy, Ecosystem, EcosystemFormatter,
1462            Metadata, OsvNaming, PackageNaming, PackageRendering, ParseResult, Registry,
1463            RequirementResolution, SourcePolicy, Version,
1464        };
1465        use std::any::Any;
1466        use std::path::Path;
1467        use tower_lsp_server::ls_types::{CompletionItemLabelDetails, Uri};
1468
1469        struct NoopRegistry;
1470        impl Registry for NoopRegistry {
1471            fn get_versions<'a>(
1472                &'a self,
1473                _name: &'a deps_core::PackageName,
1474            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1475            {
1476                Box::pin(async move { Ok(vec![]) })
1477            }
1478            fn get_latest_matching<'a>(
1479                &'a self,
1480                _name: &'a deps_core::PackageName,
1481                _req: &'a deps_core::VersionReq,
1482            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1483            {
1484                Box::pin(async move { Ok(None) })
1485            }
1486            fn search<'a>(
1487                &'a self,
1488                _query: &'a str,
1489                _limit: usize,
1490            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1491            {
1492                Box::pin(async move { Ok(vec![]) })
1493            }
1494            fn as_any(&self) -> &dyn Any {
1495                self
1496            }
1497        }
1498
1499        struct NoopFormatter;
1500        impl PackageNaming for NoopFormatter {}
1501
1502        impl PackageRendering for NoopFormatter {
1503            fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
1504                version.to_string()
1505            }
1506
1507            fn package_url(&self, name: &deps_core::PackageName) -> String {
1508                format!("https://example.com/{name}")
1509            }
1510        }
1511
1512        impl RequirementResolution for NoopFormatter {}
1513
1514        impl DiagnosticMessages for NoopFormatter {}
1515
1516        impl DiagnosticPolicy for NoopFormatter {}
1517
1518        impl SourcePolicy for NoopFormatter {}
1519
1520        impl OsvNaming for NoopFormatter {}
1521
1522        /// Stands in for a real ecosystem's `generate_completions`, echoing whatever
1523        /// `freshness.enabled` it was called with into `label_details` — exactly the
1524        /// signal real ecosystems derive from `build_version_completion`, without
1525        /// needing a real registry fetch or parsed manifest.
1526        struct FreshnessEchoEcosystem;
1527        impl Sealed for FreshnessEchoEcosystem {}
1528        impl Ecosystem for FreshnessEchoEcosystem {
1529            fn id(&self) -> &'static str {
1530                "cargo"
1531            }
1532            fn display_name(&self) -> &'static str {
1533                "cargo"
1534            }
1535            fn manifest_filenames(&self) -> &[&'static str] {
1536                &["Cargo.toml"]
1537            }
1538            fn parse_manifest<'a>(
1539                &'a self,
1540                _content: &'a str,
1541                _uri: &'a Uri,
1542            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Box<dyn ParseResult>>>
1543            {
1544                Box::pin(async move { unimplemented!() })
1545            }
1546            fn registry(&self) -> Arc<dyn Registry> {
1547                Arc::new(NoopRegistry)
1548            }
1549            fn formatter(&self) -> &dyn EcosystemFormatter {
1550                &NoopFormatter
1551            }
1552            fn generate_completions<'a>(
1553                &'a self,
1554                _parse_result: &'a dyn ParseResult,
1555                _position: tower_lsp_server::ls_types::Position,
1556                _content: &'a str,
1557                freshness: deps_core::FreshnessSettings,
1558            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
1559            {
1560                Box::pin(async move {
1561                    vec![CompletionItem {
1562                        label: "1.0.0".to_string(),
1563                        kind: Some(CompletionItemKind::VALUE),
1564                        label_details: freshness.enabled.then(|| CompletionItemLabelDetails {
1565                            detail: Some("  1 hour ago".to_string()),
1566                            description: None,
1567                        }),
1568                        ..Default::default()
1569                    }]
1570                    .into()
1571                })
1572            }
1573            fn as_any(&self) -> &dyn Any {
1574                self
1575            }
1576        }
1577
1578        struct MockParseResult {
1579            uri: Uri,
1580        }
1581        impl ParseResult for MockParseResult {
1582            fn dependencies(&self) -> Vec<&dyn Dependency> {
1583                vec![]
1584            }
1585            fn workspace_root(&self) -> Option<&Path> {
1586                None
1587            }
1588            fn uri(&self) -> &Uri {
1589                &self.uri
1590            }
1591            fn as_any(&self) -> &dyn Any {
1592                self
1593            }
1594        }
1595
1596        let state = Arc::new(ServerState::new());
1597        // Overwrites the real Cargo ecosystem for this state instance only.
1598        state
1599            .ecosystem_registry
1600            .register(Arc::new(FreshnessEchoEcosystem));
1601        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
1602
1603        let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
1604        let parse_result: Box<dyn ParseResult> = Box::new(MockParseResult { uri: uri.clone() });
1605        let doc = DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
1606        state.update_document(uri.clone(), doc);
1607
1608        let params = || CompletionParams {
1609            text_document_position: TextDocumentPositionParams {
1610                text_document: TextDocumentIdentifier { uri: uri.clone() },
1611                position: Position::new(0, 0),
1612            },
1613            work_done_progress_params: Default::default(),
1614            partial_result_params: Default::default(),
1615            context: None,
1616        };
1617
1618        let (client, config) = create_test_client_and_config();
1619        assert!(
1620            config.read().await.freshness.enabled,
1621            "default config ships freshness enabled"
1622        );
1623
1624        let before = handle_completion(
1625            Arc::clone(&state),
1626            params(),
1627            client.clone(),
1628            Arc::clone(&config),
1629        )
1630        .await
1631        .expect("completion response");
1632        let CompletionResponse::Array(items) = before else {
1633            panic!("expected an array response");
1634        };
1635        assert!(
1636            items[0].label_details.is_some(),
1637            "freshness enabled by default: label_details must be present"
1638        );
1639
1640        // Exactly what `Backend::did_change_configuration` does to the stored config —
1641        // no document reload, no server restart.
1642        config.write().await.freshness.enabled = false;
1643
1644        let after = handle_completion(state, params(), client, config)
1645            .await
1646            .expect("completion response");
1647        let CompletionResponse::Array(items) = after else {
1648            panic!("expected an array response");
1649        };
1650        assert!(
1651            items[0].label_details.is_none(),
1652            "freshness disabled via live-reload: label_details must disappear on the very \
1653             next completion request"
1654        );
1655    }
1656
1657    #[test]
1658    fn test_is_in_toml_dependencies_basic() {
1659        let content = r#"
1660[package]
1661name = "test"
1662
1663[dependencies]
1664serde
1665"#;
1666        assert!(is_in_toml_dependencies(content, 5));
1667        assert!(!is_in_toml_dependencies(content, 1));
1668    }
1669
1670    #[test]
1671    fn test_is_in_toml_dependencies_dev_deps() {
1672        let content = r"
1673[dev-dependencies]
1674tokio
1675";
1676        assert!(is_in_toml_dependencies(content, 2));
1677    }
1678
1679    #[test]
1680    fn test_is_in_toml_dependencies_build_deps() {
1681        let content = r"
1682[build-dependencies]
1683cc
1684";
1685        assert!(is_in_toml_dependencies(content, 2));
1686    }
1687
1688    #[test]
1689    fn test_is_in_toml_dependencies_project_deps() {
1690        let content = r"
1691[project.dependencies]
1692requests
1693";
1694        assert!(is_in_toml_dependencies(content, 2));
1695    }
1696
1697    #[test]
1698    fn test_is_in_toml_dependencies_workspace_deps() {
1699        let content = r#"
1700[workspace.dependencies]
1701serde = "1.0"
1702"#;
1703        assert!(is_in_toml_dependencies(content, 2));
1704    }
1705
1706    #[test]
1707    fn test_is_in_toml_dependencies_target_specific() {
1708        let content = r"
1709[target.'cfg(windows)'.dependencies]
1710winapi
1711";
1712        assert!(is_in_toml_dependencies(content, 2));
1713    }
1714
1715    #[test]
1716    fn test_is_in_toml_dependencies_wrong_section() {
1717        let content = r#"
1718[package]
1719name = "test"
1720
1721[profile.release]
1722opt-level = 3
1723"#;
1724        assert!(!is_in_toml_dependencies(content, 2));
1725        assert!(!is_in_toml_dependencies(content, 5));
1726    }
1727
1728    #[test]
1729    fn test_is_in_toml_dependencies_multiple_sections() {
1730        let content = r#"
1731[dependencies]
1732serde = "1.0"
1733
1734[dev-dependencies]
1735tokio
1736"#;
1737        assert!(is_in_toml_dependencies(content, 2));
1738        assert!(is_in_toml_dependencies(content, 5));
1739    }
1740
1741    const NPM_KEYS: &[&str] = &[
1742        "dependencies",
1743        "devDependencies",
1744        "peerDependencies",
1745        "optionalDependencies",
1746    ];
1747
1748    #[test]
1749    fn test_is_in_json_dependencies_basic() {
1750        let content = r#"{
1751  "name": "test",
1752  "dependencies": {
1753    "express"
1754  }
1755}"#;
1756        assert!(is_in_json_dependencies(content, 3, NPM_KEYS));
1757        assert!(!is_in_json_dependencies(content, 1, NPM_KEYS));
1758    }
1759
1760    #[test]
1761    fn test_is_in_json_dependencies_dev_deps() {
1762        let content = r#"{
1763  "devDependencies": {
1764    "jest": "^29.0.0"
1765  }
1766}"#;
1767        assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
1768    }
1769
1770    #[test]
1771    fn test_is_in_json_dependencies_peer_deps() {
1772        let content = r#"{
1773  "peerDependencies": {
1774    "react"
1775  }
1776}"#;
1777        assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
1778    }
1779
1780    #[test]
1781    fn test_is_in_json_dependencies_optional_deps() {
1782        let content = r#"{
1783  "optionalDependencies": {
1784    "fsevents": "^2.0.0"
1785  }
1786}"#;
1787        assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
1788    }
1789
1790    #[test]
1791    fn test_is_in_json_dependencies_outside_section() {
1792        let content = r#"{
1793  "name": "test",
1794  "dependencies": {
1795    "express": "^4.0.0"
1796  },
1797  "scripts": {
1798    "start": "node index.js"
1799  }
1800}"#;
1801        assert!(is_in_json_dependencies(content, 3, NPM_KEYS));
1802        assert!(!is_in_json_dependencies(content, 6, NPM_KEYS));
1803    }
1804
1805    #[test]
1806    fn test_is_in_json_dependencies_nested_braces() {
1807        let content = r#"{
1808  "dependencies": {
1809    "package": "1.0.0"
1810  }
1811}"#;
1812        assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
1813    }
1814
1815    #[test]
1816    fn test_is_in_json_dependencies_custom_keys() {
1817        let content = r#"{
1818  "require": {
1819    "monolog/monolog": "^2.0"
1820  },
1821  "require-dev": {
1822    "phpunit/phpunit": "^9.0"
1823  }
1824}"#;
1825        assert!(is_in_json_dependencies(
1826            content,
1827            2,
1828            &["require", "require-dev"]
1829        ));
1830        assert!(is_in_json_dependencies(
1831            content,
1832            5,
1833            &["require", "require-dev"]
1834        ));
1835    }
1836
1837    #[test]
1838    fn test_is_in_dependencies_section_cargo() {
1839        let content = r"
1840[dependencies]
1841serde
1842";
1843        assert!(is_in_dependencies_section(content, 2, EcosystemId::Cargo));
1844        assert!(!is_in_dependencies_section(content, 0, EcosystemId::Cargo));
1845    }
1846
1847    #[test]
1848    fn test_is_in_dependencies_section_pypi() {
1849        let content = r"
1850[project.dependencies]
1851requests
1852";
1853        assert!(is_in_dependencies_section(content, 2, EcosystemId::Pypi));
1854    }
1855
1856    /// #390 root cause 1: real PEP 621 files never write a literal
1857    /// `[project.dependencies]` header — the primary dependency list is a
1858    /// `dependencies = [...]` array under `[project]`.
1859    #[test]
1860    fn test_is_in_dependencies_section_pypi_project_array_no_literal_header() {
1861        let content = "[project]\nname = \"myapp\"\nversion = \"0.1.0\"\ndependencies = [\n    \"requests>=2.31.0\",\n    \"flas\n]\n";
1862        // Unterminated entry line ("flas), mid-array.
1863        assert!(is_in_dependencies_section(content, 5, EcosystemId::Pypi));
1864        // A completed entry line.
1865        assert!(is_in_dependencies_section(content, 4, EcosystemId::Pypi));
1866        // Unrelated `[project]` keys must not be treated as inside the array.
1867        assert!(!is_in_dependencies_section(content, 1, EcosystemId::Pypi));
1868        assert!(!is_in_dependencies_section(content, 2, EcosystemId::Pypi));
1869        // The `[project]` header line itself is not "inside" the array.
1870        assert!(!is_in_dependencies_section(content, 0, EcosystemId::Pypi));
1871    }
1872
1873    #[test]
1874    fn test_is_in_dependencies_section_pypi_project_array_single_line() {
1875        let content = "[project]\ndependencies = [\"requests>=2.0.0\"]\n";
1876        assert!(is_in_dependencies_section(content, 1, EcosystemId::Pypi));
1877    }
1878
1879    /// A `dependencies = [...]` array under a table other than `[project]` (e.g. an
1880    /// optional-dependencies group using the same key name) must not be picked up
1881    /// by the `[project]`-scoped array scan.
1882    #[test]
1883    fn test_is_in_dependencies_section_pypi_project_array_scoped_to_project_table() {
1884        let content = "[tool.other]\ndependencies = [\n    \"foo\n]\n";
1885        assert!(!is_in_dependencies_section(content, 2, EcosystemId::Pypi));
1886    }
1887
1888    /// #390 C2: an unbalanced `[` inside a still-typed extras spec (`"uvicorn[stan`,
1889    /// common real syntax like `celery[redis]`) must not permanently desync the
1890    /// bracket-depth counter. A later, real table header is an unambiguous resync
1891    /// point (TOML forbids a header inside an array), so lines under it must not be
1892    /// misreported as still inside the dependencies array.
1893    #[test]
1894    fn test_is_in_dependencies_section_pypi_project_array_resyncs_after_unbalanced_extras_bracket()
1895    {
1896        let content = "[project]\ndependencies = [\n    \"uvicorn[stan\n]\n\n[tool.pytest.ini_options]\naddopts = \"-v\"\n";
1897        // Mid-typing the extras spec: still correctly inside the array.
1898        assert!(is_in_dependencies_section(content, 2, EcosystemId::Pypi));
1899        // A line under the unrelated later table must not be swept in by the
1900        // desynced counter.
1901        assert!(!is_in_dependencies_section(content, 6, EcosystemId::Pypi));
1902    }
1903
1904    /// #390 C2: a `#` comment containing `[` inside the array (e.g. `# pinned per
1905    /// [PEP 621`) must not be counted as a real bracket — the comment is stripped
1906    /// before depth tracking, so the array still closes at its real `]`.
1907    #[test]
1908    fn test_is_in_dependencies_section_pypi_project_array_ignores_bracket_in_comment() {
1909        let content = "[project]\ndependencies = [\n    \"requests>=2.0.0\",  # pinned per [PEP 621\n    \"flas\n]\nrequires-python = \">=3.9\"\n";
1910        // Still inside the array on the unterminated entry.
1911        assert!(is_in_dependencies_section(content, 3, EcosystemId::Pypi));
1912        // The array has closed by the time an unrelated `[project]` key follows.
1913        assert!(!is_in_dependencies_section(content, 5, EcosystemId::Pypi));
1914    }
1915
1916    /// #390 C3: a trailing comment on the `[project]` header itself (ordinary TOML)
1917    /// must not make the whole array-detection scan inert.
1918    #[test]
1919    fn test_is_in_dependencies_section_pypi_project_header_with_trailing_comment() {
1920        let content = "[project]  # main metadata\ndependencies = [\n    \"flas\n]\n";
1921        assert!(is_in_dependencies_section(content, 2, EcosystemId::Pypi));
1922    }
1923
1924    /// #390 C4: a commented non-`[project]` header must correctly clear `in_project`
1925    /// (fixed for free by C3's comment stripping) — a later table's own
1926    /// `dependencies = [...]` array must not be mistaken for PEP 621's.
1927    #[test]
1928    fn test_is_in_dependencies_section_pypi_project_state_cleared_by_commented_other_header() {
1929        let content = "[project]\nname = \"x\"\n\n[tool.hatch.envs.default] # test env\ndependencies = [\n    \"other\n]\n";
1930        assert!(!is_in_dependencies_section(content, 5, EcosystemId::Pypi));
1931    }
1932
1933    #[test]
1934    fn test_is_in_dependencies_section_npm() {
1935        let content = r#"{
1936  "dependencies": {
1937    "express"
1938  }
1939}"#;
1940        assert!(is_in_dependencies_section(content, 2, EcosystemId::Npm));
1941    }
1942
1943    #[test]
1944    fn test_is_in_dependencies_section_composer() {
1945        let content = r#"{
1946  "require": {
1947    "monolog/monolog": "^2.0"
1948  },
1949  "scripts": {
1950    "test": "phpunit"
1951  }
1952}"#;
1953        assert!(is_in_dependencies_section(
1954            content,
1955            2,
1956            EcosystemId::Composer
1957        ));
1958        assert!(!is_in_dependencies_section(
1959            content,
1960            5,
1961            EcosystemId::Composer
1962        ));
1963    }
1964
1965    #[test]
1966    fn test_is_in_dependencies_section_maven() {
1967        let content = r"
1968<project>
1969  <dependencies>
1970    <dependency></dependency>
1971  </dependencies>
1972</project>
1973";
1974        assert!(is_in_dependencies_section(content, 3, EcosystemId::Maven));
1975        assert!(!is_in_dependencies_section(content, 1, EcosystemId::Maven));
1976    }
1977
1978    #[test]
1979    fn test_is_in_dependencies_section_maven_single_line() {
1980        let content = "<project><dependencies></dependencies></project>\n";
1981        assert!(is_in_dependencies_section(content, 0, EcosystemId::Maven));
1982    }
1983
1984    #[test]
1985    fn test_is_in_dependencies_section_maven_attributed_tag() {
1986        let content = r#"
1987<project>
1988  <dependencies xmlns="http://maven.apache.org/POM/4.0.0">
1989    <dependency></dependency>
1990  </dependencies>
1991</project>
1992"#;
1993        assert!(is_in_dependencies_section(content, 3, EcosystemId::Maven));
1994    }
1995
1996    #[test]
1997    fn test_is_in_dependencies_section_maven_no_false_positive_on_longer_tag_name() {
1998        let content = r"
1999<project>
2000  <dependencyManagement>
2001    <dependencies>
2002      <dependency></dependency>
2003    </dependencies>
2004  </dependencyManagement>
2005</project>
2006";
2007        // Line 2 opens `<dependencyManagement>`, not `<dependencies>` — must not match.
2008        assert!(!is_in_dependencies_section(content, 2, EcosystemId::Maven));
2009        // Line 4 is genuinely inside the nested `<dependencies>` block.
2010        assert!(is_in_dependencies_section(content, 4, EcosystemId::Maven));
2011    }
2012
2013    #[test]
2014    fn test_is_in_dependencies_section_go_single_line() {
2015        let content = "module example.com/myapp\n\nrequire github.com/gin-gonic/gin v1.9.1\n";
2016        assert!(is_in_dependencies_section(content, 2, EcosystemId::Go));
2017        assert!(!is_in_dependencies_section(content, 0, EcosystemId::Go));
2018    }
2019
2020    #[test]
2021    fn test_is_in_dependencies_section_go_block() {
2022        let content =
2023            "module example.com/myapp\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.1\n)\n";
2024        assert!(is_in_dependencies_section(content, 2, EcosystemId::Go));
2025        assert!(is_in_dependencies_section(content, 3, EcosystemId::Go));
2026        assert!(!is_in_dependencies_section(content, 4, EcosystemId::Go));
2027    }
2028
2029    #[test]
2030    fn test_is_in_dependencies_section_dart() {
2031        let content =
2032            "name: myapp\ndependencies:\n  http: ^1.0.0\nenvironment:\n  sdk: '>=3.0.0'\n";
2033        assert!(is_in_dependencies_section(content, 2, EcosystemId::Dart));
2034        assert!(!is_in_dependencies_section(content, 4, EcosystemId::Dart));
2035    }
2036
2037    #[test]
2038    fn test_is_in_dependencies_section_dart_column_zero_comment() {
2039        // A column-0 `#` comment inside a section must not read as a new top-level
2040        // key and reset `in_dependencies` to false.
2041        let content = "name: myapp\ndependencies:\n# a comment\n  http: ^1.0.0\n";
2042        assert!(is_in_dependencies_section(content, 2, EcosystemId::Dart));
2043        assert!(is_in_dependencies_section(content, 3, EcosystemId::Dart));
2044    }
2045
2046    #[test]
2047    fn test_is_in_dependencies_section_deno() {
2048        let content = r#"{
2049  "name": "test",
2050  "imports": {
2051    "@std/fs": "jsr:@std/fs@^1.0"
2052  }
2053}"#;
2054        assert!(is_in_dependencies_section(content, 3, EcosystemId::Deno));
2055        assert!(!is_in_dependencies_section(content, 1, EcosystemId::Deno));
2056    }
2057
2058    #[test]
2059    fn test_is_in_dependencies_section_no_raw_text_boundary_ecosystems() {
2060        // No existing raw-text section boundary: `false` preserves pre-fix behavior
2061        // (fallback completion disabled) rather than risking spurious registry
2062        // searches on arbitrary lines. See the TODO comments in
2063        // `is_in_dependencies_section` for the per-ecosystem rationale.
2064        let content = "anything at all\n";
2065        assert!(!is_in_dependencies_section(
2066            content,
2067            0,
2068            EcosystemId::Bundler
2069        ));
2070        assert!(!is_in_dependencies_section(content, 0, EcosystemId::Swift));
2071        assert!(!is_in_dependencies_section(content, 0, EcosystemId::Gradle));
2072        assert!(!is_in_dependencies_section(content, 0, EcosystemId::NuGet));
2073    }
2074
2075    #[test]
2076    fn test_create_package_completion_item_cargo() {
2077        struct MockMetadata {
2078            name: deps_core::PackageName,
2079        }
2080        impl deps_core::Metadata for MockMetadata {
2081            fn name(&self) -> &deps_core::PackageName {
2082                &self.name
2083            }
2084            fn description(&self) -> Option<&str> {
2085                Some("A serialization framework")
2086            }
2087            fn repository(&self) -> Option<&str> {
2088                None
2089            }
2090            fn documentation(&self) -> Option<&str> {
2091                None
2092            }
2093            fn latest_version(&self) -> &deps_core::ConcreteVersion {
2094                static VERSION: std::sync::LazyLock<deps_core::ConcreteVersion> =
2095                    std::sync::LazyLock::new(|| deps_core::ConcreteVersion::new("1.0.214"));
2096                &VERSION
2097            }
2098            fn as_any(&self) -> &dyn std::any::Any {
2099                self
2100            }
2101        }
2102
2103        let meta = MockMetadata {
2104            name: deps_core::PackageName::new("serde"),
2105        };
2106        let item = create_package_completion_item(&meta, EcosystemId::Cargo).unwrap();
2107
2108        assert_eq!(item.label, "serde");
2109        assert_eq!(item.kind, Some(CompletionItemKind::MODULE));
2110        assert_eq!(item.detail, Some("Latest: 1.0.214".to_string()));
2111        assert_eq!(
2112            item.insert_text,
2113            Some("\"serde\" = \"1.0.214\"".to_string())
2114        );
2115        assert_eq!(item.insert_text_format, Some(InsertTextFormat::PLAIN_TEXT));
2116    }
2117
2118    #[test]
2119    fn test_create_package_completion_item_npm() {
2120        struct MockMetadata {
2121            name: deps_core::PackageName,
2122        }
2123        impl deps_core::Metadata for MockMetadata {
2124            fn name(&self) -> &deps_core::PackageName {
2125                &self.name
2126            }
2127            fn description(&self) -> Option<&str> {
2128                Some("Fast web framework")
2129            }
2130            fn repository(&self) -> Option<&str> {
2131                None
2132            }
2133            fn documentation(&self) -> Option<&str> {
2134                None
2135            }
2136            fn latest_version(&self) -> &deps_core::ConcreteVersion {
2137                static VERSION: std::sync::LazyLock<deps_core::ConcreteVersion> =
2138                    std::sync::LazyLock::new(|| deps_core::ConcreteVersion::new("4.18.2"));
2139                &VERSION
2140            }
2141            fn as_any(&self) -> &dyn std::any::Any {
2142                self
2143            }
2144        }
2145
2146        let meta = MockMetadata {
2147            name: deps_core::PackageName::new("express"),
2148        };
2149        let item = create_package_completion_item(&meta, EcosystemId::Npm).unwrap();
2150
2151        assert_eq!(item.label, "express");
2152        assert_eq!(
2153            item.insert_text,
2154            Some("\"express\": \"^4.18.2\"".to_string())
2155        );
2156    }
2157
2158    #[test]
2159    fn test_create_package_completion_item_pypi() {
2160        struct MockMetadata {
2161            name: deps_core::PackageName,
2162        }
2163        impl deps_core::Metadata for MockMetadata {
2164            fn name(&self) -> &deps_core::PackageName {
2165                &self.name
2166            }
2167            fn description(&self) -> Option<&str> {
2168                None
2169            }
2170            fn repository(&self) -> Option<&str> {
2171                None
2172            }
2173            fn documentation(&self) -> Option<&str> {
2174                None
2175            }
2176            fn latest_version(&self) -> &deps_core::ConcreteVersion {
2177                static VERSION: std::sync::LazyLock<deps_core::ConcreteVersion> =
2178                    std::sync::LazyLock::new(|| deps_core::ConcreteVersion::new("2.31.0"));
2179                &VERSION
2180            }
2181            fn as_any(&self) -> &dyn std::any::Any {
2182                self
2183            }
2184        }
2185
2186        let meta = MockMetadata {
2187            name: deps_core::PackageName::new("requests"),
2188        };
2189        let item = create_package_completion_item(&meta, EcosystemId::Pypi).unwrap();
2190
2191        assert_eq!(item.label, "requests");
2192        // #390 C1: both real PEP 621 shapes (`dependencies = [...]` and an
2193        // `[project.optional-dependencies]` group) are TOML array elements, so the
2194        // surrounding quotes already exist in the manifest — a bare name matches
2195        // `build_package_completion`'s primary-path insert for PyPI at the same
2196        // cursor position, unlike Cargo's key=value table-entry shape.
2197        assert_eq!(item.insert_text, Some("requests".to_string()));
2198    }
2199
2200    struct MockMetadata {
2201        name: deps_core::PackageName,
2202        repository: Option<&'static str>,
2203        latest_version: deps_core::ConcreteVersion,
2204    }
2205    impl deps_core::Metadata for MockMetadata {
2206        fn name(&self) -> &deps_core::PackageName {
2207            &self.name
2208        }
2209        fn description(&self) -> Option<&str> {
2210            None
2211        }
2212        fn repository(&self) -> Option<&str> {
2213            self.repository
2214        }
2215        fn documentation(&self) -> Option<&str> {
2216            None
2217        }
2218        fn latest_version(&self) -> &deps_core::ConcreteVersion {
2219            &self.latest_version
2220        }
2221        fn as_any(&self) -> &dyn std::any::Any {
2222            self
2223        }
2224    }
2225
2226    #[test]
2227    fn test_create_package_completion_item_cargo_dotted_name_quotes_toml_key() {
2228        // S1: a bare TOML key containing `.` (legal here — real crate names can use
2229        // it) expands into a nested table instead of a dependency entry
2230        // (`serde.path = "vendor"` parses as `serde = { path = "vendor" }`). Quoting
2231        // the key keeps the dotted name a single dependency entry regardless of
2232        // ecosystem-legit or attacker-supplied intent. Cargo only: after #390 C1,
2233        // PyPI no longer emits a TOML key at all (see
2234        // `test_create_package_completion_item_pypi_dotted_name_stays_bare_string`).
2235        let meta = MockMetadata {
2236            name: deps_core::PackageName::new("some.crate"),
2237            repository: None,
2238            latest_version: "6.1".into(),
2239        };
2240        let item = create_package_completion_item(&meta, EcosystemId::Cargo).unwrap();
2241
2242        assert_eq!(
2243            item.insert_text,
2244            Some("\"some.crate\" = \"6.1\"".to_string())
2245        );
2246    }
2247
2248    #[test]
2249    fn test_create_package_completion_item_pypi_dotted_name_stays_bare_string() {
2250        // A dotted PyPI name (`zope.interface`) has no TOML-key-injection meaning
2251        // once the insert is a bare array-element string, unlike Cargo's key=value
2252        // shape (see `test_create_package_completion_item_cargo_dotted_name_quotes_toml_key`).
2253        let meta = MockMetadata {
2254            name: deps_core::PackageName::new("zope.interface"),
2255            repository: None,
2256            latest_version: "6.1".into(),
2257        };
2258        let item = create_package_completion_item(&meta, EcosystemId::Pypi).unwrap();
2259
2260        assert_eq!(item.insert_text, Some("zope.interface".to_string()));
2261    }
2262
2263    #[test]
2264    fn test_create_package_completion_item_maven_group_artifact() {
2265        let meta = MockMetadata {
2266            name: deps_core::PackageName::new("org.apache.commons:commons-lang3"),
2267            repository: None,
2268            latest_version: "3.14.0".into(),
2269        };
2270        let item = create_package_completion_item(&meta, EcosystemId::Maven).unwrap();
2271
2272        assert_eq!(
2273            item.insert_text,
2274            Some(
2275                "<groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId>\
2276                 <version>3.14.0</version>"
2277                    .to_string()
2278            )
2279        );
2280    }
2281
2282    #[test]
2283    fn test_create_package_completion_item_maven_no_colon() {
2284        let meta = MockMetadata {
2285            name: deps_core::PackageName::new("commons-lang3"),
2286            repository: None,
2287            latest_version: "3.14.0".into(),
2288        };
2289        let item = create_package_completion_item(&meta, EcosystemId::Maven).unwrap();
2290
2291        assert_eq!(
2292            item.insert_text,
2293            Some("<artifactId>commons-lang3</artifactId><version>3.14.0</version>".to_string())
2294        );
2295    }
2296
2297    #[test]
2298    fn test_create_package_completion_item_maven_rejects_xml_breakout_artifact_id() {
2299        // S1: the identical breakout `build_field_completion` (deps-maven) now guards
2300        // against must also be rejected on this fallback-search path, not just the
2301        // primary XML-context path.
2302        let meta = MockMetadata {
2303            name: deps_core::PackageName::new(
2304                "org.apache.commons:commons</artifactId><parent><groupId>evil",
2305            ),
2306            repository: None,
2307            latest_version: "3.14.0".into(),
2308        };
2309
2310        assert!(create_package_completion_item(&meta, EcosystemId::Maven).is_none());
2311    }
2312
2313    #[test]
2314    fn test_create_package_completion_item_maven_rejects_xml_breakout_group_id() {
2315        let meta = MockMetadata {
2316            name: deps_core::PackageName::new("org.evil</groupId><parent>:commons-lang3"),
2317            repository: None,
2318            latest_version: "3.14.0".into(),
2319        };
2320
2321        assert!(create_package_completion_item(&meta, EcosystemId::Maven).is_none());
2322    }
2323
2324    #[test]
2325    fn test_create_package_completion_item_maven_no_colon_rejects_xml_breakout() {
2326        let meta = MockMetadata {
2327            name: deps_core::PackageName::new("commons</artifactId><parent>"),
2328            repository: None,
2329            latest_version: "3.14.0".into(),
2330        };
2331
2332        assert!(create_package_completion_item(&meta, EcosystemId::Maven).is_none());
2333    }
2334
2335    #[test]
2336    fn test_create_package_completion_item_rejects_unsafe_latest_version() {
2337        // S2: `latest` is interpolated into every ecosystem's insert_text but was
2338        // previously never validated on this path (unlike the other five `TextEdit`
2339        // producers `is_safe_version_string` guards).
2340        let meta = MockMetadata {
2341            name: deps_core::PackageName::new("serde"),
2342            repository: None,
2343            latest_version: "1.0.0\", git = \"https://evil".into(),
2344        };
2345
2346        assert!(create_package_completion_item(&meta, EcosystemId::Cargo).is_none());
2347    }
2348
2349    #[test]
2350    fn test_create_package_completion_item_swift_with_repository() {
2351        let meta = MockMetadata {
2352            name: deps_core::PackageName::new("apple/swift-nio"),
2353            repository: Some("https://github.com/apple/swift-nio"),
2354            latest_version: "2.62.0".into(),
2355        };
2356        let item = create_package_completion_item(&meta, EcosystemId::Swift).unwrap();
2357
2358        assert_eq!(
2359            item.insert_text,
2360            Some(
2361                ".package(url: \"https://github.com/apple/swift-nio\", from: \"2.62.0\")"
2362                    .to_string()
2363            )
2364        );
2365    }
2366
2367    #[test]
2368    fn test_create_package_completion_item_swift_empty_latest_omits_from_clause() {
2369        let meta = MockMetadata {
2370            name: deps_core::PackageName::new("apple/swift-nio"),
2371            repository: Some("https://github.com/apple/swift-nio"),
2372            latest_version: "".into(),
2373        };
2374        let item = create_package_completion_item(&meta, EcosystemId::Swift).unwrap();
2375
2376        assert_eq!(
2377            item.insert_text,
2378            Some(".package(url: \"https://github.com/apple/swift-nio\")".to_string())
2379        );
2380    }
2381
2382    #[test]
2383    fn test_create_package_completion_item_swift_no_repository_falls_back_to_name() {
2384        let meta = MockMetadata {
2385            name: deps_core::PackageName::new("apple/swift-nio"),
2386            repository: None,
2387            latest_version: "2.62.0".into(),
2388        };
2389        let item = create_package_completion_item(&meta, EcosystemId::Swift).unwrap();
2390
2391        assert_eq!(
2392            item.insert_text,
2393            Some(
2394                ".package(url: \"https://github.com/apple/swift-nio\", from: \"2.62.0\")"
2395                    .to_string()
2396            )
2397        );
2398    }
2399
2400    #[test]
2401    fn test_create_package_completion_item_swift_rejects_string_literal_breakout_repository() {
2402        // S1: the identical breakout `build_url_completion` (deps-swift) now guards
2403        // against must also be rejected on this fallback-search path.
2404        let meta = MockMetadata {
2405            name: deps_core::PackageName::new("apple/swift-nio"),
2406            repository: Some(
2407                "https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
2408            ),
2409            latest_version: "2.62.0".into(),
2410        };
2411
2412        assert!(create_package_completion_item(&meta, EcosystemId::Swift).is_none());
2413    }
2414
2415    #[test]
2416    fn test_create_package_completion_item_swift_rejects_malicious_name_in_fallback_url() {
2417        let meta = MockMetadata {
2418            name: deps_core::PackageName::new("apple/swift-nio\", .exact(\"1\")) //"),
2419            repository: None,
2420            latest_version: "2.62.0".into(),
2421        };
2422
2423        assert!(create_package_completion_item(&meta, EcosystemId::Swift).is_none());
2424    }
2425
2426    #[test]
2427    fn test_create_package_completion_item_swift_rejects_malicious_name_even_with_repository() {
2428        // M4: the upfront `is_safe_package_name` gate runs before the match arm, so it
2429        // now also filters names that never reach `insert_text` on this path — when
2430        // `repository` is provided, the name-derived fallback-URL branch doesn't run
2431        // at all. Intentional: a registry response with a malicious name is
2432        // untrustworthy as a whole, not just in the fields the formatter happens to
2433        // interpolate.
2434        let meta = MockMetadata {
2435            name: deps_core::PackageName::new("apple/swift-nio\", .exact(\"1\")) //"),
2436            repository: Some("https://github.com/apple/swift-nio"),
2437            latest_version: "2.62.0".into(),
2438        };
2439
2440        assert!(create_package_completion_item(&meta, EcosystemId::Swift).is_none());
2441    }
2442
2443    #[test]
2444    fn test_create_package_completion_item_deno_strips_scheme_for_alias_key() {
2445        let meta = MockMetadata {
2446            name: deps_core::PackageName::new("jsr:@std/fs"),
2447            repository: None,
2448            latest_version: "1.0.24".into(),
2449        };
2450        let item = create_package_completion_item(&meta, EcosystemId::Deno).unwrap();
2451
2452        assert_eq!(
2453            item.insert_text,
2454            Some("\"@std/fs\": \"jsr:@std/fs@^1.0.24\"".to_string())
2455        );
2456    }
2457
2458    #[test]
2459    fn test_create_package_completion_item_deno_npm_scheme() {
2460        let meta = MockMetadata {
2461            name: deps_core::PackageName::new("npm:react"),
2462            repository: None,
2463            latest_version: "18.3.1".into(),
2464        };
2465        let item = create_package_completion_item(&meta, EcosystemId::Deno).unwrap();
2466
2467        assert_eq!(
2468            item.insert_text,
2469            Some("\"react\": \"npm:react@^18.3.1\"".to_string())
2470        );
2471    }
2472
2473    #[test]
2474    fn test_create_package_completion_item_deno_empty_latest_omits_version_clause() {
2475        // N5: a JSR search hit lacking `latestVersion` must not insert a dangling `@^`.
2476        let meta = MockMetadata {
2477            name: deps_core::PackageName::new("jsr:@std/fs"),
2478            repository: None,
2479            latest_version: "".into(),
2480        };
2481        let item = create_package_completion_item(&meta, EcosystemId::Deno).unwrap();
2482
2483        assert_eq!(
2484            item.insert_text,
2485            Some("\"@std/fs\": \"jsr:@std/fs\"".to_string())
2486        );
2487    }
2488
2489    #[test]
2490    fn test_create_package_completion_item_composer() {
2491        let meta = MockMetadata {
2492            name: deps_core::PackageName::new("monolog/monolog"),
2493            repository: None,
2494            latest_version: "3.5.0".into(),
2495        };
2496        let item = create_package_completion_item(&meta, EcosystemId::Composer).unwrap();
2497
2498        assert_eq!(
2499            item.insert_text,
2500            Some("\"monolog/monolog\": \"^3.5.0\"".to_string())
2501        );
2502    }
2503
2504    #[test]
2505    fn test_create_package_completion_item_go() {
2506        let meta = MockMetadata {
2507            name: deps_core::PackageName::new("github.com/stretchr/testify"),
2508            repository: None,
2509            latest_version: "v1.9.0".into(),
2510        };
2511        let item = create_package_completion_item(&meta, EcosystemId::Go).unwrap();
2512
2513        assert_eq!(
2514            item.insert_text,
2515            Some("github.com/stretchr/testify v1.9.0".to_string())
2516        );
2517    }
2518
2519    #[test]
2520    fn test_create_package_completion_item_dart() {
2521        let meta = MockMetadata {
2522            name: deps_core::PackageName::new("path"),
2523            repository: None,
2524            latest_version: "1.9.0".into(),
2525        };
2526        let item = create_package_completion_item(&meta, EcosystemId::Dart).unwrap();
2527
2528        assert_eq!(item.insert_text, Some("\"path\": ^1.9.0".to_string()));
2529    }
2530
2531    #[test]
2532    fn test_create_package_completion_item_gradle() {
2533        let meta = MockMetadata {
2534            name: deps_core::PackageName::new("org.apache.commons:commons-lang3"),
2535            repository: None,
2536            latest_version: "3.14.0".into(),
2537        };
2538        let item = create_package_completion_item(&meta, EcosystemId::Gradle).unwrap();
2539
2540        assert_eq!(
2541            item.insert_text,
2542            Some("implementation(\"org.apache.commons:commons-lang3:3.14.0\")".to_string())
2543        );
2544    }
2545
2546    #[test]
2547    fn test_create_package_completion_item_nuget() {
2548        let meta = MockMetadata {
2549            name: deps_core::PackageName::new("Newtonsoft.Json"),
2550            repository: None,
2551            latest_version: "13.0.3".into(),
2552        };
2553        let item = create_package_completion_item(&meta, EcosystemId::NuGet).unwrap();
2554
2555        assert_eq!(
2556            item.insert_text,
2557            Some("<PackageReference Include=\"Newtonsoft.Json\" Version=\"13.0.3\" />".to_string())
2558        );
2559    }
2560
2561    #[test]
2562    fn test_create_package_completion_item_bundler() {
2563        let meta = MockMetadata {
2564            name: deps_core::PackageName::new("rails"),
2565            repository: None,
2566            latest_version: "7.1.3".into(),
2567        };
2568        let item = create_package_completion_item(&meta, EcosystemId::Bundler).unwrap();
2569
2570        assert_eq!(
2571            item.insert_text,
2572            Some("gem \"rails\", \"~> 7.1.3\"".to_string())
2573        );
2574    }
2575
2576    #[test]
2577    fn test_create_package_completion_item_rejects_malicious_name_toml_json_breakout() {
2578        // Issue #336: a registry-reported name breaking out of a TOML/JSON/XML/YAML/
2579        // Kotlin-Groovy-DSL/Ruby string literal must be rejected for every ecosystem
2580        // that interpolates it raw, not just Maven/Swift.
2581        let evil = deps_core::PackageName::new("evil\"\nbackdoor = \"9.9.9");
2582        for ecosystem in [
2583            EcosystemId::Cargo,
2584            EcosystemId::Pypi,
2585            EcosystemId::Npm,
2586            EcosystemId::Composer,
2587            EcosystemId::Go,
2588            EcosystemId::Dart,
2589            EcosystemId::Gradle,
2590            EcosystemId::NuGet,
2591            EcosystemId::Bundler,
2592            EcosystemId::Deno,
2593        ] {
2594            let meta = MockMetadata {
2595                name: evil.clone(),
2596                repository: None,
2597                latest_version: "9.9.9".into(),
2598            };
2599            assert!(
2600                create_package_completion_item(&meta, ecosystem).is_none(),
2601                "expected {ecosystem:?} to reject the malicious name"
2602            );
2603        }
2604    }
2605
2606    #[tokio::test]
2607    async fn test_fallback_triggered_when_parse_fails() {
2608        let state = Arc::new(ServerState::new());
2609        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
2610
2611        // Malformed content that will fail to parse
2612        let content = r"[dependencies]
2613ser"
2614        .to_string();
2615
2616        // Create document without parse result (simulating parse failure)
2617        let doc = DocumentState::new_without_parse_result(EcosystemId::Cargo, content.clone());
2618        state.update_document(uri.clone(), doc);
2619
2620        let params = CompletionParams {
2621            text_document_position: TextDocumentPositionParams {
2622                text_document: TextDocumentIdentifier { uri },
2623                position: Position::new(1, 3), // After "ser"
2624            },
2625            work_done_progress_params: Default::default(),
2626            partial_result_params: Default::default(),
2627            context: None,
2628        };
2629
2630        // Should use fallback completion (won't panic, may return empty if search fails)
2631        let (client, config) = create_test_client_and_config();
2632        let result = handle_completion(state, params, client, config).await;
2633        // Just verify it doesn't panic - actual results depend on registry availability
2634        // In a real scenario with mocked registry, we'd verify it returns search results
2635        drop(result);
2636    }
2637
2638    #[test]
2639    fn test_fallback_rejects_single_char_prefix() {
2640        let content = r"
2641[dependencies]
2642s
2643";
2644
2645        // Extract prefix at position (1 char)
2646        let line = content.lines().nth(2).unwrap();
2647        let prefix = extract_prefix(line, 1, EcosystemId::Cargo);
2648
2649        // Should reject single char (< 2 chars requirement)
2650        assert_eq!(prefix.len(), 1);
2651        assert!(prefix.chars().count() < 2);
2652    }
2653
2654    #[tokio::test]
2655    async fn test_fallback_completion_rejects_single_cjk_char_prefix() {
2656        use deps_core::{Metadata, Registry, Version};
2657        use std::any::Any;
2658
2659        // A single CJK character is 3 bytes, so byte-length guard `prefix.len() < 2`
2660        // wrongly let it reach the registry; `search` panics here so the test fails
2661        // loudly if the guard regresses instead of silently returning empty either way.
2662        struct PanicsIfSearchedRegistry;
2663        impl Registry for PanicsIfSearchedRegistry {
2664            fn get_versions<'a>(
2665                &'a self,
2666                _name: &'a deps_core::PackageName,
2667            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2668            {
2669                Box::pin(async move { Ok(vec![]) })
2670            }
2671            fn get_latest_matching<'a>(
2672                &'a self,
2673                _name: &'a deps_core::PackageName,
2674                _req: &'a deps_core::VersionReq,
2675            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2676            {
2677                Box::pin(async move { Ok(None) })
2678            }
2679            fn search<'a>(
2680                &'a self,
2681                _query: &'a str,
2682                _limit: usize,
2683            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2684            {
2685                panic!("guard must short-circuit before reaching registry search");
2686            }
2687            fn as_any(&self) -> &dyn Any {
2688                self
2689            }
2690        }
2691
2692        let state = mock_cargo_state(Arc::new(PanicsIfSearchedRegistry));
2693        let content = "[dependencies]\n日\n";
2694        let position = Position::new(1, 1); // after the single CJK char
2695
2696        let items = fallback_completion(&state, EcosystemId::Cargo, position, content).await;
2697        assert!(items.is_empty());
2698    }
2699
2700    #[tokio::test]
2701    async fn test_fallback_completion_passes_two_char_prefixes_to_search() {
2702        use deps_core::{Metadata, Registry, Version};
2703        use std::any::Any;
2704
2705        struct MockMetadata {
2706            name: deps_core::PackageName,
2707        }
2708        impl Metadata for MockMetadata {
2709            fn name(&self) -> &deps_core::PackageName {
2710                &self.name
2711            }
2712            fn description(&self) -> Option<&str> {
2713                None
2714            }
2715            fn repository(&self) -> Option<&str> {
2716                None
2717            }
2718            fn documentation(&self) -> Option<&str> {
2719                None
2720            }
2721            fn latest_version(&self) -> &deps_core::ConcreteVersion {
2722                static VERSION: std::sync::LazyLock<deps_core::ConcreteVersion> =
2723                    std::sync::LazyLock::new(|| deps_core::ConcreteVersion::new("1.0.0"));
2724                &VERSION
2725            }
2726            fn as_any(&self) -> &dyn Any {
2727                self
2728            }
2729        }
2730
2731        struct StubRegistry;
2732        impl Registry for StubRegistry {
2733            fn get_versions<'a>(
2734                &'a self,
2735                _name: &'a deps_core::PackageName,
2736            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2737            {
2738                Box::pin(async move { Ok(vec![]) })
2739            }
2740            fn get_latest_matching<'a>(
2741                &'a self,
2742                _name: &'a deps_core::PackageName,
2743                _req: &'a deps_core::VersionReq,
2744            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2745            {
2746                Box::pin(async move { Ok(None) })
2747            }
2748            fn search<'a>(
2749                &'a self,
2750                _query: &'a str,
2751                _limit: usize,
2752            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2753            {
2754                Box::pin(async move {
2755                    Ok(vec![Box::new(MockMetadata {
2756                        name: deps_core::PackageName::new("serde"),
2757                    }) as Box<dyn Metadata>])
2758                })
2759            }
2760            fn as_any(&self) -> &dyn Any {
2761                self
2762            }
2763        }
2764
2765        // Two CJK characters: byte count (6) and char count (2) agree, so this was
2766        // never affected by the bug, but it must keep passing through to search.
2767        let cjk_state = mock_cargo_state(Arc::new(StubRegistry));
2768        let cjk_items = fallback_completion(
2769            &cjk_state,
2770            EcosystemId::Cargo,
2771            Position::new(1, 2),
2772            "[dependencies]\n日本\n",
2773        )
2774        .await;
2775        assert_eq!(cjk_items.len(), 1);
2776        assert_eq!(cjk_items[0].label, "serde");
2777
2778        // Two ASCII chars: regression check that the char-count guard didn't change
2779        // behavior for the common case.
2780        let ascii_state = mock_cargo_state(Arc::new(StubRegistry));
2781        let ascii_items = fallback_completion(
2782            &ascii_state,
2783            EcosystemId::Cargo,
2784            Position::new(1, 2),
2785            "[dependencies]\nse\n",
2786        )
2787        .await;
2788        assert_eq!(ascii_items.len(), 1);
2789        assert_eq!(ascii_items[0].label, "serde");
2790    }
2791
2792    #[test]
2793    fn test_extract_prefix_strips_leading_xml_tag_for_maven() {
2794        // Cursor right after "gua" in `<artifactId>gua`.
2795        assert_eq!(
2796            extract_prefix("  <artifactId>gua", 17, EcosystemId::Maven),
2797            "gua"
2798        );
2799    }
2800
2801    #[test]
2802    fn test_extract_prefix_maven_unclosed_tag_is_unchanged() {
2803        // Cursor mid-tag-name, before `>` exists yet: nothing to strip.
2804        assert_eq!(
2805            extract_prefix("  <artifactId", 13, EcosystemId::Maven),
2806            "<artifactId"
2807        );
2808    }
2809
2810    /// #282 S1 (second critic round): a first-`>`-based strip diverges from
2811    /// `MavenEcosystem::detect_xml_context`'s own `rfind`-based (last-tag) lookup
2812    /// whenever more than one tag precedes the cursor on a line — the first `>` here
2813    /// sits inside `<groupId>`, well short of the real value. Mirrored by
2814    /// `deps-maven`'s `test_detect_xml_context_compact_multi_tag_line_matches_completion_extractor`
2815    /// using the identical line/cursor position.
2816    #[test]
2817    fn test_extract_prefix_maven_strips_last_tag_not_first() {
2818        let line = "    <dependency><groupId>com.google.guava</groupId><artifactId>gua";
2819        assert_eq!(extract_prefix(line, 66, EcosystemId::Maven), "gua");
2820    }
2821
2822    /// #282 S1 (second critic round): cursor right after a fully closed tag must yield
2823    /// an empty prefix (rejected by `fallback_completion`'s existing empty-prefix
2824    /// guard), matching `detect_xml_context`'s own "no context" outcome for the same
2825    /// position (its `between.contains("</")` guard rejects it too) instead of sending
2826    /// `solrsearch` a markup-polluted live query for an ordinary explicit-invoke
2827    /// position. Mirrored by `deps-maven`'s
2828    /// `test_detect_xml_context_after_closed_tag_yields_no_context` using the identical
2829    /// line/cursor position.
2830    #[test]
2831    fn test_extract_prefix_maven_after_closed_tag_is_empty() {
2832        let line = "    <artifactId>guava</artifactId>";
2833        assert_eq!(extract_prefix(line, 34, EcosystemId::Maven), "");
2834    }
2835
2836    /// #282 C1 regression guard: the primary completion path (`MavenEcosystem::
2837    /// detect_xml_context`) searches the registry for the bare tag value (`"gua"` for
2838    /// `<artifactId>gua`), not the raw line text. Before this fix, `fallback_completion`
2839    /// searched for `"<artifactId>gua"` instead — a different query string that broke
2840    /// both search relevance and any per-query dedup/cache mechanism (the fast-failure
2841    /// amplification fix in `deps-maven`) keyed on the query matching across the
2842    /// primary and fallback paths for the same cursor position.
2843    #[tokio::test]
2844    async fn test_fallback_completion_maven_query_matches_tag_value() {
2845        use deps_core::{Metadata, Registry};
2846        use std::any::Any;
2847        use std::sync::Mutex;
2848
2849        struct CapturingRegistry {
2850            captured_query: Mutex<Option<String>>,
2851        }
2852        impl Registry for CapturingRegistry {
2853            fn get_versions<'a>(
2854                &'a self,
2855                _name: &'a deps_core::PackageName,
2856            ) -> deps_core::ecosystem::BoxFuture<
2857                'a,
2858                deps_core::Result<Vec<Box<dyn deps_core::Version>>>,
2859            > {
2860                Box::pin(async move { Ok(vec![]) })
2861            }
2862            fn get_latest_matching<'a>(
2863                &'a self,
2864                _name: &'a deps_core::PackageName,
2865                _req: &'a deps_core::VersionReq,
2866            ) -> deps_core::ecosystem::BoxFuture<
2867                'a,
2868                deps_core::Result<Option<Box<dyn deps_core::Version>>>,
2869            > {
2870                Box::pin(async move { Ok(None) })
2871            }
2872            fn search<'a>(
2873                &'a self,
2874                query: &'a str,
2875                _limit: usize,
2876            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2877            {
2878                *self.captured_query.lock().unwrap() = Some(query.to_string());
2879                Box::pin(async move { Ok(vec![]) })
2880            }
2881            fn as_any(&self) -> &dyn Any {
2882                self
2883            }
2884        }
2885
2886        let registry = Arc::new(CapturingRegistry {
2887            captured_query: Mutex::new(None),
2888        });
2889        let state = mock_maven_state(Arc::clone(&registry) as Arc<dyn Registry>);
2890
2891        let content = "<dependencies>\n  <dependency>\n    <artifactId>gua\n";
2892        fallback_completion(&state, EcosystemId::Maven, Position::new(2, 19), content).await;
2893
2894        assert_eq!(
2895            registry.captured_query.lock().unwrap().as_deref(),
2896            Some("gua")
2897        );
2898    }
2899
2900    /// #390 (C5, tester Gap 1 / critic): a direct end-to-end proof, through
2901    /// `fallback_completion` itself rather than `is_in_dependencies_section` and
2902    /// `extract_prefix` in isolation, that the two root-cause fixes actually compose.
2903    /// Verbatim issue repro step 1: an unterminated entry inside the primary
2904    /// `dependencies = [...]` array (no literal `[project.dependencies]` header
2905    /// anywhere in the fixture) — the registry must see `flas`, not `"flas`.
2906    #[tokio::test]
2907    async fn test_fallback_completion_pypi_project_array_query_has_no_leaked_quote() {
2908        use deps_core::{Metadata, Registry};
2909        use std::any::Any;
2910        use std::sync::Mutex;
2911
2912        struct CapturingRegistry {
2913            captured_query: Mutex<Option<String>>,
2914        }
2915        impl Registry for CapturingRegistry {
2916            fn get_versions<'a>(
2917                &'a self,
2918                _name: &'a deps_core::PackageName,
2919            ) -> deps_core::ecosystem::BoxFuture<
2920                'a,
2921                deps_core::Result<Vec<Box<dyn deps_core::Version>>>,
2922            > {
2923                Box::pin(async move { Ok(vec![]) })
2924            }
2925            fn get_latest_matching<'a>(
2926                &'a self,
2927                _name: &'a deps_core::PackageName,
2928                _req: &'a deps_core::VersionReq,
2929            ) -> deps_core::ecosystem::BoxFuture<
2930                'a,
2931                deps_core::Result<Option<Box<dyn deps_core::Version>>>,
2932            > {
2933                Box::pin(async move { Ok(None) })
2934            }
2935            fn search<'a>(
2936                &'a self,
2937                query: &'a str,
2938                _limit: usize,
2939            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2940            {
2941                *self.captured_query.lock().unwrap() = Some(query.to_string());
2942                Box::pin(async move { Ok(vec![]) })
2943            }
2944            fn as_any(&self) -> &dyn Any {
2945                self
2946            }
2947        }
2948
2949        let registry = Arc::new(CapturingRegistry {
2950            captured_query: Mutex::new(None),
2951        });
2952        let state = mock_pypi_state(Arc::clone(&registry) as Arc<dyn Registry>);
2953
2954        let content = "[project]\nname = \"myapp\"\nversion = \"0.1.0\"\ndependencies = [\n    \"requests>=2.31.0\",\n    \"flas\n]\n";
2955        fallback_completion(&state, EcosystemId::Pypi, Position::new(5, 9), content).await;
2956
2957        assert_eq!(
2958            registry.captured_query.lock().unwrap().as_deref(),
2959            Some("flas")
2960        );
2961    }
2962
2963    /// #390 (C5, tester Gap 1): the one PEP 621 shape that legitimately uses a
2964    /// section header (`[project.optional-dependencies]`) exercised through the real
2965    /// `is_in_dependencies_section`/`extract_prefix` composition, not just
2966    /// `is_in_toml_dependencies`'s header match in isolation — the existing
2967    /// `test_is_in_dependencies_section_pypi` only covers the literal
2968    /// `[project.dependencies]` header this fix's own docs say never occurs in real
2969    /// files.
2970    #[test]
2971    fn test_is_in_dependencies_section_and_extract_prefix_pypi_optional_dependencies_group() {
2972        let content = "[project.optional-dependencies]\ndev = [\n    \"pytest\",\n    \"flas\n]\n";
2973        assert!(is_in_dependencies_section(content, 3, EcosystemId::Pypi));
2974
2975        let line = content.lines().nth(3).unwrap();
2976        assert_eq!(
2977            extract_prefix(line, line.len() as u32, EcosystemId::Pypi),
2978            "flas"
2979        );
2980    }
2981
2982    #[test]
2983    fn test_fallback_rejects_prefix_with_equals() {
2984        let content = r#"
2985[dependencies]
2986serde = "1.0"
2987"#;
2988
2989        // Extract prefix at position (contains '=')
2990        let line = content.lines().nth(2).unwrap();
2991        let prefix = extract_prefix(line, 12, EcosystemId::Cargo); // "serde = \"1.0"
2992
2993        // Should reject prefix containing '='
2994        assert!(prefix.contains('='));
2995    }
2996
2997    #[test]
2998    fn test_prefix_extraction_cursor_beyond_line() {
2999        let content = r"
3000[dependencies]
3001serde
3002";
3003
3004        // Try to extract prefix with cursor beyond line length
3005        let line = content.lines().nth(2).unwrap();
3006        assert_eq!(line, "serde");
3007
3008        // Cursor at position 100 (beyond line)
3009        let prefix = extract_prefix(line, 100, EcosystemId::Cargo);
3010
3011        // Should clamp to line length
3012        assert_eq!(prefix, "serde");
3013        assert_eq!(prefix.len(), 5); // Not 100
3014    }
3015
3016    #[test]
3017    fn test_extract_prefix_fallback_when_character_exceeds_line() {
3018        // `character` beyond the line's UTF-16 length hits `utf16_to_byte_offset`'s
3019        // `None` branch; `unwrap_or(line.len())` must clamp to the full line rather
3020        // than panic, even when the line contains multi-byte characters.
3021        let line = "café";
3022        let character = line.chars().map(|c| c.len_utf16() as u32).sum::<u32>() + 10;
3023        assert_eq!(extract_prefix(line, character, EcosystemId::Cargo), "café");
3024    }
3025
3026    #[test]
3027    fn test_extract_prefix_strips_leading_quote_for_json_ecosystems() {
3028        // package.json / composer.json: cursor sits before the closing quote while the
3029        // key is still being typed, e.g. `    "expr` with the cursor right after "expr".
3030        let line = "    \"expr";
3031        assert_eq!(
3032            extract_prefix(line, line.len() as u32, EcosystemId::Npm),
3033            "expr"
3034        );
3035        assert_eq!(
3036            extract_prefix(line, line.len() as u32, EcosystemId::Composer),
3037            "expr"
3038        );
3039    }
3040
3041    #[test]
3042    fn test_extract_prefix_strips_trailing_quote_for_json_ecosystems() {
3043        // Cursor right after a closing quote (editor auto-close, or the user retyped
3044        // it): `    "express"` with the cursor placed just past the closing quote.
3045        let line = "    \"express\"";
3046        assert_eq!(
3047            extract_prefix(line, line.len() as u32, EcosystemId::Npm),
3048            "express"
3049        );
3050        assert_eq!(
3051            extract_prefix(line, line.len() as u32, EcosystemId::Composer),
3052            "express"
3053        );
3054    }
3055
3056    #[test]
3057    fn test_extract_prefix_leaves_quotes_for_non_json_ecosystems() {
3058        // Cargo keys are typed unquoted, so a leading/trailing `"` should never appear
3059        // in practice, but the strip must stay scoped: Cargo does not get it (unlike
3060        // PyPI's TOML array-element shape, see
3061        // `test_extract_prefix_strips_leading_quote_for_pypi_toml_array`).
3062        let line = "\"expr";
3063        assert_eq!(
3064            extract_prefix(line, line.len() as u32, EcosystemId::Cargo),
3065            "\"expr"
3066        );
3067    }
3068
3069    /// #390 root cause 2: PyPI's `dependencies`/`optional-dependencies` entries are
3070    /// TOML array elements (`"pytes`), a different quoting shape from JSON-quoted
3071    /// keys, but must still have the surviving quote stripped before it reaches the
3072    /// registry search.
3073    #[test]
3074    fn test_extract_prefix_strips_leading_quote_for_pypi_toml_array() {
3075        let line = "    \"flas";
3076        assert_eq!(
3077            extract_prefix(line, line.len() as u32, EcosystemId::Pypi),
3078            "flas"
3079        );
3080    }
3081
3082    #[test]
3083    fn test_extract_prefix_strips_trailing_quote_for_pypi_toml_array() {
3084        let line = "    \"pytest\"";
3085        assert_eq!(
3086            extract_prefix(line, line.len() as u32, EcosystemId::Pypi),
3087            "pytest"
3088        );
3089    }
3090
3091    #[test]
3092    fn test_extract_prefix_does_not_panic_on_multibyte_char_boundary() {
3093        // `character` is a UTF-16 code unit count; using it as a raw byte index (the
3094        // pre-fix bug) split "é" mid-encoding here and panicked on the slice.
3095        let line = "    \"é";
3096        let character: u32 = line.chars().map(|c| c.len_utf16() as u32).sum();
3097        assert_eq!(extract_prefix(line, character, EcosystemId::Cargo), "\"é");
3098    }
3099
3100    #[test]
3101    fn test_extract_prefix_multibyte_word_not_truncated() {
3102        let line = "café";
3103        let character: u32 = line.chars().map(|c| c.len_utf16() as u32).sum();
3104        assert_eq!(extract_prefix(line, character, EcosystemId::Cargo), "café");
3105    }
3106
3107    #[test]
3108    fn test_extract_prefix_cjk_word_not_truncated() {
3109        let line = "日本";
3110        let character: u32 = line.chars().map(|c| c.len_utf16() as u32).sum();
3111        assert_eq!(extract_prefix(line, character, EcosystemId::Cargo), "日本");
3112    }
3113
3114    #[tokio::test]
3115    async fn test_search_packages_returns_results_within_timeout() {
3116        use deps_core::{Metadata, Registry, Version};
3117        use std::any::Any;
3118
3119        struct MockMetadata {
3120            name: deps_core::PackageName,
3121        }
3122        impl Metadata for MockMetadata {
3123            fn name(&self) -> &deps_core::PackageName {
3124                &self.name
3125            }
3126            fn description(&self) -> Option<&str> {
3127                None
3128            }
3129            fn repository(&self) -> Option<&str> {
3130                None
3131            }
3132            fn documentation(&self) -> Option<&str> {
3133                None
3134            }
3135            fn latest_version(&self) -> &deps_core::ConcreteVersion {
3136                static VERSION: std::sync::LazyLock<deps_core::ConcreteVersion> =
3137                    std::sync::LazyLock::new(|| deps_core::ConcreteVersion::new("4.18.2"));
3138                &VERSION
3139            }
3140            fn as_any(&self) -> &dyn Any {
3141                self
3142            }
3143        }
3144
3145        struct FastRegistry;
3146        impl Registry for FastRegistry {
3147            fn get_versions<'a>(
3148                &'a self,
3149                _name: &'a deps_core::PackageName,
3150            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3151            {
3152                Box::pin(async move { Ok(vec![]) })
3153            }
3154
3155            fn get_latest_matching<'a>(
3156                &'a self,
3157                _name: &'a deps_core::PackageName,
3158                _req: &'a deps_core::VersionReq,
3159            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3160            {
3161                Box::pin(async move { Ok(None) })
3162            }
3163
3164            fn search<'a>(
3165                &'a self,
3166                _query: &'a str,
3167                _limit: usize,
3168            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3169            {
3170                Box::pin(async move {
3171                    Ok(vec![Box::new(MockMetadata {
3172                        name: deps_core::PackageName::new("express"),
3173                    }) as Box<dyn Metadata>])
3174                })
3175            }
3176
3177            fn as_any(&self) -> &dyn Any {
3178                self
3179            }
3180        }
3181
3182        let items = search_packages(&FastRegistry, EcosystemId::Npm, "express").await;
3183
3184        assert_eq!(items.len(), 1);
3185        assert_eq!(items[0].label, "express");
3186    }
3187
3188    #[tokio::test]
3189    async fn test_search_packages_drops_maven_xml_breakout_keeps_safe_result() {
3190        // S1: this is the fallback-search path a malicious/compromised Maven registry
3191        // response can reach when `deps-maven`'s own XML-context completion produces no
3192        // (safe) results — it must apply the same allowlist, not just the primary path.
3193        use deps_core::{Metadata, Registry, Version};
3194        use std::any::Any;
3195
3196        struct MockMetadata {
3197            name: deps_core::PackageName,
3198        }
3199        impl Metadata for MockMetadata {
3200            fn name(&self) -> &deps_core::PackageName {
3201                &self.name
3202            }
3203            fn description(&self) -> Option<&str> {
3204                None
3205            }
3206            fn repository(&self) -> Option<&str> {
3207                None
3208            }
3209            fn documentation(&self) -> Option<&str> {
3210                None
3211            }
3212            fn latest_version(&self) -> &deps_core::ConcreteVersion {
3213                static VERSION: std::sync::LazyLock<deps_core::ConcreteVersion> =
3214                    std::sync::LazyLock::new(|| deps_core::ConcreteVersion::new("3.14.0"));
3215                &VERSION
3216            }
3217            fn as_any(&self) -> &dyn Any {
3218                self
3219            }
3220        }
3221
3222        struct MavenRegistry;
3223        impl Registry for MavenRegistry {
3224            fn get_versions<'a>(
3225                &'a self,
3226                _name: &'a deps_core::PackageName,
3227            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3228            {
3229                Box::pin(async move { Ok(vec![]) })
3230            }
3231
3232            fn get_latest_matching<'a>(
3233                &'a self,
3234                _name: &'a deps_core::PackageName,
3235                _req: &'a deps_core::VersionReq,
3236            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3237            {
3238                Box::pin(async move { Ok(None) })
3239            }
3240
3241            fn search<'a>(
3242                &'a self,
3243                _query: &'a str,
3244                _limit: usize,
3245            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3246            {
3247                Box::pin(async move {
3248                    Ok(vec![
3249                        Box::new(MockMetadata {
3250                            name: deps_core::PackageName::new("org.apache.commons:commons-lang3"),
3251                        }) as Box<dyn Metadata>,
3252                        Box::new(MockMetadata {
3253                            name: deps_core::PackageName::new(
3254                                "org.evil:payload</artifactId><parent>",
3255                            ),
3256                        }) as Box<dyn Metadata>,
3257                    ])
3258                })
3259            }
3260
3261            fn as_any(&self) -> &dyn Any {
3262                self
3263            }
3264        }
3265
3266        let items = search_packages(&MavenRegistry, EcosystemId::Maven, "commons").await;
3267
3268        assert_eq!(items.len(), 1);
3269        assert_eq!(items[0].label, "org.apache.commons:commons-lang3");
3270    }
3271
3272    #[tokio::test(start_paused = true)]
3273    async fn test_search_packages_times_out_and_returns_empty() {
3274        use deps_core::{Metadata, Registry, Version};
3275        use std::any::Any;
3276        use std::time::Duration;
3277
3278        struct SlowRegistry;
3279        impl Registry for SlowRegistry {
3280            fn get_versions<'a>(
3281                &'a self,
3282                _name: &'a deps_core::PackageName,
3283            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3284            {
3285                Box::pin(async move { Ok(vec![]) })
3286            }
3287
3288            fn get_latest_matching<'a>(
3289                &'a self,
3290                _name: &'a deps_core::PackageName,
3291                _req: &'a deps_core::VersionReq,
3292            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3293            {
3294                Box::pin(async move { Ok(None) })
3295            }
3296
3297            fn search<'a>(
3298                &'a self,
3299                _query: &'a str,
3300                _limit: usize,
3301            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3302            {
3303                Box::pin(async move {
3304                    // Well beyond COMPLETION_SEARCH_TIMEOUT; paused time makes this
3305                    // resolve instantly instead of actually waiting.
3306                    tokio::time::sleep(Duration::from_mins(1)).await;
3307                    Ok(vec![])
3308                })
3309            }
3310
3311            fn as_any(&self) -> &dyn Any {
3312                self
3313            }
3314        }
3315
3316        let items = search_packages(&SlowRegistry, EcosystemId::Npm, "expr").await;
3317
3318        assert!(
3319            items.is_empty(),
3320            "should return empty on timeout, not block"
3321        );
3322    }
3323
3324    #[tokio::test(start_paused = true)]
3325    async fn test_handle_completion_primary_path_times_out_and_skips_fallback() {
3326        use deps_core::{
3327            Dependency, DiagnosticMessages, DiagnosticPolicy, Ecosystem, EcosystemFormatter,
3328            OsvNaming, PackageNaming, PackageRendering, ParseResult, RequirementResolution,
3329            SourcePolicy,
3330        };
3331        use std::any::Any;
3332        use std::path::Path;
3333        use std::time::Duration;
3334        use tower_lsp_server::ls_types::Uri;
3335
3336        struct MockFormatter;
3337        impl PackageNaming for MockFormatter {}
3338
3339        impl PackageRendering for MockFormatter {
3340            fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
3341                version.to_string()
3342            }
3343
3344            fn package_url(&self, name: &deps_core::PackageName) -> String {
3345                format!("https://example.com/{name}")
3346            }
3347        }
3348
3349        impl RequirementResolution for MockFormatter {}
3350
3351        impl DiagnosticMessages for MockFormatter {}
3352
3353        impl DiagnosticPolicy for MockFormatter {}
3354
3355        impl SourcePolicy for MockFormatter {}
3356
3357        impl OsvNaming for MockFormatter {}
3358
3359        // Deliberately `unimplemented!()`: if a primary-path timeout ever falls through
3360        // to `fallback_completion` again (the N1 double-timeout bug), that path calls
3361        // `registry()` and this test panics instead of just running slow.
3362        struct SlowEcosystem;
3363        impl deps_core::ecosystem::private::Sealed for SlowEcosystem {}
3364        impl Ecosystem for SlowEcosystem {
3365            fn id(&self) -> &'static str {
3366                "cargo"
3367            }
3368            fn display_name(&self) -> &'static str {
3369                "Cargo (slow mock)"
3370            }
3371            fn manifest_filenames(&self) -> &[&'static str] {
3372                &["Cargo.toml"]
3373            }
3374            fn parse_manifest<'a>(
3375                &'a self,
3376                _content: &'a str,
3377                _uri: &'a Uri,
3378            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Box<dyn ParseResult>>>
3379            {
3380                Box::pin(async move { unimplemented!() })
3381            }
3382            fn registry(&self) -> Arc<dyn deps_core::Registry> {
3383                unimplemented!()
3384            }
3385            fn formatter(&self) -> &dyn EcosystemFormatter {
3386                &MockFormatter
3387            }
3388            fn generate_completions<'a>(
3389                &'a self,
3390                _parse_result: &'a dyn ParseResult,
3391                _position: tower_lsp_server::ls_types::Position,
3392                _content: &'a str,
3393                _freshness: deps_core::FreshnessSettings,
3394            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::completion::Completions>
3395            {
3396                Box::pin(async move {
3397                    // Well beyond COMPLETION_SEARCH_TIMEOUT; paused time resolves
3398                    // this instantly instead of actually waiting.
3399                    tokio::time::sleep(Duration::from_mins(1)).await;
3400                    deps_core::completion::Completions::default()
3401                })
3402            }
3403            fn as_any(&self) -> &dyn Any {
3404                self
3405            }
3406        }
3407
3408        struct MockParseResult {
3409            uri: Uri,
3410        }
3411        impl ParseResult for MockParseResult {
3412            fn dependencies(&self) -> Vec<&dyn Dependency> {
3413                vec![]
3414            }
3415            fn workspace_root(&self) -> Option<&Path> {
3416                None
3417            }
3418            fn uri(&self) -> &Uri {
3419                &self.uri
3420            }
3421            fn as_any(&self) -> &dyn Any {
3422                self
3423            }
3424        }
3425
3426        let state = Arc::new(ServerState::new());
3427        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3428
3429        // Overwrites the real Cargo ecosystem for this state instance only.
3430        state.ecosystem_registry.register(Arc::new(SlowEcosystem));
3431
3432        let content = "[dependencies]\nserde = \"1\"\n".to_string();
3433        let parse_result: Box<dyn ParseResult> = Box::new(MockParseResult { uri: uri.clone() });
3434        let doc = DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
3435        state.update_document(uri.clone(), doc);
3436
3437        let params = CompletionParams {
3438            text_document_position: TextDocumentPositionParams {
3439                text_document: TextDocumentIdentifier { uri },
3440                position: Position::new(1, 5), // after "serde"
3441            },
3442            work_done_progress_params: Default::default(),
3443            partial_result_params: Default::default(),
3444            context: None,
3445        };
3446
3447        let (client, config) = create_test_client_and_config();
3448        let result = handle_completion(state, params, client, config).await;
3449
3450        // Empty items collapse to `None` (see `handle_completion`'s tail); reaching
3451        // this at all (rather than hanging or panicking) is what this test checks.
3452        assert!(result.is_none());
3453    }
3454
3455    /// #419 C1 regression, now driven by a per-call [`Completions::is_incomplete`]
3456    /// (#427) rather than a static per-ecosystem flag: an ecosystem whose
3457    /// `generate_completions` reports `is_incomplete: true` for the served context
3458    /// (PyPI's package-search-index-backed completion) must always get back
3459    /// `CompletionResponse::List { is_incomplete: true, .. }` — on the empty-items
3460    /// branch (the cold-start case rev 4's fix missed, since `None` serializes as
3461    /// LSP `null` and carries no `isIncomplete`) as well as the non-empty branch.
3462    /// An ecosystem that always reports `is_incomplete: false` (the
3463    /// `test_concurrent_document_write_not_blocked_by_in_flight_completion_search`
3464    /// test just above proves the empty case) keeps returning `None`/`Array`
3465    /// unchanged.
3466    #[tokio::test]
3467    async fn test_generate_completions_is_incomplete_flows_into_response_both_branches() {
3468        use deps_core::completion::Completions;
3469        use deps_core::ecosystem::private::Sealed;
3470        use deps_core::{
3471            Dependency, DiagnosticMessages, DiagnosticPolicy, Ecosystem, EcosystemFormatter,
3472            Metadata, OsvNaming, PackageNaming, PackageRendering, ParseResult, Registry,
3473            RequirementResolution, SourcePolicy, Version,
3474        };
3475        use std::any::Any;
3476        use tower_lsp_server::ls_types::Uri;
3477
3478        struct NoopRegistry;
3479        impl Registry for NoopRegistry {
3480            fn get_versions<'a>(
3481                &'a self,
3482                _name: &'a deps_core::PackageName,
3483            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3484            {
3485                Box::pin(async move { Ok(vec![]) })
3486            }
3487            fn get_latest_matching<'a>(
3488                &'a self,
3489                _name: &'a deps_core::PackageName,
3490                _req: &'a deps_core::VersionReq,
3491            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3492            {
3493                Box::pin(async move { Ok(None) })
3494            }
3495            fn search<'a>(
3496                &'a self,
3497                _query: &'a str,
3498                _limit: usize,
3499            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3500            {
3501                Box::pin(async move { Ok(vec![]) })
3502            }
3503            fn as_any(&self) -> &dyn Any {
3504                self
3505            }
3506        }
3507
3508        struct NoopFormatter;
3509        impl PackageNaming for NoopFormatter {}
3510
3511        impl PackageRendering for NoopFormatter {
3512            fn format_version_for_text_edit(&self, version: &deps_core::ConcreteVersion) -> String {
3513                version.to_string()
3514            }
3515
3516            fn package_url(&self, name: &deps_core::PackageName) -> String {
3517                format!("https://example.com/{name}")
3518            }
3519        }
3520
3521        impl RequirementResolution for NoopFormatter {}
3522
3523        impl DiagnosticMessages for NoopFormatter {}
3524
3525        impl DiagnosticPolicy for NoopFormatter {}
3526
3527        impl SourcePolicy for NoopFormatter {}
3528
3529        impl OsvNaming for NoopFormatter {}
3530
3531        /// Stands in for `PypiEcosystem`: always reports incomplete results, and
3532        /// returns either zero or one completion item depending on `has_item`.
3533        struct IncompleteEcosystem {
3534            has_item: bool,
3535        }
3536        impl Sealed for IncompleteEcosystem {}
3537        impl Ecosystem for IncompleteEcosystem {
3538            fn id(&self) -> &'static str {
3539                "cargo"
3540            }
3541            fn display_name(&self) -> &'static str {
3542                "cargo"
3543            }
3544            fn manifest_filenames(&self) -> &[&'static str] {
3545                &["Cargo.toml"]
3546            }
3547            fn parse_manifest<'a>(
3548                &'a self,
3549                _content: &'a str,
3550                _uri: &'a Uri,
3551            ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Box<dyn ParseResult>>>
3552            {
3553                Box::pin(async move { unimplemented!() })
3554            }
3555            fn registry(&self) -> Arc<dyn Registry> {
3556                Arc::new(NoopRegistry)
3557            }
3558            fn formatter(&self) -> &dyn EcosystemFormatter {
3559                &NoopFormatter
3560            }
3561            fn generate_completions<'a>(
3562                &'a self,
3563                _parse_result: &'a dyn ParseResult,
3564                _position: tower_lsp_server::ls_types::Position,
3565                _content: &'a str,
3566                _freshness: deps_core::FreshnessSettings,
3567            ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
3568                let items = if self.has_item {
3569                    vec![CompletionItem {
3570                        label: "requests".to_string(),
3571                        ..Default::default()
3572                    }]
3573                } else {
3574                    vec![]
3575                };
3576                Box::pin(async move {
3577                    Completions {
3578                        items,
3579                        is_incomplete: true,
3580                    }
3581                })
3582            }
3583            fn as_any(&self) -> &dyn Any {
3584                self
3585            }
3586        }
3587
3588        struct MockParseResult {
3589            uri: Uri,
3590        }
3591        impl ParseResult for MockParseResult {
3592            fn dependencies(&self) -> Vec<&dyn Dependency> {
3593                vec![]
3594            }
3595            fn workspace_root(&self) -> Option<&std::path::Path> {
3596                None
3597            }
3598            fn uri(&self) -> &Uri {
3599                &self.uri
3600            }
3601            fn as_any(&self) -> &dyn Any {
3602                self
3603            }
3604        }
3605
3606        async fn run(has_item: bool) -> Option<CompletionResponse> {
3607            let state = Arc::new(ServerState::new());
3608            state
3609                .ecosystem_registry
3610                .register(Arc::new(IncompleteEcosystem { has_item }));
3611
3612            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3613            let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
3614            let parse_result: Box<dyn ParseResult> = Box::new(MockParseResult { uri: uri.clone() });
3615            let doc =
3616                DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
3617            state.update_document(uri.clone(), doc);
3618
3619            let params = CompletionParams {
3620                text_document_position: TextDocumentPositionParams {
3621                    text_document: TextDocumentIdentifier { uri },
3622                    position: Position::new(0, 0),
3623                },
3624                work_done_progress_params: Default::default(),
3625                partial_result_params: Default::default(),
3626                context: None,
3627            };
3628
3629            let (client, config) = create_test_client_and_config();
3630            handle_completion(state, params, client, config).await
3631        }
3632
3633        match run(false).await {
3634            Some(CompletionResponse::List(list)) => {
3635                assert!(
3636                    list.is_incomplete,
3637                    "empty branch must still carry is_incomplete"
3638                );
3639                assert!(list.items.is_empty());
3640            }
3641            other => panic!("expected List{{is_incomplete:true, items:[]}}, got {other:?}"),
3642        }
3643
3644        match run(true).await {
3645            Some(CompletionResponse::List(list)) => {
3646                assert!(list.is_incomplete);
3647                assert_eq!(list.items.len(), 1);
3648                assert_eq!(list.items[0].label, "requests");
3649            }
3650            other => panic!("expected List{{is_incomplete:true, items:[requests]}}, got {other:?}"),
3651        }
3652    }
3653}