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(¶ms.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(¶ms.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(¶ms.include)?,
343 exclude: build_optional_glob_set(¶ms.exclude)?,
344 type_include_exts: build_optional_type_exts(¶ms.types)?,
345 type_exclude_exts: build_optional_type_exts(¶ms.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 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(¶ms.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(¶ms.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(¶ms.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), Box::new(GoplsConfig),
703 Box::new(JdtlsConfig),
704 ];
705
706 let mut languages = Vec::new();
707
708 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 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}