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