Skip to main content

provenant/license_detection/query/
mod.rs

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