Skip to main content

provenant/license_detection/query/
mod.rs

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