Skip to main content

deps_pypi/parser/
requirements.rs

1//! Line-oriented parsing for `requirements.txt` / `constraints.txt` — pip's
2//! requirements file format.
3//!
4//! Every requirement line is routed through the shared
5//! [`PypiParser::parse_pep508_requirement`](super::PypiParser) — the same
6//! function the `pyproject.toml` paths use — so hover, diagnostics, markers
7//! and extras render identically to a PEP 621 dependency string. This
8//! module's job is purely to turn free-form line-oriented text into
9//! `(requirement text, absolute byte span)` pairs, plus a content gate that
10//! keeps prose files that happen to match the routing pattern (e.g.
11//! `product-requirements.txt`) from producing spurious network requests and
12//! diagnostics.
13
14use super::{ParseResult, PypiParser, RequirementRef};
15use crate::config::PypiIndexConfig;
16use crate::error::Result;
17use crate::types::{PypiDependencySection, PypiDependencySource};
18use deps_core::lsp_helpers::LineOffsetTable;
19use deps_core::net_policy::RegistryAccessPolicy;
20use tower_lsp_server::ls_types::{Range, Uri};
21
22/// Pip option tokens recognized on an option line (a line whose first
23/// whitespace-delimited token starts with `-`). Matched by exact equality
24/// against the token's name — the part before an `=` for the long `--opt=value`
25/// spelling — see [`parse_requirements`] — so an unrecognized `-`-leading line
26/// (e.g. a markdown bullet `- item`) is not silently treated as an option.
27const KNOWN_OPTIONS: &[&str] = &[
28    "-r",
29    "-c",
30    "-e",
31    "-i",
32    "-f",
33    "--requirement",
34    "--constraint",
35    "--editable",
36    "--index-url",
37    "--extra-index-url",
38    "--find-links",
39    "--trusted-host",
40    "--pre",
41    "--no-binary",
42    "--only-binary",
43    "--no-index",
44    "--prefer-binary",
45    "--require-hashes",
46    "--use-feature",
47    "--global-option",
48    "--config-settings",
49    "--hash",
50];
51
52/// URL/scheme prefixes of a nameless requirement (§3.6): a bare direct
53/// reference with no package name, which must never reach the PEP 508
54/// parser but also must not count as a parse failure.
55const NAMELESS_URL_PREFIXES: &[&str] = &["http://", "https://", "git+", "file:"];
56
57/// Archive file suffixes of a nameless requirement (a bare wheel/sdist path).
58const NAMELESS_ARCHIVE_SUFFIXES: &[&str] = &[".whl", ".tar.gz", ".tar.bz2", ".tar.xz", ".zip"];
59
60impl PypiParser {
61    /// Parses a `requirements.txt`/`constraints.txt` file (pip's
62    /// requirements file format) and extracts all dependencies.
63    ///
64    /// Reuses the shared PEP 508 machinery
65    /// (`PypiParser::parse_pep508_requirement`) for every requirement line,
66    /// so hover, diagnostics, markers and extras render identically to
67    /// `pyproject.toml`. A line that fails to parse is logged and skipped
68    /// rather than failing the whole file — a requirements file is
69    /// free-form text under active editing, and a half-typed line must not
70    /// blank out hover for every other dependency.
71    ///
72    /// Applies a content gate before returning: since `.txt` is routed here
73    /// by filename pattern rather than a fixed name, a prose file that
74    /// happens to match (`product-requirements.txt`) would otherwise send a
75    /// PyPI request and emit an "Unknown package" warning for every
76    /// single-word line. The gate keeps the parsed dependencies only if the
77    /// file shows a strong pip signal — a recognized option, or a
78    /// successfully parsed line whose dependency carries a version
79    /// requirement or a Git/URL source — or, when `require_strong_signal` is
80    /// `false`, more lines parsed than failed — real prose fails every line,
81    /// but a hand-written unpinned `requests\nflask\nnumpy` (or that file
82    /// mid-edit, with one partially typed line) survives. The signal is read
83    /// off the *parsed* dependency rather than scanned from raw text, so an
84    /// operator- or `@`-looking substring inside a prose sentence (an email
85    /// address, a comparison) cannot short-circuit the gate.
86    ///
87    /// `require_strong_signal` drops that ratio-based arm entirely, requiring
88    /// the strong-signal check alone. Set this when the caller routed to this
89    /// parser via a weaker signal than a basename match — PyPI's
90    /// `requirements/*.txt` [`Ecosystem::manifest_directory_patterns`](deps_core::Ecosystem::manifest_directory_patterns)
91    /// fallback matches *every* `.txt` file under a directory literally named
92    /// `requirements/`, including requirements-engineering docs folders with
93    /// no relation to Python; without this, prose lines that happen to parse
94    /// as bare PEP 508 names (`"Introduction"`, `"Scope"`) can still clear the
95    /// ratio arm and trigger live PyPI lookups on what is not a manifest at
96    /// all (#452 S6).
97    ///
98    /// # Errors
99    ///
100    /// Never actually errs — the `Result` return type exists for symmetry
101    /// with [`PypiParser::parse_content`](super::PypiParser::parse_content).
102    ///
103    /// # Examples
104    ///
105    /// ```no_run
106    /// use deps_pypi::parser::PypiParser;
107    /// use tower_lsp_server::ls_types::Uri;
108    ///
109    /// let parser = PypiParser::new();
110    /// let uri = Uri::from_file_path("/project/requirements.txt").unwrap();
111    /// let result = parser
112    ///     .parse_requirements("requests==2.31.0\nflask>=3.0\n", &uri, false)
113    ///     .unwrap();
114    /// assert_eq!(result.dependencies.len(), 2);
115    /// ```
116    pub fn parse_requirements(
117        &self,
118        content: &str,
119        uri: &Uri,
120        require_strong_signal: bool,
121    ) -> Result<ParseResult> {
122        self.parse_requirements_with_policy(
123            content,
124            uri,
125            require_strong_signal,
126            &RegistryAccessPolicy::default(),
127        )
128    }
129
130    /// Like [`Self::parse_requirements`], but resolves `--index-url`/`--extra-index-url`
131    /// declarations (spec FR-001–FR-006) against `policy` rather than the default
132    /// (`public_only`) — the production entry point `PypiEcosystem` calls, threading through
133    /// its own live `RegistryAccessPolicy` handle.
134    ///
135    /// # Errors
136    ///
137    /// Same as [`Self::parse_requirements`] — never actually errs.
138    pub fn parse_requirements_with_policy(
139        &self,
140        content: &str,
141        uri: &Uri,
142        require_strong_signal: bool,
143        policy: &RegistryAccessPolicy,
144    ) -> Result<ParseResult> {
145        // Two-pass parse (fixes S2): pip applies `--index-url`/`--extra-index-url`
146        // file-wide, not from-this-line-down, so every declaration must be collected before
147        // any dependency's source is resolved — a dependency declared *before* a late
148        // `--index-url` line must still route through it.
149        let config = collect_index_config(content, policy);
150
151        let line_table = LineOffsetTable::new(content);
152        let mut dependencies = Vec::new();
153        let mut document_links = Vec::new();
154        let mut strong_signal = false;
155        let mut failed_lines: usize = 0;
156
157        let mut lines = content.lines().enumerate().peekable();
158        while let Some((line_idx, raw_line)) = lines.next() {
159            let Some(mut line_start) = line_table.line_start(line_idx) else {
160                continue;
161            };
162
163            // A leading BOM is skipped like whitespace rather than stripped
164            // from `content` — stripping would desync every later offset by
165            // 3 bytes from the document the editor holds.
166            let line = if line_idx == 0 {
167                match raw_line.strip_prefix('\u{feff}') {
168                    Some(stripped) => {
169                        line_start += raw_line.len() - stripped.len();
170                        stripped
171                    }
172                    None => raw_line,
173                }
174            } else {
175                raw_line
176            };
177
178            // Cut at the first `#` at index 0 or preceded by ASCII
179            // whitespace (pip's own `COMMENT_RE`), which protects URL
180            // fragments (`#egg=name`) that are never whitespace-preceded.
181            // Deliberately quote-unaware, matching pip's identical behavior:
182            // `pkg==1.0; extra == "a #b"` mis-cuts inside the quoted marker,
183            // same as it would in pip itself.
184            let without_comment = strip_comment(line);
185            let trimmed = without_comment.trim();
186            if trimmed.is_empty() {
187                continue;
188            }
189
190            let leading_ws = without_comment.len() - without_comment.trim_start().len();
191            let abs_start = line_start + leading_ws;
192
193            // A line ending in `\` (after comment-stripping) is a
194            // continuation: parse the requirement from this first physical
195            // line alone, and consume the following continuation lines
196            // without parsing them. `version_range` is nulled below whenever
197            // a continuation was present — continuations in practice exist
198            // to carry `--hash`/per-requirement options, so suppressing the
199            // "update version" edit is correct in every realistic case.
200            let (text, had_continuation) = match trimmed.strip_suffix('\\') {
201                Some(stripped) => {
202                    while let Some((_, next_raw)) = lines.peek() {
203                        let continues = strip_comment(next_raw).trim().ends_with('\\');
204                        lines.next();
205                        if !continues {
206                            break;
207                        }
208                    }
209                    (stripped.trim_end(), true)
210                }
211                None => (trimmed, false),
212            };
213
214            // Option lines: recognized by an exact match on the token's name
215            // (accepting both `--opt value` and `--opt=value` spellings) so
216            // an unrecognized `-`-leading line (a markdown bullet `- item`)
217            // counts as a parse failure instead of being silently skipped.
218            if let Some(first_token) = text.split_whitespace().next()
219                && first_token.starts_with('-')
220            {
221                let option_name = first_token.split('=').next().unwrap_or(first_token);
222                if KNOWN_OPTIONS.contains(&option_name) {
223                    strong_signal = true;
224                    if matches!(option_name, "-r" | "-c" | "--requirement" | "--constraint")
225                        && let Some((target, target_offset)) =
226                            extract_option_target(first_token, text)
227                    {
228                        let target_abs_start = abs_start + target_offset;
229                        let target_abs_end = target_abs_start + target.len();
230                        document_links.push(RequirementRef {
231                            range: Range::new(
232                                line_table.byte_offset_to_position(content, target_abs_start),
233                                line_table.byte_offset_to_position(content, target_abs_end),
234                            ),
235                            target: target.to_string(),
236                        });
237                    }
238                } else {
239                    failed_lines += 1;
240                }
241                continue;
242            }
243
244            // Per-requirement options (`--hash=...`, `--global-option=...`):
245            // cut at the first whitespace-delimited `--` token.
246            let (req_text, had_hash_option) = split_requirement_options(text);
247            let req_text = req_text.trim_end();
248            if req_text.is_empty() {
249                continue;
250            }
251
252            // A bare URL, filesystem path, or archive file has no package
253            // name and must never reach the PEP 508 parser, but is not a
254            // parse failure either — `name @ https://...` (which does have
255            // a name) is unaffected and falls through to the parser below.
256            if is_nameless_requirement(req_text) {
257                continue;
258            }
259
260            let abs_end = abs_start + req_text.len();
261            match self.parse_pep508_requirement(
262                req_text,
263                Some(abs_start..abs_end),
264                content,
265                &line_table,
266            ) {
267                Ok(mut dep) => {
268                    dep.section = PypiDependencySection::Requirements;
269                    if had_continuation || had_hash_option {
270                        dep.version_range = None;
271                    }
272                    // A strong signal is derived from the *parsed* dependency,
273                    // not a raw-text scan of the line: scanning for tokens like
274                    // `@` or `>=` over free-form text is fooled by an email
275                    // address or a comparison-looking sentence fragment
276                    // ("Author: jane@example.com"), which would otherwise
277                    // short-circuit the gate this heuristic exists to enforce.
278                    if !strong_signal
279                        && (dep.version_req.is_some()
280                            || matches!(
281                                dep.source,
282                                PypiDependencySource::Git { .. } | PypiDependencySource::Url { .. }
283                            ))
284                    {
285                        strong_signal = true;
286                    }
287                    // FR-002/003/005/006: a plain registry-sourced dependency routes through
288                    // this file's collected `--index-url`/`--extra-index-url` config; a
289                    // Git/Path/Url-sourced one is untouched — those have no PyPI index
290                    // routing concept.
291                    if dep.source == PypiDependencySource::Registry {
292                        dep.source = config.resolve_source_for(None);
293                    }
294                    dependencies.push(dep);
295                }
296                // A length-cap rejection is "we refused to parse this",
297                // not evidence the file isn't a requirements file — unlike
298                // a genuine syntax error, it must not count toward
299                // `failed_lines`, or a handful of oversized lines could
300                // starve the keep heuristic below and blank hover/diagnostics
301                // for every legitimate dependency in the file.
302                Err(crate::error::PypiError::RequirementTooLong { len, max }) => {
303                    tracing::warn!(
304                        "Requirements line too long ({len} bytes, max {max}), skipping: {}",
305                        super::truncate_for_log(req_text)
306                    );
307                }
308                Err(e) => {
309                    tracing::debug!(
310                        "Failed to parse requirements line '{}': {e}",
311                        super::truncate_for_log(req_text)
312                    );
313                    failed_lines += 1;
314                }
315            }
316        }
317
318        let keep = strong_signal
319            || (!require_strong_signal
320                && !dependencies.is_empty()
321                && failed_lines < dependencies.len());
322
323        Ok(ParseResult {
324            dependencies: if keep { dependencies } else { Vec::new() },
325            workspace_root: None,
326            uri: uri.clone(),
327            document_links: if keep { document_links } else { Vec::new() },
328            // Any `--index-url`/`--extra-index-url` occurrence sets `strong_signal` (it's a
329            // `KNOWN_OPTIONS` entry), so `keep` is always true whenever `config` has anything
330            // to register — gating on `keep` here only ever discards chains for a prose file
331            // that was never going to register any, never a real one.
332            resolved_chains: if keep {
333                config.resolved_chains()
334            } else {
335                Vec::new()
336            },
337        })
338    }
339}
340
341/// Pass 1 of the two-pass parse (fixes S2): scans every physical line of `content` and
342/// collects every `--index-url <url>`/`--index-url=<url>`/`-i <url>`/`--extra-index-url
343/// <url>`/`--extra-index-url=<url>` occurrence into a [`PypiIndexConfig`], regardless of its
344/// position relative to any dependency line. Mirrors [`PypiParser::parse_requirements_with_policy`]'s
345/// main loop's comment-stripping/trimming, but does not need continuation-joining: an
346/// option's target is read from its own physical line only, matching pip's own line-oriented
347/// option grammar (a continued `--index-url` value is not a realistic real-world shape).
348fn collect_index_config(content: &str, policy: &RegistryAccessPolicy) -> PypiIndexConfig {
349    let mut config = PypiIndexConfig::new();
350
351    for (line_idx, raw_line) in content.lines().enumerate() {
352        // A leading BOM on the first physical line is not ASCII/Unicode whitespace, so
353        // `str::trim()` alone never removes it — without stripping it here the same way the
354        // main parsing loop below does, a file starting with a BOM immediately followed by
355        // `--index-url`/`--extra-index-url` would have that line's leading token read as
356        // `"\u{feff}--index-url"` (fails the `starts_with('-')` check) and silently skip the
357        // whole declaration, leaving every dependency in the file resolving against
358        // `pypi.org` instead (validator finding S1).
359        let line = if line_idx == 0 {
360            raw_line.strip_prefix('\u{feff}').unwrap_or(raw_line)
361        } else {
362            raw_line
363        };
364        let without_comment = strip_comment(line);
365        let trimmed = without_comment.trim();
366        if trimmed.is_empty() {
367            continue;
368        }
369
370        let Some(first_token) = trimmed.split_whitespace().next() else {
371            continue;
372        };
373        if !first_token.starts_with('-') {
374            continue;
375        }
376
377        let option_name = first_token.split('=').next().unwrap_or(first_token);
378        if !matches!(option_name, "--index-url" | "-i" | "--extra-index-url") {
379            continue;
380        }
381
382        let Some((target, _offset)) = extract_option_target(first_token, trimmed) else {
383            continue;
384        };
385
386        match option_name {
387            "--index-url" | "-i" => config.set_primary(target, policy),
388            "--extra-index-url" => config.add_extra(target, policy),
389            _ => unreachable!("matched above"),
390        }
391    }
392
393    config
394}
395
396/// Extracts the target path/URL text and its byte offset within `text` for a
397/// `-r`/`-c`/`--requirement`/`--constraint` option line — either the
398/// `--long=value` spelling (target sliced out of `text` right after the
399/// matched `=`) or the space-separated spelling (target is whatever follows
400/// `first_token`, whitespace-trimmed). Returns `None` when the option carries
401/// no target text at all (a bare `-r` with nothing after it).
402fn extract_option_target<'a>(first_token: &str, text: &'a str) -> Option<(&'a str, usize)> {
403    if let Some(eq_idx) = first_token.find('=') {
404        let after_eq = &text[eq_idx + 1..];
405        // Bounded to just this token's own value (validator finding S2) — a later option on
406        // the same line (e.g. `--index-url=https://x --trusted-host x`) must not be swallowed
407        // into the value, which could otherwise silently produce a mangled-but-technically-
408        // parseable URL once whitespace gets percent-encoded rather than a clean parse
409        // failure.
410        let value_end = after_eq.find(char::is_whitespace).unwrap_or(after_eq.len());
411        let target = &after_eq[..value_end];
412        return (!target.is_empty()).then_some((target, eq_idx + 1));
413    }
414
415    let rest = &text[first_token.len()..];
416    let leading_ws = rest.len() - rest.trim_start().len();
417    let after_ws = &rest[leading_ws..];
418    // Same bound as the `=`-spelling branch above — stop at the next whitespace run rather
419    // than capturing the rest of the line, which would otherwise include any further option
420    // present on the same line.
421    let value_end = after_ws.find(char::is_whitespace).unwrap_or(after_ws.len());
422    let target = &after_ws[..value_end];
423    (!target.is_empty()).then_some((target, first_token.len() + leading_ws))
424}
425
426/// Cuts `line` at the first `#` that is at index 0 or preceded by ASCII
427/// whitespace, matching pip's `COMMENT_RE = r'(^|\s+)#.*$'`.
428fn strip_comment(line: &str) -> &str {
429    let bytes = line.as_bytes();
430    for (i, &b) in bytes.iter().enumerate() {
431        if b == b'#' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
432            return &line[..i];
433        }
434    }
435    line
436}
437
438/// Splits `text` at the first whitespace-delimited token starting with
439/// `--` (a per-requirement option like `--hash=sha256:...`), returning the
440/// requirement text before it and whether a `--hash`/`--hash=...` token was
441/// present anywhere on the line.
442fn split_requirement_options(text: &str) -> (&str, bool) {
443    let had_hash = text
444        .split_whitespace()
445        .any(|tok| tok == "--hash" || tok.starts_with("--hash="));
446
447    for token in text.split_whitespace() {
448        if token.starts_with("--") {
449            // SAFETY-free pointer arithmetic: `token` is a genuine subslice of
450            // `text` produced by `split_whitespace`, so this offset is valid.
451            let offset = token.as_ptr() as usize - text.as_ptr() as usize;
452            return (text[..offset].trim_end(), had_hash);
453        }
454    }
455
456    (text, had_hash)
457}
458
459/// True for a bare URL, filesystem path, or archive file — a line with no
460/// package name that must not reach `parse_pep508_requirement`. A `name @
461/// https://…/x.tar.gz` direct reference has a name before the `@` and is
462/// deliberately NOT nameless, even though it ends in an archive suffix.
463fn is_nameless_requirement(text: &str) -> bool {
464    if NAMELESS_URL_PREFIXES.iter().any(|p| text.starts_with(p)) {
465        return true;
466    }
467    if text == "." || text.starts_with("./") || text.starts_with("../") || text.starts_with('/') {
468        return true;
469    }
470    !text.contains('@') && NAMELESS_ARCHIVE_SUFFIXES.iter().any(|s| text.ends_with(s))
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    use std::assert_matches;
478
479    fn test_uri() -> Uri {
480        deps_core::test_util::test_uri("/test/requirements.txt")
481    }
482
483    fn parse(content: &str) -> ParseResult {
484        PypiParser::new()
485            .parse_requirements(content, &test_uri(), false)
486            .unwrap()
487    }
488
489    /// Like [`parse`], but with `require_strong_signal: true` — the gate a
490    /// directory-pattern-only match (`requirements/base.txt`) gets (#452 S6).
491    fn parse_strict(content: &str) -> ParseResult {
492        PypiParser::new()
493            .parse_requirements(content, &test_uri(), true)
494            .unwrap()
495    }
496
497    fn all_policy() -> RegistryAccessPolicy {
498        RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::All)
499    }
500
501    /// Like [`parse`], but threading an explicit policy through
502    /// [`PypiParser::parse_requirements_with_policy`] — the entry point T003's own tests use.
503    fn parse_with_policy(content: &str, policy: &RegistryAccessPolicy) -> ParseResult {
504        PypiParser::new()
505            .parse_requirements_with_policy(content, &test_uri(), false, policy)
506            .unwrap()
507    }
508
509    // --- Basics ---
510
511    #[test]
512    fn test_basic_pinned() {
513        let result = parse("requests==2.31.0\n");
514        assert_eq!(result.dependencies.len(), 1);
515        assert_eq!(result.dependencies[0].name, "requests");
516        assert_eq!(
517            result.dependencies[0]
518                .version_req
519                .as_ref()
520                .map(deps_core::VersionReq::as_str),
521            Some("==2.31.0")
522        );
523    }
524
525    #[test]
526    fn test_basic_range() {
527        let result = parse("flask>=3.0,<4\n");
528        assert_eq!(result.dependencies.len(), 1);
529        assert_eq!(result.dependencies[0].name, "flask");
530    }
531
532    #[test]
533    fn test_bare_name_no_specifier() {
534        let result = parse("requests==1.0.0\nflask\n");
535        let flask = result
536            .dependencies
537            .iter()
538            .find(|d| d.name == "flask")
539            .unwrap();
540        assert_eq!(flask.version_req, None);
541    }
542
543    #[test]
544    fn test_extras() {
545        let result = parse("requests==1.0.0\nflask[async,dotenv]>=3.0\n");
546        let flask = result
547            .dependencies
548            .iter()
549            .find(|d| d.name == "flask")
550            .unwrap();
551        assert_eq!(flask.extras, vec!["async", "dotenv"]);
552    }
553
554    #[test]
555    fn test_tilde_equal() {
556        let result = parse("numpy ~= 1.24\n");
557        assert_eq!(result.dependencies.len(), 1);
558        assert_eq!(result.dependencies[0].name, "numpy");
559    }
560
561    #[test]
562    fn test_spaces_around_operator() {
563        let result = parse("requests == 2.31.0\n");
564        assert_eq!(result.dependencies.len(), 1);
565        assert_eq!(result.dependencies[0].name, "requests");
566    }
567
568    // --- Spaced extras (S3): version_range must slice to exactly the source text ---
569
570    fn slice(content: &str, range: tower_lsp_server::ls_types::Range) -> String {
571        let table = LineOffsetTable::new(content);
572        let start = table.position_to_byte_offset(content, range.start);
573        let end = table.position_to_byte_offset(content, range.end);
574        content[start..end].to_string()
575    }
576
577    #[test]
578    fn test_spaced_extras_version_range() {
579        let content = "flask[async, dotenv]>=3.0\n";
580        let result = parse(content);
581        let dep = &result.dependencies[0];
582        let version_range = dep.version_range.expect("version_range should be set");
583        assert_eq!(slice(content, version_range), ">=3.0");
584    }
585
586    #[test]
587    fn test_spaced_extras_and_spaced_operator_version_range() {
588        let content = "flask [async] >= 3.0\n";
589        let result = parse(content);
590        let dep = &result.dependencies[0];
591        let version_range = dep.version_range.expect("version_range should be set");
592        assert_eq!(slice(content, version_range), ">= 3.0");
593    }
594
595    #[test]
596    fn test_dotted_source_name_version_range() {
597        let content = "my__pkg==1.0\n";
598        let result = parse(content);
599        let dep = &result.dependencies[0];
600        let version_range = dep.version_range.expect("version_range should be set");
601        assert_eq!(slice(content, version_range), "==1.0");
602    }
603
604    // --- Positions ---
605
606    #[test]
607    fn test_indented_line_position() {
608        let content = "  requests==2.0\n";
609        let result = parse(content);
610        let dep = &result.dependencies[0];
611        assert_eq!(dep.name_range.start.character, 2);
612    }
613
614    #[test]
615    fn test_position_after_non_ascii_comment() {
616        let content = "# héllo wörld\nrequests==2.0\n";
617        let result = parse(content);
618        let dep = &result.dependencies[0];
619        assert_eq!(dep.name_range.start.line, 1);
620        assert_eq!(dep.name_range.start.character, 0);
621    }
622
623    #[test]
624    fn test_last_line_no_trailing_newline() {
625        let content = "requests==2.0\nflask==1.0";
626        let result = parse(content);
627        assert_eq!(result.dependencies.len(), 2);
628        let flask = result
629            .dependencies
630            .iter()
631            .find(|d| d.name == "flask")
632            .unwrap();
633        assert_eq!(flask.name_range.start.line, 1);
634    }
635
636    // --- CRLF (M2) ---
637
638    #[test]
639    fn test_crlf_line_endings_positions_correct() {
640        let content = "requests==1.0\r\nflask==2.0\r\nnumpy==3.0\r\n";
641        let result = parse(content);
642        assert_eq!(result.dependencies.len(), 3);
643        let numpy = result
644            .dependencies
645            .iter()
646            .find(|d| d.name == "numpy")
647            .unwrap();
648        assert_eq!(numpy.name_range.start.line, 2);
649        assert_eq!(numpy.name_range.start.character, 0);
650    }
651
652    // --- BOM (M3) ---
653
654    #[test]
655    fn test_bom_on_first_line() {
656        let content = "\u{feff}requests==2.0\nflask==1.0\n";
657        let result = parse(content);
658        assert_eq!(result.dependencies.len(), 2);
659        let requests = result
660            .dependencies
661            .iter()
662            .find(|d| d.name == "requests")
663            .unwrap();
664        assert_eq!(requests.name_range.start.line, 0);
665        // The BOM counts as one UTF-16 code unit, same as the editor's own
666        // column count for that position — see module docs on BOM handling.
667        assert_eq!(requests.name_range.start.character, 1);
668        let flask = result
669            .dependencies
670            .iter()
671            .find(|d| d.name == "flask")
672            .unwrap();
673        assert_eq!(flask.name_range.start.line, 1);
674        assert_eq!(flask.name_range.start.character, 0);
675    }
676
677    // --- UTF-8 boundary (M1) — exercised at the deps-core level; see
678    // `deps_core::lsp_helpers` tests for `byte_offset_to_position`.
679
680    // --- Comments ---
681
682    #[test]
683    fn test_full_line_comment() {
684        let result = parse("# just a comment\nrequests==1.0\n");
685        assert_eq!(result.dependencies.len(), 1);
686    }
687
688    #[test]
689    fn test_trailing_comment() {
690        let result = parse("requests==1.0  # pinned for compat\n");
691        assert_eq!(result.dependencies.len(), 1);
692        assert_eq!(result.dependencies[0].name, "requests");
693    }
694
695    #[test]
696    fn test_hash_in_url_fragment_not_cut() {
697        // `#egg=` immediately follows a non-whitespace character, so it is
698        // not treated as a comment start.
699        let content = "mylib @ https://example.com/mylib.tar.gz#egg=mylib\n";
700        let result = parse(content);
701        assert_eq!(result.dependencies.len(), 1);
702        assert_eq!(result.dependencies[0].name, "mylib");
703    }
704
705    #[test]
706    fn test_hash_with_no_preceding_whitespace_not_cut() {
707        let content = "requests==1.0#nospace\n";
708        // The whole line (no valid PEP 508 text after the mis-included `#nospace`)
709        // fails to parse as a well-formed requirement; the point of this test is
710        // only that `strip_comment` did NOT cut before the `#`.
711        let without_comment = strip_comment(content.trim_end());
712        assert_eq!(without_comment, "requests==1.0#nospace");
713    }
714
715    #[test]
716    fn test_quoted_marker_hash_mis_cut_matches_pip() {
717        // Accepted, pip-matching behavior (M6): the comment stripper is
718        // quote-unaware, so a `#` inside a quoted marker value is still cut
719        // if whitespace-preceded, exactly like pip's own `COMMENT_RE`.
720        let content = "pkg==1.0; extra == \"a #b\"\n";
721        let without_comment = strip_comment(content.trim_end());
722        assert_eq!(without_comment, "pkg==1.0; extra == \"a ");
723    }
724
725    // --- Markers ---
726
727    #[test]
728    fn test_marker_on_line() {
729        let result = parse("pkg==1.0; python_version < \"3.9\"\n");
730        assert_eq!(result.dependencies.len(), 1);
731        assert!(result.dependencies[0].markers.is_some());
732    }
733
734    #[test]
735    fn test_compound_marker_on_line() {
736        let content = "pkg==1.0; sys_platform == 'win32' and python_version >= '3.8'\n";
737        let result = parse(content);
738        assert_eq!(result.dependencies.len(), 1);
739        let markers = result.dependencies[0].markers.as_ref().unwrap();
740        assert!(markers.contains("sys_platform"));
741    }
742
743    #[test]
744    fn test_oversized_marker_on_line_skips_normalization() {
745        let long_marker: String = "os_name == 'a' or ".repeat(200) + "os_name == 'a'";
746        assert!(long_marker.len() > super::super::MAX_MARKER_LEN);
747        let content = format!("pkg==1.0; {long_marker}\n");
748        let result = parse(&content);
749        assert_eq!(result.dependencies.len(), 1);
750        assert_eq!(result.dependencies[0].markers, Some(long_marker));
751    }
752
753    #[test]
754    fn test_deeply_nested_marker_on_line_skips_normalization() {
755        let depth = 1000;
756        let nested_marker = format!("{}os_name == 'a'{}", "(".repeat(depth), ")".repeat(depth));
757        assert!(nested_marker.len() < super::super::MAX_MARKER_LEN);
758        let content = format!("pkg==1.0; {nested_marker}\n");
759        let result = parse(&content);
760        assert_eq!(result.dependencies.len(), 1);
761        assert_eq!(result.dependencies[0].markers, Some(nested_marker));
762    }
763
764    // --- Requirement length cap (issue #229) ---
765
766    #[test]
767    fn test_oversized_extras_list_rejected_fast() {
768        // Regression test for #229: `pep508_rs` 0.9.2 parses an extras list
769        // in O(n²). Before the length cap, a single line this size would
770        // take on the order of seconds to parse (extrapolating the measured
771        // quadratic growth); with the cap it is rejected in O(1) and the
772        // rest of the file still parses normally.
773        let huge_extras = "a,".repeat(500_000); // ~1 MiB extras list
774        let oversized_requirement = format!("pkg[{huge_extras}]==1.0");
775        assert!(oversized_requirement.len() > super::super::MAX_REQUIREMENT_LEN);
776        let content = format!("{oversized_requirement}\ngood-pkg==2.0\n");
777
778        let start = std::time::Instant::now();
779        let result = parse(&content);
780        let elapsed = start.elapsed();
781
782        assert!(
783            elapsed < std::time::Duration::from_secs(2),
784            "oversized extras line took too long to reject: {elapsed:?}"
785        );
786        // The oversized line is skipped entirely (never handed to
787        // `pep508_rs`), but the rest of the file is unaffected.
788        assert_eq!(result.dependencies.len(), 1);
789        assert_eq!(result.dependencies[0].name, "good-pkg");
790    }
791
792    #[test]
793    fn test_oversized_line_rejection_does_not_count_as_failed_line() {
794        // Regression test for critic finding S2: a length-cap rejection must
795        // not count toward `failed_lines`, which feeds the "is this really a
796        // requirements file" keep heuristic. Bare package names (no version
797        // specifier, no strong signal) alone are kept only while
798        // `failed_lines < dependencies.len()`; before the fix, 3 oversized
799        // lines pushed `failed_lines` past that threshold and blanked the
800        // whole file, including the two legitimate bare-name dependencies.
801        let huge_extras = "a,".repeat(500_000);
802        let oversized = format!("pkg[{huge_extras}]==1.0");
803        assert!(oversized.len() > super::super::MAX_REQUIREMENT_LEN);
804        let content = format!("{oversized}\n{oversized}\n{oversized}\nrequests\nflask\n");
805
806        let result = parse(&content);
807
808        let mut names: Vec<&str> = result
809            .dependencies
810            .iter()
811            .map(|d| d.name.as_str())
812            .collect();
813        names.sort_unstable();
814        assert_eq!(names, vec!["flask", "requests"]);
815    }
816
817    #[test]
818    fn test_requirement_length_boundary() {
819        // Regression test for critic finding M1: pins the 4096/4097 boundary
820        // against the requirement string's own length, not incidental file
821        // length. A single extra name made entirely of 'a' gives exact,
822        // syntactically-valid control over the total byte length.
823        let build = |total_len: usize| {
824            let fixed = "pkg[]==1.0".len();
825            format!("pkg[{}]==1.0", "a".repeat(total_len - fixed))
826        };
827        let max = super::super::MAX_REQUIREMENT_LEN;
828
829        let at_cap = build(max);
830        assert_eq!(at_cap.len(), max);
831        let result = parse(&format!("{at_cap}\n"));
832        assert_eq!(
833            result.dependencies.len(),
834            1,
835            "a requirement exactly at the cap must be accepted"
836        );
837
838        let over_cap = build(max + 1);
839        assert_eq!(over_cap.len(), max + 1);
840        let result = parse(&format!("{over_cap}\n"));
841        assert_eq!(
842            result.dependencies.len(),
843            0,
844            "a requirement one byte over the cap must be rejected"
845        );
846    }
847
848    #[test]
849    fn test_marker_extras_bracket_injection_rejected() {
850        // Regression test for #261: a `;` landing before an oversized
851        // extras/version tail (rather than before an actual marker) must not
852        // have that tail stored verbatim on `markers`.
853        let huge_extras = "a".repeat(60_000);
854        let content = format!("pkg;[{huge_extras}]==1.0\n");
855        let result = parse(&content);
856        assert_eq!(result.dependencies.len(), 1);
857        assert_eq!(result.dependencies[0].name, "pkg");
858        assert_eq!(result.dependencies[0].markers, None);
859    }
860
861    #[test]
862    fn test_marker_keyword_repeated_without_separators_rejected() {
863        // Regression test for the substring-only `looks_like_marker` bypass:
864        // a marker variable name repeated with no separators contains
865        // "extra" as a substring but tokenizes as one giant unrecognized
866        // identifier, not a real reference to the `extra` marker variable.
867        let garbage = "extra".repeat(1600);
868        assert!(garbage.len() > super::super::MAX_MARKER_LEN);
869        let content = format!("pkg; {garbage}\n");
870        let result = parse(&content);
871        assert_eq!(result.dependencies.len(), 1);
872        assert_eq!(result.dependencies[0].name, "pkg");
873        assert_eq!(result.dependencies[0].markers, None);
874    }
875
876    #[test]
877    fn test_marker_keyword_padded_with_unquoted_garbage_rejected() {
878        // Regression test for the substring-only `looks_like_marker` bypass:
879        // a real marker variable followed by an unquoted run of filler bytes
880        // used to pass (keyword present as a substring, all bytes in the
881        // allowed character set); the filler is not a quoted string literal,
882        // a known identifier, or an operator, so it must now be rejected.
883        let filler = "A".repeat(5000);
884        let raw_marker = format!("python_version <{filler}>");
885        assert!(raw_marker.len() > super::super::MAX_MARKER_LEN);
886        let content = format!("pkg; {raw_marker}\n");
887        let result = parse(&content);
888        assert_eq!(result.dependencies.len(), 1);
889        assert_eq!(result.dependencies[0].name, "pkg");
890        assert_eq!(result.dependencies[0].markers, None);
891    }
892
893    #[test]
894    fn test_marker_repeated_token_no_operator_rejected() {
895        // Regression test for the reviewer's residual #261 bypass: bare
896        // whitespace-separated repetition of a recognized marker variable,
897        // with no comparison operator anywhere, used to still tokenize as
898        // "marker-shaped" (at least one recognized token present) and be
899        // retained verbatim.
900        let garbage = "python_version ".repeat(500);
901        let content = format!("pkg; {garbage}\n");
902        let result = parse(&content);
903        assert_eq!(result.dependencies.len(), 1);
904        assert_eq!(result.dependencies[0].name, "pkg");
905        assert_eq!(result.dependencies[0].markers, None);
906    }
907
908    #[test]
909    fn test_marker_and_joined_repeated_token_no_operator_rejected() {
910        // Same bypass shape, joined by `and` instead of bare whitespace —
911        // still no comparison operator anywhere in the text.
912        let garbage = "python_version and ".repeat(400) + "python_version";
913        let content = format!("pkg; {garbage}\n");
914        let result = parse(&content);
915        assert_eq!(result.dependencies.len(), 1);
916        assert_eq!(result.dependencies[0].name, "pkg");
917        assert_eq!(result.dependencies[0].markers, None);
918    }
919
920    #[test]
921    fn test_marker_chained_comparison_rejected() {
922        // Regression test for the reviewer's round-3 #261 bypass: chained
923        // comparisons share one operand across more than one clause
924        // (`a == b == c == ...`), which PEP 508's grammar has no production
925        // for — `pep508_rs` itself rejects a short version of this shape
926        // outright.
927        let chain = "python_version==".repeat(500) + "python_version";
928        assert!(chain.len() > super::super::MAX_MARKER_LEN);
929        let content = format!("pkg; {chain}\n");
930        let result = parse(&content);
931        assert_eq!(result.dependencies.len(), 1);
932        assert_eq!(result.dependencies[0].name, "pkg");
933        assert_eq!(result.dependencies[0].markers, None);
934    }
935
936    #[test]
937    fn test_marker_chained_in_rejected() {
938        // Same bypass shape using `in` instead of `==`.
939        let chain = "python_version in ".repeat(500) + "python_version";
940        assert!(chain.len() > super::super::MAX_MARKER_LEN);
941        let content = format!("pkg; {chain}\n");
942        let result = parse(&content);
943        assert_eq!(result.dependencies.len(), 1);
944        assert_eq!(result.dependencies[0].name, "pkg");
945        assert_eq!(result.dependencies[0].markers, None);
946    }
947
948    #[test]
949    fn test_oversized_in_operator_marker_still_normalizes() {
950        // Legitimate use of the `in` operator must still be preserved
951        // through the raw fallback once it's oversized enough to bypass
952        // `pep508_rs`'s parser.
953        let marker =
954            "python_version in '3.8'".to_string() + &" or python_version in '3.8'".repeat(200);
955        assert!(marker.len() > super::super::MAX_MARKER_LEN);
956        let content = format!("pkg; {marker}\n");
957        let result = parse(&content);
958        assert_eq!(result.dependencies.len(), 1);
959        assert_eq!(result.dependencies[0].name, "pkg");
960        assert_eq!(result.dependencies[0].markers, Some(marker));
961    }
962
963    // --- Options (§3.4) ---
964
965    #[test]
966    fn test_option_lines_skipped_not_failures() {
967        let content = "-r base.txt\n--requirement base.txt\n-c constraints.txt\n-e .\n-e git+https://example.com/pkg#egg=pkg\n--index-url https://example.com\n--extra-index-url https://example.com\n--find-links ./wheels\n--pre\nrequests==1.0\n";
968        let result = parse(content);
969        assert_eq!(result.dependencies.len(), 1);
970        assert_eq!(result.dependencies[0].name, "requests");
971    }
972
973    #[test]
974    fn test_option_lines_equals_form_recognized() {
975        // C1 regression: pip accepts both `--index-url URL` and
976        // `--index-url=URL`; the `=` form is idiomatic in corporate/internal
977        // requirements files and must not count as a parse failure.
978        let content = "--index-url=https://internal.example/simple\nrequests\n";
979        let result = parse(content);
980        assert_eq!(result.dependencies.len(), 1);
981        assert_eq!(result.dependencies[0].name, "requests");
982    }
983
984    // --- documentLink targets (#452) ---
985
986    #[test]
987    fn test_document_links_short_form() {
988        let content = "-r other-requirements.txt\n-c constraints.txt\nrequests==1.0\n";
989        let result = parse(content);
990        assert_eq!(result.document_links.len(), 2);
991        assert_eq!(result.document_links[0].target, "other-requirements.txt");
992        assert_eq!(result.document_links[1].target, "constraints.txt");
993    }
994
995    #[test]
996    fn test_document_links_long_form_space_separated() {
997        let content = "--requirement base.txt\n--constraint constraints.txt\n";
998        let result = parse(content);
999        assert_eq!(result.document_links.len(), 2);
1000        assert_eq!(result.document_links[0].target, "base.txt");
1001        assert_eq!(result.document_links[1].target, "constraints.txt");
1002    }
1003
1004    #[test]
1005    fn test_document_links_long_form_equals_separated() {
1006        let content = "--requirement=base.txt\n--constraint=constraints.txt\n";
1007        let result = parse(content);
1008        assert_eq!(result.document_links.len(), 2);
1009        assert_eq!(result.document_links[0].target, "base.txt");
1010        assert_eq!(result.document_links[1].target, "constraints.txt");
1011    }
1012
1013    #[test]
1014    fn test_document_links_range_slices_to_target_text_only() {
1015        let content = "-r other-requirements.txt\n";
1016        let result = parse(content);
1017        let link = &result.document_links[0];
1018        assert_eq!(slice(content, link.range), "other-requirements.txt");
1019    }
1020
1021    #[test]
1022    fn test_document_links_ignores_unrelated_options() {
1023        // Options other than -r/-c/--requirement/--constraint (even other
1024        // known ones) must never produce a document link.
1025        let content = "-e .\n--index-url https://example.com\n--pre\nrequests==1.0\n";
1026        let result = parse(content);
1027        assert!(result.document_links.is_empty());
1028    }
1029
1030    #[test]
1031    fn test_document_links_bare_option_with_no_target_is_skipped() {
1032        let content = "-r\nrequests==1.0\n";
1033        let result = parse(content);
1034        assert!(result.document_links.is_empty());
1035    }
1036
1037    // --- pip-compile shape (S1) ---
1038
1039    #[test]
1040    fn test_pip_compile_continuation_with_hashes() {
1041        let content =
1042            "pkg==1.0 \\\n    --hash=sha256:aaaa \\\n    --hash=sha256:bbbb\nother==2.0\n";
1043        let result = parse(content);
1044        assert_eq!(result.dependencies.len(), 2);
1045        let pkg = result
1046            .dependencies
1047            .iter()
1048            .find(|d| d.name == "pkg")
1049            .unwrap();
1050        assert_eq!(pkg.version_range, None);
1051        let other = result
1052            .dependencies
1053            .iter()
1054            .find(|d| d.name == "other")
1055            .unwrap();
1056        assert!(other.version_range.is_some());
1057    }
1058
1059    #[test]
1060    fn test_inline_hash_single_line_nulls_version_range() {
1061        let content = "pkg==1.0 --hash=sha256:aaaa\n";
1062        let result = parse(content);
1063        assert_eq!(result.dependencies.len(), 1);
1064        assert_eq!(result.dependencies[0].version_range, None);
1065    }
1066
1067    #[test]
1068    fn test_non_hash_continuation_still_nulls_version_range() {
1069        let content = "pkg \\\n    ==1.0\n";
1070        let result = parse(content);
1071        assert_eq!(result.dependencies.len(), 1);
1072        assert_eq!(result.dependencies[0].version_range, None);
1073    }
1074
1075    // --- Skipped shapes (§3.6) ---
1076
1077    #[test]
1078    fn test_skipped_bare_url() {
1079        let result = parse("https://example.com/pkg.whl\nrequests==1.0\n");
1080        assert_eq!(result.dependencies.len(), 1);
1081        assert_eq!(result.dependencies[0].name, "requests");
1082    }
1083
1084    #[test]
1085    fn test_skipped_local_paths() {
1086        let content = "./local/pkg\n/abs/path\n../rel\n.\nrequests==1.0\n";
1087        let result = parse(content);
1088        assert_eq!(result.dependencies.len(), 1);
1089        assert_eq!(result.dependencies[0].name, "requests");
1090    }
1091
1092    #[test]
1093    fn test_named_direct_reference_kept() {
1094        let content = "mylib @ https://example.com/mylib.tar.gz\n";
1095        let result = parse(content);
1096        assert_eq!(result.dependencies.len(), 1);
1097        assert_eq!(result.dependencies[0].name, "mylib");
1098        assert_matches!(
1099            result.dependencies[0].source,
1100            PypiDependencySource::Url { .. }
1101        );
1102        assert_eq!(result.dependencies[0].version_req, None);
1103    }
1104
1105    // --- Robustness ---
1106
1107    #[test]
1108    fn test_garbage_line_skipped_surrounding_lines_parse() {
1109        let content = "requests==1.0\n>>>> merge conflict\nflask==2.0\n!!!\n[\nnumpy==3.0\n";
1110        let result = parse(content);
1111        let names: Vec<&str> = result
1112            .dependencies
1113            .iter()
1114            .map(|d| d.name.as_str())
1115            .collect();
1116        assert!(names.contains(&"requests"));
1117        assert!(names.contains(&"flask"));
1118        assert!(names.contains(&"numpy"));
1119    }
1120
1121    #[test]
1122    fn test_empty_file() {
1123        let result = parse("");
1124        assert!(result.dependencies.is_empty());
1125    }
1126
1127    #[test]
1128    fn test_file_of_only_comments() {
1129        let result = parse("# one\n# two\n");
1130        assert!(result.dependencies.is_empty());
1131    }
1132
1133    #[test]
1134    fn test_constraints_txt_parses_identically() {
1135        let uri = deps_core::test_util::test_uri("/test/constraints.txt");
1136        let result = PypiParser::new()
1137            .parse_requirements("requests==1.0\nflask==2.0\n", &uri, false)
1138            .unwrap();
1139        assert_eq!(result.dependencies.len(), 2);
1140        assert!(
1141            result
1142                .dependencies
1143                .iter()
1144                .all(|d| matches!(d.section, PypiDependencySection::Requirements))
1145        );
1146    }
1147
1148    // --- Content gate (§2.3, S2, N2, N3) ---
1149
1150    #[test]
1151    fn test_gate_drops_prose_file() {
1152        let content = "Product requirements\n\nThe API must be fast.\n- a bullet\n";
1153        let result = parse(content);
1154        assert!(result.dependencies.is_empty());
1155    }
1156
1157    #[test]
1158    fn test_gate_keeps_unpinned_hand_written_file() {
1159        // The `failed_lines < dependencies.len()` relaxation exists for this:
1160        // no strong signal anywhere, yet all 3 lines parse successfully.
1161        let result = parse("requests\nflask\nnumpy\n");
1162        assert_eq!(result.dependencies.len(), 3);
1163    }
1164
1165    #[test]
1166    fn test_gate_survives_mid_typing_edit() {
1167        // N2: a partially typed 4th line must not wipe the 3 already-valid
1168        // dependencies. `django >` has a bare `>` NOT followed by a digit,
1169        // so it is not a strong signal, and fails to parse as PEP 508.
1170        let result = parse("requests\nflask\nnumpy\ndjango >\n");
1171        assert_eq!(result.dependencies.len(), 3);
1172    }
1173
1174    #[test]
1175    fn test_gate_bare_less_than_digit_is_strong_signal() {
1176        let result = parse("flask<4\n");
1177        assert_eq!(result.dependencies.len(), 1);
1178    }
1179
1180    #[test]
1181    fn test_gate_email_in_prose_does_not_defeat_gate() {
1182        // C2 regression: an `@` in an email address and no PEP 440 operator
1183        // anywhere must not set `strong_signal` via a raw-text scan — the
1184        // signal is derived from the parsed dependency, not the source text.
1185        // "Overview" alone parses as a bare (unpinned) dependency, so without
1186        // the fix this file would ship 1 spurious dependency with a network
1187        // request and an "Unknown package" warning.
1188        let content = "Requirements Document\n\nAuthor: jane@example.com\nOverview\n";
1189        let result = parse(content);
1190        assert!(result.dependencies.is_empty());
1191    }
1192
1193    #[test]
1194    fn test_gate_markdown_bullets_are_failures_not_prose_survivors() {
1195        // N3: an unrecognized `-`-leading line counts as a failure, so this
1196        // realistic prose shape (1 dep "Requirements", 2 bullet failures) is
1197        // dropped rather than producing a spurious "Unknown package" warning.
1198        let content = "Requirements\n\n- Fast response\n- Scalable\n";
1199        let result = parse(content);
1200        assert!(result.dependencies.is_empty());
1201    }
1202
1203    #[test]
1204    fn test_gate_option_only_file_no_panic() {
1205        let result = parse("-r base.txt\n");
1206        assert!(result.dependencies.is_empty());
1207    }
1208
1209    // --- require_strong_signal: directory-pattern-only gate (#452 S6) ---
1210
1211    #[test]
1212    fn test_strict_gate_drops_prose_that_would_survive_ratio_gate() {
1213        // A requirements-engineering docs file living under a directory literally
1214        // named `requirements/` is routed to PyPI purely by directory-name
1215        // convention (`Ecosystem::manifest_directory_patterns`) — far weaker
1216        // evidence than a basename match. Bare-word lines like "Introduction"/
1217        // "Scope" parse as valid (unpinned) PEP 508 names, so the ratio-based
1218        // arm alone keeps this file; `require_strong_signal: true` must still
1219        // drop it.
1220        let content = "Introduction\n\nScope\n\nThis document defines the requirements.\n";
1221
1222        let lenient = parse(content);
1223        assert!(
1224            !lenient.dependencies.is_empty(),
1225            "sanity check: the ratio gate alone would keep this file"
1226        );
1227
1228        let strict = parse_strict(content);
1229        assert!(strict.dependencies.is_empty());
1230    }
1231
1232    #[test]
1233    fn test_strict_gate_still_keeps_file_with_real_pip_option() {
1234        // A genuine pip option line is a strong signal regardless of the gate
1235        // mode — `require_strong_signal` only removes the *ratio* arm.
1236        let content = "-r base.txt\nrequests==1.0\n";
1237        let result = parse_strict(content);
1238        assert_eq!(result.dependencies.len(), 1);
1239        assert_eq!(result.document_links.len(), 1);
1240    }
1241
1242    #[test]
1243    fn test_strict_gate_still_keeps_file_with_version_specifier() {
1244        let content = "requests==2.31.0\n";
1245        let result = parse_strict(content);
1246        assert_eq!(result.dependencies.len(), 1);
1247    }
1248
1249    // --- T003: --index-url / --extra-index-url / -i capture (FR-001, US-001, US-002) ---
1250
1251    #[test]
1252    fn test_index_url_routes_every_dependency() {
1253        let content = "--index-url https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1254        let result = parse_with_policy(content, &all_policy());
1255        assert_eq!(result.dependencies.len(), 1);
1256        assert_matches!(
1257            result.dependencies[0].source,
1258            PypiDependencySource::AlternateRegistry { .. }
1259        );
1260    }
1261
1262    /// `--index-url=<url>` (equals spelling) is captured identically to the space-separated
1263    /// form.
1264    #[test]
1265    fn test_index_url_equals_spelling() {
1266        let content = "--index-url=https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1267        let result = parse_with_policy(content, &all_policy());
1268        assert_matches!(
1269            result.dependencies[0].source,
1270            PypiDependencySource::AlternateRegistry { .. }
1271        );
1272    }
1273
1274    /// `-i` is pip's short alias for `--index-url`.
1275    #[test]
1276    fn test_short_dash_i_alias() {
1277        let content = "-i https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1278        let result = parse_with_policy(content, &all_policy());
1279        assert_matches!(
1280            result.dependencies[0].source,
1281            PypiDependencySource::AlternateRegistry { .. }
1282        );
1283    }
1284
1285    /// S2 regression: a dependency declared *before* a late `--index-url` line still routes
1286    /// through it — the two-pass parse's whole reason for existing.
1287    #[test]
1288    fn test_index_url_after_dependency_line_still_routes_it() {
1289        let content = "requests==2.31.0\n--index-url https://pypi.mycorp.example/simple\n";
1290        let result = parse_with_policy(content, &all_policy());
1291        assert_eq!(result.dependencies.len(), 1);
1292        assert!(
1293            matches!(
1294                result.dependencies[0].source,
1295                PypiDependencySource::AlternateRegistry { .. }
1296            ),
1297            "dependency declared before a late --index-url must still resolve through it, \
1298             got {:?}",
1299            result.dependencies[0].source
1300        );
1301    }
1302
1303    /// FR-005(b): `--extra-index-url` alone, no explicit primary — routes through the
1304    /// extras+implicit-public chain, not plain `Registry`.
1305    #[test]
1306    fn test_extra_index_url_alone_routes_through_chain() {
1307        let content = "--extra-index-url https://extra.example/simple\nrequests==2.31.0\n";
1308        let result = parse_with_policy(content, &all_policy());
1309        assert_matches!(
1310            result.dependencies[0].source,
1311            PypiDependencySource::AlternateRegistry { .. }
1312        );
1313    }
1314
1315    /// FR-006: an explicit `--index-url` that fails validation fails closed
1316    /// (`CustomRegistry`), not a silent `pypi.org` fallback — the #248-class regression.
1317    #[test]
1318    fn test_invalid_index_url_fails_closed() {
1319        let content = "--index-url not-a-valid-url\nrequests==2.31.0\n";
1320        let result = parse_with_policy(content, &all_policy());
1321        assert_eq!(
1322            result.dependencies[0].source,
1323            PypiDependencySource::CustomRegistry {
1324                url: "not-a-valid-url".to_string(),
1325            }
1326        );
1327    }
1328
1329    /// US-004: no `--index-url`/`--extra-index-url` anywhere -> every dependency stays plain
1330    /// `Registry`, byte-identical to pre-feature behavior.
1331    #[test]
1332    fn test_no_index_declaration_is_plain_registry() {
1333        let result = parse("requests==2.31.0\nflask>=3.0\n");
1334        assert_eq!(result.dependencies.len(), 2);
1335        for dep in &result.dependencies {
1336            assert_eq!(dep.source, PypiDependencySource::Registry);
1337        }
1338    }
1339
1340    /// Existing `KNOWN_OPTIONS` classification (e.g. `--pre`, `--no-index`) is unaffected by
1341    /// the new capture logic — an unrelated recognized option still contributes no index
1342    /// routing.
1343    #[test]
1344    fn test_unrelated_known_option_does_not_affect_routing() {
1345        let content = "--pre\nrequests==2.31.0\n";
1346        let result = parse_with_policy(content, &all_policy());
1347        assert_eq!(result.dependencies.len(), 1);
1348        assert_eq!(
1349            result.dependencies[0].source,
1350            PypiDependencySource::Registry
1351        );
1352    }
1353
1354    /// A direct URL/Git-sourced dependency is untouched by index routing — those have no
1355    /// PyPI index concept.
1356    #[test]
1357    fn test_git_sourced_dependency_untouched_by_index_config() {
1358        let content = "--index-url https://pypi.mycorp.example/simple\nname @ https://example.com/name.tar.gz\n";
1359        let result = parse_with_policy(content, &all_policy());
1360        assert_eq!(result.dependencies.len(), 1);
1361        assert_matches!(
1362            result.dependencies[0].source,
1363            PypiDependencySource::Url { .. }
1364        );
1365    }
1366
1367    /// Validator finding S1: a UTF-8 BOM on the first physical line must not defeat
1368    /// `--index-url` capture — without stripping it in pass 1, `"\u{feff}--index-url"` fails
1369    /// the `starts_with('-')` check and the whole declaration is silently skipped.
1370    #[test]
1371    fn test_index_url_after_bom_on_first_line_still_captured() {
1372        let content = "\u{feff}--index-url https://pypi.mycorp.example/simple\nrequests==2.31.0\n";
1373        let result = parse_with_policy(content, &all_policy());
1374        assert_eq!(result.dependencies.len(), 1);
1375        assert!(
1376            matches!(
1377                result.dependencies[0].source,
1378                PypiDependencySource::AlternateRegistry { .. }
1379            ),
1380            "BOM must not defeat --index-url capture, got {:?}",
1381            result.dependencies[0].source
1382        );
1383    }
1384
1385    /// Validator finding S2: a multi-option line must not let a later option's token(s) leak
1386    /// into the earlier option's captured value — `extract_option_target` must bound the
1387    /// value to the current token only, not the rest of the line. Tested directly against
1388    /// `extract_option_target`, since the parsed-config-level chain key is an opaque hash
1389    /// that can't itself distinguish a clean value from a mangled one.
1390    #[test]
1391    fn test_extract_option_target_space_separated_stops_at_next_option() {
1392        let text =
1393            "--index-url https://pypi.mycorp.example/simple --trusted-host pypi.mycorp.example";
1394        let (target, _offset) = extract_option_target("--index-url", text).unwrap();
1395        assert_eq!(target, "https://pypi.mycorp.example/simple");
1396    }
1397
1398    /// Same bug (S2), `--opt=value` equals-spelling with a trailing option on the same line.
1399    #[test]
1400    fn test_extract_option_target_equals_separated_stops_at_next_option() {
1401        let text =
1402            "--index-url=https://pypi.mycorp.example/simple --trusted-host pypi.mycorp.example";
1403        let first_token = text.split_whitespace().next().unwrap();
1404        let (target, _offset) = extract_option_target(first_token, text).unwrap();
1405        assert_eq!(target, "https://pypi.mycorp.example/simple");
1406    }
1407
1408    /// End-to-end confirmation that the fix actually reaches `PypiIndexConfig`: a chain built
1409    /// from a multi-option line resolves via a clean single-hop URL, not a policy-rejected
1410    /// mangled one (the mangled form, once percent-encoded, is a different — and differently
1411    /// classified — URL, so this would fail closed to `CustomRegistry` if the bug regressed).
1412    #[test]
1413    fn test_index_url_multi_option_line_does_not_swallow_trailing_options() {
1414        let content = "--index-url https://pypi.mycorp.example/simple --trusted-host pypi.mycorp.example\nrequests==2.31.0\n";
1415        let result = parse_with_policy(content, &all_policy());
1416        assert_eq!(result.dependencies.len(), 1);
1417        assert!(
1418            matches!(
1419                result.dependencies[0].source,
1420                PypiDependencySource::AlternateRegistry { .. }
1421            ),
1422            "a mangled URL would still validate as *some* URL but registers a different \
1423             chain than the clean one — got {:?}",
1424            result.dependencies[0].source
1425        );
1426    }
1427
1428    /// Same bug (S2), `--extra-index-url=<url>` equals-spelling with a trailing option on the
1429    /// same line.
1430    #[test]
1431    fn test_extra_index_url_equals_multi_option_line_does_not_swallow_trailing_options() {
1432        let content = "--extra-index-url=https://extra.example/simple --trusted-host extra.example\nrequests==2.31.0\n";
1433        let result = parse_with_policy(content, &all_policy());
1434        assert_eq!(result.dependencies.len(), 1);
1435        assert_matches!(
1436            result.dependencies[0].source,
1437            PypiDependencySource::AlternateRegistry { .. }
1438        );
1439    }
1440
1441    /// T012 test gap #12: `--extra-index-url=<url>` equals-spelling is captured identically
1442    /// to the space-separated form (only `--index-url=` was previously covered).
1443    #[test]
1444    fn test_extra_index_url_equals_spelling() {
1445        let content = "--extra-index-url=https://extra.example/simple\nrequests==2.31.0\n";
1446        let result = parse_with_policy(content, &all_policy());
1447        assert_matches!(
1448            result.dependencies[0].source,
1449            PypiDependencySource::AlternateRegistry { .. }
1450        );
1451    }
1452}