Skip to main content

deps_pypi/parser/
pyproject.rs

1//! `pyproject.toml` parsing: PEP 621, PEP 735, Poetry, and PEP 517/518
2//! build-system requires.
3
4use super::{ParseResult, PypiParser, normalize_marker_string, span_start, span_to_range};
5use crate::config::PypiIndexConfig;
6use crate::error::Result;
7use crate::types::{PypiDependency, PypiDependencySection, PypiDependencySource};
8use deps_core::lsp_helpers::LineOffsetTable;
9use deps_core::net_policy::RegistryAccessPolicy;
10use std::collections::HashMap;
11use toml_span::value::{Table, Value};
12use tower_lsp_server::ls_types::Uri;
13
14/// Everything a `pyproject.toml` parse needs to resolve a dependency's PyPI index routing
15/// (spec FR-002/003/005/006/007/013) — built once from the TOML tree's `[[tool.poetry.source]]`
16/// and `[tool.uv.index]`/`[tool.uv.sources]` tables (see [`PypiParser::parse_content_with_policy`]),
17/// then threaded through every dependency-parsing function below.
18struct IndexContext<'a> {
19    /// The primary/extras/named-source router (FR-005's chain rules).
20    config: &'a PypiIndexConfig,
21    /// `[tool.uv.sources] <dep> = { index = "<name>" }` bindings, keyed by dependency name
22    /// (FR-013) — the uv analogue of Poetry's per-dependency `source = "<name>"` key.
23    uv_named_by_dep: &'a HashMap<String, String>,
24}
25
26impl IndexContext<'_> {
27    /// Resolves `dep`'s source through `self.config`, **only** when `dep.source` is still the
28    /// PEP 508-string-parser default `Registry` — a dependency already routed to Git/Path/Url
29    /// (a direct reference) has no PyPI index concept and is left untouched.
30    fn resolve(&self, dep: &mut PypiDependency) {
31        if dep.source != PypiDependencySource::Registry {
32            return;
33        }
34        let named = self
35            .uv_named_by_dep
36            .get(dep.name.as_str())
37            .map(String::as_str);
38        dep.source = self.config.resolve_source_for(named);
39    }
40}
41
42impl PypiParser {
43    /// Parse pyproject.toml content and extract all dependencies.
44    ///
45    /// Parses both PEP 621 and Poetry formats in a single pass.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if:
50    /// - TOML is malformed
51    /// - PEP 508 dependency specifications are invalid
52    ///
53    /// # Examples
54    ///
55    /// ```no_run
56    /// # use deps_pypi::parser::PypiParser;
57    /// # use tower_lsp_server::ls_types::Uri;
58    /// let parser = PypiParser::new();
59    /// let content = std::fs::read_to_string("pyproject.toml").unwrap();
60    /// let uri = Uri::from_file_path("/project/pyproject.toml").unwrap();
61    /// let result = parser.parse_content(&content, &uri).unwrap();
62    /// ```
63    pub fn parse_content(&self, content: &str, uri: &Uri) -> Result<ParseResult> {
64        self.parse_content_with_policy(content, uri, &RegistryAccessPolicy::default())
65    }
66
67    /// Like [`Self::parse_content`], but resolves `[[tool.poetry.source]]`/
68    /// `[tool.uv.index]`/`[tool.uv.sources]` index declarations (spec FR-002/003/005–007/013)
69    /// against `policy` rather than the default (`public_only`) — the production entry point
70    /// `PypiEcosystem` calls, threading through its own live `RegistryAccessPolicy` handle.
71    ///
72    /// # Errors
73    ///
74    /// Same as [`Self::parse_content`].
75    pub fn parse_content_with_policy(
76        &self,
77        content: &str,
78        uri: &Uri,
79        policy: &RegistryAccessPolicy,
80    ) -> Result<ParseResult> {
81        if let Err(depth) =
82            deps_core::check_toml_nesting_depth(content, deps_core::MAX_TOML_NESTING_DEPTH)
83        {
84            return Err(crate::error::PypiError::TomlParseError {
85                message: format!(
86                    "array/table nesting depth {depth} exceeds maximum of {}",
87                    deps_core::MAX_TOML_NESTING_DEPTH
88                ),
89            });
90        }
91
92        let doc =
93            toml_span::parse(content).map_err(|e| crate::error::PypiError::TomlParseError {
94                message: e.to_string(),
95            })?;
96
97        let line_table = LineOffsetTable::new(content);
98        let mut dependencies = Vec::new();
99
100        let root_table = match doc.as_table() {
101            Some(t) => t,
102            None => {
103                return Ok(ParseResult {
104                    dependencies,
105                    workspace_root: None,
106                    uri: uri.clone(),
107                    document_links: Vec::new(),
108                    resolved_chains: Vec::new(),
109                });
110            }
111        };
112
113        // Index declarations are resolved from the full TOML tree up front — unlike
114        // requirements.txt's line-oriented two-pass parse, a TOML document has no
115        // "position relative to a dependency" concept at all, so this single pass over
116        // `tool.poetry`/`tool.uv` before any dependency is parsed already gives every
117        // dependency function the fully-collected config (spec FR-007/FR-013).
118        let mut config = PypiIndexConfig::new();
119        let mut uv_named_by_dep: HashMap<String, String> = HashMap::new();
120        if let Some(tool_table) = get_table(root_table, "tool") {
121            if let Some(poetry) = get_table(tool_table, "poetry") {
122                collect_poetry_sources(poetry, &mut config, policy);
123            }
124            if let Some(uv) = get_table(tool_table, "uv") {
125                collect_uv_index(uv, &mut config, policy);
126                collect_uv_sources(uv, &mut uv_named_by_dep);
127            }
128        }
129        let ctx = IndexContext {
130            config: &config,
131            uv_named_by_dep: &uv_named_by_dep,
132        };
133
134        // Parse build-system requires (PEP 517/518)
135        if let Some(build_system) = get_table(root_table, "build-system") {
136            dependencies.extend(self.parse_build_system_requires(
137                build_system,
138                content,
139                &line_table,
140                &ctx,
141            )?);
142        }
143
144        // Parse PEP 621 format
145        if let Some(project) = get_table(root_table, "project") {
146            dependencies.extend(self.parse_pep621_dependencies(
147                project,
148                content,
149                &line_table,
150                &ctx,
151            )?);
152            dependencies.extend(self.parse_pep621_optional_dependencies(
153                project,
154                content,
155                &line_table,
156                &ctx,
157            )?);
158        }
159
160        // Parse PEP 735 dependency-groups format
161        if let Some(dep_groups) = get_table(root_table, "dependency-groups") {
162            dependencies.extend(self.parse_dependency_groups(
163                dep_groups,
164                content,
165                &line_table,
166                &ctx,
167            )?);
168        }
169
170        // Parse Poetry format
171        if let Some(tool_table) = get_table(root_table, "tool")
172            && let Some(poetry) = get_table(tool_table, "poetry")
173        {
174            dependencies.extend(self.parse_poetry_dependencies(
175                poetry,
176                content,
177                &line_table,
178                &ctx,
179            )?);
180            dependencies.extend(self.parse_poetry_groups(poetry, content, &line_table, &ctx)?);
181        }
182
183        Ok(ParseResult {
184            dependencies,
185            workspace_root: None,
186            uri: uri.clone(),
187            document_links: Vec::new(),
188            resolved_chains: config.resolved_chains(),
189        })
190    }
191
192    /// Parse PEP 517/518 `[build-system]` requires array.
193    fn parse_build_system_requires(
194        &self,
195        build_system: &Table<'_>,
196        content: &str,
197        line_table: &LineOffsetTable,
198        ctx: &IndexContext<'_>,
199    ) -> Result<Vec<PypiDependency>> {
200        let Some(requires_val) = build_system.get("requires") else {
201            return Ok(Vec::new());
202        };
203
204        let Some(requires_array) = requires_val.as_array() else {
205            return Ok(Vec::new());
206        };
207
208        let mut dependencies = Vec::new();
209
210        for value in requires_array {
211            if let Some(dep_str) = value.as_str() {
212                match self.parse_pep508_requirement(
213                    dep_str,
214                    Some(value.span.start..value.span.end),
215                    content,
216                    line_table,
217                ) {
218                    Ok(mut dep) => {
219                        dep.section = PypiDependencySection::BuildSystem;
220                        ctx.resolve(&mut dep);
221                        dependencies.push(dep);
222                    }
223                    Err(e) => {
224                        tracing::warn!(
225                            "Failed to parse build-system require '{}': {}",
226                            super::truncate_for_log(dep_str),
227                            e
228                        );
229                    }
230                }
231            }
232        }
233
234        Ok(dependencies)
235    }
236
237    /// Parse PEP 621 `[project.dependencies]` array.
238    fn parse_pep621_dependencies(
239        &self,
240        project: &Table<'_>,
241        content: &str,
242        line_table: &LineOffsetTable,
243        ctx: &IndexContext<'_>,
244    ) -> Result<Vec<PypiDependency>> {
245        let Some(deps_val) = project.get("dependencies") else {
246            return Ok(Vec::new());
247        };
248
249        let Some(deps_array) = deps_val.as_array() else {
250            return Ok(Vec::new());
251        };
252
253        let mut dependencies = Vec::new();
254
255        for value in deps_array {
256            if let Some(dep_str) = value.as_str() {
257                match self.parse_pep508_requirement(
258                    dep_str,
259                    Some(value.span.start..value.span.end),
260                    content,
261                    line_table,
262                ) {
263                    Ok(mut dep) => {
264                        dep.section = PypiDependencySection::Dependencies;
265                        ctx.resolve(&mut dep);
266                        dependencies.push(dep);
267                    }
268                    Err(e) => {
269                        tracing::warn!(
270                            "Failed to parse dependency '{}': {}",
271                            super::truncate_for_log(dep_str),
272                            e
273                        );
274                    }
275                }
276            }
277        }
278
279        Ok(dependencies)
280    }
281
282    /// Parse PEP 621 `[project.optional-dependencies]` tables.
283    fn parse_pep621_optional_dependencies(
284        &self,
285        project: &Table<'_>,
286        content: &str,
287        line_table: &LineOffsetTable,
288        ctx: &IndexContext<'_>,
289    ) -> Result<Vec<PypiDependency>> {
290        let Some(opt_deps_val) = project.get("optional-dependencies") else {
291            return Ok(Vec::new());
292        };
293
294        let Some(opt_deps_table) = opt_deps_val.as_table() else {
295            return Ok(Vec::new());
296        };
297
298        let mut dependencies = Vec::new();
299
300        for (group_key, group_val) in opt_deps_table {
301            if let Some(group_array) = group_val.as_array() {
302                for value in group_array {
303                    if let Some(dep_str) = value.as_str() {
304                        match self.parse_pep508_requirement(
305                            dep_str,
306                            Some(value.span.start..value.span.end),
307                            content,
308                            line_table,
309                        ) {
310                            Ok(mut dep) => {
311                                dep.section = PypiDependencySection::OptionalDependencies {
312                                    group: group_key.name.to_string(),
313                                };
314                                ctx.resolve(&mut dep);
315                                dependencies.push(dep);
316                            }
317                            Err(e) => {
318                                tracing::warn!(
319                                    "Failed to parse dependency '{}': {}",
320                                    super::truncate_for_log(dep_str),
321                                    e
322                                );
323                            }
324                        }
325                    }
326                }
327            }
328        }
329
330        Ok(dependencies)
331    }
332
333    /// Parse PEP 735 `[dependency-groups]` tables.
334    ///
335    /// Format: `[dependency-groups]` with named groups containing arrays of PEP 508 requirements.
336    /// Example:
337    /// ```toml
338    /// [dependency-groups]
339    /// dev = ["pytest>=8.0", "mypy>=1.0"]
340    /// test = ["pytest>=8.0", "pytest-cov>=4.0"]
341    /// ```
342    fn parse_dependency_groups(
343        &self,
344        dep_groups: &Table<'_>,
345        content: &str,
346        line_table: &LineOffsetTable,
347        ctx: &IndexContext<'_>,
348    ) -> Result<Vec<PypiDependency>> {
349        let mut dependencies = Vec::new();
350
351        for (group_key, group_val) in dep_groups {
352            if let Some(group_array) = group_val.as_array() {
353                for value in group_array {
354                    if let Some(dep_str) = value.as_str() {
355                        match self.parse_pep508_requirement(
356                            dep_str,
357                            Some(value.span.start..value.span.end),
358                            content,
359                            line_table,
360                        ) {
361                            Ok(mut dep) => {
362                                dep.section = PypiDependencySection::DependencyGroup {
363                                    group: group_key.name.to_string(),
364                                };
365                                ctx.resolve(&mut dep);
366                                dependencies.push(dep);
367                            }
368                            Err(e) => {
369                                tracing::warn!(
370                                    "Failed to parse dependency group '{}' item '{}': {}",
371                                    group_key.name,
372                                    super::truncate_for_log(dep_str),
373                                    e
374                                );
375                            }
376                        }
377                    }
378                }
379            }
380        }
381
382        Ok(dependencies)
383    }
384
385    /// Parse Poetry `[tool.poetry.dependencies]` table.
386    fn parse_poetry_dependencies(
387        &self,
388        poetry: &Table<'_>,
389        content: &str,
390        line_table: &LineOffsetTable,
391        ctx: &IndexContext<'_>,
392    ) -> Result<Vec<PypiDependency>> {
393        let Some(deps_val) = poetry.get("dependencies") else {
394            return Ok(Vec::new());
395        };
396
397        let Some(deps_table) = deps_val.as_table() else {
398            return Ok(Vec::new());
399        };
400
401        let mut dependencies = Vec::new();
402
403        for (name_key, value) in deps_table {
404            let name = &name_key.name;
405            // Skip Python version constraint
406            if name == "python" {
407                continue;
408            }
409
410            let position = span_start(content, line_table, name_key.span);
411
412            match self.parse_poetry_dependency(
413                name,
414                value,
415                Some(position),
416                content,
417                line_table,
418                ctx,
419            ) {
420                Ok(mut dep) => {
421                    dep.section = PypiDependencySection::PoetryDependencies;
422                    dependencies.push(dep);
423                }
424                Err(e) => {
425                    tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e);
426                }
427            }
428        }
429
430        Ok(dependencies)
431    }
432
433    /// Parse Poetry `[tool.poetry.group.*.dependencies]` tables.
434    fn parse_poetry_groups(
435        &self,
436        poetry: &Table<'_>,
437        content: &str,
438        line_table: &LineOffsetTable,
439        ctx: &IndexContext<'_>,
440    ) -> Result<Vec<PypiDependency>> {
441        let Some(group_val) = poetry.get("group") else {
442            return Ok(Vec::new());
443        };
444
445        let Some(groups_table) = group_val.as_table() else {
446            return Ok(Vec::new());
447        };
448
449        let mut dependencies = Vec::new();
450
451        for (group_name_key, group_val) in groups_table {
452            let group_name = &group_name_key.name;
453            if let Some(group_table) = group_val.as_table()
454                && let Some(deps_val) = group_table.get("dependencies")
455                && let Some(deps_table) = deps_val.as_table()
456            {
457                for (name_key, value) in deps_table {
458                    let name = &name_key.name;
459                    let position = span_start(content, line_table, name_key.span);
460
461                    match self.parse_poetry_dependency(
462                        name,
463                        value,
464                        Some(position),
465                        content,
466                        line_table,
467                        ctx,
468                    ) {
469                        Ok(mut dep) => {
470                            dep.section = PypiDependencySection::PoetryGroup {
471                                group: group_name.to_string(),
472                            };
473                            dependencies.push(dep);
474                        }
475                        Err(e) => {
476                            tracing::warn!("Failed to parse Poetry dependency '{}': {}", name, e);
477                        }
478                    }
479                }
480            }
481        }
482
483        Ok(dependencies)
484    }
485
486    /// Parse a Poetry dependency (can be string or table).
487    ///
488    /// Examples:
489    /// - String: `requests = "^2.28.0"`
490    /// - String with marker: `requests = "^2.28.0; sys_platform == 'win32'"`
491    /// - Table: `flask = { version = "^3.0", extras = ["async"] }`
492    fn parse_poetry_dependency(
493        &self,
494        name: &str,
495        value: &Value<'_>,
496        base_position: Option<tower_lsp_server::ls_types::Position>,
497        content: &str,
498        line_table: &LineOffsetTable,
499        ctx: &IndexContext<'_>,
500    ) -> Result<PypiDependency> {
501        use tower_lsp_server::ls_types::{Position, Range};
502
503        let name_range = base_position
504            .map(|pos| {
505                Range::new(
506                    pos,
507                    Position::new(pos.line, pos.character + name.len() as u32),
508                )
509            })
510            .unwrap_or_default();
511
512        // Simple string version, optionally followed by a `; <marker>` suffix
513        // mirroring PEP 508 syntax (not standard Poetry, but handled defensively).
514        if let Some(raw_value) = value.as_str() {
515            let value_span = value.span;
516            let source_slice = &content[value_span.start..value_span.end];
517
518            let (version_str, raw_marker) = match raw_value.find(';') {
519                Some(idx) => (&raw_value[..idx], Some(&raw_value[idx + 1..])),
520                None => (raw_value, None),
521            };
522
523            // Locate the split independently in the *source* slice: `;` is
524            // never produced by TOML escape decoding, so this byte offset is
525            // safe for range math even when the decoded string's length
526            // diverges from the source (e.g. `\"` escapes in the marker).
527            // Deriving both ranges from `value_span` here (rather than
528            // `name.len()` arithmetic) also makes them correct regardless of
529            // spacing around `=` or whether the key itself is quoted.
530            let source_semicolon = source_slice.find(';');
531            let version_end_byte =
532                value_span.start + source_semicolon.unwrap_or(source_slice.len());
533            let version_range = Some(span_to_range(
534                content,
535                line_table,
536                toml_span::Span::new(value_span.start, version_end_byte),
537            ));
538
539            let (markers, markers_range) = match (raw_marker, source_semicolon) {
540                (Some(marker), Some(src_idx)) => match normalize_marker_string(marker) {
541                    Some(normalized) => {
542                        let marker_span =
543                            toml_span::Span::new(value_span.start + src_idx + 1, value_span.end);
544                        (
545                            Some(normalized),
546                            Some(span_to_range(content, line_table, marker_span)),
547                        )
548                    }
549                    None => (None, None),
550                },
551                _ => (None, None),
552            };
553
554            // String-form Poetry dependencies have no `source =` key of their own — resolve
555            // through the primary/extras chain only (FR-002/003/005), same as a plain PEP
556            // 621/requirements.txt dependency.
557            return Ok(PypiDependency {
558                name: name.into(),
559                name_range,
560                version_req: Some(version_str.trim().into()),
561                version_range,
562                extras: Vec::new(),
563                extras_range: None,
564                markers,
565                markers_range,
566                section: PypiDependencySection::PoetryDependencies,
567                source: ctx.config.resolve_source_for(None),
568            });
569        }
570
571        // Table format
572        if let Some(table) = value.as_table() {
573            let version_req = table
574                .get("version")
575                .and_then(|v| v.as_str())
576                .map(String::from);
577            let extras = table
578                .get("extras")
579                .and_then(|e| e.as_array())
580                .map(|arr| {
581                    arr.iter()
582                        .filter_map(|v| v.as_str().map(String::from))
583                        .collect()
584                })
585                .unwrap_or_default();
586
587            let markers_value = table.get("markers").filter(|v| v.as_str().is_some());
588            let markers = markers_value
589                .and_then(|m| m.as_str())
590                .and_then(normalize_marker_string);
591            let markers_range = markers_value
592                .filter(|_| markers.is_some())
593                .map(|v| span_to_range(content, line_table, v.span));
594
595            let source = if table.contains_key("git") {
596                PypiDependencySource::Git {
597                    url: table
598                        .get("git")
599                        .and_then(|g| g.as_str())
600                        .unwrap_or("")
601                        .to_string(),
602                    rev: table.get("rev").and_then(|r| r.as_str()).map(String::from),
603                }
604            } else if table.contains_key("path") {
605                PypiDependencySource::Path {
606                    path: table
607                        .get("path")
608                        .and_then(|p| p.as_str())
609                        .unwrap_or("")
610                        .to_string(),
611                }
612            } else if table.contains_key("url") {
613                PypiDependencySource::Url {
614                    url: table
615                        .get("url")
616                        .and_then(|u| u.as_str())
617                        .unwrap_or("")
618                        .to_string(),
619                }
620            } else {
621                // FR-007: a `source = "<name>"` key routes to that named Poetry source;
622                // absent, the dependency routes through the primary/extras chain like any
623                // other plain dependency.
624                let source_name = table.get("source").and_then(|s| s.as_str());
625                ctx.config.resolve_source_for(source_name)
626            };
627
628            return Ok(PypiDependency {
629                name: name.into(),
630                name_range,
631                version_req: version_req.map(Into::into),
632                version_range: None,
633                extras,
634                extras_range: None,
635                markers,
636                markers_range,
637                section: PypiDependencySection::PoetryDependencies,
638                source,
639            });
640        }
641
642        Err(crate::error::PypiError::unsupported_format(format!(
643            "Unsupported Poetry dependency format for '{name}'"
644        )))
645    }
646}
647
648/// Get a nested table value by key from a toml-span Table.
649fn get_table<'a>(table: &'a Table<'a>, key: &str) -> Option<&'a Table<'a>> {
650    table.get(key)?.as_table()
651}
652
653/// Parses `[[tool.poetry.source]]` entries (`name`, `url`, `priority`) into `config` (spec
654/// FR-007). Every entry — **including** an `explicit`-priority one — is registered as a named
655/// source, reachable via a dependency's own `source = "<name>"` key, regardless of priority.
656/// Additionally routed into `config`'s primary/extras chain per the corrected priority
657/// mapping (plan.md's decision table, verified live against current Poetry documentation):
658/// `primary`/`default`/**no `priority` key at all** -> primary; `supplemental`/`secondary` ->
659/// extras; `explicit` -> named source only, never auto-included in the chain. An entry
660/// missing `name` or `url` is skipped outright — there is nothing to register it under.
661fn collect_poetry_sources(
662    poetry: &Table<'_>,
663    config: &mut PypiIndexConfig,
664    policy: &RegistryAccessPolicy,
665) {
666    let Some(sources) = poetry.get("source").and_then(Value::as_array) else {
667        return;
668    };
669
670    for entry in sources {
671        let Some(table) = entry.as_table() else {
672            continue;
673        };
674        let Some(name) = table.get("name").and_then(Value::as_str) else {
675            continue;
676        };
677        let Some(url) = table.get("url").and_then(Value::as_str) else {
678            continue;
679        };
680        let priority = table.get("priority").and_then(Value::as_str);
681
682        let resolved = crate::config::resolve_entry(url, policy);
683        config.add_named_source_resolved(name.to_string(), resolved.clone());
684
685        match priority {
686            None | Some("primary" | "default") => config.set_primary_resolved(resolved),
687            Some("supplemental" | "secondary") => config.add_extra_resolved(resolved),
688            Some(_) => {} // "explicit" or any other value: named-source only
689        }
690    }
691}
692
693/// Parses `[tool.uv.index]` entries (`name`, `url`, `default`, `explicit`) into `config`
694/// (spec FR-013). Every entry is always registered as a named source (reachable via
695/// `[tool.uv.sources] <dep> = { index = "<name>" }`, via [`collect_uv_sources`] — works for
696/// both `default`/`explicit` and plain entries per FR-013). Routing beyond that depends on
697/// the entry's own flags, verified live against `docs.astral.sh/uv/concepts/indexes/`:
698/// `explicit = true` -> named-source only, never auto-included; `default = true` (uv permits
699/// at most one) -> the chain's last-resort tail hop, **never** `config.primary` — a pure-uv
700/// config never populates that slot, always routing through the FR-005(b) shape instead;
701/// neither flag set -> an automatic chain hop (the `--extra-index-url` analogue). An entry
702/// missing `name` or `url` is skipped outright.
703fn collect_uv_index(uv: &Table<'_>, config: &mut PypiIndexConfig, policy: &RegistryAccessPolicy) {
704    let Some(entries) = uv.get("index").and_then(Value::as_array) else {
705        return;
706    };
707
708    for entry in entries {
709        let Some(table) = entry.as_table() else {
710            continue;
711        };
712        let Some(name) = table.get("name").and_then(Value::as_str) else {
713            continue;
714        };
715        let Some(url) = table.get("url").and_then(Value::as_str) else {
716            continue;
717        };
718        let is_default = table.get("default").and_then(Value::as_bool) == Some(true);
719        let is_explicit = table.get("explicit").and_then(Value::as_bool) == Some(true);
720
721        let resolved = crate::config::resolve_entry(url, policy);
722        // Validator finding S4: keyed by the PEP 503-normalized name, not the raw TOML
723        // value — `[tool.uv.sources] { index = "<name>" }` bindings are matched against this
724        // same normalized form in `collect_uv_sources` below, so a casing/separator
725        // difference between the two declarations (`Flask-SQLAlchemy` vs `flask-sqlalchemy`)
726        // no longer silently fails the binding.
727        config.add_named_source_resolved(crate::name::normalize(name), resolved.clone());
728
729        if is_explicit {
730            // Named-source only — never auto-included in the chain.
731        } else if is_default {
732            config.set_tail_hop_resolved(resolved);
733        } else {
734            config.add_extra_resolved(resolved);
735        }
736    }
737}
738
739/// Parses `[tool.uv.sources] <dep> = { index = "<name>" }` bindings into `uv_named_by_dep`,
740/// keyed by dependency name (spec FR-013) — the uv analogue of Poetry's per-dependency
741/// `source = "<name>"` key. Every other `[tool.uv.sources]` shape (`git =`, `path =`,
742/// `workspace = true`, or any table without an `index =` key) is deliberately not recognized
743/// here — dependency provenance, not registry routing, explicitly out of this feature's
744/// scope (spec Out of Scope).
745fn collect_uv_sources(uv: &Table<'_>, uv_named_by_dep: &mut HashMap<String, String>) {
746    let Some(sources) = uv.get("sources").and_then(Value::as_table) else {
747        return;
748    };
749
750    for (dep_key, value) in sources {
751        if let Some(table) = value.as_table()
752            && let Some(index_name) = table.get("index").and_then(Value::as_str)
753        {
754            // Validator finding S4: both sides normalized per PEP 503 — the dependency-name
755            // key (so it matches `dep.name.as_str()`, itself already PEP 508-normalized —
756            // see `PypiDependency::name`'s own doc) and the target index name (so it matches
757            // `collect_uv_index`'s normalized `named_sources` key above). Without this, a
758            // casing/separator mismatch between `[tool.uv.sources]`'s own key and the actual
759            // parsed dependency name would silently miss the binding, falling through to
760            // `resolve_source_for(None)` — for a file with no other index declared, that
761            // resolves to plain `pypi.org`, exactly the leak this feature exists to prevent.
762            uv_named_by_dep.insert(
763                crate::name::normalize(&dep_key.name),
764                crate::name::normalize(index_name),
765            );
766        }
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::super::{MAX_MARKER_LEN, marker_too_deep};
773    use super::*;
774    use crate::error::PypiError;
775    use std::assert_matches;
776    use tower_lsp_server::ls_types::{Position, Range};
777
778    fn test_uri() -> Uri {
779        deps_core::test_util::test_uri("/test/pyproject.toml")
780    }
781
782    #[test]
783    fn test_parse_content_rejects_excessive_nesting() {
784        // Well past MAX_TOML_NESTING_DEPTH (64) but far below the depth
785        // that would actually overflow the stack, so the guard is what's
786        // being exercised here, not the crash itself.
787        let content = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
788        let parser = PypiParser::new();
789        let result = parser.parse_content(&content, &test_uri());
790        assert_matches!(result, Err(PypiError::TomlParseError { .. }));
791    }
792
793    #[test]
794    fn test_parse_pep621_dependencies() {
795        let content = r#"
796[project]
797dependencies = [
798    "requests>=2.28.0",
799    "flask[async]>=3.0",
800]
801"#;
802
803        let parser = PypiParser::new();
804        let result = parser.parse_content(content, &test_uri()).unwrap();
805        let deps = &result.dependencies;
806
807        assert_eq!(deps.len(), 2);
808        assert_eq!(deps[0].name, "requests");
809        assert_eq!(
810            deps[0]
811                .version_req
812                .as_ref()
813                .map(deps_core::VersionReq::as_str),
814            Some(">=2.28.0")
815        );
816        assert_matches!(deps[0].section, PypiDependencySection::Dependencies);
817
818        assert_eq!(deps[1].name, "flask");
819        assert_eq!(deps[1].extras, vec!["async"]);
820    }
821
822    #[test]
823    fn test_parse_pep621_optional_dependencies() {
824        let content = r#"
825[project.optional-dependencies]
826dev = ["pytest>=7.0", "mypy>=1.0"]
827docs = ["sphinx>=5.0"]
828"#;
829
830        let parser = PypiParser::new();
831        let result = parser.parse_content(content, &test_uri()).unwrap();
832        let deps = &result.dependencies;
833
834        assert_eq!(deps.len(), 3);
835
836        let dev_deps: Vec<_> = deps.iter().filter(|d| {
837            matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "dev")
838        }).collect();
839        assert_eq!(dev_deps.len(), 2);
840
841        let docs_deps: Vec<_> = deps.iter().filter(|d| {
842            matches!(&d.section, PypiDependencySection::OptionalDependencies { group } if group == "docs")
843        }).collect();
844        assert_eq!(docs_deps.len(), 1);
845    }
846
847    #[test]
848    fn test_parse_poetry_dependencies() {
849        let content = r#"
850[tool.poetry.dependencies]
851python = "^3.9"
852requests = "^2.28.0"
853"#;
854
855        let parser = PypiParser::new();
856        let result = parser.parse_content(content, &test_uri()).unwrap();
857        let deps = &result.dependencies;
858
859        // Should skip "python"
860        assert_eq!(deps.len(), 1);
861        assert_eq!(deps[0].name, "requests");
862        assert_matches!(deps[0].section, PypiDependencySection::PoetryDependencies);
863    }
864
865    #[test]
866    fn test_parse_poetry_groups() {
867        let content = r#"
868[tool.poetry.group.dev.dependencies]
869pytest = "^7.0"
870mypy = "^1.0"
871
872[tool.poetry.group.docs.dependencies]
873sphinx = "^5.0"
874"#;
875
876        let parser = PypiParser::new();
877        let result = parser.parse_content(content, &test_uri()).unwrap();
878        let deps = &result.dependencies;
879
880        assert_eq!(deps.len(), 3);
881
882        let dev_deps: Vec<_> = deps.iter().filter(|d| {
883            matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "dev")
884        }).collect();
885        assert_eq!(dev_deps.len(), 2);
886
887        let docs_deps: Vec<_> = deps.iter().filter(|d| {
888            matches!(&d.section, PypiDependencySection::PoetryGroup { group } if group == "docs")
889        }).collect();
890        assert_eq!(docs_deps.len(), 1);
891    }
892
893    #[test]
894    fn test_parse_pep735_dependency_groups() {
895        let content = r#"
896[dependency-groups]
897dev = ["pytest>=8.0", "mypy>=1.0", "ruff>=0.8"]
898test = ["pytest>=8.0", "pytest-cov>=4.0"]
899"#;
900
901        let parser = PypiParser::new();
902        let result = parser.parse_content(content, &test_uri()).unwrap();
903        let deps = &result.dependencies;
904
905        assert_eq!(deps.len(), 5);
906
907        let dev_deps: Vec<_> = deps
908            .iter()
909            .filter(|d| {
910                matches!(&d.section, PypiDependencySection::DependencyGroup { group } if group == "dev")
911            })
912            .collect();
913        assert_eq!(dev_deps.len(), 3);
914
915        let test_deps: Vec<_> = deps
916            .iter()
917            .filter(|d| {
918                matches!(&d.section, PypiDependencySection::DependencyGroup { group } if group == "test")
919            })
920            .collect();
921        assert_eq!(test_deps.len(), 2);
922
923        // Verify package names
924        assert!(dev_deps.iter().any(|d| d.name == "pytest"));
925        assert!(dev_deps.iter().any(|d| d.name == "mypy"));
926        assert!(dev_deps.iter().any(|d| d.name == "ruff"));
927    }
928
929    #[test]
930    fn test_parse_pep508_with_markers() {
931        let content = r#"
932[project]
933dependencies = [
934    "numpy>=1.24; python_version>='3.9'",
935]
936"#;
937
938        let parser = PypiParser::new();
939        let result = parser.parse_content(content, &test_uri()).unwrap();
940        let deps = &result.dependencies;
941
942        assert_eq!(deps.len(), 1);
943        assert_eq!(deps[0].name, "numpy");
944        // pep508_rs's marker algebra canonicalizes `python_version` comparisons
945        // into `python_full_version` form when serializing back to a string.
946        assert_eq!(
947            deps[0].markers,
948            Some("python_full_version >= '3.9'".to_string())
949        );
950    }
951
952    #[test]
953    fn test_parse_pep508_without_markers() {
954        let content = r#"
955[project]
956dependencies = [
957    "requests>=2.28.0",
958]
959"#;
960
961        let parser = PypiParser::new();
962        let result = parser.parse_content(content, &test_uri()).unwrap();
963        let deps = &result.dependencies;
964
965        assert_eq!(deps.len(), 1);
966        assert_eq!(deps[0].name, "requests");
967        assert_eq!(deps[0].markers, None);
968    }
969
970    #[test]
971    fn test_parse_pep508_with_compound_marker() {
972        let content = r#"
973[project]
974dependencies = [
975    "colorama>=0.4; sys_platform == 'win32' and python_version >= '3.8'",
976]
977"#;
978
979        let parser = PypiParser::new();
980        let result = parser.parse_content(content, &test_uri()).unwrap();
981        let deps = &result.dependencies;
982
983        assert_eq!(deps.len(), 1);
984        assert_eq!(deps[0].name, "colorama");
985        assert_eq!(
986            deps[0].markers,
987            Some("python_full_version >= '3.8' and sys_platform == 'win32'".to_string())
988        );
989    }
990
991    #[test]
992    fn test_parse_mixed_formats() {
993        let content = r#"
994[project]
995dependencies = ["requests>=2.28.0"]
996
997[tool.poetry.dependencies]
998python = "^3.9"
999flask = "^3.0"
1000"#;
1001
1002        let parser = PypiParser::new();
1003        let result = parser.parse_content(content, &test_uri()).unwrap();
1004        let deps = &result.dependencies;
1005
1006        assert_eq!(deps.len(), 2);
1007
1008        let pep621_deps: Vec<_> = deps
1009            .iter()
1010            .filter(|d| matches!(d.section, PypiDependencySection::Dependencies))
1011            .collect();
1012        assert_eq!(pep621_deps.len(), 1);
1013
1014        let poetry_deps: Vec<_> = deps
1015            .iter()
1016            .filter(|d| matches!(d.section, PypiDependencySection::PoetryDependencies))
1017            .collect();
1018        assert_eq!(poetry_deps.len(), 1);
1019    }
1020
1021    #[test]
1022    fn test_parse_invalid_toml() {
1023        let content = "invalid toml {{{";
1024        let parser = PypiParser::new();
1025        let result = parser.parse_content(content, &test_uri());
1026
1027        assert!(result.is_err());
1028        assert_matches!(result.unwrap_err(), PypiError::TomlParseError { .. });
1029    }
1030
1031    #[test]
1032    fn test_parse_empty_dependencies() {
1033        let content = r#"
1034[project]
1035name = "test"
1036"#;
1037
1038        let parser = PypiParser::new();
1039        let result = parser.parse_content(content, &test_uri()).unwrap();
1040        let deps = &result.dependencies;
1041
1042        assert_eq!(deps.len(), 0);
1043    }
1044
1045    #[test]
1046    fn test_position_tracking_pep735() {
1047        // Test that position tracking works correctly for PEP 735 dependency-groups
1048        let content = r#"[dependency-groups]
1049dev = ["pytest>=8.0", "mypy>=1.0"]
1050"#;
1051
1052        let parser = PypiParser::new();
1053        let result = parser.parse_content(content, &test_uri()).unwrap();
1054        let deps = &result.dependencies;
1055
1056        assert_eq!(deps.len(), 2);
1057
1058        // Check pytest>=8.0 position
1059        let pytest = deps.iter().find(|d| d.name == "pytest").unwrap();
1060        // Line 1 (0-indexed), character should be at 'p' (position 8 after `dev = ["`)
1061        assert_eq!(pytest.name_range.start.line, 1);
1062        assert_eq!(pytest.name_range.start.character, 8);
1063        // Version range should point to >=8.0
1064        assert!(pytest.version_range.is_some());
1065        let version_range = pytest.version_range.unwrap();
1066        assert_eq!(version_range.start.line, 1);
1067        // pytest is 6 chars, so version starts at 8 + 6 = 14
1068        assert_eq!(version_range.start.character, 14);
1069        // >=8.0 is 5 chars, so version ends at 14 + 5 = 19
1070        assert_eq!(version_range.end.character, 19);
1071
1072        // Check mypy>=1.0 position
1073        let mypy = deps.iter().find(|d| d.name == "mypy").unwrap();
1074        assert_eq!(mypy.name_range.start.line, 1);
1075        // mypy starts after `dev = ["pytest>=8.0", "` = position 23
1076        // dev = ["pytest>=8.0", " = 22 chars, then position 22 is ", position 23 is m
1077        assert_eq!(mypy.name_range.start.character, 23);
1078        assert!(mypy.version_range.is_some());
1079        let version_range = mypy.version_range.unwrap();
1080        // mypy is 4 chars, so version starts at 23 + 4 = 27
1081        assert_eq!(version_range.start.character, 27);
1082        // >=1.0 is 5 chars, so version ends at 27 + 5 = 32
1083        assert_eq!(version_range.end.character, 32);
1084    }
1085
1086    #[test]
1087    fn test_version_range_position_without_space() {
1088        // Bug: pep508 normalizes ">=1.7,<2.0" to ">=1.7, <2.0" (adds space)
1089        // Version range end must use original string length, not normalized
1090        let content = r#"[dependency-groups]
1091dev = [
1092    "maturin>=1.7,<2.0",
1093]
1094"#;
1095        // Line 0: [dependency-groups]
1096        // Line 1: dev = [
1097        // Line 2:     "maturin>=1.7,<2.0",
1098        //             ^    ^         ^
1099        //             5    12        22 (end of version, before closing quote)
1100
1101        let parser = PypiParser::new();
1102        let result = parser.parse_content(content, &test_uri()).unwrap();
1103        let maturin = &result.dependencies[0];
1104
1105        let version_range = maturin.version_range.unwrap();
1106        assert_eq!(version_range.start.line, 2);
1107        assert_eq!(version_range.start.character, 12); // after "maturin"
1108        assert_eq!(version_range.end.line, 2);
1109        assert_eq!(version_range.end.character, 22); // ">=1.7,<2.0" = 10 chars
1110    }
1111
1112    #[test]
1113    fn test_version_range_position_with_space() {
1114        // With space in original - should also work correctly
1115        let content = r#"[dependency-groups]
1116dev = [
1117    "maturin>=1.7, <2.0",
1118]
1119"#;
1120        // ">=1.7, <2.0" = 11 chars, end at 12 + 11 = 23
1121
1122        let parser = PypiParser::new();
1123        let result = parser.parse_content(content, &test_uri()).unwrap();
1124        let maturin = &result.dependencies[0];
1125
1126        let version_range = maturin.version_range.unwrap();
1127        assert_eq!(version_range.start.character, 12);
1128        assert_eq!(version_range.end.character, 23);
1129    }
1130
1131    #[test]
1132    fn test_position_tracking_with_extras() {
1133        let content = r#"[project]
1134dependencies = ["flask[async]>=3.0"]
1135"#;
1136
1137        let parser = PypiParser::new();
1138        let result = parser.parse_content(content, &test_uri()).unwrap();
1139        let deps = &result.dependencies;
1140
1141        assert_eq!(deps.len(), 1);
1142
1143        let flask = &deps[0];
1144        assert_eq!(flask.name, "flask");
1145        assert_eq!(flask.extras, vec!["async"]);
1146
1147        // Version range should account for extras
1148        assert!(flask.version_range.is_some());
1149        let version_range = flask.version_range.unwrap();
1150        // dependencies = [" is 17 chars, flask starts at char 17
1151        // flask is 5 chars, [async] is 7 chars, so version starts at 17 + 5 + 7 = 29
1152        assert_eq!(version_range.start.character, 29);
1153    }
1154
1155    #[test]
1156    fn test_parse_pep621_with_comments() {
1157        let toml = r#"
1158[project]
1159name = "test"
1160dependencies = [
1161    "django>=4.0",  # Web framework
1162    # "old-package>=1.0",  # Commented out
1163    "requests>=2.0",
1164]
1165"#;
1166        let parser = PypiParser::new();
1167        let result = parser.parse_content(toml, &test_uri()).unwrap();
1168        let deps = &result.dependencies;
1169        assert_eq!(deps.len(), 2);
1170        assert_eq!(deps[0].name, "django");
1171        assert_eq!(deps[1].name, "requests");
1172    }
1173
1174    #[test]
1175    fn test_parse_poetry_with_python_constraint() {
1176        let toml = r#"
1177[tool.poetry]
1178name = "test"
1179
1180[tool.poetry.dependencies]
1181python = "^3.9"
1182django = "^4.0"
1183"#;
1184        let parser = PypiParser::new();
1185        let result = parser.parse_content(toml, &test_uri()).unwrap();
1186        let deps = &result.dependencies;
1187        assert_eq!(deps.len(), 1);
1188        assert_eq!(deps[0].name, "django");
1189    }
1190
1191    #[test]
1192    fn test_parse_pep508_with_platform_marker() {
1193        let toml = r#"
1194[project]
1195dependencies = [
1196    "pywin32>=1.0; sys_platform == 'win32'",
1197    "django>=4.0",
1198]
1199"#;
1200        let parser = PypiParser::new();
1201        let result = parser.parse_content(toml, &test_uri()).unwrap();
1202        let deps = &result.dependencies;
1203        assert_eq!(deps.len(), 2);
1204        assert_eq!(deps[0].name, "pywin32");
1205        assert_eq!(deps[1].name, "django");
1206    }
1207
1208    #[test]
1209    fn test_parse_poetry_with_multiple_constraints() {
1210        let toml = r#"
1211[tool.poetry.dependencies]
1212django = { version = "^4.0", python = "^3.9" }
1213"#;
1214        let parser = PypiParser::new();
1215        let result = parser.parse_content(toml, &test_uri()).unwrap();
1216        let deps = &result.dependencies;
1217        // Poetry table-style with python constraints may not be fully parsed yet
1218        if !deps.is_empty() {
1219            assert_eq!(deps[0].name, "django");
1220            assert_eq!(
1221                deps[0]
1222                    .version_req
1223                    .as_ref()
1224                    .map(deps_core::VersionReq::as_str),
1225                Some("^4.0")
1226            );
1227        }
1228    }
1229
1230    #[test]
1231    fn test_parse_pep621_with_git_url() {
1232        let toml = r#"
1233[project]
1234dependencies = [
1235    "mylib @ git+https://github.com/user/mylib.git@main",
1236    "django>=4.0",
1237]
1238"#;
1239        let parser = PypiParser::new();
1240        let result = parser.parse_content(toml, &test_uri()).unwrap();
1241        let deps = &result.dependencies;
1242        assert_eq!(deps.len(), 2);
1243        assert_eq!(deps[0].name, "mylib");
1244        assert_matches!(deps[0].source, PypiDependencySource::Git { .. });
1245        assert_eq!(deps[1].name, "django");
1246    }
1247
1248    #[test]
1249    fn test_parse_empty_optional_dependencies_table() {
1250        let toml = r#"
1251[project]
1252dependencies = ["django>=4.0"]
1253
1254[project.optional-dependencies]
1255"#;
1256        let parser = PypiParser::new();
1257        let result = parser.parse_content(toml, &test_uri()).unwrap();
1258        let deps = &result.dependencies;
1259        assert_eq!(deps.len(), 1);
1260        assert_eq!(deps[0].name, "django");
1261    }
1262
1263    #[test]
1264    fn test_parse_whitespace_only_dependency() {
1265        let toml = r#"
1266[project]
1267dependencies = [
1268    "django>=4.0",
1269    "   ",
1270    "requests>=2.0",
1271]
1272"#;
1273        let parser = PypiParser::new();
1274        let result = parser.parse_content(toml, &test_uri()).unwrap();
1275        let deps = &result.dependencies;
1276        assert_eq!(deps.len(), 2);
1277    }
1278
1279    #[test]
1280    fn test_parse_version_with_wildcard() {
1281        let toml = r#"
1282[project]
1283dependencies = [
1284    "django==4.*",
1285]
1286"#;
1287        let parser = PypiParser::new();
1288        let result = parser.parse_content(toml, &test_uri()).unwrap();
1289        let deps = &result.dependencies;
1290        assert_eq!(deps.len(), 1);
1291        assert_eq!(
1292            deps[0]
1293                .version_req
1294                .as_ref()
1295                .map(deps_core::VersionReq::as_str),
1296            Some("==4.*")
1297        );
1298    }
1299
1300    #[test]
1301    fn test_parse_poetry_path_dependency() {
1302        let toml = r#"
1303[tool.poetry.dependencies]
1304mylib = { path = "../mylib" }
1305django = "^4.0"
1306"#;
1307        let parser = PypiParser::new();
1308        let result = parser.parse_content(toml, &test_uri()).unwrap();
1309        let deps = &result.dependencies;
1310        // Poetry path dependencies may not be fully parsed yet
1311        let django_dep = deps.iter().find(|d| d.name == "django");
1312        assert!(django_dep.is_some());
1313    }
1314
1315    #[test]
1316    fn test_parse_pep735_with_includes() {
1317        let toml = r#"
1318[dependency-groups]
1319test = [
1320    { include-group = "dev" },
1321    "pytest>=7.0",
1322]
1323dev = [
1324    "ruff>=0.1",
1325]
1326"#;
1327        let parser = PypiParser::new();
1328        let result = parser.parse_content(toml, &test_uri()).unwrap();
1329        let deps = &result.dependencies;
1330        assert!(deps.len() >= 2);
1331        assert!(deps.iter().any(|d| d.name == "pytest"));
1332        assert!(deps.iter().any(|d| d.name == "ruff"));
1333    }
1334
1335    #[test]
1336    fn test_parse_complex_version_specifier() {
1337        let toml = r#"
1338[project]
1339dependencies = [
1340    "django>=4.0,<5.0,!=4.0.1",
1341]
1342"#;
1343        let parser = PypiParser::new();
1344        let result = parser.parse_content(toml, &test_uri()).unwrap();
1345        let deps = &result.dependencies;
1346        assert_eq!(deps.len(), 1);
1347        assert_eq!(deps[0].name, "django");
1348        // Version specifier should be preserved
1349        assert!(deps[0].version_req.is_some());
1350    }
1351
1352    #[test]
1353    fn test_parse_no_project_section() {
1354        let toml = r#"
1355[tool.my-custom-tool]
1356config = "value"
1357"#;
1358        let parser = PypiParser::new();
1359        let result = parser.parse_content(toml, &test_uri()).unwrap();
1360        let deps = &result.dependencies;
1361        assert_eq!(deps.len(), 0);
1362    }
1363
1364    #[test]
1365    fn test_parse_build_system_requires() {
1366        let toml = r#"
1367[build-system]
1368requires = ["setuptools>=61.0", "wheel", "maturin>=1.7,<2.0"]
1369build-backend = "setuptools.build_meta"
1370"#;
1371        let parser = PypiParser::new();
1372        let result = parser.parse_content(toml, &test_uri()).unwrap();
1373        let deps = &result.dependencies;
1374
1375        assert_eq!(deps.len(), 3);
1376        assert!(
1377            deps.iter()
1378                .all(|d| matches!(d.section, PypiDependencySection::BuildSystem))
1379        );
1380
1381        let setuptools = deps.iter().find(|d| d.name == "setuptools").unwrap();
1382        assert_eq!(
1383            setuptools
1384                .version_req
1385                .as_ref()
1386                .map(deps_core::VersionReq::as_str),
1387            Some(">=61.0")
1388        );
1389
1390        let maturin = deps.iter().find(|d| d.name == "maturin").unwrap();
1391        assert_eq!(
1392            maturin
1393                .version_req
1394                .as_ref()
1395                .map(deps_core::VersionReq::as_str),
1396            Some(">=1.7, <2.0")
1397        );
1398
1399        // wheel has no version constraint
1400        let wheel = deps.iter().find(|d| d.name == "wheel").unwrap();
1401        assert_eq!(wheel.version_req, None);
1402    }
1403
1404    #[test]
1405    fn test_parse_duplicate_dependency_positions() {
1406        // Test that duplicate dependency strings get correct positions
1407        let toml = r#"[build-system]
1408requires = ["maturin>=1.7,<2.0"]
1409
1410[dependency-groups]
1411dev = ["maturin>=1.7,<2.0"]
1412"#;
1413        let parser = PypiParser::new();
1414        let result = parser.parse_content(toml, &test_uri()).unwrap();
1415        let deps = &result.dependencies;
1416
1417        assert_eq!(deps.len(), 2);
1418
1419        // First maturin in [build-system] should be on line 1
1420        let build_system_maturin = deps
1421            .iter()
1422            .find(|d| matches!(d.section, PypiDependencySection::BuildSystem))
1423            .unwrap();
1424        assert_eq!(build_system_maturin.name_range.start.line, 1);
1425
1426        // Second maturin in [dependency-groups] should be on line 4
1427        let dep_group_maturin = deps
1428            .iter()
1429            .find(|d| matches!(d.section, PypiDependencySection::DependencyGroup { .. }))
1430            .unwrap();
1431        assert_eq!(dep_group_maturin.name_range.start.line, 4);
1432    }
1433
1434    #[test]
1435    fn test_version_range_for_code_actions() {
1436        // Test that version_range correctly covers the version specifier for code actions
1437        let toml = r#"[dependency-groups]
1438dev = ["pytest-cov>=4.0,<8.0"]
1439"#;
1440        // Line 0: [dependency-groups]
1441        // Line 1: dev = ["pytest-cov>=4.0,<8.0"]
1442        //               ^          ^         ^
1443        //               8          18        28 (positions)
1444        //               name_start version_start version_end
1445
1446        let parser = PypiParser::new();
1447        let result = parser.parse_content(toml, &test_uri()).unwrap();
1448        let deps = &result.dependencies;
1449
1450        assert_eq!(deps.len(), 1);
1451        let dep = &deps[0];
1452
1453        assert_eq!(dep.name, "pytest-cov");
1454        assert_eq!(dep.name_range.start.line, 1);
1455        assert_eq!(dep.name_range.start.character, 8); // after `dev = ["`
1456
1457        // Version range should cover >=4.0,<8.0
1458        let version_range = dep.version_range.expect("version_range should be set");
1459        assert_eq!(version_range.start.line, 1);
1460        // pytest-cov is 10 chars, so version starts at 8 + 10 = 18
1461        assert_eq!(version_range.start.character, 18);
1462        // >=4.0,<8.0 is 10 chars, so version ends at 18 + 10 = 28
1463        assert_eq!(version_range.end.character, 28);
1464
1465        // Verify that cursor at position 20 (on '4') is within version_range
1466        let cursor_on_version = Position::new(1, 20);
1467        assert!(
1468            cursor_on_version.character >= version_range.start.character
1469                && cursor_on_version.character < version_range.end.character,
1470            "cursor at {} should be within version_range {}..{}",
1471            cursor_on_version.character,
1472            version_range.start.character,
1473            version_range.end.character
1474        );
1475    }
1476
1477    #[test]
1478    fn test_version_range_with_space_before_specifier() {
1479        // Test version_range when there's a space between name and version specifier
1480        let toml = r#"[dependency-groups]
1481dev = ["pytest-cov >=4.0,<8.0"]
1482"#;
1483        // Line 1: dev = ["pytest-cov >=4.0,<8.0"]
1484        //               ^           ^         ^
1485        //               8           19        29 (positions)
1486        //               name_start  version   version_end
1487
1488        let parser = PypiParser::new();
1489        let result = parser.parse_content(toml, &test_uri()).unwrap();
1490        let deps = &result.dependencies;
1491
1492        assert_eq!(deps.len(), 1);
1493        let dep = &deps[0];
1494
1495        // Version range should cover exactly ">=4.0,<8.0", not the leading
1496        // whitespace: `start_offset` is derived by scanning for the first
1497        // specifier character, not from `name.len()` arithmetic (§6.2).
1498        let version_range = dep.version_range.expect("version_range should be set");
1499        assert_eq!(version_range.start.line, 1);
1500        // pytest-cov is 10 chars plus 1 space, so version starts at 8 + 11 = 19
1501        assert_eq!(version_range.start.character, 19);
1502        // ">=4.0,<8.0" is 10 chars, so version ends at 19 + 10 = 29
1503        assert_eq!(version_range.end.character, 29);
1504
1505        // Verify that a cursor within the specifier text is within version_range
1506        let cursor_on_version = Position::new(1, 21);
1507        assert!(
1508            cursor_on_version.character >= version_range.start.character
1509                && cursor_on_version.character < version_range.end.character,
1510            "cursor at {} should be within version_range {}..{}",
1511            cursor_on_version.character,
1512            version_range.start.character,
1513            version_range.end.character
1514        );
1515    }
1516
1517    /// Converts an LSP UTF-16 code-unit offset within `line` to a byte offset.
1518    fn utf16_offset_to_byte(line: &str, utf16_offset: u32) -> usize {
1519        let mut utf16_count = 0u32;
1520        for (byte_idx, ch) in line.char_indices() {
1521            if utf16_count >= utf16_offset {
1522                return byte_idx;
1523            }
1524            utf16_count += ch.len_utf16() as u32;
1525        }
1526        line.len()
1527    }
1528
1529    /// Extracts the text a single-line `Range` covers, for asserting on
1530    /// exact marker/version spans without hand-computing offsets. `Range`
1531    /// characters are UTF-16 code units per the LSP spec, so this converts
1532    /// through byte offsets rather than indexing `line` directly (which would
1533    /// panic, or silently misbehave, on non-ASCII content).
1534    fn slice_range(content: &str, range: Range) -> String {
1535        assert_eq!(
1536            range.start.line, range.end.line,
1537            "helper only supports single-line ranges"
1538        );
1539        let line = content.lines().nth(range.start.line as usize).unwrap();
1540        let start = utf16_offset_to_byte(line, range.start.character);
1541        let end = utf16_offset_to_byte(line, range.end.character);
1542        line[start..end].to_string()
1543    }
1544
1545    #[test]
1546    fn test_pep621_markers_range_covers_marker_text() {
1547        let toml = r#"[project]
1548dependencies = [
1549    "numpy>=1.24; python_version>='3.9'",
1550]
1551"#;
1552        let parser = PypiParser::new();
1553        let result = parser.parse_content(toml, &test_uri()).unwrap();
1554        let deps = &result.dependencies;
1555
1556        assert_eq!(deps.len(), 1);
1557        let dep = &deps[0];
1558        assert_eq!(
1559            dep.markers,
1560            Some("python_full_version >= '3.9'".to_string())
1561        );
1562
1563        // Range starts right after `;`, so it includes the following space.
1564        let markers_range = dep.markers_range.expect("markers_range should be set");
1565        assert_eq!(slice_range(toml, markers_range), " python_version>='3.9'");
1566    }
1567
1568    #[test]
1569    fn test_pep621_without_markers_has_no_markers_range() {
1570        let toml = r#"[project]
1571dependencies = ["requests>=2.28.0"]
1572"#;
1573        let parser = PypiParser::new();
1574        let result = parser.parse_content(toml, &test_uri()).unwrap();
1575        let deps = &result.dependencies;
1576
1577        assert_eq!(deps.len(), 1);
1578        assert_eq!(deps[0].markers, None);
1579        assert_eq!(deps[0].markers_range, None);
1580    }
1581
1582    #[test]
1583    fn test_pep621_version_range_excludes_marker_text() {
1584        // Regression test: version_range is the sole TextEdit range for the
1585        // "update version" code action. If it overlapped markers_range,
1586        // accepting that quick-fix would delete the marker from the file.
1587        let toml = r#"[project]
1588dependencies = [
1589    "numpy>=1.24; python_version>='3.9'",
1590]
1591"#;
1592        let parser = PypiParser::new();
1593        let result = parser.parse_content(toml, &test_uri()).unwrap();
1594        let dep = &result.dependencies[0];
1595
1596        let version_range = dep.version_range.expect("version_range should be set");
1597        assert_eq!(slice_range(toml, version_range), ">=1.24");
1598
1599        let markers_range = dep.markers_range.expect("markers_range should be set");
1600        assert!(version_range.end.character <= markers_range.start.character);
1601    }
1602
1603    #[test]
1604    fn test_poetry_table_form_markers_normalized() {
1605        let toml = r#"[tool.poetry.dependencies]
1606django = { version = "^4.0", markers = "python_version >= \"3.8\"" }
1607"#;
1608        let parser = PypiParser::new();
1609        let result = parser.parse_content(toml, &test_uri()).unwrap();
1610        let deps = &result.dependencies;
1611
1612        assert_eq!(deps.len(), 1);
1613        let dep = &deps[0];
1614        assert_eq!(dep.name, "django");
1615        // pep508_rs canonicalizes python_version comparisons to python_full_version.
1616        assert_eq!(
1617            dep.markers,
1618            Some("python_full_version >= '3.8'".to_string())
1619        );
1620
1621        let markers_range = dep.markers_range.expect("markers_range should be set");
1622        assert_eq!(
1623            slice_range(toml, markers_range),
1624            "python_version >= \\\"3.8\\\""
1625        );
1626    }
1627
1628    #[test]
1629    fn test_poetry_table_form_invalid_markers_falls_back_to_raw() {
1630        let toml = r#"[tool.poetry.dependencies]
1631django = { version = "^4.0", markers = "not a valid marker (((" }
1632"#;
1633        let parser = PypiParser::new();
1634        let result = parser.parse_content(toml, &test_uri()).unwrap();
1635        let deps = &result.dependencies;
1636
1637        assert_eq!(deps.len(), 1);
1638        let dep = &deps[0];
1639        // Unparseable text that also isn't marker-shaped (no recognized
1640        // marker variable token) is dropped rather than preserved verbatim.
1641        assert_eq!(dep.markers, None);
1642        assert_eq!(dep.markers_range, None);
1643    }
1644
1645    #[test]
1646    fn test_poetry_table_form_unbalanced_parens_rejected() {
1647        let toml = r#"[tool.poetry.dependencies]
1648django = { version = "^4.0", markers = "os_name == 'a' (((" }
1649"#;
1650        let parser = PypiParser::new();
1651        let result = parser.parse_content(toml, &test_uri()).unwrap();
1652        let dep = &result.dependencies[0];
1653
1654        // Text that references a real marker variable but has unbalanced
1655        // trailing parens no longer decomposes into the grammar's
1656        // `marker_atom := '(' marker_expr ')' | marker_clause` production, so
1657        // it's dropped rather than preserved (the grammar validator now
1658        // checks paren balance, unlike the earlier per-operand adjacency
1659        // check it replaced).
1660        assert_eq!(dep.markers, None);
1661        assert_eq!(dep.markers_range, None);
1662    }
1663
1664    #[test]
1665    fn test_poetry_table_form_trivially_true_marker_becomes_none() {
1666        // Matches the PEP 621 path: a marker that normalizes to always-true
1667        // has no string form and is indistinguishable from no marker at all.
1668        let toml = r#"[tool.poetry.dependencies]
1669django = { version = "^4.0", markers = "os_name == 'a' or os_name != 'a'" }
1670"#;
1671        let parser = PypiParser::new();
1672        let result = parser.parse_content(toml, &test_uri()).unwrap();
1673        let dep = &result.dependencies[0];
1674
1675        assert_eq!(dep.markers, None);
1676        assert_eq!(dep.markers_range, None);
1677    }
1678
1679    #[test]
1680    fn test_poetry_table_form_empty_markers_becomes_none() {
1681        let toml = r#"[tool.poetry.dependencies]
1682django = { version = "^4.0", markers = "   " }
1683"#;
1684        let parser = PypiParser::new();
1685        let result = parser.parse_content(toml, &test_uri()).unwrap();
1686        let dep = &result.dependencies[0];
1687
1688        assert_eq!(dep.markers, None);
1689        assert_eq!(dep.markers_range, None);
1690    }
1691
1692    #[test]
1693    fn test_poetry_table_form_oversized_marker_skips_normalization() {
1694        let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
1695        assert!(long_marker.len() > MAX_MARKER_LEN);
1696        let toml = format!(
1697            "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{long_marker}\" }}\n"
1698        );
1699        let parser = PypiParser::new();
1700        let result = parser.parse_content(&toml, &test_uri()).unwrap();
1701        let dep = &result.dependencies[0];
1702
1703        // Falls back to raw text rather than being handed to pep508_rs's
1704        // unbounded recursive-descent parser.
1705        assert_eq!(dep.markers, Some(long_marker));
1706        assert!(dep.markers_range.is_some());
1707    }
1708
1709    #[test]
1710    fn test_poetry_table_form_without_markers_key_has_no_markers() {
1711        let toml = r#"[tool.poetry.dependencies]
1712django = { version = "^4.0" }
1713"#;
1714        let parser = PypiParser::new();
1715        let result = parser.parse_content(toml, &test_uri()).unwrap();
1716        let deps = &result.dependencies;
1717
1718        assert_eq!(deps.len(), 1);
1719        assert_eq!(deps[0].markers, None);
1720        assert_eq!(deps[0].markers_range, None);
1721    }
1722
1723    #[test]
1724    fn test_poetry_string_form_without_marker_stays_none() {
1725        let toml = r#"[tool.poetry.dependencies]
1726requests = "^2.28.0"
1727"#;
1728        let parser = PypiParser::new();
1729        let result = parser.parse_content(toml, &test_uri()).unwrap();
1730        let deps = &result.dependencies;
1731
1732        assert_eq!(deps.len(), 1);
1733        let dep = &deps[0];
1734        assert_eq!(
1735            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1736            Some("^2.28.0")
1737        );
1738        assert_eq!(dep.markers, None);
1739        assert_eq!(dep.markers_range, None);
1740    }
1741
1742    #[test]
1743    fn test_poetry_string_form_with_marker_suffix_normalized() {
1744        let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0; python_version >= '3.8'\"\n";
1745        let parser = PypiParser::new();
1746        let result = parser.parse_content(toml, &test_uri()).unwrap();
1747        let deps = &result.dependencies;
1748
1749        assert_eq!(deps.len(), 1);
1750        let dep = &deps[0];
1751        assert_eq!(dep.name, "requests");
1752        // The marker suffix is split out of version_req and normalized.
1753        assert_eq!(
1754            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1755            Some("^2.28.0")
1756        );
1757        assert_eq!(
1758            dep.markers,
1759            Some("python_full_version >= '3.8'".to_string())
1760        );
1761
1762        let version_range = dep.version_range.expect("version_range should be set");
1763        assert_eq!(slice_range(toml, version_range), "^2.28.0");
1764
1765        // Range starts right after `;`, so it includes the following space.
1766        let markers_range = dep.markers_range.expect("markers_range should be set");
1767        assert_eq!(slice_range(toml, markers_range), " python_version >= '3.8'");
1768    }
1769
1770    #[test]
1771    fn test_poetry_string_form_with_invalid_marker_suffix_falls_back_to_raw() {
1772        let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0; not a valid marker (((\"\n";
1773        let parser = PypiParser::new();
1774        let result = parser.parse_content(toml, &test_uri()).unwrap();
1775        let deps = &result.dependencies;
1776
1777        assert_eq!(deps.len(), 1);
1778        let dep = &deps[0];
1779        assert_eq!(
1780            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1781            Some("^2.28.0")
1782        );
1783        // Unparseable text that also isn't marker-shaped (no recognized
1784        // marker variable token) is dropped rather than preserved verbatim.
1785        assert_eq!(dep.markers, None);
1786        assert_eq!(dep.markers_range, None);
1787    }
1788
1789    #[test]
1790    fn test_poetry_string_form_version_range_without_marker() {
1791        let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0\"\n";
1792        let parser = PypiParser::new();
1793        let result = parser.parse_content(toml, &test_uri()).unwrap();
1794        let dep = &result.dependencies[0];
1795
1796        let version_range = dep.version_range.expect("version_range should be set");
1797        assert_eq!(slice_range(toml, version_range), "^2.28.0");
1798    }
1799
1800    #[test]
1801    fn test_poetry_string_form_empty_marker_after_semicolon() {
1802        let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0;\"\n";
1803        let parser = PypiParser::new();
1804        let result = parser.parse_content(toml, &test_uri()).unwrap();
1805        let dep = &result.dependencies[0];
1806
1807        assert_eq!(
1808            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1809            Some("^2.28.0")
1810        );
1811        assert_eq!(dep.markers, None);
1812        assert_eq!(dep.markers_range, None);
1813    }
1814
1815    #[test]
1816    fn test_poetry_string_form_no_space_around_equals() {
1817        // value.span-based range derivation must not depend on `name.len()`
1818        // arithmetic assuming a fixed ` = "` layout.
1819        let toml = "[tool.poetry.dependencies]\nrequests=\"^2.28.0; python_version >= '3.8'\"\n";
1820        let parser = PypiParser::new();
1821        let result = parser.parse_content(toml, &test_uri()).unwrap();
1822        let dep = &result.dependencies[0];
1823
1824        assert_eq!(dep.name, "requests");
1825        assert_eq!(
1826            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1827            Some("^2.28.0")
1828        );
1829        assert_eq!(
1830            dep.markers,
1831            Some("python_full_version >= '3.8'".to_string())
1832        );
1833
1834        let version_range = dep.version_range.expect("version_range should be set");
1835        assert_eq!(slice_range(toml, version_range), "^2.28.0");
1836        let markers_range = dep.markers_range.expect("markers_range should be set");
1837        assert_eq!(slice_range(toml, markers_range), " python_version >= '3.8'");
1838    }
1839
1840    #[test]
1841    fn test_poetry_string_form_quoted_key() {
1842        let toml =
1843            "[tool.poetry.dependencies]\n\"requests\" = \"^2.28.0; python_version >= '3.8'\"\n";
1844        let parser = PypiParser::new();
1845        let result = parser.parse_content(toml, &test_uri()).unwrap();
1846        let dep = &result.dependencies[0];
1847
1848        assert_eq!(dep.name, "requests");
1849        assert_eq!(
1850            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1851            Some("^2.28.0")
1852        );
1853
1854        let version_range = dep.version_range.expect("version_range should be set");
1855        assert_eq!(slice_range(toml, version_range), "^2.28.0");
1856        let markers_range = dep.markers_range.expect("markers_range should be set");
1857        assert_eq!(slice_range(toml, markers_range), " python_version >= '3.8'");
1858    }
1859
1860    #[test]
1861    fn test_poetry_string_form_marker_with_escaped_quotes() {
1862        // TOML decodes `\"` to `"`, so the decoded string's byte length
1863        // diverges from the source; range math must not desync from this.
1864        let toml =
1865            "[tool.poetry.dependencies]\nrequests = \"^2.28.0; python_version >= \\\"3.8\\\"\"\n";
1866        let parser = PypiParser::new();
1867        let result = parser.parse_content(toml, &test_uri()).unwrap();
1868        let dep = &result.dependencies[0];
1869
1870        assert_eq!(
1871            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1872            Some("^2.28.0")
1873        );
1874        assert_eq!(
1875            dep.markers,
1876            Some("python_full_version >= '3.8'".to_string())
1877        );
1878
1879        let version_range = dep.version_range.expect("version_range should be set");
1880        assert_eq!(slice_range(toml, version_range), "^2.28.0");
1881    }
1882
1883    #[test]
1884    fn test_poetry_string_form_marker_with_non_ascii() {
1885        // Byte offsets must be converted to UTF-16 code units (the LSP
1886        // Position unit) via the line table, not added to Position::character
1887        // directly.
1888        let toml = "[tool.poetry.dependencies]\nrequests = \"^2.28.0; os_name == 'ПРИВЕТ🚀'\"\n";
1889        let parser = PypiParser::new();
1890        let result = parser.parse_content(toml, &test_uri()).unwrap();
1891        let dep = &result.dependencies[0];
1892
1893        assert_eq!(
1894            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1895            Some("^2.28.0")
1896        );
1897
1898        let version_range = dep.version_range.expect("version_range should be set");
1899        assert_eq!(slice_range(toml, version_range), "^2.28.0");
1900
1901        let line = toml.lines().nth(1).unwrap();
1902        let line_utf16_len = line.encode_utf16().count() as u32;
1903        let markers_range = dep.markers_range.expect("markers_range should be set");
1904        assert!(markers_range.end.character <= line_utf16_len);
1905        assert_eq!(slice_range(toml, markers_range), " os_name == 'ПРИВЕТ🚀'");
1906    }
1907
1908    #[test]
1909    fn test_poetry_string_form_oversized_marker_skips_normalization() {
1910        let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
1911        assert!(long_marker.len() > MAX_MARKER_LEN);
1912        let toml = format!("[tool.poetry.dependencies]\nrequests = \"^2.28.0; {long_marker}\"\n");
1913        let parser = PypiParser::new();
1914        let result = parser.parse_content(&toml, &test_uri()).unwrap();
1915        let dep = &result.dependencies[0];
1916
1917        assert_eq!(
1918            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1919            Some("^2.28.0")
1920        );
1921        assert_eq!(dep.markers, Some(long_marker));
1922        assert!(dep.markers_range.is_some());
1923    }
1924
1925    #[test]
1926    fn test_pep621_oversized_marker_skips_normalization() {
1927        let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
1928        assert!(long_marker.len() > MAX_MARKER_LEN);
1929        let toml = format!("[project]\ndependencies = [\n    \"numpy>=1.24; {long_marker}\",\n]\n");
1930        let parser = PypiParser::new();
1931        let result = parser.parse_content(&toml, &test_uri()).unwrap();
1932        let dep = &result.dependencies[0];
1933
1934        assert_eq!(dep.name, "numpy");
1935        assert_eq!(
1936            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
1937            Some(">=1.24")
1938        );
1939        // Skips normalization (would blow the stack in pep508_rs's parser)
1940        // but preserves the raw marker text rather than dropping it.
1941        assert_eq!(dep.markers, Some(long_marker));
1942        assert!(dep.markers_range.is_some());
1943    }
1944
1945    #[test]
1946    fn test_pep621_oversized_extras_list_rejected_fast() {
1947        // Regression test for #229: `pep508_rs` 0.9.2 parses an extras list
1948        // in O(n²). Before the length cap, a single requirement this size
1949        // would take on the order of seconds to parse (extrapolating the
1950        // measured quadratic growth); with the cap it is rejected in O(1)
1951        // and the rest of the manifest still parses normally.
1952        let huge_extras = "a,".repeat(500_000); // ~1 MiB extras list
1953        let requirement = format!("pkg[{huge_extras}]==1.0");
1954        assert!(requirement.len() > super::super::MAX_REQUIREMENT_LEN);
1955        let toml = format!(
1956            "[project]\ndependencies = [\n    \"{requirement}\",\n    \"good-pkg==2.0\",\n]\n"
1957        );
1958        let parser = PypiParser::new();
1959
1960        let start = std::time::Instant::now();
1961        let result = parser.parse_content(&toml, &test_uri()).unwrap();
1962        let elapsed = start.elapsed();
1963
1964        assert!(
1965            elapsed < std::time::Duration::from_secs(2),
1966            "oversized extras dependency took too long to reject: {elapsed:?}"
1967        );
1968        assert_eq!(result.dependencies.len(), 1);
1969        assert_eq!(result.dependencies[0].name, "good-pkg");
1970    }
1971
1972    #[test]
1973    fn test_pep621_oversized_marker_beyond_total_cap_still_survives() {
1974        // Regression test for critic finding S1: the length cap must measure
1975        // only the pre-marker (name/extras/version) portion, not the whole
1976        // requirement string including the marker. This requirement's
1977        // pre-marker portion ("numpy>=1.24") is tiny, but the marker alone
1978        // pushes the total past MAX_REQUIREMENT_LEN — it must still be kept,
1979        // with the marker falling back to raw text via the pre-existing
1980        // MAX_MARKER_LEN guard, not dropped by the new cap.
1981        let long_marker: String = "os_name == 'a' or ".repeat(230) + "os_name == 'a'";
1982        assert!(long_marker.len() > MAX_MARKER_LEN);
1983        let toml = format!("[project]\ndependencies = [\n    \"numpy>=1.24; {long_marker}\",\n]\n");
1984        assert!(
1985            "numpy>=1.24".len() < super::super::MAX_REQUIREMENT_LEN,
1986            "pre-marker portion must stay under the cap"
1987        );
1988        assert!(
1989            format!("numpy>=1.24; {long_marker}").len() > super::super::MAX_REQUIREMENT_LEN,
1990            "total requirement (incl. marker) must exceed the cap for this test to be meaningful"
1991        );
1992        let parser = PypiParser::new();
1993        let result = parser.parse_content(&toml, &test_uri()).unwrap();
1994
1995        assert_eq!(result.dependencies.len(), 1);
1996        let dep = &result.dependencies[0];
1997        assert_eq!(dep.name, "numpy");
1998        assert_eq!(
1999            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
2000            Some(">=1.24")
2001        );
2002        assert_eq!(dep.markers, Some(long_marker));
2003    }
2004
2005    #[test]
2006    fn test_pep621_deeply_nested_marker_under_length_cap_skips_normalization() {
2007        // Regression test for #146: a marker packs ~1 paren pair per 2 bytes,
2008        // so nesting depth can exceed MAX_MARKER_DEPTH while the marker text
2009        // stays well under MAX_MARKER_LEN. Must not overflow the stack in
2010        // pep508_rs's unbounded recursive-descent parser.
2011        let depth = 1000;
2012        let nested_marker = format!("{}os_name == 'a'{}", "(".repeat(depth), ")".repeat(depth));
2013        assert!(nested_marker.len() < MAX_MARKER_LEN);
2014        let toml =
2015            format!("[project]\ndependencies = [\n    \"numpy>=1.24; {nested_marker}\",\n]\n");
2016        let parser = PypiParser::new();
2017        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2018        let dep = &result.dependencies[0];
2019
2020        assert_eq!(dep.name, "numpy");
2021        assert_eq!(
2022            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
2023            Some(">=1.24")
2024        );
2025        assert_eq!(dep.markers, Some(nested_marker));
2026        assert!(dep.markers_range.is_some());
2027    }
2028
2029    #[test]
2030    fn test_poetry_table_form_deeply_nested_marker_skips_normalization() {
2031        // Same attack via the Poetry `markers` key, which goes through
2032        // `normalize_marker_string` rather than `parse_pep508_requirement`.
2033        let depth = 1000;
2034        let nested_marker = format!("{}os_name == 'a'{}", "(".repeat(depth), ")".repeat(depth));
2035        assert!(nested_marker.len() < MAX_MARKER_LEN);
2036        let toml = format!(
2037            "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{nested_marker}\" }}\n"
2038        );
2039        let parser = PypiParser::new();
2040        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2041        let dep = &result.dependencies[0];
2042
2043        assert_eq!(dep.markers, Some(nested_marker));
2044        assert!(dep.markers_range.is_some());
2045    }
2046
2047    #[test]
2048    fn test_pep621_marker_depth_bypass_via_quoted_parens_falls_back() {
2049        // Regression test for the quote-bypass gap: pep508_rs's own tokenizer
2050        // treats `(`/`)` inside a quoted marker value as opaque (marker/parse.rs
2051        // uses `take_while(|c| c != quotation_mark)`, no escape handling), so a
2052        // scanner that counted parens unconditionally could be tricked into
2053        // never observing real nesting depth. Each level here opens one real
2054        // `(` but also embeds a `)` inside a quoted extra value; a quote-unaware
2055        // scanner treats that `)` as closing the level's own `(`, capping the
2056        // observed depth at 1 forever while the real recursive-descent parser
2057        // keeps recursing one level per iteration.
2058        let levels = 60;
2059        let mut marker = String::new();
2060        for _ in 0..levels {
2061            marker.push_str("(extra==')'and ");
2062        }
2063        marker.push_str("extra=='a'");
2064        for _ in 0..levels {
2065            marker.push(')');
2066        }
2067        assert!(marker.len() < MAX_MARKER_LEN);
2068        assert!(marker_too_deep(&marker));
2069
2070        let toml = format!("[project]\ndependencies = [\n    \"numpy>=1.24; {marker}\",\n]\n");
2071        let parser = PypiParser::new();
2072        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2073        let dep = &result.dependencies[0];
2074
2075        assert_eq!(dep.name, "numpy");
2076        assert_eq!(
2077            dep.version_req.as_ref().map(deps_core::VersionReq::as_str),
2078            Some(">=1.24")
2079        );
2080        // Routed through the raw fallback rather than handed to pep508_rs.
2081        assert_eq!(dep.markers, Some(marker));
2082        assert!(dep.markers_range.is_some());
2083    }
2084
2085    #[test]
2086    fn test_pep621_reasonably_nested_marker_still_normalizes() {
2087        // Legitimate markers nest a handful of levels at most; these must
2088        // still be parsed and normalized, not routed to the raw fallback.
2089        let toml = r#"[project]
2090dependencies = [
2091    "numpy>=1.24; (os_name == 'a' and sys_platform == 'b') or os_name == 'c'",
2092]
2093"#;
2094        let parser = PypiParser::new();
2095        let result = parser.parse_content(toml, &test_uri()).unwrap();
2096        let dep = &result.dependencies[0];
2097
2098        assert_eq!(dep.name, "numpy");
2099        let markers = dep.markers.as_ref().expect("marker should normalize");
2100        assert!(markers.contains("os_name"));
2101        assert!(markers.contains("sys_platform"));
2102    }
2103
2104    #[test]
2105    fn test_pep621_marker_extras_bracket_injection_rejected() {
2106        // Regression test for #261: a `;` landing before an oversized
2107        // extras/version tail (rather than before an actual marker) used to
2108        // have that whole tail — `[...]==1.0`, not a marker expression at
2109        // all — stored verbatim on `markers` via the length-cap bypass in
2110        // #146, then rendered into hover.
2111        let huge_extras = "a".repeat(60_000);
2112        let toml = format!("[project]\ndependencies = [\n    \"pkg;[{huge_extras}]==1.0\",\n]\n");
2113        let parser = PypiParser::new();
2114        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2115        let dep = &result.dependencies[0];
2116
2117        assert_eq!(dep.name, "pkg");
2118        assert_eq!(dep.version_req, None);
2119        assert_eq!(dep.markers, None);
2120        assert_eq!(dep.markers_range, None);
2121    }
2122
2123    #[test]
2124    fn test_poetry_table_form_oversized_non_marker_text_rejected() {
2125        // Same #261 gap via the Poetry `markers` key, which goes through
2126        // `normalize_marker_string` rather than `parse_pep508_requirement`:
2127        // oversized text with no marker-like shape must not be retained.
2128        let garbage = "[".to_string() + &"x".repeat(60_000) + "]";
2129        assert!(garbage.len() > MAX_MARKER_LEN);
2130        let toml = format!(
2131            "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{garbage}\" }}\n"
2132        );
2133        let parser = PypiParser::new();
2134        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2135        let dep = &result.dependencies[0];
2136
2137        assert_eq!(dep.markers, None);
2138        assert_eq!(dep.markers_range, None);
2139    }
2140
2141    #[test]
2142    fn test_pep621_marker_keyword_repeated_without_separators_rejected() {
2143        // Regression test for the substring-only `looks_like_marker` bypass:
2144        // a marker variable name repeated with no separators contains
2145        // "extra" as a substring but tokenizes as one giant unrecognized
2146        // identifier, not a real reference to the `extra` marker variable.
2147        let garbage = "extra".repeat(1600);
2148        assert!(garbage.len() > MAX_MARKER_LEN);
2149        let toml = format!("[project]\ndependencies = [\n    \"pkg; {garbage}\",\n]\n");
2150        let parser = PypiParser::new();
2151        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2152        let dep = &result.dependencies[0];
2153
2154        assert_eq!(dep.name, "pkg");
2155        assert_eq!(dep.markers, None);
2156        assert_eq!(dep.markers_range, None);
2157    }
2158
2159    #[test]
2160    fn test_pep621_marker_keyword_padded_with_unquoted_garbage_rejected() {
2161        // Regression test for the substring-only `looks_like_marker` bypass:
2162        // a real marker variable followed by an unquoted run of filler bytes
2163        // used to pass (keyword present as a substring, all bytes in the
2164        // allowed character set); the filler is not a quoted string literal,
2165        // a known identifier, or an operator, so it must now be rejected.
2166        let filler = "A".repeat(5000);
2167        let raw_marker = format!("python_version <{filler}>");
2168        assert!(raw_marker.len() > MAX_MARKER_LEN);
2169        let toml = format!("[project]\ndependencies = [\n    \"pkg; {raw_marker}\",\n]\n");
2170        let parser = PypiParser::new();
2171        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2172        let dep = &result.dependencies[0];
2173
2174        assert_eq!(dep.name, "pkg");
2175        assert_eq!(dep.markers, None);
2176        assert_eq!(dep.markers_range, None);
2177    }
2178
2179    #[test]
2180    fn test_poetry_table_form_oversized_garbage_under_length_cap_rejected() {
2181        // Regression test for M2: text under MAX_MARKER_LEN that fails to
2182        // parse used to be retained unconditionally via the `MarkerTree::
2183        // from_str` error path, which had no shape validation of its own —
2184        // identical garbage was kept or dropped purely on whether it crossed
2185        // MAX_MARKER_LEN, not on whether it looked like a marker at all.
2186        let garbage = format!("[{}]==1.0", "a".repeat(1980));
2187        assert!(garbage.len() < MAX_MARKER_LEN);
2188        let toml = format!(
2189            "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{garbage}\" }}\n"
2190        );
2191        let parser = PypiParser::new();
2192        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2193        let dep = &result.dependencies[0];
2194
2195        assert_eq!(dep.markers, None);
2196        assert_eq!(dep.markers_range, None);
2197    }
2198
2199    #[test]
2200    fn test_pep621_marker_repeated_token_no_operator_rejected() {
2201        // Regression test for the reviewer's residual #261 bypass: bare
2202        // whitespace-separated repetition of a recognized marker variable,
2203        // with no comparison operator anywhere, used to still tokenize as
2204        // "marker-shaped" (at least one recognized token present) and be
2205        // retained verbatim.
2206        let garbage = "python_version ".repeat(500);
2207        assert!(garbage.len() > MAX_MARKER_LEN);
2208        let toml = format!("[project]\ndependencies = [\n    \"pkg; {garbage}\",\n]\n");
2209        let parser = PypiParser::new();
2210        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2211        let dep = &result.dependencies[0];
2212
2213        assert_eq!(dep.name, "pkg");
2214        assert_eq!(dep.markers, None);
2215        assert_eq!(dep.markers_range, None);
2216    }
2217
2218    #[test]
2219    fn test_pep621_marker_and_joined_repeated_token_no_operator_rejected() {
2220        // Same bypass shape, joined by `and` instead of bare whitespace —
2221        // still no comparison operator anywhere in the text.
2222        let garbage = "python_version and ".repeat(400) + "python_version";
2223        assert!(garbage.len() > MAX_MARKER_LEN);
2224        let toml = format!("[project]\ndependencies = [\n    \"pkg; {garbage}\",\n]\n");
2225        let parser = PypiParser::new();
2226        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2227        let dep = &result.dependencies[0];
2228
2229        assert_eq!(dep.name, "pkg");
2230        assert_eq!(dep.markers, None);
2231        assert_eq!(dep.markers_range, None);
2232    }
2233
2234    #[test]
2235    fn test_poetry_table_form_repeated_token_no_operator_rejected() {
2236        // Same bypass shape via the Poetry `markers` key, which goes through
2237        // `normalize_marker_string` rather than `parse_pep508_requirement`.
2238        let garbage = "python_version ".repeat(500);
2239        assert!(garbage.len() > MAX_MARKER_LEN);
2240        let toml = format!(
2241            "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{garbage}\" }}\n"
2242        );
2243        let parser = PypiParser::new();
2244        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2245        let dep = &result.dependencies[0];
2246
2247        assert_eq!(dep.markers, None);
2248        assert_eq!(dep.markers_range, None);
2249    }
2250
2251    #[test]
2252    fn test_pep621_marker_chained_comparison_rejected() {
2253        // Regression test for the reviewer's round-3 #261 bypass: chained
2254        // comparisons share one operand across more than one clause
2255        // (`a == b == c == ...`). The per-operand adjacency check this
2256        // replaces treated every operand as valid ("touches an operator on
2257        // some side"), but PEP 508's grammar has no production for chaining
2258        // — `pep508_rs` itself rejects a short version of this shape
2259        // outright (confirmed: `pkg; python_version==python_version==
2260        // python_version` fails to parse at all).
2261        let chain = "python_version==".repeat(500) + "python_version";
2262        assert!(chain.len() > MAX_MARKER_LEN);
2263        let toml = format!("[project]\ndependencies = [\n    \"pkg; {chain}\",\n]\n");
2264        let parser = PypiParser::new();
2265        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2266        let dep = &result.dependencies[0];
2267
2268        assert_eq!(dep.name, "pkg");
2269        assert_eq!(dep.markers, None);
2270        assert_eq!(dep.markers_range, None);
2271    }
2272
2273    #[test]
2274    fn test_pep621_marker_chained_in_rejected() {
2275        // Same bypass shape using `in` instead of `==`.
2276        let chain = "python_version in ".repeat(500) + "python_version";
2277        assert!(chain.len() > MAX_MARKER_LEN);
2278        let toml = format!("[project]\ndependencies = [\n    \"pkg; {chain}\",\n]\n");
2279        let parser = PypiParser::new();
2280        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2281        let dep = &result.dependencies[0];
2282
2283        assert_eq!(dep.name, "pkg");
2284        assert_eq!(dep.markers, None);
2285        assert_eq!(dep.markers_range, None);
2286    }
2287
2288    #[test]
2289    fn test_poetry_table_form_chained_comparison_rejected() {
2290        // Same bypass shape via the Poetry `markers` key.
2291        let chain = "python_version==".repeat(500) + "python_version";
2292        assert!(chain.len() > MAX_MARKER_LEN);
2293        let toml = format!(
2294            "[tool.poetry.dependencies]\ndjango = {{ version = \"^4.0\", markers = \"{chain}\" }}\n"
2295        );
2296        let parser = PypiParser::new();
2297        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2298        let dep = &result.dependencies[0];
2299
2300        assert_eq!(dep.markers, None);
2301        assert_eq!(dep.markers_range, None);
2302    }
2303
2304    #[test]
2305    fn test_pep621_oversized_in_operator_marker_still_normalizes() {
2306        // Legitimate use of the `in` operator must still be preserved
2307        // through the raw fallback once it's oversized enough to bypass
2308        // `pep508_rs`'s parser.
2309        let marker =
2310            "python_version in '3.8'".to_string() + &" or python_version in '3.8'".repeat(200);
2311        assert!(marker.len() > MAX_MARKER_LEN);
2312        let toml = format!("[project]\ndependencies = [\n    \"pkg; {marker}\",\n]\n");
2313        let parser = PypiParser::new();
2314        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2315        let dep = &result.dependencies[0];
2316
2317        assert_eq!(dep.name, "pkg");
2318        assert_eq!(dep.markers, Some(marker));
2319        assert!(dep.markers_range.is_some());
2320    }
2321
2322    #[test]
2323    fn test_pep621_oversized_not_in_operator_marker_still_normalizes() {
2324        // Same as above for `not in`.
2325        let marker = "python_version not in '3.8'".to_string()
2326            + &" or python_version not in '3.8'".repeat(200);
2327        assert!(marker.len() > MAX_MARKER_LEN);
2328        let toml = format!("[project]\ndependencies = [\n    \"pkg; {marker}\",\n]\n");
2329        let parser = PypiParser::new();
2330        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2331        let dep = &result.dependencies[0];
2332
2333        assert_eq!(dep.name, "pkg");
2334        assert_eq!(dep.markers, Some(marker));
2335        assert!(dep.markers_range.is_some());
2336    }
2337
2338    #[test]
2339    fn test_pep621_non_ascii_marker_literal_still_normalizes() {
2340        // Regression test for M3: a genuine, if oversized, marker whose
2341        // quoted string literal contains non-ASCII bytes must not be
2342        // rejected just because those bytes aren't ASCII — only unquoted
2343        // text is required to tokenize as known marker-grammar elements.
2344        let filler = "é".repeat(1500);
2345        let raw_marker = format!("platform_release == '{filler}' or python_version >= '3.8'");
2346        assert!(raw_marker.len() > MAX_MARKER_LEN);
2347        let toml = format!("[project]\ndependencies = [\n    \"pkg; {raw_marker}\",\n]\n");
2348        let parser = PypiParser::new();
2349        let result = parser.parse_content(&toml, &test_uri()).unwrap();
2350        let dep = &result.dependencies[0];
2351
2352        assert_eq!(dep.name, "pkg");
2353        assert_eq!(dep.markers, Some(raw_marker));
2354        assert!(dep.markers_range.is_some());
2355    }
2356
2357    // --- T004: Poetry [[tool.poetry.source]] + per-dependency source = (FR-007) ---
2358
2359    fn parse_with_all_policy(content: &str) -> ParseResult {
2360        let policy = deps_core::net_policy::RegistryAccessPolicy::new(
2361            deps_core::net_policy::WorkspaceRegistryAccess::All,
2362        );
2363        PypiParser::new()
2364            .parse_content_with_policy(content, &test_uri(), &policy)
2365            .unwrap()
2366    }
2367
2368    #[test]
2369    fn test_poetry_unlabeled_source_is_primary() {
2370        // No `priority` key at all -> primary (FR-007 corrected mapping), so every plain
2371        // dependency in the file routes through it.
2372        let content = r#"
2373[[tool.poetry.source]]
2374name = "internal"
2375url = "https://pypi.mycorp.example/simple"
2376
2377[tool.poetry.dependencies]
2378requests = "^2.28.0"
2379"#;
2380        let result = parse_with_all_policy(content);
2381        let dep = result
2382            .dependencies
2383            .iter()
2384            .find(|d| d.name == "requests")
2385            .unwrap();
2386        assert_matches!(dep.source, PypiDependencySource::AlternateRegistry { .. });
2387    }
2388
2389    /// Validator finding S3: two primary-priority Poetry sources must not silently pick an
2390    /// arbitrary (last-declared) one — the first registered wins, verified end-to-end via the
2391    /// registered chain's sole hop.
2392    #[test]
2393    fn test_poetry_multiple_primary_sources_keeps_first() {
2394        let content = r#"
2395[[tool.poetry.source]]
2396name = "first"
2397url = "https://first.example/simple"
2398
2399[[tool.poetry.source]]
2400name = "second"
2401url = "https://second.example/simple"
2402priority = "primary"
2403
2404[tool.poetry.dependencies]
2405requests = "^2.28.0"
2406"#;
2407        let result = parse_with_all_policy(content);
2408        let dep = result
2409            .dependencies
2410            .iter()
2411            .find(|d| d.name == "requests")
2412            .unwrap();
2413        let PypiDependencySource::AlternateRegistry { index, .. } = &dep.source else {
2414            panic!("expected AlternateRegistry, got {:?}", dep.source);
2415        };
2416
2417        let chain = result
2418            .resolved_chains
2419            .iter()
2420            .find(|c| &c.key == index)
2421            .expect("chain must be registered");
2422        assert_eq!(chain.hops.len(), 1);
2423        assert_eq!(chain.hops[0].as_str(), "https://first.example/simple");
2424    }
2425
2426    #[test]
2427    fn test_poetry_explicit_source_via_dependency_key() {
2428        let content = r#"
2429[[tool.poetry.source]]
2430name = "internal"
2431url = "https://pypi.mycorp.example/simple"
2432priority = "explicit"
2433
2434[tool.poetry.dependencies]
2435flask = { version = "^3.0", source = "internal" }
2436requests = "^2.28.0"
2437"#;
2438        let result = parse_with_all_policy(content);
2439        let flask = result
2440            .dependencies
2441            .iter()
2442            .find(|d| d.name == "flask")
2443            .unwrap();
2444        assert_eq!(
2445            flask.source,
2446            PypiDependencySource::AlternateRegistry {
2447                index: "https://pypi.mycorp.example/simple".to_string(),
2448                mirrors_crates_io: false,
2449            }
2450        );
2451
2452        // An `explicit`-priority source is never auto-included in the chain — an unrelated
2453        // dependency with no `source =` key stays plain `Registry`.
2454        let requests = result
2455            .dependencies
2456            .iter()
2457            .find(|d| d.name == "requests")
2458            .unwrap();
2459        assert_eq!(requests.source, PypiDependencySource::Registry);
2460    }
2461
2462    #[test]
2463    fn test_poetry_source_reference_with_no_matching_entry_fails_closed() {
2464        let content = r#"
2465[tool.poetry.dependencies]
2466flask = { version = "^3.0", source = "does-not-exist" }
2467"#;
2468        let result = parse_with_all_policy(content);
2469        assert_eq!(
2470            result.dependencies[0].source,
2471            PypiDependencySource::CustomRegistry {
2472                url: "does-not-exist".to_string(),
2473            }
2474        );
2475    }
2476
2477    #[test]
2478    fn test_poetry_supplemental_priority_is_extra_not_primary() {
2479        let content = r#"
2480[[tool.poetry.source]]
2481name = "extra"
2482url = "https://extra.example/simple"
2483priority = "supplemental"
2484
2485[tool.poetry.dependencies]
2486requests = "^2.28.0"
2487"#;
2488        let result = parse_with_all_policy(content);
2489        // No primary declared, but a supplemental source exists -> case (b): still routes
2490        // through an AlternateRegistry chain (extras + implicit public), not plain Registry.
2491        assert_matches!(
2492            result.dependencies[0].source,
2493            PypiDependencySource::AlternateRegistry { .. }
2494        );
2495    }
2496
2497    // --- T005: uv [tool.uv.index] + [tool.uv.sources] (FR-013) ---
2498
2499    #[test]
2500    fn test_uv_plain_entry_is_automatic_extra() {
2501        let content = r#"
2502[[tool.uv.index]]
2503name = "extra"
2504url = "https://extra.example/simple"
2505
2506[project]
2507dependencies = ["requests>=2.28.0"]
2508"#;
2509        let result = parse_with_all_policy(content);
2510        assert_matches!(
2511            result.dependencies[0].source,
2512            PypiDependencySource::AlternateRegistry { .. }
2513        );
2514    }
2515
2516    /// FR-013's corrected mapping: `default = true` is uv's lowest-priority, last-resort
2517    /// hop — checked *after* every non-default entry, and never populates `primary`. A
2518    /// package present on both a non-default entry and the `default` entry must resolve via
2519    /// the non-default one (asserted at the `PypiIndexConfig` level in `config.rs`; here we
2520    /// assert the parse-level contract: a pure-uv config never produces `CustomRegistry`'s
2521    /// primary-fail-closed shape, since `primary` is never populated).
2522    #[test]
2523    fn test_uv_default_entry_is_last_resort_not_primary() {
2524        let content = r#"
2525[[tool.uv.index]]
2526name = "non-default"
2527url = "https://non-default.example/simple"
2528
2529[[tool.uv.index]]
2530name = "default-index"
2531url = "https://default.example/simple"
2532default = true
2533
2534[project]
2535dependencies = ["requests>=2.28.0"]
2536"#;
2537        let result = parse_with_all_policy(content);
2538        // Both entries route through the same case-(b)-shaped chain (AlternateRegistry, not
2539        // CustomRegistry) — proving `default = true` did not populate `primary` (which would
2540        // make an invalid primary fail closed instead, a structurally different source).
2541        assert_matches!(
2542            result.dependencies[0].source,
2543            PypiDependencySource::AlternateRegistry { .. }
2544        );
2545    }
2546
2547    #[test]
2548    fn test_uv_explicit_entry_named_source_only() {
2549        let content = r#"
2550[[tool.uv.index]]
2551name = "internal"
2552url = "https://pypi.mycorp.example/simple"
2553explicit = true
2554
2555[tool.uv.sources]
2556mypkg = { index = "internal" }
2557
2558[project]
2559dependencies = ["mypkg>=1.0.0", "requests>=2.28.0"]
2560"#;
2561        let result = parse_with_all_policy(content);
2562        let mypkg = result
2563            .dependencies
2564            .iter()
2565            .find(|d| d.name == "mypkg")
2566            .unwrap();
2567        assert_eq!(
2568            mypkg.source,
2569            PypiDependencySource::AlternateRegistry {
2570                index: "https://pypi.mycorp.example/simple".to_string(),
2571                mirrors_crates_io: false,
2572            }
2573        );
2574
2575        // An `explicit` entry is never auto-included — an unrelated dependency stays plain
2576        // `Registry`.
2577        let requests = result
2578            .dependencies
2579            .iter()
2580            .find(|d| d.name == "requests")
2581            .unwrap();
2582        assert_eq!(requests.source, PypiDependencySource::Registry);
2583    }
2584
2585    /// Validator finding S4: a casing/separator mismatch between `[tool.uv.index]`'s `name`
2586    /// and `[tool.uv.sources]`'s `index = "<name>"` value must not silently break the
2587    /// binding — both are matched PEP 503-normalized. Without this fix, `mypkg` would fall
2588    /// through to `resolve_source_for(None)` and, with no other index declared, resolve as
2589    /// plain `pypi.org` — the exact leak this feature exists to prevent for an `explicit`
2590    /// index a dependency is supposed to be routed to deliberately.
2591    #[test]
2592    fn test_uv_sources_index_binding_normalizes_name_mismatch() {
2593        let content = r#"
2594[[tool.uv.index]]
2595name = "Internal-Index"
2596url = "https://pypi.mycorp.example/simple"
2597explicit = true
2598
2599[tool.uv.sources]
2600mypkg = { index = "internal_index" }
2601
2602[project]
2603dependencies = ["mypkg>=1.0.0"]
2604"#;
2605        let result = parse_with_all_policy(content);
2606        let mypkg = result
2607            .dependencies
2608            .iter()
2609            .find(|d| d.name == "mypkg")
2610            .unwrap();
2611        assert_eq!(
2612            mypkg.source,
2613            PypiDependencySource::AlternateRegistry {
2614                index: "https://pypi.mycorp.example/simple".to_string(),
2615                mirrors_crates_io: false,
2616            }
2617        );
2618    }
2619
2620    /// Same fix, verified against the dependency-name side of the binding: a casing mismatch
2621    /// between `[tool.uv.sources]`'s own TOML key and the actual normalized dependency name
2622    /// must not defeat the binding either.
2623    #[test]
2624    fn test_uv_sources_dependency_key_normalizes_name_mismatch() {
2625        let content = r#"
2626[[tool.uv.index]]
2627name = "internal"
2628url = "https://pypi.mycorp.example/simple"
2629explicit = true
2630
2631[tool.uv.sources]
2632"Flask-SQLAlchemy" = { index = "internal" }
2633
2634[project]
2635dependencies = ["flask-sqlalchemy>=1.0.0"]
2636"#;
2637        let result = parse_with_all_policy(content);
2638        let dep = result
2639            .dependencies
2640            .iter()
2641            .find(|d| d.name == "flask-sqlalchemy")
2642            .unwrap();
2643        assert_eq!(
2644            dep.source,
2645            PypiDependencySource::AlternateRegistry {
2646                index: "https://pypi.mycorp.example/simple".to_string(),
2647                mirrors_crates_io: false,
2648            }
2649        );
2650    }
2651
2652    /// `[tool.uv.sources] { index = "<name>" }` also works for a non-`explicit` entry (FR-013
2653    /// says "works for both explicit and non-explicit named entries").
2654    #[test]
2655    fn test_uv_sources_index_binding_works_for_non_explicit_entry() {
2656        let content = r#"
2657[[tool.uv.index]]
2658name = "extra"
2659url = "https://extra.example/simple"
2660
2661[tool.uv.sources]
2662mypkg = { index = "extra" }
2663
2664[project]
2665dependencies = ["mypkg>=1.0.0"]
2666"#;
2667        let result = parse_with_all_policy(content);
2668        assert_eq!(
2669            result.dependencies[0].source,
2670            PypiDependencySource::AlternateRegistry {
2671                index: "https://extra.example/simple".to_string(),
2672                mirrors_crates_io: false,
2673            }
2674        );
2675    }
2676
2677    /// Every other `[tool.uv.sources]` shape (`git =`, `path =`, `workspace = true`) has no
2678    /// effect — confirmed by a dependency whose binding uses one of these shapes still
2679    /// resolving as a plain dependency (its own PEP 508 string form is unaffected, since
2680    /// `[tool.uv.sources]` never overrides a dependency's own declared version/extras — only
2681    /// registry routing).
2682    #[test]
2683    fn test_uv_sources_non_index_shapes_have_no_routing_effect() {
2684        let content = r#"
2685[tool.uv.sources]
2686git-pkg = { git = "https://github.com/example/git-pkg" }
2687path-pkg = { path = "../path-pkg" }
2688workspace-pkg = { workspace = true }
2689
2690[project]
2691dependencies = ["git-pkg>=1.0.0", "path-pkg>=1.0.0", "workspace-pkg>=1.0.0"]
2692"#;
2693        let result = parse_with_all_policy(content);
2694        for dep in &result.dependencies {
2695            assert_eq!(
2696                dep.source,
2697                PypiDependencySource::Registry,
2698                "{} should be unaffected by a non-index uv.sources shape",
2699                dep.name
2700            );
2701        }
2702    }
2703
2704    /// US-004: a `pyproject.toml` with no index declaration anywhere is unaffected —
2705    /// byte-identical to today's behavior.
2706    #[test]
2707    fn test_no_index_declaration_pyproject_is_plain_registry() {
2708        let content = r#"
2709[project]
2710dependencies = ["requests>=2.28.0"]
2711
2712[tool.poetry.dependencies]
2713flask = "^3.0"
2714"#;
2715        let result = PypiParser::new()
2716            .parse_content(content, &test_uri())
2717            .unwrap();
2718        for dep in &result.dependencies {
2719            assert_eq!(dep.source, PypiDependencySource::Registry);
2720        }
2721    }
2722}