Skip to main content

provenant/license_detection/query/
mod.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Query processing - tokenized input for license matching.
8
9use crate::license_detection::LicenseDetectionError;
10use crate::license_detection::index::LicenseIndex;
11use crate::license_detection::index::dictionary::{KnownToken, QueryToken, TokenId, TokenKind};
12use crate::license_detection::models::PositionSpan;
13use crate::license_detection::position_set::PositionSet;
14use crate::license_detection::spdx_lid::split_spdx_lid;
15use crate::license_detection::tokenize::STOPWORDS;
16use crate::license_detection::tokenize::tokenize_as_ids;
17use regex::Regex;
18use std::cell::{OnceCell, RefCell};
19use std::collections::HashMap;
20use std::sync::LazyLock;
21use std::time::Instant;
22
23static QUERY_PATTERN: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"[^_\W]+\+?[^_\W]*").expect("valid query regex"));
25static MATCHED_TEXT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(r"(?P<token>[^_\W]+\+?[^_\W]*)|(?P<punct>[_\W\s\+]+[_\W\s]?)")
27        .expect("valid matched text regex")
28});
29
30#[derive(Clone)]
31struct MatchedTextToken {
32    value: String,
33    line_num: usize,
34    pos: Option<usize>,
35    is_text: bool,
36    is_matched: bool,
37}
38
39///
40/// Query holds:
41/// - Known token IDs (tokens existing in the index dictionary)
42/// - Token positions and their corresponding line numbers (line_by_pos)
43/// - Unknown tokens (tokens not in dictionary) tracked per position
44/// - Stopwords tracked per position
45/// - Positions with short/digit-only tokens
46/// - High and low matchable token positions (for tracking what's been matched)
47///
48/// Based on Python Query class at:
49/// reference/scancode-toolkit/src/licensedcode/query.py (lines 155-295)
50#[derive(Debug)]
51pub struct Query<'a> {
52    /// The original input text.
53    ///
54    /// Corresponds to Python: `self.query_string` (line 215)
55    pub text: String,
56
57    /// Token IDs for known tokens (tokens found in the index dictionary)
58    ///
59    /// Corresponds to Python: `self.tokens = []` (line 228)
60    pub tokens: Vec<TokenId>,
61
62    /// Mapping from token position to line number (1-based)
63    ///
64    /// Each token position in `self.tokens` maps to the line number where it appears.
65    /// This is used for match position reporting.
66    ///
67    /// Corresponds to Python: `self.line_by_pos = []` (line 231)
68    pub line_by_pos: Vec<usize>,
69
70    /// Mapping from token position to count of unknown tokens after that position
71    ///
72    /// Unknown tokens are those not found in the dictionary. We track them by
73    /// counting how many unknown tokens appear after each known position.
74    /// Unknown tokens before the first known token are tracked with the key `None`.
75    ///
76    /// Corresponds to Python: `self.unknowns_by_pos = {}` (line 236)
77    pub unknowns_by_pos: HashMap<Option<usize>, usize>,
78
79    /// Mapping from token position to count of stopwords after that position
80    ///
81    /// Similar to unknown_tokens, but for stopwords.
82    ///
83    /// Corresponds to Python: `self.stopwords_by_pos = {}` (line 244)
84    pub stopwords_by_pos: HashMap<Option<usize>, usize>,
85
86    /// Set of positions with single-character or digit-only tokens
87    ///
88    /// These tokens have special handling in matching.
89    ///
90    /// Corresponds to Python: `self.shorts_and_digits_pos = set()` (line 249)
91    pub shorts_and_digits_pos: PositionSet,
92
93    /// High-value matchable token positions (legalese tokens)
94    ///
95    /// These are tokens with ID < len_legalese.
96    ///
97    /// Corresponds to Python: `self.high_matchables` (line 293)
98    pub high_matchables: PositionSet,
99
100    /// Low-value matchable token positions (non-legalese tokens)
101    ///
102    /// These are tokens with ID >= len_legalese.
103    ///
104    /// Corresponds to Python: `self.low_matchables` (line 294)
105    pub low_matchables: PositionSet,
106
107    /// True if the query is detected as binary content
108    ///
109    /// Corresponds to Python: `self.is_binary = False` (line 225)
110    pub is_binary: bool,
111
112    /// Raw query run ranges (start, end) computed during tokenization.
113    ///
114    /// QueryRuns are created on-demand from these ranges.
115    ///
116    /// Corresponds to Python: `self.query_runs = []` (line 274)
117    pub(crate) query_run_ranges: Vec<(usize, Option<usize>)>,
118
119    /// SPDX-License-Identifier lines found during tokenization.
120    ///
121    /// Each tuple is (spdx_text, start_token_pos, end_token_pos).
122    /// Used for creating LicenseMatches with correct token positions.
123    ///
124    /// Corresponds to Python: `self.spdx_lines = []` (line 507)
125    pub spdx_lines: Vec<(String, usize, usize)>,
126
127    /// Lazily-computed byte ranges (into `text`) of each line's content, in the
128    /// exact form `str::lines()` yields (line terminators and a preceding `\r`
129    /// excluded, no trailing empty line after a final `\n`). Built once on first
130    /// `matched_text` call so per-match extraction is O(lines-in-range) instead
131    /// of re-scanning the whole file via `text.lines()` on every call.
132    ///
133    /// `OnceLock` (not `OnceCell`) so `Query` stays `Sync`.
134    pub(crate) line_content_ranges: std::sync::OnceLock<Vec<std::ops::Range<usize>>>,
135
136    /// Reference to the license index for dictionary access and metadata
137    pub index: &'a LicenseIndex,
138}
139
140pub fn matched_text_from_text(text: &str, start_line: usize, end_line: usize) -> String {
141    if start_line == 0 || end_line == 0 || start_line > end_line {
142        return String::new();
143    }
144
145    text.lines()
146        .enumerate()
147        .filter_map(|(idx, line)| {
148            let line_num = idx + 1;
149            if line_num >= start_line && line_num <= end_line {
150                Some(line)
151            } else {
152                None
153            }
154        })
155        .collect::<Vec<_>>()
156        .join("\n")
157}
158
159pub fn matched_text_diagnostics_from_text(
160    text: &str,
161    query: &Query<'_>,
162    matched_positions: &PositionSet,
163    start_pos: usize,
164    end_pos: usize,
165    start_line: usize,
166    end_line: usize,
167) -> String {
168    let tokens = tokenize_matched_text(text, query);
169    let reportable_tokens = collect_reportable_tokens(
170        tokens,
171        matched_positions,
172        start_pos,
173        end_pos,
174        start_line,
175        end_line,
176    );
177    let line_endings = collect_line_endings(text);
178
179    render_diagnostic_tokens(&reportable_tokens, &line_endings)
180}
181
182/// Extracts matched text using token-span mode instead of whole-line mode.
183///
184/// This is used for files with very long lines (e.g., minified JS) where
185/// whole-line extraction would return megabytes of text for a small match.
186/// Instead, it returns only the tokens within the matched span, producing
187/// output similar to `matched_text_diagnostics_from_text()` but without
188/// the diagnostic `[bracket]` wrapping.
189///
190/// Falls back to `matched_text_from_text()` if token positions are unavailable.
191pub fn matched_text_from_tokens(
192    text: &str,
193    query: &Query<'_>,
194    matched_positions: &PositionSet,
195    start_pos: usize,
196    end_pos: usize,
197    start_line: usize,
198    end_line: usize,
199) -> String {
200    let tokens = tokenize_matched_text(text, query);
201    let reportable_tokens = collect_reportable_tokens(
202        tokens,
203        matched_positions,
204        start_pos,
205        end_pos,
206        start_line,
207        end_line,
208    );
209    let line_endings = collect_line_endings(text);
210
211    render_plain_tokens(&reportable_tokens, &line_endings)
212}
213
214fn render_plain_tokens(tokens: &[MatchedTextToken], line_endings: &[String]) -> String {
215    let mut rendered = String::new();
216    let mut previous_line: Option<usize> = None;
217
218    for token in tokens {
219        if let Some(prev_line) = previous_line
220            && token.line_num > prev_line
221        {
222            for line in prev_line..token.line_num {
223                if let Some(line_ending) = line_endings.get(line.saturating_sub(1)) {
224                    rendered.push_str(line_ending.as_str());
225                }
226            }
227        }
228
229        let token_value = if token.is_text {
230            token.value.as_str()
231        } else {
232            token
233                .value
234                .strip_suffix("\r\n")
235                .or_else(|| token.value.strip_suffix('\n'))
236                .unwrap_or(token.value.as_str())
237        };
238
239        rendered.push_str(token_value);
240
241        previous_line = Some(token.line_num);
242    }
243
244    rendered
245}
246
247fn tokenize_matched_text(text: &str, query: &Query<'_>) -> Vec<MatchedTextToken> {
248    let mut tokens = Vec::new();
249    let mut pos = 0usize;
250    for (line_num, line) in (1usize..).zip(text.split_inclusive('\n')) {
251        for capture in MATCHED_TEXT_PATTERN.captures_iter(line) {
252            if let Some(token_match) = capture.name("token") {
253                let token_text = token_match.as_str();
254                let retokenized: Vec<String> = QUERY_PATTERN
255                    .find_iter(&token_text.to_lowercase())
256                    .map(|m| m.as_str().to_string())
257                    .filter(|token| !STOPWORDS.contains(token.as_str()))
258                    .collect();
259
260                if retokenized.is_empty() {
261                    tokens.push(MatchedTextToken {
262                        value: token_text.to_string(),
263                        line_num,
264                        pos: None,
265                        is_text: true,
266                        is_matched: false,
267                    });
268                } else if retokenized.len() == 1 {
269                    let token = &retokenized[0];
270                    let token_pos = if query.index.dictionary.get(token).is_some() {
271                        let current_pos = pos;
272                        pos += 1;
273                        Some(current_pos)
274                    } else {
275                        None
276                    };
277
278                    tokens.push(MatchedTextToken {
279                        value: token_text.to_string(),
280                        line_num,
281                        pos: token_pos,
282                        is_text: true,
283                        is_matched: false,
284                    });
285                } else {
286                    for token in retokenized {
287                        let token_pos = if query.index.dictionary.get(&token).is_some() {
288                            let current_pos = pos;
289                            pos += 1;
290                            Some(current_pos)
291                        } else {
292                            None
293                        };
294
295                        tokens.push(MatchedTextToken {
296                            value: token,
297                            line_num,
298                            pos: token_pos,
299                            is_text: true,
300                            is_matched: false,
301                        });
302                    }
303                }
304            } else if let Some(punct_match) = capture.name("punct") {
305                tokens.push(MatchedTextToken {
306                    value: punct_match.as_str().to_string(),
307                    line_num,
308                    pos: None,
309                    is_text: false,
310                    is_matched: false,
311                });
312            }
313        }
314    }
315
316    tokens
317}
318
319fn collect_reportable_tokens(
320    tokens: Vec<MatchedTextToken>,
321    matched_positions: &PositionSet,
322    start_pos: usize,
323    end_pos: usize,
324    start_line: usize,
325    end_line: usize,
326) -> Vec<MatchedTextToken> {
327    let mut reportable = Vec::new();
328    let mut started = false;
329    let mut finished = false;
330    let mut end_real_pos = None;
331    let mut last_real_pos = None;
332
333    for (real_pos, mut token) in tokens.into_iter().enumerate() {
334        if token.line_num < start_line {
335            continue;
336        }
337
338        if token.line_num > end_line {
339            break;
340        }
341
342        let mut is_included = false;
343
344        if token.pos.is_some_and(|pos| matched_positions.contains(pos)) {
345            token.is_matched = true;
346            is_included = true;
347        }
348
349        if !started && token.pos == Some(start_pos) {
350            started = true;
351            is_included = true;
352        }
353
354        if started && !finished {
355            is_included = true;
356        }
357
358        if token.pos == Some(end_pos) {
359            finished = true;
360            started = false;
361            end_real_pos = Some(real_pos);
362        }
363
364        if finished && !started && end_real_pos.is_some() && last_real_pos == end_real_pos {
365            end_real_pos = None;
366            if !token.is_text && !token.value.trim().is_empty() {
367                is_included = true;
368            }
369        }
370
371        last_real_pos = Some(real_pos);
372
373        if is_included {
374            reportable.push(token);
375        }
376    }
377
378    reportable
379}
380
381fn collect_line_endings(text: &str) -> Vec<String> {
382    text.split_inclusive('\n')
383        .map(|line| {
384            if line.ends_with("\r\n") {
385                "\r\n".to_string()
386            } else if line.ends_with('\n') {
387                "\n".to_string()
388            } else {
389                String::new()
390            }
391        })
392        .collect()
393}
394
395fn render_diagnostic_tokens(tokens: &[MatchedTextToken], line_endings: &[String]) -> String {
396    let mut rendered = String::new();
397    let mut previous_line: Option<usize> = None;
398
399    for token in tokens {
400        if let Some(prev_line) = previous_line
401            && token.line_num > prev_line
402        {
403            for line in prev_line..token.line_num {
404                if let Some(line_ending) = line_endings.get(line.saturating_sub(1)) {
405                    rendered.push_str(line_ending.as_str());
406                }
407            }
408        }
409
410        let token_value = if token.is_text {
411            token.value.as_str()
412        } else {
413            token
414                .value
415                .strip_suffix("\r\n")
416                .or_else(|| token.value.strip_suffix('\n'))
417                .unwrap_or(token.value.as_str())
418        };
419
420        if token.is_text && !STOPWORDS.contains(token.value.to_lowercase().as_str()) {
421            if token.is_matched {
422                rendered.push_str(token_value);
423            } else {
424                rendered.push('[');
425                rendered.push_str(token_value);
426                rendered.push(']');
427            }
428        } else {
429            rendered.push_str(token_value);
430        }
431
432        previous_line = Some(token.line_num);
433    }
434
435    rendered
436}
437
438impl<'a> Query<'a> {
439    /// Create a new query from text string and license index.
440    ///
441    /// This tokenizes the input text, looks up each token in the index dictionary,
442    /// and builds the query structures for matching.
443    ///
444    /// # Arguments
445    /// * `text` - The input text to tokenize
446    /// * `index` - The license index containing the token dictionary
447    ///
448    /// # Returns
449    /// A Result containing the Query or an error if binary detection fails
450    ///
451    /// Detection scans file-like text, so this uses Python's
452    /// `build_query(..., text_line_threshold=15)` threshold.
453    const TEXT_LINE_THRESHOLD: usize = 15;
454    const BINARY_LINE_THRESHOLD: usize = 50;
455    const MAX_TOKEN_PER_LINE: usize = 25;
456
457    fn compute_spdx_offset(
458        tokens: &[QueryToken],
459        dictionary: &crate::license_detection::index::dictionary::TokenDictionary,
460    ) -> Option<usize> {
461        let get_known_id = |i: usize| -> Option<TokenId> {
462            match tokens.get(i)? {
463                QueryToken::Known(known) => Some(known.id),
464                _ => None,
465            }
466        };
467
468        let spdx_id = dictionary.get("spdx")?;
469        let license_id = dictionary.get("license")?;
470        let identifier_id = dictionary.get("identifier")?;
471        let licence_id = dictionary.get("licence");
472
473        let licenses_id = dictionary.get("licenses");
474        let nuget_id = dictionary.get("nuget");
475        let org_id = dictionary.get("org");
476
477        let is_spdx_prefix = |ids: [Option<TokenId>; 3]| -> bool {
478            ids.iter().all(|id| id.is_some())
479                && ids[0] == Some(spdx_id)
480                && (ids[1] == Some(license_id) || ids[1] == licence_id)
481                && ids[2] == Some(identifier_id)
482        };
483
484        let is_nuget_prefix = |ids: [Option<TokenId>; 3]| -> bool {
485            licenses_id.is_some()
486                && nuget_id.is_some()
487                && org_id.is_some()
488                && ids[0] == licenses_id
489                && ids[1] == Some(nuget_id.unwrap())
490                && ids[2] == Some(org_id.unwrap())
491        };
492
493        if tokens.len() >= 3 {
494            let first_three = [get_known_id(0), get_known_id(1), get_known_id(2)];
495            if is_spdx_prefix(first_three) || is_nuget_prefix(first_three) {
496                return Some(0);
497            }
498        }
499
500        if tokens.len() >= 4 {
501            let second_three = [get_known_id(1), get_known_id(2), get_known_id(3)];
502            if is_spdx_prefix(second_three) || is_nuget_prefix(second_three) {
503                return Some(1);
504            }
505        }
506
507        if tokens.len() >= 5 {
508            let third_three = [get_known_id(2), get_known_id(3), get_known_id(4)];
509            if is_spdx_prefix(third_three) || is_nuget_prefix(third_three) {
510                return Some(2);
511            }
512        }
513
514        None
515    }
516
517    pub(crate) fn from_extracted_text(
518        text: &str,
519        index: &'a LicenseIndex,
520        binary_derived: bool,
521    ) -> Result<Self, LicenseDetectionError> {
522        Self::from_extracted_text_with_deadline(text, index, binary_derived, None)
523    }
524
525    pub(crate) fn from_extracted_text_with_deadline(
526        text: &str,
527        index: &'a LicenseIndex,
528        binary_derived: bool,
529        deadline: Option<Instant>,
530    ) -> Result<Self, LicenseDetectionError> {
531        let line_threshold = if binary_derived {
532            Self::BINARY_LINE_THRESHOLD
533        } else {
534            Self::TEXT_LINE_THRESHOLD
535        };
536
537        Self::with_source_options(text, index, line_threshold, Some(binary_derived), deadline)
538    }
539
540    /// Iterate over query runs.
541    ///
542    /// Corresponds to Python: `query.query_runs` property iteration
543    pub fn query_runs(&self) -> Vec<QueryRun<'_>> {
544        self.query_run_ranges
545            .iter()
546            .map(|&(start, end)| QueryRun::new(self, start, end))
547            .collect()
548    }
549
550    fn with_source_options(
551        text: &str,
552        index: &'a LicenseIndex,
553        line_threshold: usize,
554        binary_derived: Option<bool>,
555        deadline: Option<Instant>,
556    ) -> Result<Self, LicenseDetectionError> {
557        crate::license_detection::ensure_within_deadline(deadline)?;
558        let is_binary = match binary_derived {
559            Some(is_binary) => is_binary,
560            None => Self::detect_binary(text),
561        };
562        let has_long_lines = Self::detect_long_lines(text);
563
564        let mut tokens = Vec::new();
565        let mut line_by_pos = Vec::new();
566        let mut unknowns_by_pos: HashMap<Option<usize>, usize> = HashMap::new();
567        let mut stopwords_by_pos: HashMap<Option<usize>, usize> = HashMap::new();
568        let mut shorts_and_digits_pos = PositionSet::new();
569        let mut spdx_lines: Vec<(String, usize, usize)> = Vec::new();
570
571        let mut known_pos: Option<usize> = None;
572        let mut started = false;
573        let mut tokens_by_line: Vec<Vec<Option<KnownToken>>> = Vec::new();
574
575        for (current_line, (line_index, line)) in (1usize..).zip(text.lines().enumerate()) {
576            if line_index.is_multiple_of(128) {
577                crate::license_detection::ensure_within_deadline(deadline)?;
578            }
579
580            let line_trimmed = line.trim();
581            let mut line_tokens: Vec<Option<KnownToken>> = Vec::new();
582
583            let mut line_first_known_pos = None;
584
585            let line_query_tokens = tokenize_as_ids(line_trimmed, &index.dictionary);
586
587            for query_token in &line_query_tokens {
588                match query_token {
589                    QueryToken::Known(known_token) => {
590                        known_pos = Some(known_pos.map_or(0, |p| p + 1));
591                        started = true;
592                        tokens.push(known_token.id);
593                        line_by_pos.push(current_line);
594                        line_tokens.push(Some(*known_token));
595
596                        if line_first_known_pos.is_none() {
597                            line_first_known_pos = known_pos;
598                        }
599
600                        if known_token.is_short_or_digit {
601                            let _ = shorts_and_digits_pos.insert(known_pos.unwrap());
602                        }
603                    }
604                    QueryToken::Unknown if !started => {
605                        *unknowns_by_pos.entry(None).or_insert(0) += 1;
606                        line_tokens.push(None);
607                    }
608                    QueryToken::Unknown => {
609                        *unknowns_by_pos.entry(known_pos).or_insert(0) += 1;
610                        line_tokens.push(None);
611                    }
612                    QueryToken::Stopword if !started => {
613                        *stopwords_by_pos.entry(None).or_insert(0) += 1;
614                    }
615                    QueryToken::Stopword => {
616                        *stopwords_by_pos.entry(known_pos).or_insert(0) += 1;
617                    }
618                }
619            }
620
621            let line_last_known_pos = known_pos;
622
623            let spdx_start_offset =
624                Self::compute_spdx_offset(&line_query_tokens, &index.dictionary);
625
626            if let Some(offset) = spdx_start_offset
627                && let Some(line_first_known_pos) = line_first_known_pos
628            {
629                let (spdx_prefix, spdx_expression) = split_spdx_lid(line);
630                let spdx_text = format!("{}{}", spdx_prefix.unwrap_or_default(), spdx_expression);
631                let spdx_start_known_pos = line_first_known_pos + offset;
632
633                if spdx_start_known_pos <= line_last_known_pos.unwrap() {
634                    let spdx_end = line_last_known_pos.unwrap() + 1;
635                    spdx_lines.push((spdx_text, spdx_start_known_pos, spdx_end));
636                }
637            }
638            tokens_by_line.push(line_tokens);
639        }
640
641        crate::license_detection::ensure_within_deadline(deadline)?;
642
643        let high_matchables: PositionSet = tokens
644            .iter()
645            .enumerate()
646            .filter(|(_pos, tid)| index.dictionary.token_kind(**tid) == TokenKind::Legalese)
647            .map(|(pos, _tid)| pos)
648            .collect();
649
650        let low_matchables: PositionSet = tokens
651            .iter()
652            .enumerate()
653            .filter(|(_pos, tid)| index.dictionary.token_kind(**tid) == TokenKind::Regular)
654            .map(|(pos, _tid)| pos)
655            .collect();
656
657        let query_runs = Self::compute_query_runs(&tokens_by_line, line_threshold, has_long_lines);
658
659        Ok(Query {
660            text: text.to_string(),
661            tokens,
662            line_by_pos,
663            unknowns_by_pos,
664            stopwords_by_pos,
665            shorts_and_digits_pos,
666            high_matchables,
667            low_matchables,
668            is_binary,
669            query_run_ranges: query_runs,
670            spdx_lines,
671            line_content_ranges: std::sync::OnceLock::new(),
672            index,
673        })
674    }
675
676    /// Detect if text is binary content.
677    ///
678    /// Binary detection checks for:
679    /// - Null bytes (0x00)
680    /// - High ratio of non-printable characters
681    ///
682    /// # Arguments
683    /// * `text` - The text to analyze
684    ///
685    /// # Returns
686    /// true if binary, false otherwise
687    ///
688    /// Corresponds to Python: `typecode.get_type().is_binary` usage (lines 123-135)
689    fn detect_binary(text: &str) -> bool {
690        let null_byte_count = text.bytes().filter(|&b| b == 0).count();
691
692        if null_byte_count > 0 {
693            return true;
694        }
695
696        let non_printable_ratio = text
697            .chars()
698            .filter(|&c| {
699                !c.is_ascii() && !c.is_ascii_graphic() && c != '\n' && c != '\r' && c != '\t'
700            })
701            .count() as f64
702            / text.len().max(1) as f64;
703
704        non_printable_ratio > 0.3
705    }
706
707    /// Detect if text has very long lines (for minified JS/CSS).
708    ///
709    /// # Arguments
710    /// * `text` - The text to analyze
711    ///
712    /// # Returns
713    /// true if there are lines with many tokens, false otherwise
714    ///
715    /// Corresponds to Python: `typecode.get_type().is_text_with_long_lines` usage
716    fn detect_long_lines(text: &str) -> bool {
717        text.lines()
718            .any(|line| crate::license_detection::tokenize::count_tokens(line) > 25)
719    }
720
721    fn break_long_lines(lines: &[Vec<Option<KnownToken>>]) -> Vec<Vec<Option<KnownToken>>> {
722        lines
723            .iter()
724            .flat_map(|line| {
725                if line.is_empty() {
726                    return Vec::new();
727                }
728
729                if line.len() <= Self::MAX_TOKEN_PER_LINE {
730                    vec![line.clone()]
731                } else {
732                    line.chunks(Self::MAX_TOKEN_PER_LINE)
733                        .map(|chunk| chunk.to_vec())
734                        .collect()
735                }
736            })
737            .collect()
738    }
739
740    fn compute_query_runs(
741        tokens_by_line: &[Vec<Option<KnownToken>>],
742        line_threshold: usize,
743        has_long_lines: bool,
744    ) -> Vec<(usize, Option<usize>)> {
745        let processed_lines = if has_long_lines {
746            Self::break_long_lines(tokens_by_line)
747        } else {
748            tokens_by_line.to_vec()
749        };
750
751        let mut query_runs = Vec::new();
752        let mut query_run_start = 0usize;
753        let mut query_run_end = None;
754        let mut empty_lines = 0usize;
755        let mut pos = 0usize;
756        let mut query_run_is_all_digit = true;
757
758        for line_tokens in processed_lines {
759            if query_run_end.is_some() && empty_lines >= line_threshold {
760                if !query_run_is_all_digit {
761                    query_runs.push((query_run_start, query_run_end));
762                }
763                query_run_start = pos;
764                query_run_end = None;
765                empty_lines = 0;
766                query_run_is_all_digit = true;
767            }
768
769            if query_run_end.is_none() {
770                query_run_start = pos;
771            }
772
773            if line_tokens.is_empty() {
774                empty_lines += 1;
775                continue;
776            }
777
778            let line_is_all_digit = line_tokens
779                .iter()
780                .all(|token_id| token_id.map(|known| known.is_digit_only).unwrap_or(true));
781            let mut line_has_known_tokens = false;
782            let mut line_has_good_tokens = false;
783
784            for known in line_tokens.into_iter().flatten() {
785                line_has_known_tokens = true;
786                if known.kind == TokenKind::Legalese {
787                    line_has_good_tokens = true;
788                }
789                if !known.is_digit_only {
790                    query_run_is_all_digit = false;
791                }
792                query_run_end = Some(pos);
793                pos += 1;
794            }
795
796            if line_is_all_digit || !line_has_known_tokens {
797                empty_lines += 1;
798                continue;
799            }
800
801            if line_has_good_tokens {
802                empty_lines = 0;
803            } else {
804                empty_lines += 1;
805            }
806        }
807
808        if let Some(end) = query_run_end
809            && !query_run_is_all_digit
810        {
811            query_runs.push((query_run_start, Some(end)));
812        }
813
814        query_runs
815    }
816
817    /// Get the length of the query in tokens.
818    ///
819    /// Get the line number for a token position.
820    ///
821    /// # Arguments
822    /// * `pos` - The token position
823    ///
824    /// # Returns
825    /// The line number (1-based)
826    #[inline]
827    pub fn line_for_pos(&self, pos: usize) -> Option<usize> {
828        self.line_by_pos.get(pos).copied()
829    }
830
831    /// Check if the query is empty (no known tokens).
832    #[inline]
833    pub fn is_empty(&self) -> bool {
834        self.tokens.is_empty()
835    }
836
837    /// Get a query run covering the entire query.
838    ///
839    /// Corresponds to Python: `whole_query_run()` method (lines 306-317)
840    pub fn whole_query_run(&self) -> QueryRun<'a> {
841        QueryRun::whole_query_snapshot(self)
842    }
843
844    /// Subtract matched span positions from matchables.
845    ///
846    /// This removes the positions from both high and low matchables.
847    ///
848    /// # Arguments
849    /// * `span` - The span of positions to subtract
850    ///
851    /// Corresponds to Python: `subtract()` method (lines 328-334)
852    pub fn subtract(&mut self, span: &PositionSpan) {
853        self.high_matchables.remove_span(span);
854        self.low_matchables.remove_span(span);
855    }
856
857    /// Extract matched text for a given line range.
858    ///
859    /// Returns the text from the original input between start_line and end_line
860    /// (both inclusive, 1-indexed).
861    ///
862    /// # Arguments
863    /// * `start_line` - Starting line number (1-indexed)
864    /// * `end_line` - Ending line number (1-indexed)
865    ///
866    /// # Returns
867    /// The matched text, or empty string if lines are out of range
868    ///
869    /// Corresponds to Python: `matched_text()` method in match.py (lines 757-795)
870    pub fn matched_text(&self, start_line: usize, end_line: usize) -> String {
871        if start_line == 0 || end_line == 0 || start_line > end_line {
872            return String::new();
873        }
874        let ranges = self.line_content_ranges();
875        let from = start_line - 1;
876        if from >= ranges.len() {
877            return String::new();
878        }
879        // `end_line` is an inclusive 1-indexed bound, so `end_line` (not +1) is
880        // the exclusive 0-indexed end; clamp to the available line count.
881        let to = end_line.min(ranges.len());
882        ranges[from..to]
883            .iter()
884            .map(|r| &self.text[r.clone()])
885            .collect::<Vec<_>>()
886            .join("\n")
887    }
888
889    /// True if any line in the inclusive 1-indexed `[start_line, end_line]`
890    /// range has content longer than `max_line_length`. Uses the cached line
891    /// ranges; byte-identical to checking `line.len()` over `text.lines()`.
892    pub(crate) fn line_range_has_oversized_line(
893        &self,
894        start_line: usize,
895        end_line: usize,
896        max_line_length: usize,
897    ) -> bool {
898        if start_line == 0 || end_line == 0 || start_line > end_line {
899            return false;
900        }
901        let ranges = self.line_content_ranges();
902        let from = start_line - 1;
903        if from >= ranges.len() {
904            return false;
905        }
906        let to = end_line.min(ranges.len());
907        ranges[from..to].iter().any(|r| r.len() > max_line_length)
908    }
909
910    /// Byte ranges of each line's content within `text`, computed once and
911    /// cached. Built directly from `str::lines()` so the slices reproduce its
912    /// semantics exactly (terminators / leading `\r` excluded, no trailing empty
913    /// line after a final `\n`), keeping [`Query::matched_text`] byte-identical
914    /// to the previous `text.lines()`-per-call implementation.
915    fn line_content_ranges(&self) -> &[std::ops::Range<usize>] {
916        self.line_content_ranges.get_or_init(|| {
917            let base = self.text.as_ptr() as usize;
918            self.text
919                .lines()
920                .map(|line| {
921                    let start = line.as_ptr() as usize - base;
922                    start..start + line.len()
923                })
924                .collect()
925        })
926    }
927}
928
929#[derive(Debug, Clone)]
930struct WholeQueryRunSnapshot<'a> {
931    index: &'a LicenseIndex,
932    tokens: Vec<TokenId>,
933    line_by_pos: Vec<usize>,
934    high_matchables: PositionSet,
935    low_matchables: PositionSet,
936}
937
938/// A query run is a slice of query tokens identified by a start and end positions.
939///
940/// Query runs break a query into manageable chunks for efficient matching.
941/// They track matchable token positions and support subtraction of matched spans.
942///
943/// Based on Python QueryRun class at:
944/// reference/scancode-toolkit/src/licensedcode/query.py (lines 720-914)
945#[derive(Debug, Clone)]
946pub struct QueryRun<'a> {
947    query: Option<&'a Query<'a>>,
948    whole_query_snapshot: Option<WholeQueryRunSnapshot<'a>>,
949    pub start: usize,
950    pub end: Option<usize>,
951    cached_high_matchables: OnceCell<PositionSet>,
952    cached_low_matchables: OnceCell<PositionSet>,
953    combined_matchables: RefCell<Option<PositionSet>>,
954}
955
956impl<'a> QueryRun<'a> {
957    /// Create a new query run from a query with start and end positions.
958    ///
959    /// # Arguments
960    /// * `query` - The parent query
961    /// * `start` - The start position (inclusive)
962    /// * `end` - The end position (inclusive), or None for an empty run
963    ///
964    /// Corresponds to Python: `QueryRun.__init__()` (lines 735-749)
965    pub fn new(query: &'a Query<'a>, start: usize, end: Option<usize>) -> Self {
966        Self {
967            query: Some(query),
968            whole_query_snapshot: None,
969            start,
970            end,
971            cached_high_matchables: OnceCell::new(),
972            cached_low_matchables: OnceCell::new(),
973            combined_matchables: RefCell::new(None),
974        }
975    }
976
977    fn whole_query_snapshot(query: &Query<'a>) -> Self {
978        let end = if query.is_empty() {
979            None
980        } else {
981            Some(query.tokens.len() - 1)
982        };
983
984        Self {
985            query: None,
986            whole_query_snapshot: Some(WholeQueryRunSnapshot {
987                index: query.index,
988                tokens: query.tokens.clone(),
989                line_by_pos: query.line_by_pos.clone(),
990                high_matchables: query.high_matchables.clone(),
991                low_matchables: query.low_matchables.clone(),
992            }),
993            start: 0,
994            end,
995            cached_high_matchables: OnceCell::new(),
996            cached_low_matchables: OnceCell::new(),
997            combined_matchables: RefCell::new(None),
998        }
999    }
1000
1001    fn source_tokens(&self) -> &[TokenId] {
1002        if let Some(query) = self.query {
1003            &query.tokens
1004        } else {
1005            &self
1006                .whole_query_snapshot
1007                .as_ref()
1008                .expect("snapshot-backed whole query run should have snapshot data")
1009                .tokens
1010        }
1011    }
1012
1013    fn source_line_by_pos(&self) -> &[usize] {
1014        if let Some(query) = self.query {
1015            &query.line_by_pos
1016        } else {
1017            &self
1018                .whole_query_snapshot
1019                .as_ref()
1020                .expect("snapshot-backed whole query run should have snapshot data")
1021                .line_by_pos
1022        }
1023    }
1024
1025    fn source_high_matchables(&self) -> &PositionSet {
1026        if let Some(query) = self.query {
1027            &query.high_matchables
1028        } else {
1029            &self
1030                .whole_query_snapshot
1031                .as_ref()
1032                .expect("snapshot-backed whole query run should have snapshot data")
1033                .high_matchables
1034        }
1035    }
1036
1037    fn source_low_matchables(&self) -> &PositionSet {
1038        if let Some(query) = self.query {
1039            &query.low_matchables
1040        } else {
1041            &self
1042                .whole_query_snapshot
1043                .as_ref()
1044                .expect("snapshot-backed whole query run should have snapshot data")
1045                .low_matchables
1046        }
1047    }
1048
1049    /// Get the license index used by this query run.
1050    pub fn get_index(&self) -> &LicenseIndex {
1051        if let Some(query) = self.query {
1052            query.index
1053        } else {
1054            self.whole_query_snapshot
1055                .as_ref()
1056                .expect("snapshot-backed whole query run should have snapshot data")
1057                .index
1058        }
1059    }
1060
1061    /// Get the line number for a specific token position.
1062    ///
1063    /// # Arguments
1064    /// * `pos` - Absolute token position in the query
1065    ///
1066    /// # Returns
1067    /// The line number (1-based), or None if position is out of range
1068    pub fn line_for_pos(&self, pos: usize) -> Option<usize> {
1069        self.source_line_by_pos().get(pos).copied()
1070    }
1071
1072    /// Get the sequence of token IDs for this run.
1073    ///
1074    /// Returns empty slice if end is None.
1075    ///
1076    /// Corresponds to Python: `tokens` property (lines 779-786)
1077    pub fn tokens(&self) -> &[TokenId] {
1078        match self.end {
1079            Some(end) => &self.source_tokens()[self.start..=end],
1080            None => &[],
1081        }
1082    }
1083
1084    /// Iterate over token IDs with their absolute positions.
1085    ///
1086    /// Corresponds to Python: `tokens_with_pos()` method (lines 788-789)
1087    pub fn tokens_with_pos(&self) -> impl Iterator<Item = (usize, TokenId)> + '_ {
1088        self.tokens()
1089            .iter()
1090            .copied()
1091            .enumerate()
1092            .map(|(i, tid)| (self.start + i, tid))
1093    }
1094
1095    /// Check if this query run contains only digit tokens.
1096    ///
1097    /// Corresponds to Python: `is_digits_only()` method (lines 791-796)
1098    pub fn is_digits_only(&self) -> bool {
1099        self.tokens()
1100            .iter()
1101            .all(|&tid| self.get_index().dictionary.is_digit_only_token(tid))
1102    }
1103
1104    /// Check if this query run has matchable tokens.
1105    ///
1106    /// # Arguments
1107    /// * `include_low` - If true, include low-value tokens in the check
1108    /// * `exclude_positions` - Optional set of spans containing positions to exclude
1109    ///
1110    /// Returns true if there are matchable tokens remaining
1111    ///
1112    /// Corresponds to Python: `is_matchable()` method (lines 798-818)
1113    pub fn is_matchable(&self, include_low: bool, exclude_positions: &[PositionSpan]) -> bool {
1114        if self.is_digits_only() {
1115            return false;
1116        }
1117
1118        let matchables = self.matchables(include_low);
1119
1120        if exclude_positions.is_empty() {
1121            return !matchables.is_empty();
1122        }
1123
1124        let mut matchable_set = matchables;
1125        for span in exclude_positions {
1126            matchable_set.remove_span(span);
1127        }
1128
1129        !matchable_set.is_empty()
1130    }
1131
1132    pub fn matchables(&self, include_low: bool) -> PositionSet {
1133        if include_low {
1134            if let Some(ref cached) = *self.combined_matchables.borrow() {
1135                return cached.clone();
1136            }
1137            let combined = self.low_matchables().union(&self.high_matchables());
1138            *self.combined_matchables.borrow_mut() = Some(combined.clone());
1139            combined
1140        } else {
1141            self.high_matchables()
1142        }
1143    }
1144
1145    pub fn matchable_tokens(&self) -> Vec<Option<TokenId>> {
1146        let high_matchables = self.high_matchables();
1147        if high_matchables.is_empty() {
1148            return Vec::new();
1149        }
1150
1151        let matchables = self.matchables(true);
1152        self.tokens_with_pos()
1153            .map(|(pos, tid)| {
1154                if matchables.contains(pos) {
1155                    Some(tid)
1156                } else {
1157                    None
1158                }
1159            })
1160            .collect()
1161    }
1162
1163    pub fn high_matchables(&self) -> PositionSet {
1164        self.cached_high_matchables
1165            .get_or_init(|| {
1166                let start = self.start;
1167                let end = self.end.map(|e| e + 1).unwrap_or(usize::MAX);
1168                self.source_high_matchables()
1169                    .restricted_to_range(start, end)
1170            })
1171            .clone()
1172    }
1173
1174    pub fn low_matchables(&self) -> PositionSet {
1175        self.cached_low_matchables
1176            .get_or_init(|| {
1177                let start = self.start;
1178                let end = self.end.map(|e| e + 1).unwrap_or(usize::MAX);
1179                self.source_low_matchables().restricted_to_range(start, end)
1180            })
1181            .clone()
1182    }
1183}
1184
1185#[cfg(test)]
1186mod test;