Skip to main content

scope_engine/
engine.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::analyzer::Analyzer;
6use crate::api::*;
7use crate::language::LanguageRegistry;
8use crate::lsp::TsJsConfig;
9use crate::lsp::{
10    GoplsConfig, JdtlsConfig, LspAnalyzer, LspServerConfig, PyrightConfig, RustAnalyzerConfig,
11};
12use crate::patch;
13use crate::state::PropagationState;
14use crate::treesitter::TreeSitterAnalyzer;
15use globset::{Glob, GlobSet, GlobSetBuilder};
16use ignore::{DirEntry, WalkBuilder};
17use regex::{Regex, RegexBuilder};
18use std::sync::Mutex;
19
20const DEFAULT_SEARCH_LIMIT: usize = 100;
21const MAX_SEARCH_LIMIT: usize = 1000;
22const DEFAULT_REVIEW_LIMIT: usize = 1;
23const MAX_REVIEW_LIMIT: usize = 100;
24const MAX_LSP_DID_OPEN_FILES: usize = 500;
25const READ_AROUND_CONTEXT_LINES: usize = 12;
26
27fn lsp_config_for_language(lsp_lang: &str) -> Option<Box<dyn LspServerConfig>> {
28    match lsp_lang {
29        "rust" => Some(Box::new(RustAnalyzerConfig)),
30        "python" => Some(Box::new(PyrightConfig)),
31        "typescript" | "javascript" => Some(Box::new(TsJsConfig)),
32        "go" => Some(Box::new(GoplsConfig)),
33        "java" => Some(Box::new(JdtlsConfig)),
34        _ => None,
35    }
36}
37
38fn lsp_extensions_for_language(lsp_lang: &str) -> &'static [&'static str] {
39    match lsp_lang {
40        "rust" => &["rs"],
41        "python" => &["py"],
42        "typescript" | "javascript" => &["ts", "tsx", "js", "jsx"],
43        "go" => &["go"],
44        "java" => &["java"],
45        _ => &[],
46    }
47}
48
49fn lsp_language_for_extension(ext: &str) -> Option<&'static str> {
50    match ext {
51        "rs" => Some("rust"),
52        "py" => Some("python"),
53        "ts" | "tsx" => Some("typescript"),
54        "js" | "jsx" => Some("javascript"),
55        "go" => Some("go"),
56        "java" => Some("java"),
57        _ => None,
58    }
59}
60
61fn detect_project_lsp_language(root: &Path) -> Option<&'static str> {
62    if root.join("Cargo.toml").is_file() {
63        return Some("rust");
64    }
65    if root.join("pyproject.toml").is_file()
66        || root.join("requirements.txt").is_file()
67        || root.join("setup.py").is_file()
68    {
69        return Some("python");
70    }
71    if root.join("go.mod").is_file() {
72        return Some("go");
73    }
74    if root.join("pom.xml").is_file()
75        || root.join("build.gradle").is_file()
76        || root.join("build.gradle.kts").is_file()
77    {
78        return Some("java");
79    }
80    if root.join("tsconfig.json").is_file() {
81        return Some("typescript");
82    }
83    if root.join("package.json").is_file() {
84        return Some("typescript");
85    }
86
87    let mut counts: HashMap<&'static str, usize> = HashMap::new();
88    for entry in WalkBuilder::new(root)
89        .hidden(false)
90        .git_ignore(true)
91        .ignore(true)
92        .parents(true)
93        .build()
94        .filter_map(Result::ok)
95        .filter(|entry| {
96            entry
97                .file_type()
98                .is_some_and(|file_type| file_type.is_file())
99        })
100        .take(MAX_LSP_DID_OPEN_FILES)
101    {
102        let Some(ext) = entry.path().extension().and_then(|ext| ext.to_str()) else {
103            continue;
104        };
105        if let Some(language) = lsp_language_for_extension(ext) {
106            *counts.entry(language).or_default() += 1;
107        }
108    }
109
110    ["rust", "typescript", "javascript", "python", "go", "java"]
111        .into_iter()
112        .max_by_key(|language| counts.get(language).copied().unwrap_or(0))
113        .filter(|language| counts.get(language).copied().unwrap_or(0) > 0)
114}
115
116fn open_existing_source_files_for_lsp(lsp: &dyn Analyzer, root: &Path, lsp_lang: &str) {
117    let exts = lsp_extensions_for_language(lsp_lang);
118    if exts.is_empty() {
119        return;
120    }
121    let scan_root = {
122        let src_dir = root.join("src");
123        if src_dir.exists() {
124            src_dir
125        } else {
126            root.to_path_buf()
127        }
128    };
129    for entry in WalkBuilder::new(scan_root)
130        .hidden(false)
131        .git_ignore(true)
132        .ignore(true)
133        .parents(true)
134        .build()
135        .filter_map(Result::ok)
136        .filter(|entry| {
137            entry
138                .file_type()
139                .is_some_and(|file_type| file_type.is_file())
140        })
141        .take(MAX_LSP_DID_OPEN_FILES)
142    {
143        let path = entry.path();
144        if let Some(ext) = path.extension().and_then(|ext| ext.to_str())
145            && exts.contains(&ext)
146            && let Ok(content) = std::fs::read_to_string(path)
147        {
148            lsp.notify_did_open(path, &content);
149        }
150    }
151}
152
153pub fn open_project(
154    project_root: &Path,
155    current_project_root: Option<&Path>,
156    lsp_analyzer: &Mutex<Option<Box<dyn Analyzer + Send>>>,
157) -> Result<OpenProjectOutput, String> {
158    if current_project_root == Some(project_root) {
159        return Ok(OpenProjectOutput {
160            status: "already_open".to_string(),
161            project_root: project_root.to_string_lossy().into_owned(),
162            detected_lsp_language: None,
163            lsp: None,
164        });
165    }
166
167    let detected_lsp_language = detect_project_lsp_language(project_root);
168    let Some(config) = detected_lsp_language.and_then(lsp_config_for_language) else {
169        let mut lsp_guard = lsp_analyzer
170            .lock()
171            .map_err(|_| "lock poisoned".to_string())?;
172        *lsp_guard = None;
173        return Ok(OpenProjectOutput {
174            status: "opened".to_string(),
175            project_root: project_root.to_string_lossy().into_owned(),
176            detected_lsp_language: detected_lsp_language.map(str::to_string),
177            lsp: Some("unsupported".to_string()),
178        });
179    };
180
181    {
182        let mut lsp_guard = lsp_analyzer
183            .lock()
184            .map_err(|_| "lock poisoned".to_string())?;
185        *lsp_guard = None;
186        let new_lsp = LspAnalyzer::new(project_root, config.as_ref());
187        *lsp_guard = Some(Box::new(new_lsp));
188    }
189
190    {
191        let lsp_guard = lsp_analyzer
192            .lock()
193            .map_err(|_| "lock poisoned".to_string())?;
194        if let (Some(lsp), Some(lsp_lang)) = (&*lsp_guard, detected_lsp_language) {
195            open_existing_source_files_for_lsp(lsp.as_ref(), project_root, lsp_lang);
196        }
197    }
198
199    Ok(OpenProjectOutput {
200        status: "opened".to_string(),
201        project_root: project_root.to_string_lossy().into_owned(),
202        detected_lsp_language: detected_lsp_language.map(str::to_string),
203        lsp: None,
204    })
205}
206
207pub fn search_code(
208    project_root: &Path,
209    params: &SearchCodeInput,
210) -> Result<SearchCodeOutput, String> {
211    if params.query.is_empty() {
212        return Err("query is required".to_string());
213    }
214
215    let limit = normalize_search_limit(params.limit);
216    let target = project_relative_arg(project_root, params.path.as_deref())?;
217    let matches = search_project_matches(project_root, params, target.as_deref(), limit)?;
218    Ok(SearchCodeOutput { matches })
219}
220
221pub fn is_responsible_source(
222    project_root: &Path,
223    params: &SourceResponsibilityInput,
224) -> Result<SourceResponsibility, String> {
225    let relative = project_relative_arg(project_root, Some(&params.path))?
226        .ok_or_else(|| "path is required".to_string())?;
227    let path = project_root.join(&relative);
228    let extension = path
229        .extension()
230        .and_then(|ext| ext.to_str())
231        .map(str::to_string);
232    let analyzer = TreeSitterAnalyzer::new();
233    let language = extension
234        .as_deref()
235        .and_then(|ext| analyzer.responsible_language_for_extension(ext))
236        .map(str::to_string);
237    let is_responsible = language.is_some();
238    let reason = match (&extension, &language) {
239        (Some(extension), Some(language)) => {
240            format!("SCOPE recognizes .{extension} as {language} source")
241        }
242        (Some(extension), None) => {
243            format!("SCOPE has no source adapter for .{extension}")
244        }
245        (None, _) => "path has no file extension for SCOPE source ownership".to_string(),
246    };
247
248    Ok(SourceResponsibility {
249        is_responsible,
250        path: relative,
251        extension,
252        language,
253        reason,
254    })
255}
256
257fn normalize_search_limit(limit: Option<usize>) -> usize {
258    limit
259        .unwrap_or(DEFAULT_SEARCH_LIMIT)
260        .clamp(1, MAX_SEARCH_LIMIT)
261}
262
263fn search_project_matches(
264    project_root: &Path,
265    params: &SearchCodeInput,
266    target: Option<&str>,
267    limit: usize,
268) -> Result<Vec<SearchHit>, String> {
269    let regex = build_search_regex(params)?;
270    let filters = SearchFileFilters::from_input(params)?;
271    let mut matches = Vec::new();
272
273    for entry in project_files(project_root, target, &filters)? {
274        let path = entry.path();
275        let relative = relative_file_path(project_root, path);
276        if !filters.matches(&relative, path) {
277            continue;
278        }
279
280        let Ok(content) = fs::read_to_string(path) else {
281            continue;
282        };
283        for (line_index, text) in content.lines().enumerate() {
284            if !regex.is_match(text) {
285                continue;
286            }
287            let line = line_index + 1;
288            matches.push(SearchHit {
289                path: relative.clone(),
290                hit: format_line_with_hash(line, text),
291            });
292            if matches.len() >= limit {
293                return Ok(matches);
294            }
295        }
296    }
297
298    Ok(matches)
299}
300
301fn build_search_regex(params: &SearchCodeInput) -> Result<Regex, String> {
302    let mut pattern = match params.mode {
303        SearchMode::Literal => regex::escape(&params.query),
304        SearchMode::Regex => params.query.clone(),
305    };
306    if params.whole_line {
307        pattern = format!("^(?:{pattern})$");
308    } else if params.word {
309        pattern = format!(r"\b(?:{pattern})\b");
310    }
311
312    let case_insensitive = match params.case_mode {
313        SearchCase::Sensitive => false,
314        SearchCase::Insensitive => true,
315        SearchCase::Smart => !params.query.chars().any(char::is_uppercase),
316    };
317
318    RegexBuilder::new(&pattern)
319        .case_insensitive(case_insensitive)
320        .build()
321        .map_err(|err| match params.mode {
322            SearchMode::Literal => format!("search pattern error: {err}"),
323            SearchMode::Regex => {
324                format!("search regex error: {err}; use mode=\"literal\" for code fragments")
325            }
326        })
327}
328
329struct SearchFileFilters {
330    include: Option<GlobSet>,
331    exclude: Option<GlobSet>,
332    type_include_exts: Option<HashSet<String>>,
333    type_exclude_exts: HashSet<String>,
334    hidden: bool,
335    respect_ignore: bool,
336    follow: bool,
337}
338
339impl SearchFileFilters {
340    fn from_input(params: &SearchCodeInput) -> Result<Self, String> {
341        Ok(Self {
342            include: build_optional_glob_set(&params.include)?,
343            exclude: build_optional_glob_set(&params.exclude)?,
344            type_include_exts: build_optional_type_exts(&params.types)?,
345            type_exclude_exts: build_optional_type_exts(&params.type_not)?.unwrap_or_default(),
346            hidden: params.hidden,
347            respect_ignore: params.respect_ignore,
348            follow: params.follow,
349        })
350    }
351
352    fn matches(&self, relative: &str, path: &Path) -> bool {
353        if let Some(include) = self.include.as_ref()
354            && !include.is_match(relative)
355        {
356            return false;
357        }
358        if let Some(exclude) = self.exclude.as_ref()
359            && exclude.is_match(relative)
360        {
361            return false;
362        }
363        let ext = path
364            .extension()
365            .and_then(|ext| ext.to_str())
366            .map(|ext| ext.to_ascii_lowercase());
367        if let Some(type_include_exts) = self.type_include_exts.as_ref()
368            && !ext
369                .as_ref()
370                .is_some_and(|ext| type_include_exts.contains(ext))
371        {
372            return false;
373        }
374        if ext
375            .as_ref()
376            .is_some_and(|ext| self.type_exclude_exts.contains(ext))
377        {
378            return false;
379        }
380        true
381    }
382}
383
384fn project_files(
385    project_root: &Path,
386    target: Option<&str>,
387    filters: &SearchFileFilters,
388) -> Result<Vec<DirEntry>, String> {
389    let root = project_root.to_path_buf();
390    let walk_root = match target {
391        Some(target) => root.join(target),
392        None => root.clone(),
393    };
394
395    let mut entries = Vec::new();
396    let mut builder = WalkBuilder::new(walk_root);
397    builder
398        .hidden(!filters.hidden)
399        .follow_links(filters.follow)
400        .require_git(true);
401    if !filters.respect_ignore {
402        builder
403            .git_ignore(false)
404            .git_global(false)
405            .git_exclude(false)
406            .ignore(false)
407            .parents(false);
408    }
409
410    for entry in builder.build() {
411        let Ok(entry) = entry else {
412            continue;
413        };
414        if !entry
415            .file_type()
416            .is_some_and(|file_type| file_type.is_file())
417        {
418            continue;
419        }
420        entries.push(entry);
421    }
422
423    entries.sort_by(|left, right| {
424        relative_file_path(&root, left.path()).cmp(&relative_file_path(&root, right.path()))
425    });
426    Ok(entries)
427}
428
429fn build_optional_glob_set(patterns: &[String]) -> Result<Option<GlobSet>, String> {
430    let patterns = patterns
431        .iter()
432        .map(|pattern| pattern.trim())
433        .filter(|pattern| !pattern.is_empty())
434        .collect::<Vec<_>>();
435    if patterns.is_empty() {
436        return Ok(None);
437    }
438    build_glob_set(&patterns).map(Some)
439}
440
441fn build_glob_set(patterns: &[&str]) -> Result<GlobSet, String> {
442    let mut builder = GlobSetBuilder::new();
443    for pattern in patterns {
444        builder.add(Glob::new(pattern).map_err(|e| format!("glob error: {e}"))?);
445        if !pattern.contains('/') && !pattern.contains('\\') {
446            builder
447                .add(Glob::new(&format!("**/{pattern}")).map_err(|e| format!("glob error: {e}"))?);
448        }
449    }
450    builder.build().map_err(|e| format!("glob error: {e}"))
451}
452
453fn build_optional_type_exts(types: &[String]) -> Result<Option<HashSet<String>>, String> {
454    let requested = types
455        .iter()
456        .map(|type_name| {
457            type_name
458                .trim()
459                .trim_start_matches('.')
460                .to_ascii_lowercase()
461        })
462        .filter(|type_name| !type_name.is_empty())
463        .collect::<Vec<_>>();
464    if requested.is_empty() {
465        return Ok(None);
466    }
467
468    let registry = LanguageRegistry::new();
469    let languages = registry.list_languages();
470    let supported = languages
471        .iter()
472        .map(|(name, _)| *name)
473        .collect::<Vec<_>>()
474        .join(", ");
475    let mut exts = HashSet::new();
476    for requested_type in requested {
477        let matching_language_exts = languages
478            .iter()
479            .filter(|(name, _)| name.eq_ignore_ascii_case(&requested_type))
480            .flat_map(|(_, language_exts)| language_exts.iter())
481            .map(|ext| ext.to_ascii_lowercase())
482            .collect::<Vec<_>>();
483
484        if !matching_language_exts.is_empty() {
485            exts.extend(matching_language_exts);
486            continue;
487        }
488        if registry.get(&requested_type).is_some() {
489            exts.insert(requested_type);
490            continue;
491        }
492        return Err(format!(
493            "unknown search type `{requested_type}`; supported SCOPE types: {supported}"
494        ));
495    }
496    Ok(Some(exts))
497}
498
499fn relative_file_path(project_root: &Path, path: &Path) -> String {
500    let normalized_root = normalize_for_comparison(project_root);
501    let normalized_path = normalize_for_comparison(path);
502    normalized_path
503        .strip_prefix(&normalized_root)
504        .ok()
505        .map(|p| normalize_relative_path(&p.to_string_lossy()))
506        .unwrap_or_else(|| normalize_relative_path(&path.to_string_lossy()))
507}
508
509fn normalize_for_comparison(p: &Path) -> PathBuf {
510    // Strip Windows extended-length prefix \\?\ for comparison; it is a
511    // syntactic prefix that does not change the logical path.
512    let s = p.to_string_lossy();
513    if let Some(rest) = s.strip_prefix(r"\\?\") {
514        PathBuf::from(rest)
515    } else {
516        p.to_path_buf()
517    }
518}
519
520fn project_relative_arg(root: &Path, path: Option<&str>) -> Result<Option<String>, String> {
521    let Some(path) = path.map(str::trim).filter(|path| !path.is_empty()) else {
522        return Ok(None);
523    };
524    let path = PathBuf::from(path);
525    if path.is_absolute() {
526        let normalized_root = normalize_for_comparison(root);
527        let normalized_path = normalize_for_comparison(&path);
528        let relative = normalized_path
529            .strip_prefix(&normalized_root)
530            .map_err(|_| {
531                format!(
532                    "path {} is outside project root {}",
533                    path.display(),
534                    root.display()
535                )
536            })?;
537        Ok(Some(normalize_relative_path(&relative.to_string_lossy())))
538    } else {
539        Ok(Some(normalize_relative_path(&path.to_string_lossy())))
540    }
541}
542
543fn normalize_relative_path(path: &str) -> String {
544    path.replace('\\', "/")
545}
546
547fn clamp_range(
548    start_line: usize,
549    end_line: usize,
550    line_count: usize,
551) -> Result<(usize, usize), String> {
552    if start_line == 0 || end_line == 0 || start_line > end_line {
553        return Err(format!("invalid line range {start_line}-{end_line}"));
554    }
555    if start_line > line_count {
556        return Err(format!(
557            "line range starts after end of file: {start_line} > {line_count}"
558        ));
559    }
560    Ok((start_line, end_line.min(line_count)))
561}
562
563fn read_line_range(content: &str, start_line: usize, end_line: usize) -> String {
564    let lines = content.lines().collect::<Vec<_>>();
565    if start_line == 0 || end_line < start_line || start_line > lines.len() {
566        return String::new();
567    }
568    let mut snippet = lines[(start_line - 1)..end_line.min(lines.len())].join("\n");
569    if content.ends_with('\n') || end_line < lines.len() {
570        snippet.push('\n');
571    }
572    snippet
573}
574
575pub fn read_code(project_root: &Path, params: &ReadCodeInput) -> Result<ReadCodeOutput, String> {
576    let relative = project_relative_arg(project_root, Some(&params.path))?
577        .ok_or_else(|| "path is required".to_string())?;
578    let full_path = project_root.join(&relative);
579    let file_content = fs::read_to_string(&full_path)
580        .map_err(|e| format!("Failed to read {}: {e}", full_path.display()))?;
581    let (anchor_line, anchor_hash) = parse_line_anchor(&params.anchor)?;
582    verify_anchor_line(&file_content, anchor_line, &anchor_hash)?;
583    let line_count = file_content.lines().count().max(1);
584    let (start_line, end_line) =
585        read_range_for_mode(params.mode, &full_path, anchor_line, line_count)?;
586    let raw_content = read_line_range(&file_content, start_line, end_line);
587    let prefixed_content = prefix_lines_with_hash(&raw_content, start_line);
588
589    Ok(ReadCodeOutput {
590        content: prefixed_content,
591    })
592}
593
594fn read_range_for_mode(
595    mode: ReadCodeMode,
596    full_path: &Path,
597    anchor_line: usize,
598    line_count: usize,
599) -> Result<(usize, usize), String> {
600    match mode {
601        ReadCodeMode::Around => {
602            let start_line = anchor_line.saturating_sub(READ_AROUND_CONTEXT_LINES).max(1);
603            let end_line = (anchor_line + READ_AROUND_CONTEXT_LINES).min(line_count);
604            clamp_range(start_line, end_line, line_count)
605        }
606        ReadCodeMode::Full => {
607            let analyzer = TreeSitterAnalyzer::new();
608            if let Some(symbol) = analyzer.find_containing_symbol_match(full_path, anchor_line) {
609                return clamp_range(symbol.start_line, symbol.end_line, line_count);
610            }
611            read_range_for_mode(ReadCodeMode::Around, full_path, anchor_line, line_count)
612        }
613    }
614}
615
616fn parse_line_anchor(anchor: &str) -> Result<(usize, String), String> {
617    let (line_str, hash_str) = anchor
618        .split_once('#')
619        .ok_or_else(|| format!("invalid anchor (expected line#hash): {anchor}"))?;
620    let line = line_str
621        .parse::<usize>()
622        .map_err(|_| format!("invalid line number in anchor: {anchor}"))?;
623    if line == 0 {
624        return Err(format!("line number must be >= 1 in anchor: {anchor}"));
625    }
626    if hash_str.is_empty() {
627        return Err(format!("missing hash in anchor: {anchor}"));
628    }
629    Ok((line, hash_str.to_string()))
630}
631
632fn verify_anchor_line(content: &str, line_num: usize, expected_hash: &str) -> Result<(), String> {
633    let lines: Vec<&str> = content.lines().collect();
634    if line_num > lines.len() {
635        return Err(format!(
636            "line {line_num} out of bounds (file has {} lines); search or read again",
637            lines.len()
638        ));
639    }
640    let actual = lines[line_num - 1];
641    let actual_hash = patch::line_hash(actual);
642    if actual_hash != expected_hash {
643        return Err(format!(
644            "line {line_num} hash mismatch: expected {expected_hash}, got {actual_hash} — file may have changed; search or read again"
645        ));
646    }
647    Ok(())
648}
649
650fn format_line_with_hash(line_num: usize, line: &str) -> String {
651    let hash = patch::line_hash(line);
652    format!("{line_num}#{hash}|{line}")
653}
654
655fn prefix_lines_with_hash(content: &str, start_line: usize) -> String {
656    content
657        .lines()
658        .enumerate()
659        .map(|(i, line)| format_line_with_hash(start_line + i, line))
660        .collect::<Vec<_>>()
661        .join("\n")
662        + if content.ends_with('\n') || content.is_empty() {
663            "\n"
664        } else {
665            ""
666        }
667}
668
669pub fn edit_code(
670    project_root: &Path,
671    params: &EditCodeInput,
672    propagation_state: &Mutex<PropagationState>,
673    lsp_analyzer: &Mutex<Option<Box<dyn Analyzer + Send>>>,
674) -> Result<EditCodeOutput, String> {
675    match patch::edit_code_apply(&params.edits, project_root, lsp_analyzer) {
676        Ok((results, applied_summary)) => {
677            if !results.is_empty()
678                && let Ok(mut state) = propagation_state.lock()
679            {
680                state.accumulate(results.clone());
681            }
682            Ok(EditCodeOutput {
683                propagation_results: results,
684                applied_summary,
685            })
686        }
687        Err(e) => Err(e),
688    }
689}
690
691pub fn config_hints() -> serde_json::Value {
692    use crate::language::LanguageRegistry;
693    use crate::lsp::{
694        GoplsConfig, JdtlsConfig, LspServerConfig, PyrightConfig, RustAnalyzerConfig, TsJsConfig,
695    };
696
697    let registry = LanguageRegistry::new();
698    let configs: Vec<Box<dyn LspServerConfig>> = vec![
699        Box::new(RustAnalyzerConfig),
700        Box::new(PyrightConfig),
701        Box::new(TsJsConfig), // covers both TS and JS
702        Box::new(GoplsConfig),
703        Box::new(JdtlsConfig),
704    ];
705
706    let mut languages = Vec::new();
707
708    // Tree-sitter languages from registry
709    let ts_langs: Vec<serde_json::Value> = registry
710        .list_languages()
711        .into_iter()
712        .map(|(name, exts)| {
713            serde_json::json!({
714                "name": name,
715                "extensions": exts,
716            })
717        })
718        .collect();
719
720    // LSP configs
721    for cfg in &configs {
722        let binary_found = std::process::Command::new("which")
723            .arg(cfg.binary_name())
724            .output()
725            .map(|o| o.status.success())
726            .unwrap_or(false);
727
728        let mut lang_entry = serde_json::json!({
729            "language": cfg.language_id(),
730            "lsp_server": cfg.server_name(),
731            "lsp_binary": cfg.binary_name(),
732            "lsp_available": binary_found,
733            "setup_hints": cfg.setup_hints(),
734        });
735
736        if let Some((cmd, args)) = cfg.install_command() {
737            lang_entry["install_command"] = serde_json::json!({
738                "command": cmd,
739                "args": args,
740            });
741        }
742
743        if let Some(url) = cfg.download_url() {
744            lang_entry["download_url"] = serde_json::json!(url);
745        }
746
747        languages.push(lang_entry);
748    }
749
750    serde_json::json!({
751        "tree_sitter_languages": ts_langs,
752        "lsp_languages": languages,
753    })
754}
755
756pub fn ack_next_events(
757    propagation_state: &Mutex<PropagationState>,
758    limit: Option<usize>,
759) -> Result<ReviewBatch, String> {
760    let limit = limit
761        .unwrap_or(DEFAULT_REVIEW_LIMIT)
762        .clamp(1, MAX_REVIEW_LIMIT);
763    let mut state = propagation_state
764        .lock()
765        .map_err(|_| "lock poisoned".to_string())?;
766    let reviews = state.next_reviews(limit);
767    let review = reviews.first().cloned();
768    let returned = reviews.len();
769    let remaining = state.pending_count();
770    Ok(ReviewBatch {
771        review,
772        reviews,
773        returned,
774        remaining,
775    })
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::state::PropagationState;
782    use std::sync::Mutex;
783
784    #[test]
785    fn open_project_detects_lsp_language_from_project_files() {
786        let dir = tempfile::tempdir().unwrap();
787        std::fs::write(
788            dir.path().join("Cargo.toml"),
789            "[package]\nname = \"tmp\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
790        )
791        .unwrap();
792        let lsp_analyzer: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
793
794        let output = open_project(dir.path(), None, &lsp_analyzer).unwrap();
795
796        assert_eq!(output.detected_lsp_language.as_deref(), Some("rust"));
797    }
798
799    #[test]
800    fn open_project_is_idempotent_for_current_project_root() {
801        let dir = tempfile::tempdir().unwrap();
802        let lsp_analyzer: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
803
804        let output = open_project(dir.path(), Some(dir.path()), &lsp_analyzer).unwrap();
805
806        assert_eq!(output.status, "already_open");
807        assert_eq!(output.detected_lsp_language, None);
808    }
809
810    #[test]
811    fn search_code_returns_matched_line_hits() {
812        let dir = tempfile::tempdir().unwrap();
813        std::fs::create_dir_all(dir.path().join("src/nested")).unwrap();
814        std::fs::create_dir_all(dir.path().join("tests")).unwrap();
815        std::fs::write(
816            dir.path().join("src/lib.rs"),
817            "pub fn lib() { let needle = true; }\n",
818        )
819        .unwrap();
820        std::fs::write(
821            dir.path().join("src/nested/mod.rs"),
822            "pub fn nested() { let needle = true; }\n",
823        )
824        .unwrap();
825        std::fs::write(
826            dir.path().join("tests/lib_test.rs"),
827            "pub fn test() { let needle = true; }\n",
828        )
829        .unwrap();
830
831        let output = search_code(
832            dir.path(),
833            &SearchCodeInput {
834                query: "needle".to_string(),
835                path: Some("src".to_string()),
836                include: vec!["*.rs".to_string()],
837                ..SearchCodeInput::default()
838            },
839        )
840        .unwrap();
841
842        let hits = output
843            .matches
844            .iter()
845            .map(|item| (item.path.clone(), item.hit.clone()))
846            .collect::<Vec<_>>();
847        assert_eq!(
848            hits,
849            vec![
850                (
851                    "src/lib.rs".to_string(),
852                    format_line_with_hash(1, "pub fn lib() { let needle = true; }")
853                ),
854                (
855                    "src/nested/mod.rs".to_string(),
856                    format_line_with_hash(1, "pub fn nested() { let needle = true; }")
857                )
858            ]
859        );
860    }
861
862    #[test]
863    fn search_code_defaults_to_literal_smart_case() {
864        let dir = tempfile::tempdir().unwrap();
865        std::fs::write(
866            dir.path().join("lib.rs"),
867            "pub fn matching_commands() {}\nlet Needle = true;\n",
868        )
869        .unwrap();
870
871        let output = search_code(
872            dir.path(),
873            &SearchCodeInput {
874                query: "matching_commands(".to_string(),
875                ..SearchCodeInput::default()
876            },
877        )
878        .unwrap();
879        assert_eq!(output.matches.len(), 1);
880        assert_eq!(output.matches[0].path, "lib.rs");
881        assert!(output.matches[0].hit.contains("matching_commands"));
882
883        let output = search_code(
884            dir.path(),
885            &SearchCodeInput {
886                query: "needle".to_string(),
887                ..SearchCodeInput::default()
888            },
889        )
890        .unwrap();
891        assert_eq!(output.matches.len(), 1);
892    }
893
894    #[test]
895    fn search_code_supports_regex_mode_opt_in() {
896        let dir = tempfile::tempdir().unwrap();
897        std::fs::write(dir.path().join("lib.rs"), "let needle = true;\n").unwrap();
898
899        let literal_output = search_code(
900            dir.path(),
901            &SearchCodeInput {
902                query: r"needle\s+=\s+true".to_string(),
903                ..SearchCodeInput::default()
904            },
905        )
906        .unwrap();
907        assert!(literal_output.matches.is_empty());
908
909        let regex_output = search_code(
910            dir.path(),
911            &SearchCodeInput {
912                query: r"needle\s+=\s+true".to_string(),
913                mode: SearchMode::Regex,
914                ..SearchCodeInput::default()
915            },
916        )
917        .unwrap();
918        assert_eq!(regex_output.matches.len(), 1);
919    }
920
921    #[test]
922    fn search_code_honors_case_word_and_whole_line_modes() {
923        let dir = tempfile::tempdir().unwrap();
924        std::fs::write(dir.path().join("lower.rs"), "let needle = true;\n").unwrap();
925        std::fs::write(dir.path().join("upper.rs"), "let Needle = true;\n").unwrap();
926        std::fs::write(dir.path().join("plural.rs"), "let needles = true;\n").unwrap();
927        std::fs::write(dir.path().join("line.rs"), "needle extra\nneedle\n").unwrap();
928
929        let smart_lower = search_code(
930            dir.path(),
931            &SearchCodeInput {
932                query: "needle".to_string(),
933                ..SearchCodeInput::default()
934            },
935        )
936        .unwrap();
937        assert_eq!(smart_lower.matches.len(), 5);
938
939        let smart_upper = search_code(
940            dir.path(),
941            &SearchCodeInput {
942                query: "Needle".to_string(),
943                ..SearchCodeInput::default()
944            },
945        )
946        .unwrap();
947        assert_eq!(
948            smart_upper
949                .matches
950                .iter()
951                .map(|hit| hit.path.as_str())
952                .collect::<Vec<_>>(),
953            vec!["upper.rs"]
954        );
955
956        let word = search_code(
957            dir.path(),
958            &SearchCodeInput {
959                query: "needle".to_string(),
960                word: true,
961                case_mode: SearchCase::Sensitive,
962                ..SearchCodeInput::default()
963            },
964        )
965        .unwrap();
966        assert!(
967            word.matches.iter().all(|hit| hit.path != "plural.rs"),
968            "word search should not match plural.rs: {:?}",
969            word.matches
970        );
971
972        let whole_line = search_code(
973            dir.path(),
974            &SearchCodeInput {
975                query: "needle".to_string(),
976                whole_line: true,
977                case_mode: SearchCase::Sensitive,
978                ..SearchCodeInput::default()
979            },
980        )
981        .unwrap();
982        assert_eq!(
983            whole_line
984                .matches
985                .iter()
986                .map(|hit| (hit.path.clone(), hit.hit.clone()))
987                .collect::<Vec<_>>(),
988            vec![("line.rs".to_string(), format_line_with_hash(2, "needle"))]
989        );
990    }
991
992    #[test]
993    fn search_code_honors_path_glob_type_hidden_and_ignore_filters() {
994        let dir = tempfile::tempdir().unwrap();
995        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
996        std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
997        std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
998        std::fs::write(dir.path().join("ignored.rs"), "let needle = true;\n").unwrap();
999        std::fs::write(dir.path().join(".hidden.rs"), "let needle = true;\n").unwrap();
1000        std::fs::write(dir.path().join("src/lib.rs"), "let needle = true;\n").unwrap();
1001        std::fs::write(
1002            dir.path().join("src/generated/mod.rs"),
1003            "let needle = true;\n",
1004        )
1005        .unwrap();
1006        std::fs::write(dir.path().join("src/main.ts"), "let needle = true;\n").unwrap();
1007
1008        let filtered = search_code(
1009            dir.path(),
1010            &SearchCodeInput {
1011                query: "needle".to_string(),
1012                path: Some("src".to_string()),
1013                include: vec!["*.rs".to_string()],
1014                exclude: vec!["src/generated/**".to_string()],
1015                types: vec!["rust".to_string()],
1016                ..SearchCodeInput::default()
1017            },
1018        )
1019        .unwrap();
1020        assert_eq!(
1021            filtered
1022                .matches
1023                .iter()
1024                .map(|hit| hit.path.as_str())
1025                .collect::<Vec<_>>(),
1026            vec!["src/lib.rs"]
1027        );
1028
1029        let default_visibility = search_code(
1030            dir.path(),
1031            &SearchCodeInput {
1032                query: "needle".to_string(),
1033                path: None,
1034                include: vec!["*.rs".to_string()],
1035                exclude: vec!["src/**".to_string()],
1036                ..SearchCodeInput::default()
1037            },
1038        )
1039        .unwrap();
1040        assert!(default_visibility.matches.is_empty());
1041
1042        let unrestricted_visibility = search_code(
1043            dir.path(),
1044            &SearchCodeInput {
1045                query: "needle".to_string(),
1046                path: None,
1047                include: vec!["*.rs".to_string()],
1048                exclude: vec!["src/**".to_string()],
1049                hidden: true,
1050                respect_ignore: false,
1051                ..SearchCodeInput::default()
1052            },
1053        )
1054        .unwrap();
1055        assert_eq!(
1056            unrestricted_visibility
1057                .matches
1058                .iter()
1059                .map(|hit| hit.path.as_str())
1060                .collect::<Vec<_>>(),
1061            vec![".hidden.rs", "ignored.rs"]
1062        );
1063    }
1064
1065    #[test]
1066    fn search_code_type_filter_includes_all_same_named_language_extensions() {
1067        let dir = tempfile::tempdir().unwrap();
1068        std::fs::write(dir.path().join("component.ts"), "const needle = true;\n").unwrap();
1069        std::fs::write(dir.path().join("component.tsx"), "const needle = true;\n").unwrap();
1070        std::fs::write(dir.path().join("component.js"), "const needle = true;\n").unwrap();
1071
1072        let output = search_code(
1073            dir.path(),
1074            &SearchCodeInput {
1075                query: "needle".to_string(),
1076                types: vec!["typescript".to_string()],
1077                ..SearchCodeInput::default()
1078            },
1079        )
1080        .unwrap();
1081
1082        assert_eq!(
1083            output
1084                .matches
1085                .iter()
1086                .map(|hit| hit.path.as_str())
1087                .collect::<Vec<_>>(),
1088            vec!["component.ts", "component.tsx"]
1089        );
1090    }
1091
1092    #[test]
1093    fn search_code_input_accepts_legacy_single_include_glob() {
1094        let value = serde_json::json!({
1095            "query": "needle",
1096            "include": "*.rs"
1097        });
1098
1099        let input: SearchCodeInput = serde_json::from_value(value).unwrap();
1100        assert_eq!(input.include, vec!["*.rs".to_string()]);
1101        assert_eq!(input.mode, SearchMode::Literal);
1102        assert_eq!(input.case_mode, SearchCase::Smart);
1103        assert!(input.respect_ignore);
1104    }
1105
1106    #[test]
1107    fn search_code_honors_explicit_limit() {
1108        let dir = tempfile::tempdir().unwrap();
1109        std::fs::write(
1110            dir.path().join("lib.rs"),
1111            "pub fn one() { let needle = 1; }\npub fn two() { let needle = 2; }\npub fn three() { let needle = 3; }\n",
1112        )
1113        .unwrap();
1114
1115        let output = search_code(
1116            dir.path(),
1117            &SearchCodeInput {
1118                query: "needle".to_string(),
1119                limit: Some(2),
1120                ..SearchCodeInput::default()
1121            },
1122        )
1123        .unwrap();
1124        assert_eq!(output.matches.len(), 2);
1125    }
1126
1127    #[test]
1128    fn search_code_clamps_zero_limit_to_one() {
1129        let dir = tempfile::tempdir().unwrap();
1130        std::fs::write(
1131            dir.path().join("lib.rs"),
1132            "pub fn one() { let needle = 1; }\npub fn two() { let needle = 2; }\n",
1133        )
1134        .unwrap();
1135
1136        let output = search_code(
1137            dir.path(),
1138            &SearchCodeInput {
1139                query: "needle".to_string(),
1140                limit: Some(0),
1141                ..SearchCodeInput::default()
1142            },
1143        )
1144        .unwrap();
1145        assert_eq!(output.matches.len(), 1);
1146    }
1147
1148    #[test]
1149    fn search_code_returns_top_level_matched_line() {
1150        let dir = tempfile::tempdir().unwrap();
1151        let source = "use std::fmt;\n\npub fn second() {\n    println!(\"second\");\n}\n";
1152        std::fs::write(dir.path().join("lib.rs"), source).unwrap();
1153
1154        let output = search_code(
1155            dir.path(),
1156            &SearchCodeInput {
1157                query: "std::fmt".to_string(),
1158                ..SearchCodeInput::default()
1159            },
1160        )
1161        .unwrap();
1162
1163        assert_eq!(output.matches.len(), 1);
1164        assert_eq!(output.matches[0].path, "lib.rs");
1165        assert_eq!(
1166            output.matches[0].hit,
1167            format_line_with_hash(1, "use std::fmt;")
1168        );
1169    }
1170
1171    #[test]
1172    fn read_code_full_reads_enclosing_symbol_from_line_anchor() {
1173        let dir = tempfile::tempdir().unwrap();
1174        let source = "pub fn first() {\n    println!(\"first\");\n}\n\npub fn second() {\n    println!(\"second\");\n}\n";
1175        std::fs::write(dir.path().join("lib.rs"), source).unwrap();
1176
1177        let search = search_code(
1178            dir.path(),
1179            &SearchCodeInput {
1180                query: "second".to_string(),
1181                ..SearchCodeInput::default()
1182            },
1183        )
1184        .unwrap();
1185        let anchor = search.matches[0]
1186            .hit
1187            .split_once('|')
1188            .expect("line anchor")
1189            .0
1190            .to_string();
1191        let result = read_code(
1192            dir.path(),
1193            &ReadCodeInput {
1194                path: "lib.rs".to_string(),
1195                anchor,
1196                mode: ReadCodeMode::Full,
1197            },
1198        )
1199        .unwrap();
1200        let content = result.content.as_str();
1201        assert!(
1202            content.contains("pub fn second()"),
1203            "should contain fn second, got: {content}"
1204        );
1205        assert!(
1206            !content.contains("pub fn first()"),
1207            "should not contain fn first"
1208        );
1209        assert!(
1210            content.lines().all(|line| {
1211                let parts: Vec<&str> = line.splitn(2, '|').collect();
1212                parts.len() == 2 && parts[0].contains('#')
1213            }),
1214            "each line should have line#hash| prefix, got: {content}"
1215        );
1216    }
1217
1218    #[test]
1219    fn read_code_around_reads_fixed_context_without_enclosing_symbol() {
1220        let dir = tempfile::tempdir().unwrap();
1221        let mut source = String::new();
1222        for line in 1..=40 {
1223            source.push_str(&format!("let value_{line} = {line};\n"));
1224        }
1225        std::fs::write(dir.path().join("lib.rs"), source).unwrap();
1226        let anchor = format!("20#{}", patch::line_hash("let value_20 = 20;"));
1227
1228        let result = read_code(
1229            dir.path(),
1230            &ReadCodeInput {
1231                path: "lib.rs".to_string(),
1232                anchor,
1233                mode: ReadCodeMode::Around,
1234            },
1235        )
1236        .unwrap();
1237
1238        assert!(result.content.starts_with("8#"));
1239        assert!(result.content.contains("|let value_20 = 20;"));
1240        assert!(result.content.contains("|let value_32 = 32;"));
1241        assert!(!result.content.contains("|let value_7 = 7;"));
1242        assert!(!result.content.contains("|let value_33 = 33;"));
1243    }
1244
1245    #[test]
1246    fn read_code_rejects_stale_line_anchor() {
1247        let dir = tempfile::tempdir().unwrap();
1248        std::fs::write(dir.path().join("lib.rs"), "let current = true;\n").unwrap();
1249
1250        let err = read_code(
1251            dir.path(),
1252            &ReadCodeInput {
1253                path: "lib.rs".to_string(),
1254                anchor: "1#00".to_string(),
1255                mode: ReadCodeMode::Around,
1256            },
1257        )
1258        .unwrap_err();
1259
1260        assert!(err.contains("hash mismatch"), "{err}");
1261        assert!(err.contains("search or read again"), "{err}");
1262    }
1263
1264    #[test]
1265    fn ack_next_event_can_return_a_limited_batch() {
1266        let propagation_state = Mutex::new(PropagationState::new());
1267        propagation_state.lock().unwrap().accumulate(vec![
1268            PropagationResult {
1269                selector: "src/a.rs::fn foo".to_string(),
1270                reason: "first".to_string(),
1271                source: PropagationSource::Lsp,
1272                lsp_references: Some(vec![]),
1273                diff_summary: None,
1274                file_snippet: None,
1275                project_files: None,
1276            },
1277            PropagationResult {
1278                selector: "src/b.rs::fn bar".to_string(),
1279                reason: "second".to_string(),
1280                source: PropagationSource::Lsp,
1281                lsp_references: Some(vec![]),
1282                diff_summary: None,
1283                file_snippet: None,
1284                project_files: None,
1285            },
1286            PropagationResult {
1287                selector: "src/c.rs::fn baz".to_string(),
1288                reason: "third".to_string(),
1289                source: PropagationSource::Lsp,
1290                lsp_references: Some(vec![]),
1291                diff_summary: None,
1292                file_snippet: None,
1293                project_files: None,
1294            },
1295        ]);
1296
1297        let result = ack_next_events(&propagation_state, Some(2)).unwrap();
1298
1299        assert_eq!(result.returned, 2);
1300        assert_eq!(result.remaining, 1);
1301        assert_eq!(result.reviews.len(), 2);
1302        match result.review.unwrap() {
1303            ReviewEvent::KnownReferences {
1304                modified_symbol, ..
1305            } => assert_eq!(modified_symbol, "src/c.rs::fn baz"),
1306            _ => panic!("expected KnownReferences review"),
1307        }
1308    }
1309
1310    #[test]
1311    fn is_responsible_source_reports_scope_owned_source() {
1312        let dir = tempfile::tempdir().unwrap();
1313
1314        let result = is_responsible_source(
1315            dir.path(),
1316            &SourceResponsibilityInput {
1317                path: "src/lib.rs".to_string(),
1318            },
1319        )
1320        .unwrap();
1321        assert!(result.is_responsible);
1322        assert_eq!(result.path, "src/lib.rs");
1323        assert_eq!(result.extension.as_deref(), Some("rs"));
1324        assert_eq!(result.language.as_deref(), Some("rust"));
1325    }
1326
1327    #[test]
1328    fn is_responsible_source_reports_non_source_file() {
1329        let dir = tempfile::tempdir().unwrap();
1330
1331        let result = is_responsible_source(
1332            dir.path(),
1333            &SourceResponsibilityInput {
1334                path: "README.md".to_string(),
1335            },
1336        )
1337        .unwrap();
1338        assert!(!result.is_responsible);
1339        assert_eq!(result.path, "README.md");
1340        assert_eq!(result.extension.as_deref(), Some("md"));
1341        assert_eq!(result.language, None);
1342    }
1343}