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}