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
159pub 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
219pub 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
238pub fn is_responsible_source(
244 project_root: &Path,
245 params: &SourceResponsibilityInput,
246) -> Result<SourceResponsibility, String> {
247 let relative = project_relative_arg(project_root, Some(¶ms.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(¶ms.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(¶ms.include)?,
365 exclude: build_optional_glob_set(¶ms.exclude)?,
366 type_include_exts: build_optional_type_exts(¶ms.types)?,
367 type_exclude_exts: build_optional_type_exts(¶ms.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 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
594pub fn read_code(project_root: &Path, params: &ReadCodeInput) -> Result<ReadCodeOutput, String> {
600 let relative = project_relative_arg(project_root, Some(¶ms.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(¶ms.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
701pub 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(¶ms.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), Box::new(GoplsConfig),
741 Box::new(JdtlsConfig),
742 ];
743
744 let mut languages = Vec::new();
745
746 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 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
793pub 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}