Skip to main content

scope_engine/
engine.rs

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