Skip to main content

deps_pypi/parser/
mod.rs

1//! Python dependency manifest parsing.
2//!
3//! Two manifest shapes share the [`PypiParser`] type and its `parse_result`
4//! representation:
5//! - `pyproject.rs`: TOML-based manifests (PEP 621, PEP 735, Poetry, PEP
6//!   517/518 build-system requires).
7//! - `requirements.rs`: line-oriented `requirements.txt`/`constraints.txt`
8//!   files (pip's requirements file format).
9//!
10//! Both paths funnel every PEP 508 requirement string through the shared
11//! `PypiParser::parse_pep508_requirement` below (private — an implementation
12//! detail, not part of the public API), so hover, diagnostics, markers and
13//! extras render identically regardless of which manifest shape produced the
14//! dependency.
15
16use crate::error::{PypiError, Result};
17use crate::types::{PypiDependency, PypiDependencySection, PypiDependencySource};
18use deps_core::lsp_helpers::LineOffsetTable;
19use pep508_rs::{MarkerTree, Requirement, VersionOrUrl};
20use std::any::Any;
21use std::str::FromStr;
22use tower_lsp_server::ls_types::{Position, Range, Uri};
23
24pub mod pyproject;
25pub mod requirements;
26
27/// Marker expressions longer than this are not handed to `pep508_rs`'s
28/// recursive-descent parser, which has no depth limit and can overflow the
29/// stack on deeply nested expressions (verified: ~5000 nested parens, ~10 KiB,
30/// aborts the process; ~4000 survives). Text over the cap falls back to its
31/// raw, unnormalized form (see [`bounded_marker_fallback`]) rather than
32/// being parsed.
33const MAX_MARKER_LEN: usize = 2048;
34
35/// Requirement strings (name + extras + version specifier, excluding the
36/// marker section) longer than this are rejected outright rather than handed
37/// to `pep508_rs`'s extras-list parser, which runs in O(n²) on the extras
38/// list (verified: `pkg[a,a,a,...]` with ~256 KiB of repeated single-char
39/// extras takes ~400ms to parse; the cost grows quadratically with input
40/// length). Real-world requirement lines are well under 200 bytes, so this
41/// cap leaves ample headroom while bounding worst-case parse time for a
42/// single requirement to well under a millisecond.
43const MAX_REQUIREMENT_LEN: usize = 4096;
44
45/// Generous bound on PEP 508 marker parenthesis nesting depth.
46///
47/// `pep508_rs`'s recursive-descent marker parser recurses once per nesting
48/// level with no depth limit, so a marker can overflow the stack from
49/// nesting alone while staying well under [`MAX_MARKER_LEN`] — a marker can
50/// pack roughly one `(`/`)` pair per 2 bytes (verified: 1016 levels in 2047
51/// bytes aborts the process on a 256 KiB stack). Real-world markers rarely
52/// nest more than 2-3 levels, so this cap leaves ample headroom.
53const MAX_MARKER_DEPTH: u32 = 32;
54
55/// PEP 508 marker environment variable names (`marker.rs`'s `env_var`
56/// production), plus `extra`. A genuine marker expression is built around
57/// one of these; text that lacks all of them is not a marker at all — e.g.
58/// leftover extras/version syntax that ended up past a `;` by accident.
59const MARKER_VARIABLE_NAMES: &[&str] = &[
60    "python_version",
61    "python_full_version",
62    "os_name",
63    "sys_platform",
64    "platform_release",
65    "platform_system",
66    "platform_version",
67    "platform_machine",
68    "platform_python_implementation",
69    "implementation_name",
70    "implementation_version",
71    "extra",
72];
73
74/// Independent, generous ceiling on marker text retained by the raw-marker
75/// fallback below (triggered when text is too long or too deeply nested for
76/// `pep508_rs`'s parser). Kept well above [`MAX_MARKER_LEN`] so
77/// legitimate-if-verbose marker chains that only barely miss the parser's
78/// cap are still retained, while still bounding what a crafted
79/// marker-shaped payload can push into a dependency's `markers` field and,
80/// from there, hover.
81const MAX_FALLBACK_MARKER_LEN: usize = MAX_MARKER_LEN * 4;
82
83/// One lexical unit of a candidate marker expression, as classified by
84/// [`tokenize_marker`] and consumed by [`validate_marker_grammar`].
85#[derive(Clone, Copy, PartialEq, Eq)]
86enum MarkerToken {
87    /// `(`, opening a grouped sub-expression.
88    LParen,
89    /// `)`, closing a grouped sub-expression.
90    RParen,
91    /// The `and` boolean connective.
92    And,
93    /// The `or` boolean connective.
94    Or,
95    /// A comparison operator: `==`, `!=`, `<=`, `>=`, `~=`, `<`, `>`.
96    CmpOp,
97    /// The `in` keyword (also the second half of a `not in` operator).
98    In,
99    /// The `not` keyword, valid only as the first half of `not in`.
100    Not,
101    /// A recognized marker variable name (an operand).
102    Var,
103    /// A quoted string literal (an operand).
104    Str,
105}
106
107/// Tokenizes `text` for [`looks_like_marker`], or returns `None` if any byte
108/// or word doesn't fit one of PEP 508's marker-expression lexical classes.
109///
110/// Quote-matching mirrors `pep508_rs`'s tokenizer: a quote opens on an
111/// unquoted `'`/`"` and closes on the next occurrence of that same byte, with
112/// no escape handling — so quoted content, including non-ASCII bytes, is
113/// opaque to this scanner.
114fn tokenize_marker(text: &str) -> Option<Vec<MarkerToken>> {
115    let bytes = text.as_bytes();
116    let mut i = 0;
117    let mut tokens: Vec<MarkerToken> = Vec::new();
118
119    while i < bytes.len() {
120        match bytes[i] {
121            b' ' | b'\t' => i += 1,
122            b'(' => {
123                tokens.push(MarkerToken::LParen);
124                i += 1;
125            }
126            b')' => {
127                tokens.push(MarkerToken::RParen);
128                i += 1;
129            }
130            quote @ (b'\'' | b'"') => {
131                i += 1;
132                while i < bytes.len() && bytes[i] != quote {
133                    i += 1;
134                }
135                if i >= bytes.len() {
136                    return None; // unterminated string literal
137                }
138                i += 1; // consume closing quote
139                tokens.push(MarkerToken::Str);
140            }
141            b'=' | b'!' | b'<' | b'>' | b'~' => {
142                let start = i;
143                while i < bytes.len() && matches!(bytes[i], b'=' | b'!' | b'<' | b'>' | b'~') {
144                    i += 1;
145                }
146                if !matches!(
147                    &text[start..i],
148                    "==" | "!=" | "<=" | ">=" | "~=" | "<" | ">"
149                ) {
150                    return None;
151                }
152                tokens.push(MarkerToken::CmpOp);
153            }
154            b if b.is_ascii_alphanumeric() || b == b'_' => {
155                let start = i;
156                while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
157                    i += 1;
158                }
159                let word = &text[start..i];
160                tokens.push(if MARKER_VARIABLE_NAMES.contains(&word) {
161                    MarkerToken::Var
162                } else {
163                    match word {
164                        "and" => MarkerToken::And,
165                        "or" => MarkerToken::Or,
166                        "not" => MarkerToken::Not,
167                        "in" => MarkerToken::In,
168                        _ => return None,
169                    }
170                });
171            }
172            _ => return None,
173        }
174    }
175
176    Some(tokens)
177}
178
179/// One step in the bounded grammar walk performed by [`validate_marker_grammar`].
180#[derive(Clone, Copy, PartialEq, Eq)]
181enum ClauseState {
182    /// Expecting the start of a new atom: `(` or an operand.
183    ExpectAtom,
184    /// Consumed a clause's first operand; expecting its comparison operator.
185    ExpectOperator,
186    /// Consumed `not`; the only valid continuation is `in`.
187    ExpectIn,
188    /// Consumed a clause's operator; expecting its second operand.
189    ExpectSecondOperand,
190    /// Just completed a full atom (clause or parenthesized group); expecting
191    /// `and`, `or`, `)`, or end of input.
192    AfterAtom,
193}
194
195/// Validates that `tokens` decomposes as a PEP 508 `marker_expr`:
196///
197/// ```text
198/// marker_expr    := marker_and ('or' marker_and)*
199/// marker_and     := marker_atom ('and' marker_atom)*
200/// marker_atom    := '(' marker_expr ')' | marker_clause
201/// marker_clause  := operand comparison_op operand
202/// operand        := marker_variable | quoted_string
203/// comparison_op  := '==' | '!=' | '<=' | '>=' | '<' | '>' | '~=' | 'in' | 'not' 'in'
204/// ```
205///
206/// Each `marker_clause` consumes exactly one `operand op operand` triple, so
207/// an operand can never be shared between two clauses — this is what rejects
208/// chained comparisons like `a == b == c` or `a in b in c`, which PEP 508's
209/// grammar has no production for (`pep508_rs` itself rejects them).
210///
211/// This walks the token stream once, left to right, tracking only the
212/// current [`ClauseState`] and a paren-nesting counter — no recursion and no
213/// per-level state stack, so nesting depth is unbounded (needed: a genuine
214/// marker can nest hundreds of levels within [`MAX_FALLBACK_MARKER_LEN`] and
215/// must still validate, since parenthesis-nesting depth is already handled
216/// separately by routing such text away from `pep508_rs`'s own unbounded
217/// recursive-descent parser — see [`MAX_MARKER_DEPTH`], [`marker_too_deep`]).
218/// A closing `)` always returns the *enclosing* level to `AfterAtom`
219/// regardless of depth, since a parenthesized group is itself a complete atom
220/// to whatever follows it — no per-level bookkeeping beyond the depth count
221/// is needed to know that.
222fn validate_marker_grammar(tokens: &[MarkerToken]) -> bool {
223    let mut state = ClauseState::ExpectAtom;
224    let mut depth: usize = 0;
225
226    for &tok in tokens {
227        state = match (state, tok) {
228            (ClauseState::ExpectAtom, MarkerToken::LParen) => {
229                depth += 1;
230                ClauseState::ExpectAtom
231            }
232            (ClauseState::ExpectAtom, MarkerToken::Var | MarkerToken::Str) => {
233                ClauseState::ExpectOperator
234            }
235            (ClauseState::ExpectOperator, MarkerToken::CmpOp | MarkerToken::In) => {
236                ClauseState::ExpectSecondOperand
237            }
238            (ClauseState::ExpectOperator, MarkerToken::Not) => ClauseState::ExpectIn,
239            (ClauseState::ExpectIn, MarkerToken::In) => ClauseState::ExpectSecondOperand,
240            (ClauseState::ExpectSecondOperand, MarkerToken::Var | MarkerToken::Str) => {
241                ClauseState::AfterAtom
242            }
243            (ClauseState::AfterAtom, MarkerToken::And | MarkerToken::Or) => ClauseState::ExpectAtom,
244            (ClauseState::AfterAtom, MarkerToken::RParen) if depth > 0 => {
245                depth -= 1;
246                ClauseState::AfterAtom
247            }
248            _ => return false,
249        };
250    }
251
252    state == ClauseState::AfterAtom && depth == 0
253}
254
255/// Returns `true` if `text` tokenizes and parses cleanly as a PEP 508
256/// `marker_expr` (see [`validate_marker_grammar`] for the grammar).
257///
258/// This is enough to reject the length/depth-bypass fallback's actual threat
259/// model: arbitrary attacker-controlled bytes with no marker structure at
260/// all — e.g. PEP 508 extras/version syntax that ends up past a `;` by
261/// accident (#261), a marker keyword padded with unrelated filler, bare
262/// repetition of recognized marker-variable tokens with no operator between
263/// them, or operand-sharing chained comparisons (`a == b == c`, `a in b in
264/// c`) that a per-operand adjacency check cannot distinguish from a real
265/// clause. Genuine, if oversized or deeply-nested, marker expressions still
266/// tokenize and parse cleanly and are preserved verbatim.
267fn looks_like_marker(text: &str) -> bool {
268    tokenize_marker(text).is_some_and(|tokens| validate_marker_grammar(&tokens))
269}
270
271/// Bounds raw marker text before it is stored verbatim on a dependency's
272/// `markers` field (and, from there, rendered into hover), for text that
273/// bypassed `pep508_rs`'s parser entirely because it was too long or too
274/// deeply nested (see [`marker_too_deep`], [`MAX_MARKER_LEN`]).
275///
276/// Text that isn't plausibly a marker expression ([`looks_like_marker`]) or
277/// that exceeds [`MAX_FALLBACK_MARKER_LEN`] is dropped rather than
278/// retained.
279fn bounded_marker_fallback(raw: &str) -> Option<String> {
280    if raw.is_empty() || raw.len() > MAX_FALLBACK_MARKER_LEN || !looks_like_marker(raw) {
281        return None;
282    }
283    Some(raw.to_string())
284}
285
286/// Returns `true` if `marker` nests parentheses deeper than [`MAX_MARKER_DEPTH`].
287///
288/// Tracks quoted-string state the same way `pep508_rs`'s tokenizer does
289/// (`marker/parse.rs`: a quote opens on an unquoted `'`/`"` and closes on the
290/// next occurrence of that same character, with no escape handling) so that
291/// `(`/`)` bytes inside a quoted marker value — e.g. `extra == ')'` — are not
292/// mistaken for real nesting. A scanner that counted paren bytes unconditionally
293/// could be tricked into undercounting depth by parentheses hidden in quoted
294/// values while the real recursive-descent parser, which treats quoted content
295/// as opaque, keeps recursing.
296fn marker_too_deep(marker: &str) -> bool {
297    let mut depth: u32 = 0;
298    let mut quote: Option<u8> = None;
299    for b in marker.bytes() {
300        if let Some(q) = quote {
301            if b == q {
302                quote = None;
303            }
304            continue;
305        }
306        match b {
307            b'\'' | b'"' => quote = Some(b),
308            b'(' => {
309                depth += 1;
310                if depth > MAX_MARKER_DEPTH {
311                    return true;
312                }
313            }
314            b')' => depth = depth.saturating_sub(1),
315            _ => {}
316        }
317    }
318    false
319}
320
321/// Longest prefix of an attacker-controlled requirement/dependency string
322/// logged verbatim by [`truncate_for_log`].
323const MAX_LOGGED_LEN: usize = 200;
324
325/// Truncates `s` to a safe-to-log prefix, so a warn!/debug! call site can
326/// never turn into a multi-megabyte synchronous write to the (by default,
327/// unbuffered, stderr-backed) log sink — exactly the size range
328/// [`MAX_REQUIREMENT_LEN`] exists to reject, up to the ~10 MB overall file
329/// cap. Falls back to `s` unchanged when it's already short enough.
330fn truncate_for_log(s: &str) -> std::borrow::Cow<'_, str> {
331    if s.len() <= MAX_LOGGED_LEN {
332        return std::borrow::Cow::Borrowed(s);
333    }
334    let boundary = s.floor_char_boundary(MAX_LOGGED_LEN);
335    std::borrow::Cow::Owned(format!("{}... ({} bytes total)", &s[..boundary], s.len()))
336}
337
338/// A `-r`/`-c` reference to another requirements/constraints file.
339///
340/// Surfaced as a `textDocument/documentLink` so it can be ctrl/cmd-clicked
341/// open. Only produced by [`PypiParser::parse_requirements`] —
342/// `pyproject.toml` has no equivalent file-to-file reference.
343#[derive(Debug, Clone)]
344pub struct RequirementRef {
345    /// Source range of the referenced path text on the option line.
346    pub range: tower_lsp_server::ls_types::Range,
347    /// The target as written in the file (e.g. `"constraints.txt"`), not yet
348    /// resolved to an absolute URI — resolution happens against the
349    /// containing document's URI in `PypiEcosystem`'s
350    /// [`Ecosystem::generate_document_links`](deps_core::Ecosystem::generate_document_links) override.
351    pub target: String,
352}
353
354/// Parse result containing all dependencies from a Python dependency manifest.
355///
356/// Stores dependencies and optional workspace information for LSP operations.
357#[derive(Debug, Clone)]
358pub struct ParseResult {
359    /// All dependencies found in the manifest
360    pub dependencies: Vec<PypiDependency>,
361    /// Workspace root path (None for Python - no workspace concept like Cargo)
362    pub workspace_root: Option<std::path::PathBuf>,
363    /// URI of the parsed file
364    pub uri: Uri,
365    /// `-r`/`-c` file references found in a requirements file (always empty
366    /// for `pyproject.toml`).
367    pub document_links: Vec<RequirementRef>,
368    /// Every private-index chain this file's `--index-url`/`--extra-index-url`/Poetry-source/
369    /// uv-index declarations imply (spec FR-002/003/005/007/013), ready for
370    /// `PypiRegistry::register_chain`/`register_named_source` — the only point where this
371    /// per-document resolution and the long-lived, shared `PypiRegistry` router meet (see
372    /// `PypiEcosystem::parse_manifest`). Empty for a file with no such declaration (US-004).
373    pub resolved_chains: Vec<crate::config::ResolvedChain>,
374}
375
376impl deps_core::ParseResult for ParseResult {
377    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
378        self.dependencies
379            .iter()
380            .map(|d| d as &dyn deps_core::Dependency)
381            .collect()
382    }
383
384    fn workspace_root(&self) -> Option<&std::path::Path> {
385        self.workspace_root.as_deref()
386    }
387
388    fn uri(&self) -> &Uri {
389        &self.uri
390    }
391
392    fn as_any(&self) -> &dyn Any {
393        self
394    }
395}
396
397/// Parser for Python dependency manifests.
398///
399/// Supports `pyproject.toml` (PEP 621, PEP 735, Poetry, PEP 517/518) via
400/// [`PypiParser::parse_content`] and `requirements.txt`/`constraints.txt`
401/// (pip's requirements file format) via
402/// [`PypiParser::parse_requirements`].
403///
404/// # Examples
405///
406/// ```no_run
407/// use deps_pypi::parser::PypiParser;
408/// use tower_lsp_server::ls_types::Uri;
409///
410/// let content = r#"
411/// [project]
412/// dependencies = ["requests>=2.28.0", "flask[async]>=3.0"]
413/// "#;
414///
415/// let parser = PypiParser::new();
416/// let uri = Uri::from_file_path("/test/pyproject.toml").unwrap();
417/// let result = parser.parse_content(content, &uri).unwrap();
418/// assert_eq!(result.dependencies.len(), 2);
419/// ```
420pub struct PypiParser;
421
422impl PypiParser {
423    /// Create a new PyPI parser.
424    pub const fn new() -> Self {
425        Self
426    }
427
428    /// Parse a PEP 508 requirement string, shared by every manifest shape.
429    ///
430    /// Example: `requests[security,socks]>=2.28.0,<3.0; python_version>='3.8'`
431    ///
432    /// `span` is the requirement string's source byte range (used for both
433    /// `Position` tracking and, via [`span_to_range`], UTF-16-correct
434    /// `markers_range` computation). TOML callers pass `value.span.start..value.span.end`;
435    /// the requirements.txt line parser passes the requirement text's absolute
436    /// byte offsets directly, with no TOML dependency.
437    fn parse_pep508_requirement(
438        &self,
439        requirement_str: &str,
440        span: Option<std::ops::Range<usize>>,
441        content: &str,
442        line_table: &LineOffsetTable,
443    ) -> Result<PypiDependency> {
444        let base_position = span
445            .clone()
446            .map(|r| span_start(content, line_table, toml_span::Span::new(r.start, r.end)));
447
448        // `;` never appears inside a version/extras clause, so the first
449        // occurrence unambiguously anchors the marker section (direct-reference
450        // URLs containing `;` are a known, documented edge case - see #<follow-up>).
451        let semicolon_idx = requirement_str.find(';');
452
453        // The name/extras/version portion — excluding the marker section —
454        // over the cap is rejected outright rather than handed to
455        // `pep508_rs`, whose extras-list parser runs in O(n²) on the extras
456        // list — an attacker-controlled `pkg[a,a,a,...]` list can otherwise
457        // block a tokio worker for minutes. Measured against this portion
458        // only (not the whole `requirement_str`) so an oversized *marker*
459        // still takes the `MAX_MARKER_LEN` graceful-degradation path below
460        // instead of being rejected here too. Unlike that marker guard,
461        // there's no cheap subset to fall back to for extras: the quadratic
462        // cost lives in the name/extras/version portion itself, so the whole
463        // dependency is skipped instead.
464        let pre_marker_len = semicolon_idx.unwrap_or(requirement_str.len());
465        if pre_marker_len > MAX_REQUIREMENT_LEN {
466            tracing::warn!(
467                "Requirement string exceeds the {MAX_REQUIREMENT_LEN}-byte length cap ({} bytes), skipping: {}",
468                pre_marker_len,
469                truncate_for_log(requirement_str)
470            );
471            return Err(PypiError::RequirementTooLong {
472                len: pre_marker_len,
473                max: MAX_REQUIREMENT_LEN,
474            });
475        }
476
477        // Pathologically long or deeply nested marker expressions can overflow
478        // the stack in `pep508_rs`'s unbounded recursive-descent parser. Parse
479        // only the name/version/extras portion and skip marker normalization
480        // instead of handing the oversized/deeply-nested marker text to the
481        // parser.
482        let marker_too_complex = semicolon_idx.is_some_and(|idx| {
483            let marker_text = &requirement_str[idx..];
484            marker_text.len() > MAX_MARKER_LEN || marker_too_deep(marker_text)
485        });
486        let parse_str = if marker_too_complex {
487            &requirement_str[..semicolon_idx.unwrap()]
488        } else {
489            requirement_str
490        };
491
492        let requirement = Requirement::from_str(parse_str)
493            .map_err(|e| PypiError::InvalidDependencySpec { source: e })?;
494
495        let name = requirement.name.to_string();
496        let name_range = base_position
497            .map(|pos| {
498                Range::new(
499                    pos,
500                    Position::new(pos.line, pos.character + name.len() as u32),
501                )
502            })
503            .unwrap_or_default();
504
505        // Version/extras text never extends past the marker section.
506        let version_end = semicolon_idx.unwrap_or(requirement_str.len());
507
508        let (version_req, version_range, source) = match requirement.version_or_url {
509            Some(VersionOrUrl::VersionSpecifier(specs)) => {
510                let version_str = specs.to_string();
511                // Derive `start_offset` by scanning the *raw* requirement text
512                // for the first specifier character at bracket-depth 0, rather
513                // than computing it from the pep508-normalized name and
514                // rejoined extras — those diverge from source spacing/casing
515                // (spaced extras `flask [async] >= 3.0`, a normalized name
516                // like `my-pkg` for source `my__pkg`), which would otherwise
517                // point `version_range` at the wrong bytes.
518                let mut depth = 0usize;
519                let derived = requirement_str[..version_end]
520                    .char_indices()
521                    .find_map(|(i, c)| match c {
522                        '[' => {
523                            depth += 1;
524                            None
525                        }
526                        ']' => {
527                            depth = depth.saturating_sub(1);
528                            None
529                        }
530                        '=' | '<' | '>' | '!' | '~' if depth == 0 => Some(i),
531                        _ => None,
532                    });
533                let start_offset = derived.unwrap_or_else(|| {
534                    let extras_str_len = if requirement.extras.is_empty() {
535                        0
536                    } else {
537                        let extras_joined = requirement
538                            .extras
539                            .iter()
540                            .map(std::string::ToString::to_string)
541                            .collect::<Vec<_>>()
542                            .join(",");
543                        extras_joined.len() + 2 // +2 for [ and ]
544                    };
545                    name.len() + extras_str_len
546                });
547
548                // Calculate original version length from requirement_str, bounded
549                // at the marker section so the range never overlaps markers_range
550                // (it is the sole TextEdit target for the "update version" code
551                // action, so overlap would delete the marker on accept).
552                // pep508 normalizes version specifiers (e.g., ">=1.7,<2.0" -> ">=1.7, <2.0")
553                // We need the original length for correct position tracking
554                let original_version_len = version_end.saturating_sub(start_offset);
555
556                // `start_offset` is a byte index added directly to `pos.character`,
557                // an LSP UTF-16 code-unit count. Safe only because every character
558                // that can precede a PEP 508 specifier (name, `[`, extras, `]`,
559                // whitespace) is guaranteed ASCII by the PEP 508 grammar — do not
560                // generalize this arithmetic to a context where that isn't true.
561                let version_range = base_position.map(|pos| {
562                    Range::new(
563                        Position::new(pos.line, pos.character + start_offset as u32),
564                        Position::new(
565                            pos.line,
566                            pos.character + start_offset as u32 + original_version_len as u32,
567                        ),
568                    )
569                });
570                (
571                    Some(version_str),
572                    version_range,
573                    PypiDependencySource::Registry,
574                )
575            }
576            Some(VersionOrUrl::Url(url)) => {
577                let url_str = url.to_string();
578                if url_str.starts_with("git+") {
579                    (
580                        None,
581                        None,
582                        PypiDependencySource::Git {
583                            url: url_str,
584                            rev: None,
585                        },
586                    )
587                } else if url_str.ends_with(".whl") || url_str.ends_with(".tar.gz") {
588                    (None, None, PypiDependencySource::Url { url: url_str })
589                } else {
590                    (None, None, PypiDependencySource::Registry)
591                }
592            }
593            None => (None, None, PypiDependencySource::Registry),
594        };
595
596        let extras: Vec<String> = requirement
597            .extras
598            .into_iter()
599            .map(|e| e.to_string())
600            .collect();
601
602        let markers = if marker_too_complex {
603            let raw_marker = requirement_str[semicolon_idx.unwrap() + 1..].trim();
604            if raw_marker.is_empty() {
605                None
606            } else {
607                tracing::warn!(
608                    "Marker expression for '{}' is too complex ({} bytes, over the {}-byte length cap or {}-level nesting cap), skipping normalization",
609                    name,
610                    raw_marker.len(),
611                    MAX_MARKER_LEN,
612                    MAX_MARKER_DEPTH
613                );
614                bounded_marker_fallback(raw_marker)
615            }
616        } else {
617            requirement.marker.try_to_string()
618        };
619
620        // The marker text starts right after the first `;` in the original
621        // requirement string; `pep508_rs` doesn't expose a source span for it.
622        let markers_range = markers.as_ref().and_then(|_| {
623            let idx = semicolon_idx?;
624            span.map(|r| {
625                span_to_range(
626                    content,
627                    line_table,
628                    toml_span::Span::new(r.start + idx + 1, r.end),
629                )
630            })
631        });
632
633        Ok(PypiDependency {
634            name: name.into(),
635            name_range,
636            version_req: version_req.map(Into::into),
637            version_range,
638            extras,
639            extras_range: None,
640            markers,
641            markers_range,
642            section: PypiDependencySection::Dependencies,
643            source,
644        })
645    }
646}
647
648impl Default for PypiParser {
649    fn default() -> Self {
650        Self::new()
651    }
652}
653
654/// Convert the start of a byte span to an LSP Position.
655///
656/// toml-span string spans exclude surrounding quotes, so the span start
657/// points directly to the first character of the string content.
658fn span_start(content: &str, line_table: &LineOffsetTable, span: toml_span::Span) -> Position {
659    line_table.byte_offset_to_position(content, span.start)
660}
661
662/// Converts a byte span to an LSP `Range` using the pre-computed line table.
663fn span_to_range(content: &str, line_table: &LineOffsetTable, span: toml_span::Span) -> Range {
664    let start = line_table.byte_offset_to_position(content, span.start);
665    let end = line_table.byte_offset_to_position(content, span.end);
666    Range::new(start, end)
667}
668
669/// Parses a raw PEP 508 marker expression and serializes it back through
670/// `MarkerTree` for consistency with the PEP 621 requirement-string path,
671/// which canonicalizes markers on serialization (e.g. `python_version`
672/// comparisons become `python_full_version`).
673///
674/// Returns `None` for an empty/whitespace-only expression, or one that
675/// normalizes to the trivially-true marker (which has no string form, e.g.
676/// `os_name == 'a' or os_name != 'a'`) — matching the PEP 621 path, which
677/// likewise yields `None` for an absent or always-true marker. If the
678/// expression fails to parse, or exceeds [`MAX_MARKER_LEN`] or
679/// [`MAX_MARKER_DEPTH`] and bypasses the parser entirely, it falls back to
680/// [`bounded_marker_fallback`], which drops text that isn't plausibly a
681/// marker expression or that exceeds [`MAX_FALLBACK_MARKER_LEN`].
682fn normalize_marker_string(raw: &str) -> Option<String> {
683    let trimmed = raw.trim();
684    if trimmed.is_empty() {
685        return None;
686    }
687    if trimmed.len() > MAX_MARKER_LEN || marker_too_deep(trimmed) {
688        tracing::warn!(
689            "Marker expression is too complex ({} bytes, over the {}-byte length cap or {}-level nesting cap), skipping normalization: '{}'",
690            trimmed.len(),
691            MAX_MARKER_LEN,
692            MAX_MARKER_DEPTH,
693            truncate_for_log(trimmed)
694        );
695        return bounded_marker_fallback(trimmed);
696    }
697    match MarkerTree::from_str(trimmed) {
698        Ok(tree) => tree.try_to_string(),
699        Err(e) => {
700            tracing::warn!("Failed to parse marker expression '{}': {}", trimmed, e);
701            bounded_marker_fallback(trimmed)
702        }
703    }
704}