1pub mod architecture_request;
32pub mod capability_fixes;
33pub mod code_actions;
34pub mod completion;
35mod document_symbols;
36pub mod documentation_request;
37mod extract;
38pub mod hover;
39pub mod index_queries;
40mod inlay_hints;
41mod locals_nav;
42pub mod position;
43mod project;
44mod publish;
45pub mod sequence_request;
46mod signature_help;
47mod structure;
48pub mod symbols;
49
50use std::path::PathBuf;
51use std::sync::Arc;
52
53use tokio::sync::RwLock;
54use tower_lsp::jsonrpc::Result as JsonRpcResult;
55use tower_lsp::lsp_types::request::{
56 GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
57 GotoTypeDefinitionResponse,
58};
59use tower_lsp::lsp_types::*;
60use tower_lsp::{Client, LanguageServer, LspService, Server};
61
62use crate::project::ProjectConfig;
63
64const SERVER_NAME: &str = "bynkc-lsp";
65const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
66
67#[derive(Debug, Clone)]
69struct DocumentState {
70 text: String,
71 version: i32,
72}
73
74#[derive(Debug)]
79struct Analysis {
80 project_root: PathBuf,
85 index: bynk_check::index::ProjectIndex,
86 snapshots: std::collections::HashMap<PathBuf, String>,
88 versions: std::collections::HashMap<PathBuf, i32>,
91 diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>>,
97 hints: bynk_check::hints::FileHints,
100 requirements: bynk_check::requirements::FileRequirements,
104 locals: bynk_check::locals::FileLocals,
108 expr_types: bynk_check::expr_types::FileExprTypes,
111 unit_sources: std::collections::HashMap<String, Vec<PathBuf>>,
114 sequence_info: std::collections::HashMap<String, bynk_ide::ContextSequenceInfo>,
118 doc_scope: std::collections::HashMap<String, Vec<String>>,
123}
124
125impl Analysis {
126 fn diag_categories(&self) -> Vec<(PathBuf, String)> {
129 self.diagnostics
130 .iter()
131 .flat_map(|(path, diags)| {
132 diags
133 .iter()
134 .map(|d| (path.clone(), d.error.category.to_string()))
135 })
136 .collect()
137 }
138}
139
140#[derive(Debug, Default)]
145struct ProjectState {
146 config: ProjectConfig,
150 analysis: Option<Arc<Analysis>>,
154 published: std::collections::HashSet<Url>,
159 analysis_generation: u64,
163 analysis_round_started: u64,
169 analysis_round_committed: u64,
171}
172
173#[derive(Debug, Clone, Copy, Default)]
177struct RefreshSupport {
178 semantic_tokens: bool,
179 inlay_hints: bool,
180 code_lens: bool,
181}
182
183#[derive(Debug, Default)]
186struct State {
187 projects: std::collections::HashMap<PathBuf, ProjectState>,
193 folders: Vec<PathBuf>,
198 docs: std::collections::HashMap<Url, DocumentState>,
201 supports_dynamic_watchers: bool,
207 supports_refresh: RefreshSupport,
213 single_file_generations: std::collections::HashMap<Url, u64>,
218 root_cache: std::collections::HashMap<Url, Option<PathBuf>>,
235 root_cache_generation: u64,
242}
243
244#[derive(Clone)]
245pub struct Backend {
246 client: Client,
247 state: Arc<RwLock<State>>,
248 refresh_lock: Arc<tokio::sync::Mutex<()>>,
253}
254
255impl Backend {
256 fn new(client: Client) -> Self {
257 Self {
258 client,
259 state: Arc::new(RwLock::new(State::default())),
260 refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
261 }
262 }
263
264 fn find_project_root(start: &std::path::Path) -> Option<PathBuf> {
267 let mut current = if start.is_file() {
268 start.parent()?.to_path_buf()
269 } else {
270 start.to_path_buf()
271 };
272 loop {
273 let candidate = current.join("bynk.toml");
274 if candidate.is_file() {
275 return Some(current);
276 }
277 current = current.parent()?.to_path_buf();
278 }
279 }
280
281 fn find_source_root(start: &std::path::Path) -> Option<PathBuf> {
287 let mut current = if start.is_file() {
288 start.parent()?.to_path_buf()
289 } else {
290 start.to_path_buf()
291 };
292 loop {
293 if current.file_name().and_then(|n| n.to_str()) == Some("src") {
294 return Some(current);
295 }
296 current = current.parent()?.to_path_buf();
297 }
298 }
299
300 fn resolve_root(start: &std::path::Path) -> Option<(PathBuf, project::ProjectConfig)> {
307 if let Some(root) = Self::find_project_root(start) {
308 let config = project::load_config(&root).unwrap_or_default();
309 return Some((root, config));
310 }
311 let src = Self::find_source_root(start)?;
316 let root = src.parent()?.to_path_buf();
317 Some((root, project::ProjectConfig::default()))
318 }
319
320 fn resolve_canonical(uri: &Url) -> Option<(PathBuf, project::ProjectConfig)> {
327 let path = uri.to_file_path().ok()?;
328 let (root, config) = Self::resolve_root(&path)?;
329 Some((root.canonicalize().unwrap_or(root), config))
330 }
331
332 fn root_for_uri_uncached(uri: &Url) -> Option<PathBuf> {
339 Self::resolve_canonical(uri).map(|(root, _)| root)
340 }
341
342 async fn root_for_uri(&self, uri: &Url) -> Option<PathBuf> {
359 let generation = {
360 let state = self.state.read().await;
361 if let Some(cached) = state.root_cache.get(uri) {
362 return cached.clone();
363 }
364 state.root_cache_generation
365 };
366 let root = Self::root_for_uri_uncached(uri);
367 let mut state = self.state.write().await;
368 if Self::root_cache_write_is_current(generation, state.root_cache_generation) {
369 state.root_cache.insert(uri.clone(), root.clone());
370 }
371 root
372 }
373
374 fn root_cache_write_is_current(read_generation: u64, current_generation: u64) -> bool {
382 read_generation == current_generation
383 }
384
385 fn discover_projects_under(folder: &std::path::Path) -> Vec<PathBuf> {
397 fn should_skip(name: &std::ffi::OsStr) -> bool {
398 let name = name.to_string_lossy();
399 matches!(name.as_ref(), "out" | "node_modules" | "target" | ".git")
400 || name.starts_with('.')
401 }
402 fn walk(
403 dir: &std::path::Path,
404 out: &mut Vec<PathBuf>,
405 visited: &mut std::collections::HashSet<PathBuf>,
406 ) {
407 let canon_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
410 if !visited.insert(canon_dir.clone()) {
411 return;
412 }
413 if dir.join("bynk.toml").is_file() && !out.contains(&canon_dir) {
414 out.push(canon_dir);
415 }
416 let Ok(entries) = std::fs::read_dir(dir) else {
417 return;
418 };
419 for entry in entries.flatten() {
420 let path = entry.path();
421 if path.is_dir() && !should_skip(&entry.file_name()) {
422 walk(&path, out, visited);
423 }
424 }
425 }
426 let mut roots = Vec::new();
427 if let Some((root, _)) = Self::resolve_root(folder) {
429 let canon = root.canonicalize().unwrap_or(root);
430 roots.push(canon);
431 }
432 if folder.join("src").is_dir() && !folder.join("bynk.toml").is_file() {
437 let canon = folder
438 .canonicalize()
439 .unwrap_or_else(|_| folder.to_path_buf());
440 if !roots.contains(&canon) {
441 roots.push(canon);
442 }
443 }
444 let mut visited = std::collections::HashSet::new();
445 walk(folder, &mut roots, &mut visited);
446 roots
447 }
448
449 async fn schedule_diagnostics(&self, uri: &Url) {
456 if let Some(root) = self.root_for_uri(uri).await {
462 {
463 let mut state = self.state.write().await;
464 if !state.projects.contains_key(&root) {
465 let config = project::load_config(&root).unwrap_or_default();
466 state.projects.insert(
467 root.clone(),
468 ProjectState {
469 config,
470 ..Default::default()
471 },
472 );
473 }
474 }
475 self.schedule_project_diagnostics(root).await;
476 } else {
477 self.schedule_single_file(uri.clone()).await;
478 }
479 }
480
481 async fn schedule_project_diagnostics(&self, root: PathBuf) {
491 let (generation, debounce) = {
492 let mut state = self.state.write().await;
493 let Some(ps) = state.projects.get_mut(&root) else {
494 return;
495 };
496 ps.analysis_generation += 1;
497 (ps.analysis_generation, ps.config.diagnostics_debounce_ms)
498 };
499 let this = self.clone();
500 tokio::spawn(async move {
501 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
502 let superseded = match this.state.read().await.projects.get(&root) {
503 Some(ps) => ps.analysis_generation != generation,
504 None => true, };
506 if superseded {
507 return;
508 }
509 this.run_project_diagnostics(root).await;
510 });
511 }
512
513 async fn schedule_single_file(&self, uri: Url) {
519 let debounce = ProjectConfig::default().diagnostics_debounce_ms;
520 let generation = {
521 let mut state = self.state.write().await;
522 let g = state
523 .single_file_generations
524 .entry(uri.clone())
525 .or_insert(0);
526 *g += 1;
527 *g
528 };
529 let this = self.clone();
530 tokio::spawn(async move {
531 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
532 let current = this
533 .state
534 .read()
535 .await
536 .single_file_generations
537 .get(&uri)
538 .copied();
539 if current != Some(generation) {
540 return;
541 }
542 this.diagnose_single_file(&uri).await;
543 });
544 }
545
546 async fn diagnose_single_file(&self, uri: &Url) {
550 let (text, version) = {
551 let state = self.state.read().await;
552 match state.docs.get(uri) {
553 Some(d) => (d.text.clone(), d.version),
554 None => return,
555 }
556 };
557 let positions = crate::position::PositionMap::new(&text);
558 let lsp_diags: Vec<Diagnostic> = bynk_ide::diagnose(&text)
559 .into_iter()
560 .map(|d| make_diagnostic(&d, &positions, uri))
561 .collect();
562 self.client
563 .publish_diagnostics(uri.clone(), lsp_diags, Some(version))
564 .await;
565 }
566
567 async fn run_project_diagnostics(&self, root: PathBuf) {
572 let (round, root, canonical_root, overlay, versions, previously_dirty) = {
573 let mut state = self.state.write().await;
574 let Some(ps) = state.projects.get_mut(&root) else {
578 return;
579 };
580 ps.analysis_round_started += 1;
581 let round = ps.analysis_round_started;
582 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
586 let previously_dirty = ps.published.clone();
587 let mut overlay = std::collections::HashMap::new();
588 let mut versions = std::collections::HashMap::new();
589 for (uri, doc) in &state.docs {
595 if let Ok(p) = uri.to_file_path() {
596 let canonical = p.canonicalize().unwrap_or(p);
597 if let Ok(rel) = canonical.strip_prefix(&canonical_root) {
600 versions.insert(rel.to_path_buf(), doc.version);
601 }
602 overlay.insert(canonical, doc.text.clone());
603 }
604 }
605 (
606 round,
607 root,
608 canonical_root,
609 overlay,
610 versions,
611 previously_dirty,
612 )
613 };
614
615 let roots = bynk_ide::AnalysisRoots::Project(root.clone());
617 let Ok(result) =
618 tokio::task::spawn_blocking(move || bynk_ide::diagnose_project_with(&roots, &overlay))
619 .await
620 else {
621 return;
622 };
623
624 let mut new_by_uri: std::collections::HashMap<Url, Vec<Diagnostic>> =
625 std::collections::HashMap::new();
626 let mut version_by_uri: std::collections::HashMap<Url, Option<i32>> =
631 std::collections::HashMap::new();
632 let mut snapshots = std::collections::HashMap::new();
633 let mut diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>> =
634 std::collections::HashMap::new();
635 for file in &result.files {
636 let abs = canonical_root.join(&file.source_path);
637 let abs = abs.canonicalize().unwrap_or(abs);
638 let Ok(uri) = Url::from_file_path(&abs) else {
639 continue;
640 };
641 let positions = crate::position::PositionMap::new(&file.text);
644 let diags: Vec<Diagnostic> = file
645 .diagnostics
646 .iter()
647 .map(|d| make_diagnostic(d, &positions, &uri))
648 .collect();
649 version_by_uri.insert(uri.clone(), versions.get(&file.source_path).copied());
650 new_by_uri.insert(uri, diags);
651 diagnostics.insert(file.source_path.clone(), file.diagnostics.clone());
652 snapshots.insert(file.source_path.clone(), file.text.clone());
653 }
654 {
658 let analysis = Arc::new(Analysis {
659 project_root: canonical_root.clone(),
660 index: result.index.clone(),
661 snapshots,
662 versions,
663 diagnostics,
664 hints: result.hints,
665 requirements: result.requirements,
666 locals: result.locals,
667 expr_types: result.expr_types,
668 unit_sources: result.unit_sources,
669 sequence_info: result.sequence_info,
670 doc_scope: result.doc_scope,
671 });
672 let mut state = self.state.write().await;
673 let Some(ps) = state.projects.get_mut(&root) else {
674 return; };
676 if ps.analysis_round_committed >= round {
679 return;
680 }
681 ps.analysis_round_committed = round;
682 ps.analysis = Some(analysis);
683 }
684 let unattributed_anchor = {
689 let toml = root.join("bynk.toml");
690 if toml.is_file() {
691 Url::from_file_path(toml).ok()
692 } else {
693 result.files.first().and_then(|f| {
694 let abs = canonical_root.join(&f.source_path);
695 let abs = abs.canonicalize().unwrap_or(abs);
696 Url::from_file_path(abs).ok()
697 })
698 }
699 };
700 if !result.unattributed.is_empty()
701 && let Some(anchor_uri) = unattributed_anchor
702 {
703 let entry = new_by_uri.entry(anchor_uri).or_default();
704 for d in &result.unattributed {
705 entry.push(Diagnostic {
706 range: Default::default(),
707 severity: Some(match d.severity {
708 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
709 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
710 }),
711 code: Some(tower_lsp::lsp_types::NumberOrString::String(
712 d.error.category.to_string(),
713 )),
714 message: d.error.message.clone(),
715 ..Default::default()
716 });
717 }
718 }
719
720 let (publishes, dirty) = publish::publish_plan(&previously_dirty, new_by_uri);
721 for (uri, diags) in publishes {
722 let version = version_by_uri.get(&uri).copied().flatten();
727 self.client.publish_diagnostics(uri, diags, version).await;
728 }
729 let still_current = {
730 let mut state = self.state.write().await;
731 if let Some(ps) = state.projects.get_mut(&root)
732 && ps.analysis_round_committed == round
733 {
734 ps.published = dirty;
735 true
736 } else {
737 false
738 }
739 };
740 if still_current {
751 let refresh = self.state.read().await.supports_refresh;
752 if refresh.semantic_tokens || refresh.inlay_hints || refresh.code_lens {
753 let client = self.client.clone();
754 tokio::spawn(async move {
755 if refresh.semantic_tokens {
756 let _ = client.semantic_tokens_refresh().await;
757 }
758 if refresh.inlay_hints {
759 let _ = client.inlay_hint_refresh().await;
760 }
761 if refresh.code_lens {
762 let _ = client.code_lens_refresh().await;
763 }
764 });
765 }
766 }
767 }
768
769 async fn analysis_roots_for(&self, uri: &Url) -> Option<bynk_ide::AnalysisRoots> {
779 Some(bynk_ide::AnalysisRoots::Project(
780 self.root_for_uri(uri).await?,
781 ))
782 }
783
784 async fn project_files(&self, uri: &Url) -> Option<Vec<PathBuf>> {
795 let roots = self.analysis_roots_for(uri).await?;
796 let current = uri
803 .to_file_path()
804 .ok()
805 .and_then(|p| std::fs::canonicalize(&p).ok());
806 tokio::task::spawn_blocking(move || {
807 let mut files = bynk_ide::discover_files(&roots);
808 if let Some(current) = current {
809 files.retain(|p| p != ¤t);
810 }
811 files
812 })
813 .await
814 .ok()
815 }
816
817 fn local_sites(
820 &self,
821 analysis: &Analysis,
822 rel: &std::path::Path,
823 offset: usize,
824 ) -> Option<Vec<bynk_syntax::span::Span>> {
825 let text = analysis.snapshots.get(rel)?;
826 let locals = analysis.locals.get(rel)?;
827 crate::locals_nav::local_sites_at(locals, text, offset)
828 }
829
830 async fn locals_completions(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
836 let analysis = self.analysis_for(uri).await;
840 let Some(analysis) = analysis else {
841 return Vec::new();
842 };
843 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
844 return Vec::new();
845 };
846 let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
847 else {
848 return Vec::new();
849 };
850 let Some(offset) = crate::position::position_to_offset(text, pos) else {
851 return Vec::new();
852 };
853 bynk_check::locals::locals_at(locals, offset)
854 .into_iter()
855 .map(|b| CompletionItem {
856 label: b.name.clone(),
857 kind: Some(CompletionItemKind::VARIABLE),
858 detail: Some(b.ty.clone()),
859 ..Default::default()
860 })
861 .collect()
862 }
863
864 fn local_locations(
866 &self,
867 analysis: &Analysis,
868 rel: &std::path::Path,
869 spans: &[bynk_syntax::span::Span],
870 ) -> Vec<Location> {
871 let Some(text) = analysis.snapshots.get(rel) else {
872 return Vec::new();
873 };
874 let Ok(uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
875 return Vec::new();
876 };
877 spans
878 .iter()
879 .map(|s| Location {
880 uri: uri.clone(),
881 range: crate::position::span_to_range(text, *s),
882 })
883 .collect()
884 }
885
886 async fn value_member_completions(
905 &self,
906 uri: &Url,
907 text: &str,
908 offset: usize,
909 ) -> Vec<CompletionItem> {
910 let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(text, offset)
911 else {
912 return Vec::new();
913 };
914 let mut items: Vec<CompletionItem> = Vec::new();
915 if let Some(ty) = self
916 .type_receiver(uri, rewritten.clone(), recv_offset)
917 .await
918 {
919 let files = self.project_files(uri).await;
920 items.extend(
921 completion::value_member_candidates(&ty, text, files.as_deref())
922 .into_iter()
923 .map(to_completion_item),
924 );
925 }
926 let locals = self.fast_path_locals(uri, &rewritten).await;
927 items.extend(
928 completion::store_field_member_candidates(&rewritten, recv_offset, &locals)
929 .into_iter()
930 .map(to_completion_item),
931 );
932 items
933 }
934
935 async fn fast_path_locals(
941 &self,
942 uri: &Url,
943 rewritten: &str,
944 ) -> Vec<bynk_check::locals::LocalBinding> {
945 let Some(analysis) = self.project_analysis_for(uri).await else {
946 return Vec::new();
947 };
948 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
949 return Vec::new();
950 };
951 if analysis.snapshots.get(&rel).map(String::as_str) != Some(rewritten) {
952 return Vec::new();
953 }
954 analysis.locals.get(&rel).cloned().unwrap_or_default()
955 }
956
957 async fn is_pattern_completions(
962 &self,
963 uri: &Url,
964 text: &str,
965 offset: usize,
966 ) -> Vec<CompletionItem> {
967 let Some(scrut_off) = is_scrutinee_offset(text, offset) else {
968 return Vec::new();
969 };
970 self.scrutinee_variant_completions(uri, text, scrut_off)
971 .await
972 }
973
974 async fn match_arm_completions(
978 &self,
979 uri: &Url,
980 text: &str,
981 offset: usize,
982 ) -> Vec<CompletionItem> {
983 let Some(scrut_off) = match_scrutinee_offset(text, offset) else {
984 return Vec::new();
985 };
986 self.scrutinee_variant_completions(uri, text, scrut_off)
987 .await
988 }
989
990 async fn scrutinee_variant_completions(
997 &self,
998 uri: &Url,
999 text: &str,
1000 scrut_off: usize,
1001 ) -> Vec<CompletionItem> {
1002 let Some(ty) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1003 return Vec::new();
1004 };
1005 let files = self.project_files(uri).await;
1006 completion::variants_for_ty(&ty, text, files.as_deref())
1007 .into_iter()
1008 .map(to_completion_item)
1009 .collect()
1010 }
1011
1012 async fn nested_pattern_completions(
1019 &self,
1020 uri: &Url,
1021 text: &str,
1022 offset: usize,
1023 ) -> Vec<CompletionItem> {
1024 let Some((scrut_off, variant)) = nested_pattern_offset(text, offset) else {
1025 return Vec::new();
1026 };
1027 let Some(ty) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1028 return Vec::new();
1029 };
1030 let files = self.project_files(uri).await;
1031 completion::nested_variant_completions(&ty, &variant, text, files.as_deref())
1032 .into_iter()
1033 .map(to_completion_item)
1034 .collect()
1035 }
1036
1037 async fn type_receiver(
1042 &self,
1043 uri: &Url,
1044 rewritten: String,
1045 recv_offset: usize,
1046 ) -> Option<bynk_check::checker::Ty> {
1047 let roots = self.analysis_roots_for(uri).await?;
1048 let project_root = roots.project_root().to_path_buf();
1049 let canonical_root = project_root
1050 .canonicalize()
1051 .unwrap_or_else(|_| project_root.clone());
1052 let cur = uri.to_file_path().ok()?;
1053 let cur = cur.canonicalize().unwrap_or(cur);
1054 let rel = cur.strip_prefix(&canonical_root).ok()?.to_path_buf();
1056 let overlay = {
1058 let state = self.state.read().await;
1059 let mut ov = std::collections::HashMap::new();
1060 for (u, doc) in &state.docs {
1061 if let Ok(p) = u.to_file_path() {
1062 let canonical = p.canonicalize().unwrap_or(p);
1063 let t = if u == uri {
1064 rewritten.clone()
1065 } else {
1066 doc.text.clone()
1067 };
1068 ov.insert(canonical, t);
1069 }
1070 }
1071 ov
1072 };
1073 if let Some(analysis) = self.project_analysis_for(uri).await
1079 && analysis.snapshots.get(&rel).map(String::as_str) == Some(rewritten.as_str())
1080 && let Some((_, entries)) = analysis.expr_types.iter().find(|(p, _)| **p == rel)
1081 {
1082 return bynk_check::expr_types::type_at_offset(entries, recv_offset).cloned();
1083 }
1084 let result =
1085 tokio::task::spawn_blocking(move || bynk_ide::diagnose_project_with(&roots, &overlay))
1086 .await
1087 .ok()?;
1088 let (_, entries) = result.expr_types.iter().find(|(p, _)| **p == rel)?;
1089 bynk_check::expr_types::type_at_offset(entries, recv_offset).cloned()
1090 }
1091
1092 async fn project_analysis(&self, root: &std::path::Path) -> Option<Arc<Analysis>> {
1095 self.state.read().await.projects.get(root)?.analysis.clone()
1096 }
1097
1098 async fn project_analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1102 let root = self.root_for_uri(uri).await?;
1103 self.project_analysis(&root).await
1104 }
1105
1106 async fn ensure_project_analysed(
1111 &self,
1112 root: PathBuf,
1113 config: ProjectConfig,
1114 ) -> Option<Arc<Analysis>> {
1115 {
1116 let mut state = self.state.write().await;
1117 state
1118 .projects
1119 .entry(root.clone())
1120 .or_insert_with(|| ProjectState {
1121 config,
1122 ..Default::default()
1123 });
1124 }
1125 if let Some(a) = self.project_analysis(&root).await {
1126 return Some(a);
1127 }
1128 self.refresh_now(root.clone()).await;
1129 self.project_analysis(&root).await
1130 }
1131
1132 async fn prune_orphaned_projects(&self) -> Vec<Url> {
1143 let (folders, open_uris) = {
1166 let state = self.state.read().await;
1167 (
1168 state.folders.clone(),
1169 state.docs.keys().cloned().collect::<Vec<_>>(),
1170 )
1171 };
1172 let open_roots: std::collections::HashSet<PathBuf> = open_uris
1173 .iter()
1174 .filter_map(Self::root_for_uri_uncached)
1175 .collect();
1176 let covered = |root: &std::path::Path| {
1177 folders
1178 .iter()
1179 .any(|f| f.starts_with(root) || root.starts_with(f))
1180 || open_roots.contains(root)
1181 };
1182 let mut state = self.state.write().await;
1183 let orphaned: Vec<PathBuf> = state
1184 .projects
1185 .keys()
1186 .filter(|r| !covered(r))
1187 .cloned()
1188 .collect();
1189 let mut to_clear = Vec::new();
1190 for root in orphaned {
1191 if let Some(ps) = state.projects.remove(&root) {
1192 to_clear.extend(ps.published);
1193 }
1194 }
1195 to_clear
1196 }
1197
1198 async fn warm_projects(&self, folders: &[PathBuf]) {
1205 if folders.is_empty() {
1206 return;
1207 }
1208 let folders = folders.to_vec();
1212 let roots = tokio::task::spawn_blocking(move || {
1213 let mut roots: Vec<PathBuf> = Vec::new();
1214 for folder in &folders {
1215 for root in Self::discover_projects_under(folder) {
1216 if !roots.contains(&root) {
1217 roots.push(root);
1218 }
1219 }
1220 }
1221 roots
1222 })
1223 .await
1224 .unwrap_or_default();
1225 for root in roots {
1226 let config = project::load_config(&root).unwrap_or_default();
1227 {
1228 let mut state = self.state.write().await;
1229 state
1230 .projects
1231 .entry(root.clone())
1232 .or_insert_with(|| ProjectState {
1233 config,
1234 ..Default::default()
1235 });
1236 }
1237 self.schedule_project_diagnostics(root).await;
1238 }
1239 }
1240
1241 async fn register_file_watchers(&self) {
1247 use tower_lsp::lsp_types::{
1248 DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, Registration,
1249 };
1250 let watchers = ["**/*.bynk", "**/bynk.toml"]
1251 .into_iter()
1252 .map(|g| FileSystemWatcher {
1253 glob_pattern: GlobPattern::String(g.to_string()),
1254 kind: None, })
1256 .collect();
1257 let registration = Registration {
1258 id: "bynk-watched-files".to_string(),
1259 method: "workspace/didChangeWatchedFiles".to_string(),
1260 register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
1261 watchers,
1262 })
1263 .ok(),
1264 };
1265 if let Err(e) = self.client.register_capability(vec![registration]).await {
1266 self.client
1267 .log_message(
1268 MessageType::WARNING,
1269 format!("bynkc-lsp: file-watcher registration failed: {e}"),
1270 )
1271 .await;
1272 }
1273 }
1274
1275 async fn config_for(&self, uri: &Url) -> ProjectConfig {
1279 let Some(root) = self.root_for_uri(uri).await else {
1280 return ProjectConfig::default();
1281 };
1282 self.state
1283 .read()
1284 .await
1285 .projects
1286 .get(&root)
1287 .map(|p| p.config.clone())
1288 .unwrap_or_default()
1289 }
1290
1291 async fn analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1311 let root = self.root_for_uri(uri).await?;
1312 let want = self.state.read().await.docs.get(uri).map(|d| d.version);
1317 let current = |a: &Arc<Analysis>| {
1318 let Some(rel) = Self::uri_to_rel(a, uri) else {
1319 return false; };
1321 if !a.snapshots.contains_key(&rel) {
1326 return false;
1327 }
1328 match want {
1329 Some(v) => a.versions.get(&rel) == Some(&v),
1332 None => true,
1335 }
1336 };
1337
1338 if let Some(a) = self.project_analysis(&root).await
1339 && current(&a)
1340 {
1341 return Some(a);
1342 }
1343
1344 let _guard = self.refresh_lock.lock().await;
1349 if let Some(a) = self.project_analysis(&root).await
1350 && current(&a)
1351 {
1352 return Some(a);
1353 }
1354 self.refresh_now(root.clone()).await;
1355 let a = self.project_analysis(&root).await?;
1356 current(&a).then_some(a)
1360 }
1361
1362 async fn committed_analysis(&self, uri: &Url) -> Option<Arc<Analysis>> {
1387 let root = self.root_for_uri(uri).await?;
1388 let a = self.project_analysis(&root).await?;
1389 let rel = Self::uri_to_rel(&a, uri)?;
1392 a.snapshots.contains_key(&rel).then_some(a)
1393 }
1394
1395 async fn analysis_covering_open_buffers(
1416 &self,
1417 root: &std::path::Path,
1418 ) -> Option<Arc<Analysis>> {
1419 let all_current =
1423 |a: &Arc<Analysis>, docs: &std::collections::HashMap<Url, DocumentState>| {
1424 docs.iter()
1425 .all(|(uri, doc)| match Self::uri_to_rel(a, uri) {
1426 Some(rel) if a.snapshots.contains_key(&rel) => {
1427 a.versions.get(&rel) == Some(&doc.version)
1428 }
1429 _ => true,
1430 })
1431 };
1432
1433 {
1434 let state = self.state.read().await;
1435 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1436 && all_current(&a, &state.docs)
1437 {
1438 return Some(a);
1439 }
1440 }
1441 let _guard = self.refresh_lock.lock().await;
1442 {
1443 let state = self.state.read().await;
1444 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1445 && all_current(&a, &state.docs)
1446 {
1447 return Some(a);
1448 }
1449 }
1450 self.refresh_now(root.to_path_buf()).await;
1451 let state = self.state.read().await;
1452 let a = state.projects.get(root).and_then(|p| p.analysis.clone())?;
1453 all_current(&a, &state.docs).then_some(a)
1454 }
1455
1456 async fn refresh_now(&self, root: PathBuf) {
1461 if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
1462 ps.analysis_generation += 1;
1463 }
1464 self.run_project_diagnostics(root).await;
1465 }
1466
1467 fn uri_to_rel(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1469 let p = uri.to_file_path().ok()?;
1470 let canonical = p.canonicalize().unwrap_or(p);
1471 canonical
1476 .strip_prefix(&analysis.project_root)
1477 .ok()
1478 .map(|r| r.to_path_buf())
1479 }
1480
1481 fn uri_to_rel_for_new_path(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1492 let p = uri.to_file_path().ok()?;
1493 let file_name = p.file_name()?;
1494 let parent = p.parent()?;
1495 let canonical_parent = parent
1496 .canonicalize()
1497 .unwrap_or_else(|_| parent.to_path_buf());
1498 canonical_parent
1499 .join(file_name)
1500 .strip_prefix(&analysis.project_root)
1501 .ok()
1502 .map(|r| r.to_path_buf())
1503 }
1504
1505 async fn unit_reference_definition(&self, uri: &Url, pos: Position) -> Option<Location> {
1511 let analysis = self.analysis_for(uri).await;
1517 let text = self
1518 .state
1519 .read()
1520 .await
1521 .docs
1522 .get(uri)
1523 .map(|d| d.text.clone());
1524 let (text, analysis) = (text?, analysis?);
1525 let offset = cursor_offset(&text, pos);
1526 for (unit, span) in crate::symbols::unit_reference_spans(&text) {
1527 if span.start <= offset && offset <= span.end {
1528 let rel = analysis.unit_sources.get(&unit)?.first()?;
1529 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
1530 return Some(Location {
1531 uri: target,
1532 range: Range::default(),
1533 });
1534 }
1535 }
1536 None
1537 }
1538
1539 fn site_to_location(
1542 analysis: &Analysis,
1543 site: &bynk_check::index::SiteRef,
1544 ) -> Option<Location> {
1545 let text = analysis.snapshots.get(&site.path)?;
1546 let abs = analysis.project_root.join(&site.path);
1547 let uri = Url::from_file_path(abs).ok()?;
1548 Some(Location {
1549 uri,
1550 range: crate::position::span_to_range(text, site.span),
1551 })
1552 }
1553
1554 fn call_hierarchy_item(
1559 analysis: &Analysis,
1560 key: &bynk_check::index::SymbolKey,
1561 def: &bynk_check::index::SiteRef,
1562 ) -> Option<CallHierarchyItem> {
1563 let location = Self::site_to_location(analysis, def)?;
1564 Some(CallHierarchyItem {
1565 name: key.name.clone(),
1566 kind: lsp_symbol_kind(key.kind),
1567 tags: None,
1568 detail: Some(key.unit.clone()),
1569 uri: location.uri,
1570 range: location.range,
1571 selection_range: location.range,
1572 data: serde_json::to_value(SerKey::from(key)).ok(),
1573 })
1574 }
1575
1576 fn call_ranges(analysis: &Analysis, sites: &[&bynk_check::index::SiteRef]) -> Vec<Range> {
1579 sites
1580 .iter()
1581 .filter_map(|s| {
1582 let text = analysis.snapshots.get(&s.path)?;
1583 Some(crate::position::span_to_range(text, s.span))
1584 })
1585 .collect()
1586 }
1587
1588 async fn semantic_tokens_for(&self, uri: &Url, range: Option<Range>) -> Vec<SemanticToken> {
1593 let analysis = self.committed_analysis(uri).await;
1597 let Some(analysis) = analysis else {
1598 return Vec::new();
1599 };
1600 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1601 return Vec::new();
1602 };
1603 let Some(text) = analysis.snapshots.get(&rel) else {
1604 return Vec::new();
1605 };
1606 let span = match range {
1607 None => None,
1608 Some(r) => {
1611 let (Some(start), Some(end)) = (
1612 crate::position::position_to_offset(text, r.start),
1613 crate::position::position_to_offset(text, r.end),
1614 ) else {
1615 return Vec::new();
1616 };
1617 Some(bynk_syntax::span::Span::new(start, end))
1618 }
1619 };
1620 let lt = analysis
1621 .locals
1622 .get(&rel)
1623 .map(|l| crate::locals_nav::local_token_sites(l, text))
1624 .unwrap_or_default();
1625 let dt = crate::symbols::handler_annotation_token_spans(text);
1629 crate::index_queries::semantic_tokens(&analysis.index, <, &dt, &rel, text, span)
1630 }
1631
1632 async fn index_position(
1635 &self,
1636 uri: &Url,
1637 position: Position,
1638 ) -> Option<(Arc<Analysis>, PathBuf, usize)> {
1639 let analysis = self.analysis_for(uri).await?;
1644 let rel = Self::uri_to_rel(&analysis, uri)?;
1645 let text = analysis.snapshots.get(&rel)?;
1646 let offset = crate::position::position_to_offset(text, position)?;
1647 Some((analysis, rel, offset))
1648 }
1649
1650 async fn identifier_at(
1654 &self,
1655 uri: &Url,
1656 position: Position,
1657 ) -> Option<(String, bynk_syntax::span::Span, String)> {
1658 let text = {
1659 let state = self.state.read().await;
1660 state.docs.get(uri)?.text.clone()
1661 };
1662 let offset = crate::position::position_to_offset(&text, position)?;
1663 let tokens = bynk_syntax::lexer::tokenize_expanding_holes(&text).ok()?;
1667 for t in &tokens {
1669 if t.span.start <= offset
1670 && offset < t.span.end
1671 && matches!(
1672 t.kind,
1673 bynk_syntax::lexer::TokenKind::Ident
1674 | bynk_syntax::lexer::TokenKind::Int
1675 | bynk_syntax::lexer::TokenKind::String
1676 | bynk_syntax::lexer::TokenKind::Bool
1677 | bynk_syntax::lexer::TokenKind::Float
1678 | bynk_syntax::lexer::TokenKind::Result
1679 | bynk_syntax::lexer::TokenKind::Option
1680 | bynk_syntax::lexer::TokenKind::Effect
1681 )
1682 {
1683 let name = text[t.span.start..t.span.end].to_string();
1684 return Some((name, t.span, text));
1685 }
1686 }
1687 None
1688 }
1689
1690 async fn sequence_model(
1697 &self,
1698 params: sequence_request::SequenceModelParams,
1699 ) -> JsonRpcResult<Option<sequence_request::WireSequenceModel>> {
1700 let uri = params.text_document.uri;
1701 let Some(analysis) = self.committed_analysis(&uri).await else {
1702 return Ok(None);
1703 };
1704 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1705 return Ok(None);
1706 };
1707 let Some(text) = analysis.snapshots.get(&rel) else {
1708 return Ok(None);
1709 };
1710 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
1711 return Ok(None);
1712 };
1713 let info = bynk_ide::symbols::own_declaration_name(text)
1714 .and_then(|(name, _)| analysis.sequence_info.get(&name));
1715 let model = sequence_request::sequence_model_at(text, offset, info);
1716 Ok(model.map(|m| sequence_request::to_wire(&m, text)))
1717 }
1718
1719 async fn documentation_model(
1728 &self,
1729 params: documentation_request::DocumentationModelParams,
1730 ) -> JsonRpcResult<Option<documentation_request::WireDocModel>> {
1731 let uri = params.text_document.uri;
1732 let Some(analysis) = self.committed_analysis(&uri).await else {
1733 return Ok(None);
1734 };
1735 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1736 return Ok(None);
1737 };
1738 let Some(text) = analysis.snapshots.get(&rel) else {
1739 return Ok(None);
1740 };
1741 let model = documentation_request::documentation_model_at(text);
1742 Ok(model.map(|m| documentation_request::to_wire(&m, text)))
1743 }
1744
1745 async fn architecture_model(
1755 &self,
1756 params: architecture_request::ArchitectureModelParams,
1757 ) -> JsonRpcResult<Option<architecture_request::WireArchModel>> {
1758 let uri = params.text_document.uri;
1759 let Some(analysis) = self.committed_analysis(&uri).await else {
1760 return Ok(None);
1761 };
1762 let model = architecture_request::architecture_model_for(
1763 &analysis.unit_sources,
1764 &analysis.snapshots,
1765 &analysis.sequence_info,
1766 );
1767 Ok(Some(architecture_request::to_wire(
1768 &model,
1769 &analysis.project_root,
1770 &analysis.snapshots,
1771 )))
1772 }
1773}
1774
1775#[tower_lsp::async_trait]
1776impl LanguageServer for Backend {
1777 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1778 let dynamic_watchers = params
1785 .capabilities
1786 .workspace
1787 .as_ref()
1788 .and_then(|w| w.did_change_watched_files.as_ref())
1789 .and_then(|d| d.dynamic_registration)
1790 .unwrap_or(false);
1791 let ws = params.capabilities.workspace.as_ref();
1795 let supports_refresh = RefreshSupport {
1796 semantic_tokens: ws
1797 .and_then(|w| w.semantic_tokens.as_ref())
1798 .and_then(|s| s.refresh_support)
1799 .unwrap_or(false),
1800 inlay_hints: ws
1801 .and_then(|w| w.inlay_hint.as_ref())
1802 .and_then(|i| i.refresh_support)
1803 .unwrap_or(false),
1804 code_lens: ws
1805 .and_then(|w| w.code_lens.as_ref())
1806 .and_then(|c| c.refresh_support)
1807 .unwrap_or(false),
1808 };
1809 {
1810 let mut state = self.state.write().await;
1811 state.supports_dynamic_watchers = dynamic_watchers;
1812 state.supports_refresh = supports_refresh;
1813 if let Some(folders) = ¶ms.workspace_folders {
1814 state.folders = folders
1815 .iter()
1816 .filter_map(|f| f.uri.to_file_path().ok())
1817 .map(|p| p.canonicalize().unwrap_or(p))
1818 .collect();
1819 }
1820 }
1821 Ok(InitializeResult {
1822 capabilities: server_capabilities(),
1823 server_info: Some(ServerInfo {
1824 name: SERVER_NAME.into(),
1825 version: Some(SERVER_VERSION.into()),
1826 }),
1827 })
1828 }
1829
1830 async fn initialized(&self, _: InitializedParams) {
1831 let (folders, dynamic) = {
1832 let s = self.state.read().await;
1833 (s.folders.clone(), s.supports_dynamic_watchers)
1834 };
1835 if dynamic {
1841 self.register_file_watchers().await;
1842 }
1843 self.warm_projects(&folders).await;
1846 let msg = if folders.is_empty() {
1847 "bynkc-lsp: no workspace folders; single-file mode".to_string()
1848 } else {
1849 format!(
1850 "bynkc-lsp: {} workspace folder(s); projects resolved per file",
1851 folders.len()
1852 )
1853 };
1854 self.client.log_message(MessageType::INFO, msg).await;
1855 }
1856
1857 async fn shutdown(&self) -> JsonRpcResult<()> {
1858 Ok(())
1859 }
1860
1861 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1862 let uri = params.text_document.uri.clone();
1863 {
1864 let mut state = self.state.write().await;
1865 state.docs.insert(
1866 uri.clone(),
1867 DocumentState {
1868 text: params.text_document.text,
1869 version: params.text_document.version,
1870 },
1871 );
1872 }
1873 self.schedule_diagnostics(&uri).await;
1876 }
1877
1878 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1879 let uri = params.text_document.uri.clone();
1880 {
1881 let mut state = self.state.write().await;
1882 if let Some(doc) = state.docs.get_mut(&uri)
1883 && let Some(change) = params.content_changes.into_iter().next_back()
1884 {
1885 doc.text = change.text;
1886 doc.version = params.text_document.version;
1887 }
1888 }
1889 if self.config_for(&uri).await.diagnostics_mode == crate::project::DiagnosticsMode::OnSave {
1894 return;
1895 }
1896 self.schedule_diagnostics(&uri).await;
1900 }
1901
1902 async fn did_save(&self, params: DidSaveTextDocumentParams) {
1903 self.schedule_diagnostics(¶ms.text_document.uri).await;
1906 }
1907
1908 async fn did_close(&self, params: DidCloseTextDocumentParams) {
1909 let uri = params.text_document.uri;
1910 {
1911 let mut state = self.state.write().await;
1912 state.docs.remove(&uri);
1913 state.single_file_generations.remove(&uri);
1916 }
1917 for cleared in self.prune_orphaned_projects().await {
1922 self.client
1923 .publish_diagnostics(cleared, Vec::new(), None)
1924 .await;
1925 }
1926 }
1927
1928 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
1933 let uri = params.text_document_position_params.text_document.uri;
1934 let pos = params.text_document_position_params.position;
1935 let positioned = self.index_position(&uri, pos).await;
1937 let doc_text = {
1940 let state = self.state.read().await;
1941 state.docs.get(&uri).map(|d| d.text.clone())
1942 };
1943 let doc = doc_text
1944 .as_deref()
1945 .and_then(|t| Some((t, crate::position::position_to_offset(t, pos)?)));
1946 let files = self.project_files(&uri).await;
1947 let analysis = positioned
1948 .as_ref()
1949 .map(|(a, rel, offset)| crate::hover::HoverAnalysis {
1950 index: &a.index,
1951 snapshots: &a.snapshots,
1952 locals: &a.locals,
1953 expr_types: &a.expr_types,
1954 rel,
1955 offset: *offset,
1956 project_root: &a.project_root,
1957 doc_scope: &a.doc_scope,
1958 });
1959 let content = crate::hover::hover_content(&crate::hover::HoverInput {
1960 analysis,
1961 doc,
1962 uri: &uri,
1963 files: files.as_deref(),
1964 });
1965 Ok(content.map(|value| Hover {
1966 contents: HoverContents::Markup(MarkupContent {
1967 kind: MarkupKind::Markdown,
1968 value,
1969 }),
1970 range: None,
1971 }))
1972 }
1973
1974 async fn signature_help(
1976 &self,
1977 params: SignatureHelpParams,
1978 ) -> JsonRpcResult<Option<SignatureHelp>> {
1979 let uri = params.text_document_position_params.text_document.uri;
1980 let pos = params.text_document_position_params.position;
1981 let text = {
1982 let s = self.state.read().await;
1983 s.docs.get(&uri).map(|d| d.text.clone())
1984 };
1985 let Some(text) = text else { return Ok(None) };
1986 let offset = cursor_offset(&text, pos);
1987 let Some(ctx) = crate::signature_help::call_context(&text, offset) else {
1988 return Ok(None);
1989 };
1990 let files = self.project_files(&uri).await;
1991 let resolved_label = {
1996 let callee = ctx.callee.clone();
1997 let text = text.clone();
1998 let files = files.clone();
1999 match tokio::task::spawn_blocking(move || {
2000 crate::signature_help::resolve_label(&callee, &text, files.as_deref())
2001 })
2002 .await
2003 {
2004 Ok(l) => l,
2005 Err(e) => {
2006 tracing::error!("signature-help label task failed: {e}");
2007 None
2008 }
2009 }
2010 };
2011 let label = match resolved_label {
2012 Some(l) => Some(l),
2013 None => match crate::signature_help::value_receiver_method(&ctx.callee) {
2016 Some((_, method)) => {
2017 if let Some((rewritten, recv_offset)) =
2018 crate::signature_help::value_receiver_rewrite(
2019 &text,
2020 &ctx.callee,
2021 ctx.open_paren,
2022 offset,
2023 )
2024 && let Some(ty) = self.type_receiver(&uri, rewritten, recv_offset).await
2025 {
2026 crate::signature_help::kernel_method_signature(&ty, method)
2027 } else {
2028 None
2029 }
2030 }
2031 None => None,
2032 },
2033 };
2034 let Some(label) = label else { return Ok(None) };
2035 let active = ctx.active_param as u32;
2036 let parameters: Vec<ParameterInformation> = crate::signature_help::param_ranges(&label)
2037 .into_iter()
2038 .map(|(s, e)| ParameterInformation {
2039 label: ParameterLabel::LabelOffsets([s as u32, e as u32]),
2040 documentation: None,
2041 })
2042 .collect();
2043 Ok(Some(SignatureHelp {
2044 signatures: vec![SignatureInformation {
2045 label,
2046 documentation: None,
2047 parameters: Some(parameters),
2048 active_parameter: Some(active),
2049 }],
2050 active_signature: Some(0),
2051 active_parameter: Some(active),
2052 }))
2053 }
2054
2055 async fn code_lens(&self, params: CodeLensParams) -> JsonRpcResult<Option<Vec<CodeLens>>> {
2058 let uri = params.text_document.uri;
2059 let analysis = self.committed_analysis(&uri).await;
2061 let Some(analysis) = analysis else {
2062 return Ok(Some(Vec::new()));
2063 };
2064 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2065 return Ok(Some(Vec::new()));
2066 };
2067 let Some(text) = analysis.snapshots.get(&rel) else {
2068 return Ok(Some(Vec::new()));
2069 };
2070 let show_references = |range: Range, locations: Vec<Location>, title: String| CodeLens {
2074 range,
2075 command: Some(Command {
2076 title,
2077 command: "editor.action.showReferences".to_string(),
2078 arguments: Some(vec![
2079 serde_json::to_value(&uri).unwrap_or_default(),
2080 serde_json::to_value(range.start).unwrap_or_default(),
2081 serde_json::to_value(&locations).unwrap_or_default(),
2082 ]),
2083 }),
2084 data: None,
2085 };
2086 let mut lenses: Vec<CodeLens> = crate::index_queries::code_lenses(&analysis.index, &rel)
2087 .into_iter()
2088 .map(|(def, refs)| {
2089 let range = crate::position::span_to_range(text, def.span);
2090 let locations: Vec<Location> = refs
2091 .iter()
2092 .filter_map(|r| Self::site_to_location(&analysis, r))
2093 .collect();
2094 let n = refs.len();
2095 show_references(
2096 range,
2097 locations,
2098 format!("{n} reference{}", if n == 1 { "" } else { "s" }),
2099 )
2100 })
2101 .collect();
2102 lenses.extend(
2106 crate::index_queries::capability_provider_lenses(&analysis.index, &rel)
2107 .into_iter()
2108 .map(|(def, providers)| {
2109 let range = crate::position::span_to_range(text, def.span);
2110 let locations: Vec<Location> = providers
2111 .iter()
2112 .filter_map(|r| Self::site_to_location(&analysis, r))
2113 .collect();
2114 let n = providers.len();
2115 show_references(
2116 range,
2117 locations,
2118 format!("{n} provider{}", if n == 1 { "" } else { "s" }),
2119 )
2120 }),
2121 );
2122 lenses.extend(
2126 crate::index_queries::refinement_family_lenses(&analysis.index, &rel)
2127 .into_iter()
2128 .map(|(def, base, family)| {
2129 let range = crate::position::span_to_range(text, def.span);
2130 let locations: Vec<Location> = family
2131 .iter()
2132 .filter_map(|r| Self::site_to_location(&analysis, r))
2133 .collect();
2134 let n = family.len();
2135 show_references(
2136 range,
2137 locations,
2138 format!("{n} refinements of {}", base.name()),
2139 )
2140 }),
2141 );
2142 lenses.extend(
2151 crate::sequence_request::handler_lens_sites(text)
2152 .into_iter()
2153 .map(|span| {
2154 let range = crate::position::span_to_range(text, span);
2155 CodeLens {
2156 range,
2157 command: Some(Command {
2158 title: "Show Sequence".to_string(),
2159 command: "bynk.showSequenceDiagram".to_string(),
2160 arguments: Some(vec![
2161 serde_json::to_value(&uri).unwrap_or_default(),
2162 serde_json::to_value(range.start).unwrap_or_default(),
2163 ]),
2164 }),
2165 data: None,
2166 }
2167 }),
2168 );
2169 Ok(Some(lenses))
2170 }
2171
2172 async fn prepare_call_hierarchy(
2173 &self,
2174 params: CallHierarchyPrepareParams,
2175 ) -> JsonRpcResult<Option<Vec<CallHierarchyItem>>> {
2176 let uri = params.text_document_position_params.text_document.uri;
2177 let pos = params.text_document_position_params.position;
2178 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2179 return Ok(None);
2180 };
2181 let Some((key, def)) =
2182 crate::index_queries::prepare_call_hierarchy(&analysis.index, &rel, offset)
2183 else {
2184 return Ok(None);
2185 };
2186 Ok(Self::call_hierarchy_item(&analysis, key, def).map(|item| vec![item]))
2187 }
2188
2189 async fn incoming_calls(
2190 &self,
2191 params: CallHierarchyIncomingCallsParams,
2192 ) -> JsonRpcResult<Option<Vec<CallHierarchyIncomingCall>>> {
2193 let analysis = self.analysis_for(¶ms.item.uri).await;
2194 let Some(analysis) = analysis else {
2195 return Ok(Some(Vec::new()));
2196 };
2197 let Some(key) = SerKey::read(¶ms.item.data) else {
2198 return Ok(Some(Vec::new()));
2199 };
2200 let calls = crate::index_queries::incoming_calls(&analysis.index, &key)
2201 .into_iter()
2202 .filter_map(|rel| {
2203 let from = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2204 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2205 Some(CallHierarchyIncomingCall { from, from_ranges })
2206 })
2207 .collect();
2208 Ok(Some(calls))
2209 }
2210
2211 async fn outgoing_calls(
2212 &self,
2213 params: CallHierarchyOutgoingCallsParams,
2214 ) -> JsonRpcResult<Option<Vec<CallHierarchyOutgoingCall>>> {
2215 let analysis = self.analysis_for(¶ms.item.uri).await;
2216 let Some(analysis) = analysis else {
2217 return Ok(Some(Vec::new()));
2218 };
2219 let Some(key) = SerKey::read(¶ms.item.data) else {
2220 return Ok(Some(Vec::new()));
2221 };
2222 let calls = crate::index_queries::outgoing_calls(&analysis.index, &key)
2223 .into_iter()
2224 .filter_map(|rel| {
2225 let to = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2226 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2227 Some(CallHierarchyOutgoingCall { to, from_ranges })
2228 })
2229 .collect();
2230 Ok(Some(calls))
2231 }
2232
2233 async fn goto_implementation(
2238 &self,
2239 params: GotoImplementationParams,
2240 ) -> JsonRpcResult<Option<GotoImplementationResponse>> {
2241 let uri = params.text_document_position_params.text_document.uri;
2242 let pos = params.text_document_position_params.position;
2243 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2244 return Ok(None);
2245 };
2246 let Some((key, _)) = analysis.index.symbol_at(&rel, offset) else {
2247 return Ok(None);
2248 };
2249 if key.kind != bynk_check::index::SymbolKind::Capability {
2250 return Ok(None);
2251 }
2252 let locations: Vec<Location> = crate::index_queries::implementations(&analysis.index, key)
2253 .into_iter()
2254 .filter_map(|d| Self::site_to_location(&analysis, d))
2255 .collect();
2256 if locations.is_empty() {
2257 return Ok(None);
2258 }
2259 Ok(Some(GotoDefinitionResponse::Array(locations)))
2260 }
2261
2262 async fn goto_type_definition(
2268 &self,
2269 params: GotoTypeDefinitionParams,
2270 ) -> JsonRpcResult<Option<GotoTypeDefinitionResponse>> {
2271 let uri = params.text_document_position_params.text_document.uri;
2272 let pos = params.text_document_position_params.position;
2273 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2274 return Ok(None);
2275 };
2276 let Some(entries) = analysis.expr_types.get(&rel) else {
2277 return Ok(None);
2278 };
2279 let Some(ty) = bynk_check::expr_types::type_at_offset(entries, offset) else {
2280 return Ok(None);
2281 };
2282 let Some(name) = crate::index_queries::named_type_target(ty) else {
2283 return Ok(None);
2284 };
2285 let locations: Vec<Location> =
2286 crate::index_queries::type_definitions_named(&analysis.index, name)
2287 .into_iter()
2288 .filter_map(|d| Self::site_to_location(&analysis, d))
2289 .collect();
2290 if locations.is_empty() {
2291 return Ok(None);
2292 }
2293 Ok(Some(GotoDefinitionResponse::Array(locations)))
2294 }
2295
2296 async fn document_link(
2309 &self,
2310 params: DocumentLinkParams,
2311 ) -> JsonRpcResult<Option<Vec<DocumentLink>>> {
2312 let uri = params.text_document.uri;
2313 let analysis = self.committed_analysis(&uri).await;
2317 let text = self
2318 .state
2319 .read()
2320 .await
2321 .docs
2322 .get(&uri)
2323 .map(|d| d.text.clone());
2324 let (Some(text), Some(analysis)) = (text, analysis) else {
2325 return Ok(None);
2326 };
2327 let mut links: Vec<DocumentLink> = crate::symbols::unit_reference_spans(&text)
2328 .into_iter()
2329 .filter_map(|(unit, span)| {
2330 let rel = analysis.unit_sources.get(&unit)?.first()?;
2331 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
2332 Some(DocumentLink {
2333 range: crate::position::span_to_range(&text, span),
2334 target: Some(target),
2335 tooltip: Some(format!("Open unit `{unit}`")),
2336 data: None,
2337 })
2338 })
2339 .collect();
2340 if let Some((owner_unit, _)) = crate::symbols::own_declaration_name(&text) {
2344 for (name, span) in crate::symbols::doc_link_spans(&text) {
2345 let Some(def) = crate::index_queries::resolve_doc_link(
2346 &analysis.index,
2347 &analysis.doc_scope,
2348 &owner_unit,
2349 &name,
2350 ) else {
2351 continue;
2352 };
2353 let Ok(target) = Url::from_file_path(analysis.project_root.join(&def.path)) else {
2354 continue;
2355 };
2356 links.push(DocumentLink {
2357 range: crate::position::span_to_range(&text, span),
2358 target: Some(target),
2359 tooltip: Some(format!("Go to `{name}`")),
2360 data: None,
2361 });
2362 }
2363 }
2364 Ok((!links.is_empty()).then_some(links))
2365 }
2366
2367 async fn completion(
2368 &self,
2369 params: CompletionParams,
2370 ) -> JsonRpcResult<Option<CompletionResponse>> {
2371 let uri = params.text_document_position.text_document.uri;
2372 let pos = params.text_document_position.position;
2373 let text = {
2374 let s = self.state.read().await;
2375 s.docs.get(&uri).map(|d| d.text.clone())
2376 };
2377 let Some(text) = text else { return Ok(None) };
2378 let offset = cursor_offset(&text, pos);
2379 let line_prefix = text[..offset].rsplit('\n').next().unwrap_or("").to_string();
2383 let files = self.project_files(&uri).await;
2384 let candidates = {
2389 let line_prefix = line_prefix.clone();
2390 let text = text.clone();
2391 match tokio::task::spawn_blocking(move || {
2392 completion::complete(&line_prefix, &text, files.as_deref())
2393 })
2394 .await
2395 {
2396 Ok(c) => c,
2397 Err(e) => {
2402 tracing::error!("completion enumeration task failed: {e}");
2403 Vec::new()
2404 }
2405 }
2406 };
2407 let mut items: Vec<CompletionItem> =
2408 candidates.into_iter().map(to_completion_item).collect();
2409 if completion::is_keyword_position(&line_prefix)
2414 || completion::is_expression_position(&line_prefix)
2415 {
2416 items.extend(self.locals_completions(&uri, pos).await);
2417 }
2418 items.extend(contract_param_completions(&text, offset, &line_prefix));
2423 items.extend(cors_completions(&text, offset, &line_prefix));
2427 items.extend(security_completions(&text, offset, &line_prefix));
2430 items.extend(cache_completions(&text, offset, &line_prefix));
2434 items.extend(limits_completions(&text, offset, &line_prefix));
2437 items.extend(limit_completions(&text, offset, &line_prefix));
2440 let mut pattern_items = self.nested_pattern_completions(&uri, &text, offset).await;
2452 pattern_items.extend(self.match_arm_completions(&uri, &text, offset).await);
2453 if !pattern_items.is_empty() {
2454 let mut merged = pattern_items;
2455 merged.extend(items);
2456 stamp_resolve_data(&mut merged, &uri);
2457 return Ok(Some(CompletionResponse::Array(merged)));
2458 }
2459 if items.is_empty() {
2460 let is_items = self.is_pattern_completions(&uri, &text, offset).await;
2463 if !is_items.is_empty() {
2464 return Ok(Some(CompletionResponse::Array(is_items)));
2465 }
2466 let value_items = self.value_member_completions(&uri, &text, offset).await;
2470 return Ok((!value_items.is_empty()).then_some(CompletionResponse::Array(value_items)));
2471 }
2472 stamp_resolve_data(&mut items, &uri);
2474 Ok(Some(CompletionResponse::Array(items)))
2475 }
2476
2477 async fn completion_resolve(&self, mut item: CompletionItem) -> JsonRpcResult<CompletionItem> {
2484 if item.documentation.is_some() {
2485 return Ok(item);
2486 }
2487 let Some(uri) = item
2488 .data
2489 .as_ref()
2490 .and_then(|d| d.get("uri"))
2491 .and_then(serde_json::Value::as_str)
2492 .and_then(|s| Url::parse(s).ok())
2493 else {
2494 return Ok(item);
2495 };
2496 let local = {
2497 let s = self.state.read().await;
2498 s.docs.get(&uri).map(|d| d.text.clone())
2499 };
2500 let doc = match local
2501 .as_deref()
2502 .and_then(|t| crate::symbols::describe_symbol(t, &item.label))
2503 {
2504 Some(md) => Some(md),
2505 None => {
2511 let files = self.project_files(&uri).await;
2512 let uri = uri.clone();
2513 let label = item.label.clone();
2514 match tokio::task::spawn_blocking(move || {
2515 files
2516 .and_then(|files| {
2517 crate::symbols::describe_symbol_cross_file(&files, &uri, &label)
2518 })
2519 .map(|(_uri, md)| md)
2520 .or_else(|| crate::symbols::describe_firstparty_symbol(&label))
2524 })
2525 .await
2526 {
2527 Ok(md) => md,
2528 Err(e) => {
2529 tracing::error!("completion-resolve describe task failed: {e}");
2530 None
2531 }
2532 }
2533 }
2534 };
2535 if let Some(md) = doc {
2536 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
2537 kind: MarkupKind::Markdown,
2538 value: md,
2539 }));
2540 }
2541 Ok(item)
2542 }
2543
2544 async fn goto_definition(
2545 &self,
2546 params: GotoDefinitionParams,
2547 ) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
2548 let uri = params
2549 .text_document_position_params
2550 .text_document
2551 .uri
2552 .clone();
2553 let pos = params.text_document_position_params.position;
2554 if let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await {
2559 if let Some((_, def)) =
2560 crate::index_queries::definition_at(&analysis.index, &rel, offset)
2561 && let Some(location) = Self::site_to_location(&analysis, def)
2562 {
2563 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2564 }
2565 if let Some(text) = analysis.snapshots.get(&rel)
2568 && let Some(locals) = analysis.locals.get(&rel)
2569 && let Some(def) = crate::locals_nav::local_definition_at(locals, text, offset)
2570 && let Some(location) = self
2571 .local_locations(&analysis, &rel, &[def])
2572 .into_iter()
2573 .next()
2574 {
2575 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2576 }
2577 }
2578 if let Some(location) = self.unit_reference_definition(&uri, pos).await {
2583 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2584 }
2585 let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
2586 return Ok(None);
2587 };
2588 if let Some(decl_span) = crate::symbols::find_declaration_span(&text, &name) {
2589 let range = crate::position::span_to_range(&text, decl_span);
2590 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2591 uri,
2592 range,
2593 })));
2594 }
2595 if let Some(files) = self.project_files(&uri).await
2597 && let Some(found) = crate::symbols::find_declaration_cross_file(&files, &uri, &name)
2598 {
2599 let range = crate::position::span_to_range(&found.source, found.span);
2600 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2601 uri: found.uri,
2602 range,
2603 })));
2604 }
2605 Ok(None)
2606 }
2607
2608 async fn formatting(
2609 &self,
2610 params: DocumentFormattingParams,
2611 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2612 let uri = params.text_document.uri;
2613 let text = {
2614 let s = self.state.read().await;
2615 s.docs.get(&uri).map(|d| d.text.clone())
2616 };
2617 let Some(text) = text else { return Ok(None) };
2618 let opts = self.config_for(&uri).await.format_options();
2621 match bynk_fmt::format_source(&text, &opts) {
2622 Ok(formatted) => {
2623 if formatted == text {
2624 Ok(Some(Vec::new()))
2625 } else {
2626 let end_pos = crate::position::end_position(&text);
2628 Ok(Some(vec![TextEdit {
2629 range: Range {
2630 start: Position::new(0, 0),
2631 end: end_pos,
2632 },
2633 new_text: formatted,
2634 }]))
2635 }
2636 }
2637 Err(_) => {
2638 Ok(Some(Vec::new()))
2641 }
2642 }
2643 }
2644
2645 async fn range_formatting(
2646 &self,
2647 params: DocumentRangeFormattingParams,
2648 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2649 self.formatting(DocumentFormattingParams {
2652 text_document: params.text_document,
2653 options: params.options,
2654 work_done_progress_params: params.work_done_progress_params,
2655 })
2656 .await
2657 }
2658
2659 async fn document_symbol(
2660 &self,
2661 params: DocumentSymbolParams,
2662 ) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
2663 let uri = params.text_document.uri;
2665 let text = {
2666 let s = self.state.read().await;
2667 s.docs.get(&uri).map(|d| d.text.clone())
2668 };
2669 let Some(text) = text else { return Ok(None) };
2670 let syms = crate::document_symbols::outline(&text);
2671 if syms.is_empty() {
2672 return Ok(None);
2673 }
2674 Ok(Some(DocumentSymbolResponse::Nested(syms)))
2675 }
2676
2677 async fn folding_range(
2680 &self,
2681 params: FoldingRangeParams,
2682 ) -> JsonRpcResult<Option<Vec<FoldingRange>>> {
2683 let uri = params.text_document.uri;
2684 let text = {
2685 let s = self.state.read().await;
2686 s.docs.get(&uri).map(|d| d.text.clone())
2687 };
2688 let Some(text) = text else { return Ok(None) };
2689 Ok(Some(crate::structure::folding_ranges(&text)))
2690 }
2691
2692 async fn selection_range(
2695 &self,
2696 params: SelectionRangeParams,
2697 ) -> JsonRpcResult<Option<Vec<SelectionRange>>> {
2698 let uri = params.text_document.uri;
2699 let text = {
2700 let s = self.state.read().await;
2701 s.docs.get(&uri).map(|d| d.text.clone())
2702 };
2703 let Some(text) = text else { return Ok(None) };
2704 Ok(Some(crate::structure::selection_ranges(
2705 &text,
2706 ¶ms.positions,
2707 )))
2708 }
2709
2710 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
2711 let uri = params.text_document_position.text_document.uri;
2712 let pos = params.text_document_position.position;
2713 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2714 return Ok(None);
2715 };
2716 let include_decl = params.context.include_declaration;
2717 if let Some(sites) =
2718 crate::index_queries::sites_for(&analysis.index, &rel, offset, include_decl)
2719 {
2720 let locations: Vec<Location> = sites
2721 .into_iter()
2722 .filter_map(|site| Self::site_to_location(&analysis, site))
2723 .collect();
2724 return Ok(Some(locations));
2725 }
2726 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
2728 let spans = if include_decl {
2729 &spans[..]
2730 } else {
2731 &spans[1..]
2732 }; let locations = self.local_locations(&analysis, &rel, spans);
2734 return Ok(Some(locations));
2735 }
2736 Ok(None)
2737 }
2738
2739 async fn code_action(
2751 &self,
2752 params: CodeActionParams,
2753 ) -> JsonRpcResult<Option<CodeActionResponse>> {
2754 let uri = params.text_document.uri;
2755 let analysis = self.committed_analysis(&uri).await;
2759 let Some(analysis) = analysis else {
2760 return Ok(Some(Vec::new()));
2761 };
2762 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2763 return Ok(Some(Vec::new()));
2764 };
2765 let (Some(text), Some(diags)) =
2766 (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
2767 else {
2768 return Ok(Some(Vec::new()));
2769 };
2770 let (Some(start), Some(end)) = (
2773 crate::position::position_to_offset(text, params.range.start),
2774 crate::position::position_to_offset(text, params.range.end),
2775 ) else {
2776 return Ok(Some(Vec::new()));
2777 };
2778 let version = analysis.versions.get(&rel).copied();
2779 let span = bynk_syntax::span::Span::new(start, end);
2780 let mut actions = crate::code_actions::quick_fixes(text, diags, span, &uri, version);
2781 actions.extend(crate::capability_fixes::header_quick_fixes(
2784 text,
2785 diags,
2786 span,
2787 &uri,
2788 version,
2789 &analysis.index,
2790 ));
2791 actions.extend(crate::extract::extract_variable(text, span, &uri, version));
2792 let empty_reqs = Vec::new();
2793 let empty_locals = Vec::new();
2794 let empty_types = Vec::new();
2795 actions.extend(crate::extract::extract_function(
2796 text,
2797 span,
2798 &uri,
2799 version,
2800 analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
2801 analysis.locals.get(&rel).unwrap_or(&empty_locals),
2802 analysis.expr_types.get(&rel).unwrap_or(&empty_types),
2803 ));
2804 let actions = crate::code_actions::filter_by_only(actions, params.context.only.as_deref());
2806 Ok(Some(actions))
2807 }
2808
2809 async fn inlay_hint(&self, params: InlayHintParams) -> JsonRpcResult<Option<Vec<InlayHint>>> {
2814 let uri = params.text_document.uri;
2815 let analysis = self.committed_analysis(&uri).await;
2817 let Some(analysis) = analysis else {
2818 return Ok(Some(Vec::new()));
2819 };
2820 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2821 return Ok(Some(Vec::new()));
2822 };
2823 let Some(text) = analysis.snapshots.get(&rel) else {
2824 return Ok(Some(Vec::new()));
2825 };
2826 let (Some(start), Some(end)) = (
2829 crate::position::position_to_offset(text, params.range.start),
2830 crate::position::position_to_offset(text, params.range.end),
2831 ) else {
2832 return Ok(Some(Vec::new()));
2833 };
2834 let visible = bynk_syntax::span::Span::new(start, end);
2835 let mut hints = analysis
2839 .hints
2840 .get(&rel)
2841 .map(|h| crate::inlay_hints::inlay_hints(text, h, visible))
2842 .unwrap_or_default();
2843 if let Some(reqs) = analysis.requirements.get(&rel) {
2844 hints.extend(crate::inlay_hints::given_hints(text, reqs, visible));
2845 }
2846 Ok(Some(hints))
2847 }
2848
2849 async fn semantic_tokens_full(
2853 &self,
2854 params: SemanticTokensParams,
2855 ) -> JsonRpcResult<Option<SemanticTokensResult>> {
2856 let data = self
2857 .semantic_tokens_for(¶ms.text_document.uri, None)
2858 .await;
2859 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
2860 result_id: None,
2861 data,
2862 })))
2863 }
2864
2865 async fn semantic_tokens_range(
2868 &self,
2869 params: SemanticTokensRangeParams,
2870 ) -> JsonRpcResult<Option<SemanticTokensRangeResult>> {
2871 let data = self
2872 .semantic_tokens_for(¶ms.text_document.uri, Some(params.range))
2873 .await;
2874 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
2875 result_id: None,
2876 data,
2877 })))
2878 }
2879
2880 async fn symbol(
2890 &self,
2891 params: WorkspaceSymbolParams,
2892 ) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
2893 let candidates: Vec<(PathBuf, ProjectConfig)> = {
2894 let (mut set, folders) = {
2897 let state = self.state.read().await;
2898 let known: std::collections::HashMap<PathBuf, ProjectConfig> = state
2899 .projects
2900 .iter()
2901 .map(|(r, p)| (r.clone(), p.config.clone()))
2902 .collect();
2903 (known, state.folders.clone())
2904 };
2905 for folder in &folders {
2906 if let Some((root, config)) = Self::resolve_root(folder) {
2907 let root = root.canonicalize().unwrap_or(root);
2908 set.entry(root).or_insert(config);
2909 }
2910 }
2911 set.into_iter().collect()
2912 };
2913 let mut symbols: Vec<SymbolInformation> = Vec::new();
2914 for (root, config) in candidates {
2915 let Some(analysis) = self.ensure_project_analysed(root, config).await else {
2916 continue;
2917 };
2918 for (key, def) in
2919 crate::index_queries::workspace_symbols(&analysis.index, ¶ms.query)
2920 {
2921 let Some(location) = Self::site_to_location(&analysis, def) else {
2922 continue;
2923 };
2924 #[allow(deprecated)]
2925 symbols.push(SymbolInformation {
2926 name: key.name.clone(),
2927 kind: lsp_symbol_kind(key.kind),
2928 tags: None,
2929 deprecated: None,
2930 location,
2931 container_name: Some(key.unit.clone()),
2932 });
2933 }
2934 }
2935 symbols.sort_by(|a, b| {
2940 a.name
2941 .cmp(&b.name)
2942 .then_with(|| a.container_name.cmp(&b.container_name))
2943 });
2944 Ok(Some(symbols))
2945 }
2946
2947 async fn document_highlight(
2951 &self,
2952 params: DocumentHighlightParams,
2953 ) -> JsonRpcResult<Option<Vec<DocumentHighlight>>> {
2954 let uri = params.text_document_position_params.text_document.uri;
2955 let pos = params.text_document_position_params.position;
2956 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2957 return Ok(None);
2958 };
2959 let Some(text) = analysis.snapshots.get(&rel) else {
2960 return Ok(None);
2961 };
2962 if let Some(sites) =
2963 crate::index_queries::document_highlights(&analysis.index, &rel, offset)
2964 {
2965 let highlights: Vec<DocumentHighlight> = sites
2966 .into_iter()
2967 .map(|s| DocumentHighlight {
2968 range: crate::position::span_to_range(text, s.span),
2969 kind: None,
2970 })
2971 .collect();
2972 return Ok(Some(highlights));
2973 }
2974 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
2976 let highlights = spans
2977 .iter()
2978 .map(|s| DocumentHighlight {
2979 range: crate::position::span_to_range(text, *s),
2980 kind: None,
2981 })
2982 .collect();
2983 return Ok(Some(highlights));
2984 }
2985 Ok(None)
2986 }
2987
2988 async fn prepare_rename(
2989 &self,
2990 params: TextDocumentPositionParams,
2991 ) -> JsonRpcResult<Option<PrepareRenameResponse>> {
2992 let uri = params.text_document.uri;
2993 let pos = params.position;
2994 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2998 return Ok(None);
2999 };
3000 let Some((key, site)) = crate::index_queries::prepare_rename(&analysis.index, &rel, offset)
3001 else {
3002 return Ok(None);
3003 };
3004 let Some(text) = analysis.snapshots.get(&rel) else {
3005 return Ok(None);
3006 };
3007 Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
3008 range: crate::position::span_to_range(text, site.span),
3009 placeholder: key.name.clone(),
3010 }))
3011 }
3012
3013 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
3014 let uri = params.text_document_position.text_document.uri;
3015 let pos = params.text_document_position.position;
3016 let new_name = params.new_name;
3017 let refused = |msg: String| tower_lsp::jsonrpc::Error {
3018 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3019 message: msg.into(),
3020 data: None,
3021 };
3022 let Some(root) = self.root_for_uri(&uri).await else {
3033 return Err(refused("rename requires a project (bynk.toml)".into()));
3034 };
3035 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3036 return Err(refused("rename requires a project (bynk.toml)".into()));
3037 };
3038 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3039 return Ok(None);
3040 };
3041 let Some(text) = analysis.snapshots.get(&rel) else {
3042 return Ok(None);
3043 };
3044 let Some(offset) = crate::position::position_to_offset(text, pos) else {
3045 return Ok(None);
3046 };
3047 let plan = crate::index_queries::plan_rename(&analysis.index, &rel, offset, &new_name)
3048 .map_err(refused)?;
3049
3050 let mut overlay = std::collections::HashMap::new();
3064 for (rel_path, text) in &analysis.snapshots {
3065 let edited = match plan.edits.get(rel_path) {
3066 Some(spans) => crate::index_queries::apply_edits(text, spans, &plan.new_name),
3067 None => text.clone(),
3068 };
3069 let abs = analysis.project_root.join(rel_path);
3070 let abs = abs.canonicalize().unwrap_or(abs);
3071 overlay.insert(abs, edited);
3072 }
3073 let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
3074 let Ok(post) =
3075 tokio::task::spawn_blocking(move || bynk_ide::diagnose_project_with(&roots, &overlay))
3076 .await
3077 else {
3078 return Err(refused("rename validation failed to run".into()));
3079 };
3080
3081 let post_diags: Vec<(PathBuf, String)> = post
3083 .files
3084 .iter()
3085 .flat_map(|f| {
3086 f.diagnostics
3087 .iter()
3088 .map(|d| (f.source_path.clone(), d.error.category.to_string()))
3089 })
3090 .collect();
3091 crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags)
3092 .map_err(refused)?;
3093
3094 if !crate::index_queries::index_unchanged_modulo_rename(&analysis.index, &post.index, &plan)
3097 {
3098 return Err(refused(format!(
3099 "renaming `{}` to `{new_name}` would silently re-bind another name — refused",
3100 plan.key.name
3101 )));
3102 }
3103
3104 let mut document_edits: Vec<TextDocumentEdit> = Vec::new();
3107 for (rel_path, spans) in &plan.edits {
3108 let Some(text) = analysis.snapshots.get(rel_path) else {
3109 continue;
3110 };
3111 let abs = analysis.project_root.join(rel_path);
3112 let Ok(file_uri) = Url::from_file_path(&abs) else {
3113 continue;
3114 };
3115 let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = spans
3116 .iter()
3117 .map(|span| {
3118 OneOf::Left(TextEdit {
3119 range: crate::position::span_to_range(text, *span),
3120 new_text: plan.new_name.clone(),
3121 })
3122 })
3123 .collect();
3124 document_edits.push(TextDocumentEdit {
3125 text_document: OptionalVersionedTextDocumentIdentifier {
3126 uri: file_uri,
3127 version: analysis.versions.get(rel_path).copied(),
3128 },
3129 edits,
3130 });
3131 }
3132 Ok(Some(WorkspaceEdit {
3133 changes: None,
3134 document_changes: Some(DocumentChanges::Edits(document_edits)),
3135 change_annotations: None,
3136 }))
3137 }
3138
3139 async fn will_rename_files(
3165 &self,
3166 params: RenameFilesParams,
3167 ) -> JsonRpcResult<Option<WorkspaceEdit>> {
3168 let mut combined: std::collections::HashMap<Url, (Option<i32>, Vec<TextEdit>)> =
3169 std::collections::HashMap::new();
3170 for fr in ¶ms.files {
3171 let (Ok(old_uri), Ok(new_uri)) = (Url::parse(&fr.old_uri), Url::parse(&fr.new_uri))
3172 else {
3173 continue;
3174 };
3175 let Some(root) = self.root_for_uri(&old_uri).await else {
3176 continue;
3177 };
3178 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3179 continue;
3180 };
3181 let Some(old_rel) = Self::uri_to_rel(&analysis, &old_uri) else {
3182 continue;
3183 };
3184 let Some(new_rel) = Self::uri_to_rel_for_new_path(&analysis, &new_uri) else {
3193 continue;
3194 };
3195 let Some(text) = analysis.snapshots.get(&old_rel) else {
3196 continue;
3197 };
3198 let Some((old_name, name_span)) = crate::symbols::own_declaration_name(text) else {
3199 continue;
3200 };
3201 let Some(new_name) = bynk_ide::renamed_unit_name(&old_rel, &old_name, &new_rel) else {
3202 continue;
3203 };
3204 if new_name == old_name {
3205 continue;
3206 }
3207 if analysis.unit_sources.contains_key(&new_name) {
3210 continue;
3211 }
3212 let Ok(old_file_uri) = Url::from_file_path(analysis.project_root.join(&old_rel)) else {
3219 continue;
3220 };
3221 combined
3224 .entry(old_file_uri)
3225 .or_insert_with(|| (analysis.versions.get(&old_rel).copied(), Vec::new()))
3226 .1
3227 .push(TextEdit {
3228 range: crate::position::span_to_range(text, name_span),
3229 new_text: new_name.clone(),
3230 });
3231 for (rel, snap_text) in &analysis.snapshots {
3233 if *rel == old_rel {
3234 continue;
3235 }
3236 let edits: Vec<TextEdit> = crate::symbols::unit_reference_spans(snap_text)
3237 .into_iter()
3238 .filter(|(unit, _)| *unit == old_name)
3239 .map(|(_, span)| TextEdit {
3240 range: crate::position::span_to_range(snap_text, span),
3241 new_text: new_name.clone(),
3242 })
3243 .collect();
3244 if edits.is_empty() {
3245 continue;
3246 }
3247 let Ok(file_uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
3248 continue;
3249 };
3250 combined
3251 .entry(file_uri)
3252 .or_insert_with(|| (analysis.versions.get(rel).copied(), Vec::new()))
3253 .1
3254 .extend(edits);
3255 }
3256 }
3257 if combined.is_empty() {
3258 return Ok(None);
3259 }
3260 let document_edits: Vec<TextDocumentEdit> = combined
3261 .into_iter()
3262 .map(|(uri, (version, edits))| TextDocumentEdit {
3263 text_document: OptionalVersionedTextDocumentIdentifier { uri, version },
3264 edits: edits.into_iter().map(OneOf::Left).collect(),
3265 })
3266 .collect();
3267 Ok(Some(WorkspaceEdit {
3268 changes: None,
3269 document_changes: Some(DocumentChanges::Edits(document_edits)),
3270 change_annotations: None,
3271 }))
3272 }
3273
3274 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
3275 if params.changes.iter().any(|ev| is_bynk_toml(&ev.uri)) {
3282 let mut state = self.state.write().await;
3283 state.root_cache.clear();
3284 state.root_cache_generation += 1;
3285 }
3286 let mut uris_to_refresh = Vec::new();
3291 let mut roots_to_reanalyse: std::collections::HashSet<PathBuf> =
3294 std::collections::HashSet::new();
3295 let mut config_changed_roots: std::collections::HashSet<PathBuf> =
3301 std::collections::HashSet::new();
3302 let open_docs: std::collections::HashSet<Url> =
3306 self.state.read().await.docs.keys().cloned().collect();
3307 for ev in ¶ms.changes {
3308 if is_bynk_toml(&ev.uri) {
3309 if let Ok(p) = ev.uri.to_file_path()
3311 && let Some(dir) = p.parent()
3312 {
3313 let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
3314 config_changed_roots.insert(root.clone());
3315 roots_to_reanalyse.insert(root);
3316 }
3317 } else if open_docs.contains(&ev.uri) {
3318 uris_to_refresh.push(ev.uri.clone());
3319 } else if ev.uri.path().ends_with(".bynk")
3320 && let Some(root) = self.root_for_uri(&ev.uri).await
3321 {
3322 roots_to_reanalyse.insert(root);
3323 }
3324 }
3325 for root in &config_changed_roots {
3330 let config = project::load_config(root).unwrap_or_default();
3331 let mut state = self.state.write().await;
3332 state
3333 .projects
3334 .entry(root.clone())
3335 .and_modify(|ps| ps.config = config.clone())
3336 .or_insert_with(|| ProjectState {
3337 config,
3338 ..Default::default()
3339 });
3340 }
3341 for uri in uris_to_refresh {
3342 self.schedule_diagnostics(&uri).await;
3343 }
3344 for root in roots_to_reanalyse {
3349 self.schedule_project_diagnostics(root).await;
3350 }
3351 }
3352
3353 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
3354 let added_dirs: Vec<PathBuf> = {
3359 let mut state = self.state.write().await;
3360 let mut added = Vec::new();
3361 for a in ¶ms.event.added {
3362 if let Ok(p) = a.uri.to_file_path() {
3363 let dir = p.canonicalize().unwrap_or(p);
3364 if !state.folders.contains(&dir) {
3365 state.folders.push(dir.clone());
3366 added.push(dir);
3367 }
3368 }
3369 }
3370 for removed in ¶ms.event.removed {
3371 if let Ok(p) = removed.uri.to_file_path() {
3372 let dir = p.canonicalize().unwrap_or(p);
3373 state.folders.retain(|f| f != &dir);
3374 }
3375 }
3376 state.root_cache.clear();
3383 state.root_cache_generation += 1;
3384 added
3385 };
3386 self.warm_projects(&added_dirs).await;
3389 for uri in self.prune_orphaned_projects().await {
3392 self.client.publish_diagnostics(uri, Vec::new(), None).await;
3393 }
3394 }
3395}
3396
3397fn server_capabilities() -> ServerCapabilities {
3400 ServerCapabilities {
3401 text_document_sync: Some(TextDocumentSyncCapability::Options(
3404 TextDocumentSyncOptions {
3405 open_close: Some(true),
3406 change: Some(TextDocumentSyncKind::FULL),
3407 save: Some(TextDocumentSyncSaveOptions::Supported(true)),
3408 ..Default::default()
3409 },
3410 )),
3411 hover_provider: Some(HoverProviderCapability::Simple(true)),
3412 definition_provider: Some(OneOf::Left(true)),
3413 completion_provider: Some(CompletionOptions {
3418 trigger_characters: Some(vec![
3419 " ".to_string(),
3420 "{".to_string(),
3421 ",".to_string(),
3422 ".".to_string(),
3423 ]),
3424 resolve_provider: Some(true),
3427 ..Default::default()
3428 }),
3429 signature_help_provider: Some(SignatureHelpOptions {
3431 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
3432 retrigger_characters: Some(vec![",".to_string()]),
3433 ..Default::default()
3434 }),
3435 code_lens_provider: Some(CodeLensOptions {
3437 resolve_provider: Some(false),
3438 }),
3439 call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
3441 implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
3443 type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
3445 document_link_provider: Some(DocumentLinkOptions {
3447 resolve_provider: Some(false),
3448 work_done_progress_options: Default::default(),
3449 }),
3450 document_formatting_provider: Some(OneOf::Left(true)),
3451 document_range_formatting_provider: Some(OneOf::Left(true)),
3452 document_symbol_provider: Some(OneOf::Left(true)),
3453 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
3455 selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
3456 references_provider: Some(OneOf::Left(true)),
3459 rename_provider: Some(OneOf::Right(RenameOptions {
3460 prepare_provider: Some(true),
3461 work_done_progress_options: Default::default(),
3462 })),
3463 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
3466 code_action_kinds: Some(vec![
3467 CodeActionKind::QUICKFIX,
3468 CodeActionKind::REFACTOR,
3469 CodeActionKind::REFACTOR_EXTRACT,
3470 ]),
3471 ..Default::default()
3472 })),
3473 inlay_hint_provider: Some(OneOf::Left(true)),
3476 semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions(
3480 SemanticTokensOptions {
3481 legend: crate::index_queries::semantic_tokens_legend(),
3482 full: Some(SemanticTokensFullOptions::Bool(true)),
3483 range: Some(true),
3484 ..Default::default()
3485 },
3486 )),
3487 workspace_symbol_provider: Some(OneOf::Left(true)),
3489 document_highlight_provider: Some(OneOf::Left(true)),
3490 workspace: Some(WorkspaceServerCapabilities {
3491 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
3492 supported: Some(true),
3493 change_notifications: Some(OneOf::Left(true)),
3494 }),
3495 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
3499 will_rename: Some(FileOperationRegistrationOptions {
3500 filters: vec![FileOperationFilter {
3501 scheme: Some("file".to_string()),
3502 pattern: FileOperationPattern {
3503 glob: "**/*.bynk".to_string(),
3504 matches: Some(FileOperationPatternKind::File),
3505 options: None,
3506 },
3507 }],
3508 }),
3509 ..Default::default()
3510 }),
3511 }),
3512 experimental: Some(serde_json::json!({
3517 "sequenceModel": true,
3518 "documentationModel": true,
3519 "architectureModel": true,
3520 })),
3521 ..Default::default()
3522 }
3523}
3524
3525fn stamp_resolve_data(items: &mut [CompletionItem], uri: &Url) {
3533 let data = serde_json::json!({ "uri": uri.to_string() });
3534 for item in items.iter_mut() {
3535 item.data = Some(data.clone());
3536 }
3537}
3538
3539fn cors_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3548 if completion::in_cors_field_position(text, offset) {
3549 return completion::CORS_FIELDS
3550 .iter()
3551 .map(|(name, doc)| CompletionItem {
3552 label: name.to_string(),
3553 kind: Some(CompletionItemKind::FIELD),
3554 detail: Some((*doc).to_string()),
3555 insert_text: Some(format!("{name}: ")),
3556 ..Default::default()
3557 })
3558 .collect();
3559 }
3560 if completion::in_service_body_item_position(text, offset, line) {
3561 return vec![CompletionItem {
3562 label: "cors".to_string(),
3563 kind: Some(CompletionItemKind::KEYWORD),
3564 detail: Some("a cross-origin (CORS) policy for this HTTP service".to_string()),
3565 insert_text: Some("cors {\n\torigins: [$0],\n}".to_string()),
3566 insert_text_format: Some(InsertTextFormat::SNIPPET),
3567 ..Default::default()
3568 }];
3569 }
3570 Vec::new()
3571}
3572
3573fn security_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3578 if completion::in_security_field_position(text, offset) {
3579 return completion::SECURITY_FIELDS
3580 .iter()
3581 .map(|(name, doc)| CompletionItem {
3582 label: name.to_string(),
3583 kind: Some(CompletionItemKind::FIELD),
3584 detail: Some((*doc).to_string()),
3585 insert_text: Some(format!("{name}: ")),
3586 ..Default::default()
3587 })
3588 .collect();
3589 }
3590 if completion::in_service_body_item_position(text, offset, line) {
3591 return vec![CompletionItem {
3592 label: "security".to_string(),
3593 kind: Some(CompletionItemKind::KEYWORD),
3594 detail: Some("security response headers for this HTTP service".to_string()),
3595 insert_text: Some("security {\n\tnosniff: $0,\n}".to_string()),
3596 insert_text_format: Some(InsertTextFormat::SNIPPET),
3597 ..Default::default()
3598 }];
3599 }
3600 Vec::new()
3601}
3602
3603fn cache_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3608 if completion::in_cache_arg_position(text, offset) {
3609 return completion::CACHE_ARGS
3610 .iter()
3611 .map(|(name, doc)| CompletionItem {
3612 label: name.to_string(),
3613 kind: Some(CompletionItemKind::FIELD),
3614 detail: Some((*doc).to_string()),
3615 insert_text: Some(format!("{name}: ")),
3616 ..Default::default()
3617 })
3618 .collect();
3619 }
3620 if completion::in_service_body_item_position(text, offset, line) {
3621 return vec![CompletionItem {
3622 label: "@cache".to_string(),
3623 kind: Some(CompletionItemKind::SNIPPET),
3624 detail: Some(
3625 "cache a GET read — a synthesised ETag/304 revalidation with a freshness window"
3626 .to_string(),
3627 ),
3628 insert_text: Some("@cache(maxAge: ${1:5.minutes})".to_string()),
3629 insert_text_format: Some(InsertTextFormat::SNIPPET),
3630 ..Default::default()
3631 }];
3632 }
3633 Vec::new()
3634}
3635
3636fn limits_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3641 if completion::in_limits_field_position(text, offset) {
3642 return completion::LIMITS_FIELDS
3643 .iter()
3644 .map(|(name, doc)| CompletionItem {
3645 label: name.to_string(),
3646 kind: Some(CompletionItemKind::FIELD),
3647 detail: Some((*doc).to_string()),
3648 insert_text: Some(format!("{name}: ")),
3649 ..Default::default()
3650 })
3651 .collect();
3652 }
3653 if completion::in_service_body_item_position(text, offset, line) {
3654 return vec![CompletionItem {
3655 label: "limits".to_string(),
3656 kind: Some(CompletionItemKind::KEYWORD),
3657 detail: Some("request limits for this HTTP service".to_string()),
3658 insert_text: Some("limits {\n\tmaxBody: $0,\n}".to_string()),
3659 insert_text_format: Some(InsertTextFormat::SNIPPET),
3660 ..Default::default()
3661 }];
3662 }
3663 Vec::new()
3664}
3665
3666fn limit_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3671 if completion::in_limit_arg_position(text, offset) {
3672 return completion::LIMIT_ARGS
3673 .iter()
3674 .map(|(name, doc)| CompletionItem {
3675 label: name.to_string(),
3676 kind: Some(CompletionItemKind::FIELD),
3677 detail: Some((*doc).to_string()),
3678 insert_text: Some(format!("{name}: ")),
3679 ..Default::default()
3680 })
3681 .collect();
3682 }
3683 if completion::in_service_body_item_position(text, offset, line) {
3684 return vec![CompletionItem {
3685 label: "@limit".to_string(),
3686 kind: Some(CompletionItemKind::SNIPPET),
3687 detail: Some(
3688 "cap the request body size — a `413` synthesised before the body is read"
3689 .to_string(),
3690 ),
3691 insert_text: Some("@limit(maxBody: ${1:1048576})".to_string()),
3692 insert_text_format: Some(InsertTextFormat::SNIPPET),
3693 ..Default::default()
3694 }];
3695 }
3696 Vec::new()
3697}
3698
3699fn contract_param_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3700 use bynk_syntax::ast::{CommonsItem, SourceUnit};
3701 let Some(is_ensures) = completion::contract_clause_kind(line) else {
3702 return Vec::new();
3703 };
3704 let Ok(tokens) = bynk_syntax::lexer::tokenize(text) else {
3705 return Vec::new();
3706 };
3707 let (Some(unit), _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text) else {
3708 return Vec::new();
3709 };
3710 let items = match &unit {
3711 SourceUnit::Commons(c) => &c.items,
3712 SourceUnit::Context(c) => &c.items,
3713 SourceUnit::Adapter(a) => &a.items,
3714 _ => return Vec::new(),
3715 };
3716 for item in items {
3717 if let CommonsItem::Fn(f) = item
3720 && f.span.start <= offset
3721 && offset <= f.body.span.start
3722 {
3723 let mut out: Vec<CompletionItem> = f
3726 .params
3727 .iter()
3728 .filter(|p| p.name.name != "_")
3729 .map(|p| CompletionItem {
3730 label: p.name.name.clone(),
3731 kind: Some(CompletionItemKind::VARIABLE),
3732 detail: Some(format!(
3733 "parameter: {}",
3734 crate::symbols::type_ref_str(&p.type_ref)
3735 )),
3736 ..Default::default()
3737 })
3738 .collect();
3739 if is_ensures {
3740 out.push(CompletionItem {
3741 label: "result".to_string(),
3742 kind: Some(CompletionItemKind::VARIABLE),
3743 detail: Some("the function's return value".to_string()),
3744 ..Default::default()
3745 });
3746 }
3747 return out;
3748 }
3749 }
3750 Vec::new()
3751}
3752
3753fn is_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
3758 let before = text.get(..cursor)?;
3759 let before = before
3761 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
3762 .trim_end();
3763 let before = before.strip_suffix("is")?;
3764 if !before.ends_with(char::is_whitespace) {
3765 return None;
3766 }
3767 let before = before.trim_end();
3768 (!before.is_empty()).then(|| before.len() - 1)
3769}
3770
3771fn match_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
3778 let before = text.get(..cursor)?;
3779 let brace = innermost_open_brace(before)?;
3781 let arm_start = arm_start_offset(before, brace);
3784 let arm = before.get(arm_start..)?;
3785 if arm.contains("=>") {
3786 return None;
3787 }
3788 if !arm
3791 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
3792 .trim()
3793 .is_empty()
3794 {
3795 return None;
3796 }
3797 match_head_scrutinee_offset(before, brace)
3798}
3799
3800fn nested_pattern_offset(text: &str, cursor: usize) -> Option<(usize, String)> {
3807 let before = text.get(..cursor)?;
3808 let brace = innermost_open_brace(before)?;
3809 let arm_start = arm_start_offset(before, brace);
3810 let arm = before.get(arm_start..)?;
3811 if arm.contains("=>") {
3812 return None; }
3814 let paren = innermost_open_paren(arm)?;
3816 let head = arm.get(..paren)?.trim_end();
3819 let variant: String = head
3820 .chars()
3821 .rev()
3822 .take_while(|c| c.is_alphanumeric() || *c == '_')
3823 .collect::<Vec<_>>()
3824 .into_iter()
3825 .rev()
3826 .collect();
3827 if !variant.chars().next().is_some_and(char::is_uppercase) {
3828 return None;
3829 }
3830 let after = arm.get(paren + 1..)?;
3833 if !after
3834 .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '.')
3835 .trim()
3836 .is_empty()
3837 {
3838 return None;
3839 }
3840 let scrut_off = match_head_scrutinee_offset(before, brace)?;
3841 Some((scrut_off, variant))
3842}
3843
3844fn match_head_scrutinee_offset(before: &str, brace: usize) -> Option<usize> {
3850 let head = before.get(..brace)?.trim_end();
3851 let m = head.rfind("match")?;
3852 if head[..m]
3853 .chars()
3854 .next_back()
3855 .is_some_and(|c| c.is_alphanumeric() || c == '_')
3856 {
3857 return None; }
3859 let after = head.get(m + "match".len()..)?;
3860 if !after.starts_with(char::is_whitespace) {
3861 return None;
3862 }
3863 let scrut = after.trim();
3864 if scrut.is_empty() || scrut.contains(['{', '}']) || scrut.contains("=>") {
3865 return None;
3866 }
3867 Some(head.len() - 1)
3869}
3870
3871fn innermost_open_paren(arm: &str) -> Option<usize> {
3875 let mut stack: Vec<usize> = Vec::new();
3876 for (i, c) in arm.char_indices() {
3877 match c {
3878 '(' => stack.push(i),
3879 ')' => {
3880 stack.pop();
3881 }
3882 _ => {}
3883 }
3884 }
3885 stack.pop()
3886}
3887
3888fn innermost_open_brace(before: &str) -> Option<usize> {
3891 let mut stack: Vec<usize> = Vec::new();
3892 for (i, c) in before.char_indices() {
3893 match c {
3894 '{' => stack.push(i),
3895 '}' => {
3896 stack.pop();
3897 }
3898 _ => {}
3899 }
3900 }
3901 stack.pop()
3902}
3903
3904fn arm_start_offset(before: &str, brace: usize) -> usize {
3908 let mut depth = 0i32;
3909 let mut start = brace + 1; for (rel, c) in before[brace + 1..].char_indices() {
3911 match c {
3912 '{' | '(' | '[' => depth += 1,
3913 '}' | ')' | ']' => depth -= 1,
3914 ',' if depth == 0 => start = brace + 1 + rel + c.len_utf8(),
3915 _ => {}
3916 }
3917 }
3918 start
3919}
3920
3921fn to_completion_item(c: completion::Completion) -> CompletionItem {
3922 CompletionItem {
3923 kind: Some(match c.kind {
3924 completion::CompletionKind::Unit => CompletionItemKind::MODULE,
3925 completion::CompletionKind::Capability => CompletionItemKind::INTERFACE,
3926 completion::CompletionKind::Type => CompletionItemKind::STRUCT,
3927 completion::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
3928 completion::CompletionKind::Snippet => CompletionItemKind::SNIPPET,
3929 completion::CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
3930 completion::CompletionKind::Member => CompletionItemKind::METHOD,
3931 completion::CompletionKind::Field => CompletionItemKind::FIELD,
3932 completion::CompletionKind::Constructor => CompletionItemKind::CONSTRUCTOR,
3933 completion::CompletionKind::Function => CompletionItemKind::FUNCTION,
3934 }),
3935 insert_text_format: c.insert_text.as_ref().map(|_| InsertTextFormat::SNIPPET),
3938 insert_text: c.insert_text,
3939 label: c.label,
3940 detail: c.detail,
3941 ..Default::default()
3942 }
3943}
3944
3945fn cursor_offset(text: &str, pos: Position) -> usize {
3951 crate::position::position_to_offset(text, pos).unwrap_or(text.len())
3952}
3953
3954#[derive(serde::Serialize, serde::Deserialize)]
3958struct SerKey {
3959 unit: String,
3960 kind: String,
3961 name: String,
3962}
3963
3964impl From<&bynk_check::index::SymbolKey> for SerKey {
3965 fn from(k: &bynk_check::index::SymbolKey) -> Self {
3966 SerKey {
3967 unit: k.unit.clone(),
3968 kind: k.kind.display().to_string(),
3969 name: k.name.clone(),
3970 }
3971 }
3972}
3973
3974impl SerKey {
3975 fn read(data: &Option<serde_json::Value>) -> Option<bynk_check::index::SymbolKey> {
3979 let sk: SerKey = serde_json::from_value(data.as_ref()?.clone()).ok()?;
3980 let kind = match sk.kind.as_str() {
3981 "type" => bynk_check::index::SymbolKind::Type,
3982 "fn" => bynk_check::index::SymbolKind::Fn,
3983 "capability" => bynk_check::index::SymbolKind::Capability,
3984 "service" => bynk_check::index::SymbolKind::Service,
3985 "agent" => bynk_check::index::SymbolKind::Agent,
3986 "provider" => bynk_check::index::SymbolKind::Provider,
3987 _ => return None,
3988 };
3989 Some(bynk_check::index::SymbolKey {
3990 unit: sk.unit,
3991 kind,
3992 name: sk.name,
3993 })
3994 }
3995}
3996
3997fn lsp_symbol_kind(kind: bynk_check::index::SymbolKind) -> SymbolKind {
3998 match kind {
3999 bynk_check::index::SymbolKind::Type => SymbolKind::STRUCT,
4000 bynk_check::index::SymbolKind::Fn => SymbolKind::FUNCTION,
4001 bynk_check::index::SymbolKind::Capability => SymbolKind::INTERFACE,
4002 bynk_check::index::SymbolKind::Service | bynk_check::index::SymbolKind::Agent => {
4003 SymbolKind::CLASS
4004 }
4005 bynk_check::index::SymbolKind::Provider => SymbolKind::OBJECT,
4006 bynk_check::index::SymbolKind::Method => SymbolKind::METHOD,
4007 bynk_check::index::SymbolKind::CapabilityOp => SymbolKind::METHOD,
4008 bynk_check::index::SymbolKind::Field => SymbolKind::FIELD,
4009 bynk_check::index::SymbolKind::Actor => SymbolKind::INTERFACE,
4010 bynk_check::index::SymbolKind::Handler => SymbolKind::METHOD,
4011 bynk_check::index::SymbolKind::Messages => SymbolKind::STRUCT,
4012 }
4013}
4014
4015fn is_bynk_toml(uri: &Url) -> bool {
4019 let Ok(path) = uri.to_file_path() else {
4020 return false;
4021 };
4022 path.file_name().and_then(|n| n.to_str()) == Some("bynk.toml")
4023}
4024
4025fn code_description(code: &str) -> Option<CodeDescription> {
4031 let href = Url::parse(&bynk_syntax::diagnostics::explain(code)?.href()).ok()?;
4032 Some(CodeDescription { href })
4033}
4034
4035#[cfg(test)]
4036mod code_description_tests {
4037 use super::code_description;
4038
4039 #[test]
4040 fn mapped_code_gets_a_valid_book_link() {
4041 let cd = code_description("bynk.resolve.unknown_type")
4042 .expect("a curated code produces a codeDescription");
4043 assert_eq!(cd.href.scheme(), "https");
4044 assert_eq!(cd.href.host_str(), Some("bynk-lang.org"));
4045 assert!(cd.href.path().starts_with("/book/"));
4046 }
4047
4048 #[test]
4049 fn uncurated_code_gets_no_link() {
4050 assert!(code_description("bynk.resolve.duplicate_type").is_none());
4053 assert!(code_description("bynk.not.a_real_code").is_none());
4054 }
4055
4056 #[test]
4057 fn every_curated_explanation_yields_a_parseable_url() {
4058 for e in bynk_syntax::diagnostics::EXPLANATIONS {
4061 assert!(
4062 code_description(e.code).is_some(),
4063 "curated explanation `{}` produced no codeDescription — its href \
4064 `{}` did not parse as a URL",
4065 e.code,
4066 e.href()
4067 );
4068 }
4069 }
4070}
4071
4072fn make_diagnostic(
4073 d: &bynk_ide::Diagnostic,
4074 positions: &crate::position::PositionMap,
4075 uri: &Url,
4076) -> Diagnostic {
4077 let range = positions.range(d.error.span);
4078 let severity = match d.severity {
4079 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
4080 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
4081 };
4082 let related_information: Vec<DiagnosticRelatedInformation> = d
4083 .error
4084 .labels
4085 .iter()
4086 .map(|(span, msg)| DiagnosticRelatedInformation {
4087 location: Location {
4088 uri: uri.clone(),
4092 range: positions.range(*span),
4093 },
4094 message: msg.clone(),
4095 })
4096 .collect();
4097 let mut message = d.error.message.clone();
4098 for note in &d.error.notes {
4099 message.push_str("\n\n");
4100 message.push_str("note: ");
4101 message.push_str(note);
4102 }
4103 Diagnostic {
4104 range,
4105 severity: Some(severity),
4106 code: Some(NumberOrString::String(d.error.category.to_string())),
4107 code_description: code_description(d.error.category),
4112 source: Some(SERVER_NAME.to_string()),
4113 message,
4114 related_information: if related_information.is_empty() {
4115 None
4116 } else {
4117 Some(related_information)
4118 },
4119 tags: None,
4120 data: None,
4121 }
4122}
4123
4124pub async fn run() {
4127 if std::env::args()
4131 .skip(1)
4132 .any(|a| a == "--version" || a == "-V")
4133 {
4134 println!("{SERVER_NAME} {SERVER_VERSION}");
4135 return;
4136 }
4137 if let Some(home) = std::env::var_os("HOME") {
4140 let path: PathBuf = PathBuf::from(home).join(".bynk-lsp.log");
4141 if let Ok(file) = std::fs::OpenOptions::new()
4142 .create(true)
4143 .append(true)
4144 .open(&path)
4145 {
4146 use tracing_subscriber::prelude::*;
4147 let env_filter = tracing_subscriber::EnvFilter::try_from_env("BYNK_LSP_LOG")
4148 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
4149 let file_layer = tracing_subscriber::fmt::layer()
4150 .with_writer(std::sync::Mutex::new(file))
4151 .with_ansi(false);
4152 tracing_subscriber::registry()
4153 .with(env_filter)
4154 .with(file_layer)
4155 .try_init()
4156 .ok();
4157 }
4158 }
4159 tracing::info!("bynkc-lsp v{} starting", SERVER_VERSION);
4160 let stdin = tokio::io::stdin();
4161 let stdout = tokio::io::stdout();
4162 let (service, socket) = LspService::build(Backend::new)
4167 .custom_method("bynk/sequenceModel", Backend::sequence_model)
4168 .custom_method("bynk/documentationModel", Backend::documentation_model)
4169 .custom_method("bynk/architectureModel", Backend::architecture_model)
4170 .finish();
4171 Server::new(stdin, stdout, socket).serve(service).await;
4172}
4173
4174#[cfg(test)]
4175mod tests {
4176 use super::*;
4177
4178 struct Scratch(PathBuf);
4194 impl Drop for Scratch {
4195 fn drop(&mut self) {
4196 let _ = std::fs::remove_dir_all(&self.0);
4197 }
4198 }
4199
4200 fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
4201 let dir = std::env::temp_dir().join(format!(
4202 "bynk_lsp_sliceA_{tag}_{}_{:?}",
4203 std::process::id(),
4204 std::thread::current().id()
4205 ));
4206 let _ = std::fs::remove_dir_all(&dir);
4207 for (rel, body) in files {
4208 let p = dir.join(rel);
4209 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
4210 std::fs::write(&p, body).unwrap();
4211 }
4212 Scratch(dir)
4213 }
4214
4215 async fn backend_at(root: &std::path::Path) -> Backend {
4221 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4222 let backend = service.inner().clone();
4223 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
4226 {
4227 let mut state = backend.state.write().await;
4228 state.folders.push(canonical.clone());
4229 state.projects.insert(
4230 canonical.clone(),
4231 ProjectState {
4232 config: project::load_config(&canonical).unwrap_or_default(),
4233 ..Default::default()
4234 },
4235 );
4236 }
4237 backend
4238 }
4239
4240 impl Backend {
4244 async fn test_root(&self) -> PathBuf {
4245 self.state
4246 .read()
4247 .await
4248 .projects
4249 .keys()
4250 .next()
4251 .cloned()
4252 .expect("a test project entry")
4253 }
4254 async fn run_round(&self) {
4255 let root = self.test_root().await;
4256 self.run_project_diagnostics(root).await;
4257 }
4258 async fn test_analysis(&self) -> Option<Arc<Analysis>> {
4259 let root = self.test_root().await;
4260 self.project_analysis(&root).await
4261 }
4262 async fn test_round_started(&self) -> u64 {
4263 let root = self.test_root().await;
4264 self.state
4265 .read()
4266 .await
4267 .projects
4268 .get(&root)
4269 .map(|p| p.analysis_round_started)
4270 .unwrap_or(0)
4271 }
4272 }
4273
4274 #[tokio::test]
4279 async fn a_round_covers_every_include_tree() {
4280 let s = scratch_project(
4281 "round",
4282 &[
4283 ("bynk.toml", "[project]\nname = \"round\"\n"),
4284 ("src/thing.bynk", "context thing\n"),
4285 ("tests/thing.bynk", "suite thing\n"),
4287 ],
4288 );
4289 let backend = backend_at(&s.0).await;
4290 backend.run_round().await;
4291
4292 let analysis = backend.test_analysis().await.expect("a round committed");
4293 let mut keys: Vec<String> = analysis
4294 .snapshots
4295 .keys()
4296 .map(|p| p.to_string_lossy().replace('\\', "/"))
4297 .collect();
4298 keys.sort();
4299 assert_eq!(
4300 keys,
4301 vec!["src/thing.bynk", "tests/thing.bynk"],
4302 "the round must cover both include trees, with distinct identities",
4303 );
4304 }
4305
4306 #[tokio::test]
4310 async fn a_uri_in_any_include_tree_resolves_to_its_analysed_file() {
4311 let s = scratch_project(
4312 "uri",
4313 &[
4314 ("bynk.toml", "[project]\nname = \"uri\"\n"),
4315 ("src/thing.bynk", "context thing\n"),
4316 ("tests/thing.bynk", "suite thing\n"),
4317 ],
4318 );
4319 let backend = backend_at(&s.0).await;
4320 backend.run_round().await;
4321 let analysis = backend.test_analysis().await.expect("round");
4322
4323 for (rel, label) in [
4324 ("src/thing.bynk", "primary"),
4325 ("tests/thing.bynk", "secondary"),
4326 ] {
4327 let abs = s.0.join(rel);
4328 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4329 let resolved = Backend::uri_to_rel(&analysis, &uri)
4330 .unwrap_or_else(|| panic!("{label} root URI must resolve"));
4331 assert_eq!(
4332 resolved.to_string_lossy().replace('\\', "/"),
4333 rel,
4334 "a {label}-tree URI must name its own analysed file",
4335 );
4336 assert!(
4337 analysis.snapshots.contains_key(&resolved),
4338 "…and that file must be in the round",
4339 );
4340 }
4341 }
4342
4343 #[tokio::test]
4348 async fn project_files_excludes_the_calling_file() {
4349 let s = scratch_project(
4350 "self_excl",
4351 &[
4352 ("bynk.toml", "[project]\nname = \"self_excl\"\n"),
4353 ("a.bynk", "context a\n"),
4354 ("b.bynk", "context b\n"),
4355 ],
4356 );
4357 let backend = backend_at(&s.0).await;
4358 let abs = s.0.join("a.bynk");
4359 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4360 let files = backend
4361 .project_files(&uri)
4362 .await
4363 .expect("a project root resolves to a file list");
4364 assert!(
4365 !files.iter().any(|p| p.file_name().unwrap() == "a.bynk"),
4366 "the calling file's own path must not be in its own project file list: {files:?}"
4367 );
4368 assert!(
4369 files.iter().any(|p| p.file_name().unwrap() == "b.bynk"),
4370 "a sibling project file must still be present: {files:?}"
4371 );
4372 }
4373
4374 #[tokio::test]
4378 async fn an_excluded_tree_is_not_analysed() {
4379 let s = scratch_project(
4380 "excl",
4381 &[
4382 (
4383 "bynk.toml",
4384 "[project]\nname = \"excl\"\n\n[paths]\ninclude = [\".\"]\nexclude = [\"generated\"]\n",
4385 ),
4386 ("a.bynk", "context a\n"),
4387 ("generated/gen.bynk", "context gen\n"),
4388 ],
4389 );
4390 let backend = backend_at(&s.0).await;
4391 backend.run_round().await;
4392 let analysis = backend.test_analysis().await.expect("round");
4393 let keys: Vec<String> = analysis
4394 .snapshots
4395 .keys()
4396 .map(|p| p.to_string_lossy().replace('\\', "/"))
4397 .collect();
4398 assert_eq!(keys, vec!["a.bynk"], "excluded trees stay out of the round");
4399 }
4400
4401 #[tokio::test]
4406 async fn references_resolve_in_the_vscode_fixture_layout() {
4407 let s = scratch_project(
4408 "vsc",
4409 &[
4410 (
4411 "bynk.toml",
4412 "[project]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[paths]\nsrc = \"src\"\ntests = \"tests\"\n",
4413 ),
4414 (
4415 "src/text.bynk",
4416 "commons fixture.text\n\nfn shout(s: String) -> String {\n s\n}\n\nfn greet(name: String) -> String {\n \"Hi, \\(shout(name))!\"\n}\n",
4417 ),
4418 ],
4419 );
4420 let (root, config) = Backend::resolve_root(&s.0).expect("bynk.toml is present");
4422 assert_eq!(root, s.0, "the manifest's directory is the project root");
4423
4424 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4425 let backend = service.inner().clone();
4426 {
4427 let mut st = backend.state.write().await;
4428 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
4431 st.projects.insert(
4432 canonical,
4433 ProjectState {
4434 config,
4435 ..Default::default()
4436 },
4437 );
4438 }
4439 backend.run_round().await;
4440
4441 let analysis = backend.test_analysis().await.expect("a round committed");
4442 let keys: Vec<String> = analysis
4443 .snapshots
4444 .keys()
4445 .map(|p| p.to_string_lossy().replace('\\', "/"))
4446 .collect();
4447 assert_eq!(keys, vec!["src/text.bynk"], "the fixture file is analysed");
4448
4449 let abs = s.0.join("src/text.bynk");
4451 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4452 let rel = Backend::uri_to_rel(&analysis, &uri).expect("URI resolves into the round");
4453 assert_eq!(rel, PathBuf::from("src/text.bynk"));
4454
4455 let text = analysis.snapshots.get(&rel).expect("snapshot present");
4457 let decl = text.find("shout").expect("`shout` in source");
4458 let pos = crate::position::offset_to_position(text, decl);
4459
4460 let refs = backend
4461 .references(ReferenceParams {
4462 text_document_position: TextDocumentPositionParams {
4463 text_document: TextDocumentIdentifier { uri: uri.clone() },
4464 position: pos,
4465 },
4466 work_done_progress_params: Default::default(),
4467 partial_result_params: Default::default(),
4468 context: ReferenceContext {
4469 include_declaration: true,
4470 },
4471 })
4472 .await
4473 .expect("references must not error");
4474 let found = refs.unwrap_or_default();
4475 assert!(
4476 !found.is_empty(),
4477 "`shout` is referenced by `greet` — references must resolve; got none",
4478 );
4479 }
4480
4481 async fn open_round_edit(
4491 backend: &Backend,
4492 root: &std::path::Path,
4493 v1_text: &str,
4494 v2_text: &str,
4495 ) -> (Url, PathBuf) {
4496 let abs = root.join("src/a.bynk");
4497 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4498
4499 backend
4500 .did_open(DidOpenTextDocumentParams {
4501 text_document: TextDocumentItem {
4502 uri: uri.clone(),
4503 language_id: "bynk".into(),
4504 version: 1,
4505 text: v1_text.to_string(),
4506 },
4507 })
4508 .await;
4509 backend.run_round().await;
4510
4511 let a1 = backend.test_analysis().await.expect("round 1");
4513 let rel = Backend::uri_to_rel(&a1, &uri).expect("uri resolves");
4514 assert_eq!(a1.versions.get(&rel), Some(&1), "round 1 is version 1");
4515
4516 backend
4519 .did_change(DidChangeTextDocumentParams {
4520 text_document: VersionedTextDocumentIdentifier {
4521 uri: uri.clone(),
4522 version: 2,
4523 },
4524 content_changes: vec![TextDocumentContentChangeEvent {
4525 range: None,
4526 range_length: None,
4527 text: v2_text.to_string(),
4528 }],
4529 })
4530 .await;
4531 (uri, rel)
4532 }
4533
4534 #[tokio::test]
4540 async fn a_position_after_an_edit_resolves_against_the_new_text() {
4541 let v1 = "commons q.a\n\nfn target(x: Int) -> Int {\n x\n}\n";
4542 let v2 = format!("\n{v1}");
4544 let s = scratch_project(
4545 "fresh_hd",
4546 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4547 );
4548 let backend = backend_at(&s.0).await;
4549 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4550
4551 let a = backend.analysis_for(&uri).await.expect("current analysis");
4553 assert_eq!(a.versions.get(&rel), Some(&2), "gate refreshed to the edit");
4554 assert_eq!(a.snapshots.get(&rel).map(String::as_str), Some(v2.as_str()));
4555
4556 let off_v2 = v2.find("target").unwrap();
4558 let new_pos = crate::position::offset_to_position(&v2, off_v2);
4559 let (a2, rel2, off) = backend
4560 .index_position(&uri, new_pos)
4561 .await
4562 .expect("position resolves");
4563 assert!(
4564 a2.snapshots.get(&rel2).unwrap()[off..].starts_with("target"),
4565 "the new position must land on `target` in the current snapshot — \
4566 the whole point of refreshing",
4567 );
4568 }
4569
4570 #[tokio::test]
4574 async fn a_stale_round_is_never_served() {
4575 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4576 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
4577 let s = scratch_project(
4578 "fresh_stale",
4579 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4580 );
4581 let backend = backend_at(&s.0).await;
4582 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4583
4584 let cached = backend.test_analysis().await.unwrap();
4586 assert_eq!(cached.versions.get(&rel), Some(&1), "cached round is stale");
4587
4588 let a = backend.analysis_for(&uri).await.unwrap();
4590 assert_eq!(
4591 a.versions.get(&rel),
4592 Some(&2),
4593 "analysis_for must refresh past a stale cached round, never serve it",
4594 );
4595 }
4596
4597 #[tokio::test]
4604 async fn committed_analysis_serves_the_stale_round_without_refreshing() {
4605 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4606 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
4607 let s = scratch_project(
4608 "fresh_committed",
4609 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4610 );
4611 let backend = backend_at(&s.0).await;
4612 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4613
4614 assert_eq!(
4616 backend.test_analysis().await.unwrap().versions.get(&rel),
4617 Some(&1),
4618 "cached round is stale",
4619 );
4620
4621 let a = backend
4623 .committed_analysis(&uri)
4624 .await
4625 .expect("committed round");
4626 assert_eq!(
4627 a.versions.get(&rel),
4628 Some(&1),
4629 "committed_analysis serves the committed round, stale and all",
4630 );
4631 assert_eq!(
4633 backend.test_analysis().await.unwrap().versions.get(&rel),
4634 Some(&1),
4635 "committed_analysis must not trigger a refresh",
4636 );
4637 }
4638
4639 #[tokio::test]
4643 async fn concurrent_requests_after_one_edit_share_one_round() {
4644 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4645 let v2 = format!("\n{v1}");
4646 let s = scratch_project(
4647 "fresh_coal",
4648 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4649 );
4650 let backend = backend_at(&s.0).await;
4651 let (uri, _rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4652
4653 let started_before = backend.test_round_started().await;
4654
4655 let calls = (0..5).map(|_| {
4657 let b = backend.clone();
4658 let u = uri.clone();
4659 tokio::spawn(async move { b.analysis_for(&u).await.is_some() })
4660 });
4661 for c in calls {
4662 assert!(
4663 c.await.unwrap(),
4664 "each concurrent request must get an analysis"
4665 );
4666 }
4667
4668 let started_after = backend.test_round_started().await;
4669 assert_eq!(
4670 started_after - started_before,
4671 1,
4672 "five concurrent requests after one edit must share ONE round, not run five",
4673 );
4674 }
4675
4676 #[tokio::test]
4679 async fn a_file_outside_the_project_is_declined() {
4680 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4681 let s = scratch_project(
4682 "fresh_out",
4683 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4684 );
4685 let backend = backend_at(&s.0).await;
4686 backend.run_round().await;
4688
4689 let outside = s.0.join("elsewhere.bynk");
4691 std::fs::write(&outside, v1).unwrap();
4692 let uri = Url::from_file_path(outside.canonicalize().unwrap()).unwrap();
4693 backend
4694 .did_open(DidOpenTextDocumentParams {
4695 text_document: TextDocumentItem {
4696 uri: uri.clone(),
4697 language_id: "bynk".into(),
4698 version: 1,
4699 text: v1.to_string(),
4700 },
4701 })
4702 .await;
4703
4704 assert!(
4705 backend.analysis_for(&uri).await.is_none(),
4706 "a file outside the include roots is never a snapshot key — decline, \
4707 don't serve a round that doesn't cover it",
4708 );
4709 }
4710
4711 #[tokio::test]
4719 async fn a_multi_file_rename_stamps_a_dirty_non_cursor_file_at_its_current_version() {
4720 let util = "commons demo.util\n\ntype Money = Int where Positive\n";
4721 let thing = "commons demo.thing\n\nuses demo.util\n\nfn f(m: Money) -> Money {\n m\n}\n";
4722 let s = scratch_project(
4723 "rename_multi",
4724 &[
4725 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4726 ("src/demo/util.bynk", util),
4727 ("src/demo/thing.bynk", thing),
4728 ],
4729 );
4730 let backend = backend_at(&s.0).await;
4731 let uri = |rel: &str| {
4732 let abs = s.0.join(rel);
4733 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
4734 };
4735 let util_uri = uri("src/demo/util.bynk");
4736 let thing_uri = uri("src/demo/thing.bynk");
4737
4738 for (u, text) in [(&util_uri, util), (&thing_uri, thing)] {
4739 backend
4740 .did_open(DidOpenTextDocumentParams {
4741 text_document: TextDocumentItem {
4742 uri: u.clone(),
4743 language_id: "bynk".into(),
4744 version: 1,
4745 text: text.to_string(),
4746 },
4747 })
4748 .await;
4749 }
4750 backend.run_round().await;
4751
4752 let thing_v2 = format!("\n{thing}");
4755 backend
4756 .did_change(DidChangeTextDocumentParams {
4757 text_document: VersionedTextDocumentIdentifier {
4758 uri: thing_uri.clone(),
4759 version: 2,
4760 },
4761 content_changes: vec![TextDocumentContentChangeEvent {
4762 range: None,
4763 range_length: None,
4764 text: thing_v2.clone(),
4765 }],
4766 })
4767 .await;
4768
4769 let money_off = util.find("Money").unwrap();
4771 let pos = crate::position::offset_to_position(util, money_off);
4772 let edit = backend
4773 .rename(RenameParams {
4774 text_document_position: TextDocumentPositionParams {
4775 text_document: TextDocumentIdentifier {
4776 uri: util_uri.clone(),
4777 },
4778 position: pos,
4779 },
4780 new_name: "Amount".into(),
4781 work_done_progress_params: Default::default(),
4782 })
4783 .await
4784 .expect("rename must not error")
4785 .expect("rename must produce edits");
4786
4787 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
4788 panic!("expected document-change edits");
4789 };
4790 let thing_edit = edits
4791 .iter()
4792 .find(|e| e.text_document.uri == thing_uri)
4793 .expect("the rename must edit `thing`, which references the symbol");
4794 assert_eq!(
4795 thing_edit.text_document.version,
4796 Some(2),
4797 "the dirty non-cursor file's edit must carry its current version (2), \
4798 not the stale round's (1) — else the client rejects the whole rename",
4799 );
4800 }
4801
4802 #[tokio::test]
4807 async fn will_rename_files_updates_the_declaration_and_every_reference() {
4808 let charge = "commons billing.charge\n\ntype ChargeId = Int where Positive\n";
4809 let main = "context app.main\n\nuses billing.charge\n";
4810 let s = scratch_project(
4811 "will_rename_basic",
4812 &[
4813 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4814 ("src/billing/charge.bynk", charge),
4815 ("src/app/main.bynk", main),
4816 ],
4817 );
4818 let backend = backend_at(&s.0).await;
4819 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
4820 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
4821 let old_uri = uri("src/billing/charge.bynk");
4822 let new_uri = uri("src/billing/pay.bynk");
4823 let main_uri = uri("src/app/main.bynk");
4824
4825 backend.run_round().await;
4826
4827 let edit = backend
4828 .will_rename_files(RenameFilesParams {
4829 files: vec![FileRename {
4830 old_uri: old_uri.to_string(),
4831 new_uri: new_uri.to_string(),
4832 }],
4833 })
4834 .await
4835 .expect("will_rename_files must not error")
4836 .expect("must produce edits");
4837
4838 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
4839 panic!("expected document-change edits");
4840 };
4841
4842 let own = edits
4843 .iter()
4844 .find(|e| e.text_document.uri == old_uri)
4845 .expect("the moved file's own declaration must be rewritten");
4846 assert_eq!(own.edits.len(), 1);
4847 let OneOf::Left(own_edit) = &own.edits[0] else {
4848 panic!("expected a plain TextEdit");
4849 };
4850 assert_eq!(own_edit.new_text, "billing.pay");
4851
4852 let referencer = edits
4853 .iter()
4854 .find(|e| e.text_document.uri == main_uri)
4855 .expect("the referencing file must be rewritten");
4856 assert_eq!(referencer.edits.len(), 1);
4857 let OneOf::Left(ref_edit) = &referencer.edits[0] else {
4858 panic!("expected a plain TextEdit");
4859 };
4860 assert_eq!(ref_edit.new_text, "billing.pay");
4861 }
4862
4863 #[tokio::test]
4867 async fn will_rename_files_is_a_noop_for_a_multi_file_unit_member() {
4868 let s = scratch_project(
4869 "will_rename_multi_file_noop",
4870 &[
4871 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4872 (
4873 "src/billing/charge/one.bynk",
4874 "context billing.charge\n\ntype ChargeId = Int where Positive\n",
4875 ),
4876 (
4877 "src/billing/charge/two.bynk",
4878 "context billing.charge\n\ntype PaymentId = Int where Positive\n",
4879 ),
4880 ],
4881 );
4882 let backend = backend_at(&s.0).await;
4883 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
4884 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
4885
4886 backend.run_round().await;
4887
4888 let edit = backend
4889 .will_rename_files(RenameFilesParams {
4890 files: vec![FileRename {
4891 old_uri: uri("src/billing/charge/one.bynk").to_string(),
4892 new_uri: uri("src/billing/charge/renamed.bynk").to_string(),
4893 }],
4894 })
4895 .await
4896 .expect("will_rename_files must not error");
4897 assert!(
4898 edit.is_none(),
4899 "renaming a member file within the same directory must not edit anything"
4900 );
4901 }
4902
4903 #[tokio::test]
4907 async fn will_rename_files_is_a_noop_for_a_suite() {
4908 let s = scratch_project(
4909 "will_rename_suite_noop",
4910 &[
4911 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4912 (
4913 "src/billing/charge.bynk",
4914 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
4915 ),
4916 ("tests/billing_charge.bynk", "suite billing.charge\n"),
4917 ],
4918 );
4919 let backend = backend_at(&s.0).await;
4920 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
4921 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
4922
4923 backend.run_round().await;
4924
4925 let edit = backend
4926 .will_rename_files(RenameFilesParams {
4927 files: vec![FileRename {
4928 old_uri: uri("tests/billing_charge.bynk").to_string(),
4929 new_uri: uri("tests/billing_charge_renamed.bynk").to_string(),
4930 }],
4931 })
4932 .await
4933 .expect("will_rename_files must not error");
4934 assert!(edit.is_none(), "a suite rename must produce no edits");
4935 }
4936
4937 #[tokio::test]
4942 async fn will_rename_files_updates_a_suite_s_target_reference() {
4943 let s = scratch_project(
4944 "will_rename_suite_target",
4945 &[
4946 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4947 (
4948 "src/billing/charge.bynk",
4949 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
4950 ),
4951 ("tests/billing_charge.bynk", "suite billing.charge\n"),
4952 ],
4953 );
4954 let backend = backend_at(&s.0).await;
4955 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
4956 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
4957 let suite_uri = uri("tests/billing_charge.bynk");
4958
4959 backend.run_round().await;
4960
4961 let edit = backend
4962 .will_rename_files(RenameFilesParams {
4963 files: vec![FileRename {
4964 old_uri: uri("src/billing/charge.bynk").to_string(),
4965 new_uri: uri("src/billing/pay.bynk").to_string(),
4966 }],
4967 })
4968 .await
4969 .expect("will_rename_files must not error")
4970 .expect("must produce edits");
4971
4972 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
4973 panic!("expected document-change edits");
4974 };
4975 let suite_edit = edits
4976 .iter()
4977 .find(|e| e.text_document.uri == suite_uri)
4978 .expect("the suite's own `suite <target>` header must be rewritten");
4979 assert_eq!(suite_edit.edits.len(), 1);
4980 let OneOf::Left(e) = &suite_edit.edits[0] else {
4981 panic!("expected a plain TextEdit");
4982 };
4983 assert_eq!(e.new_text, "billing.pay");
4984 }
4985
4986 #[tokio::test]
4991 async fn will_rename_files_refuses_a_rename_that_collides_with_an_existing_unit() {
4992 let s = scratch_project(
4993 "will_rename_collision",
4994 &[
4995 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4996 (
4997 "src/billing/charge.bynk",
4998 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
4999 ),
5000 (
5001 "src/billing/pay.bynk",
5002 "commons billing.pay\n\ntype PaymentId = Int where Positive\n",
5003 ),
5004 ],
5005 );
5006 let backend = backend_at(&s.0).await;
5007 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5008 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5009
5010 backend.run_round().await;
5011
5012 let edit = backend
5015 .will_rename_files(RenameFilesParams {
5016 files: vec![FileRename {
5017 old_uri: uri("src/billing/charge.bynk").to_string(),
5018 new_uri: uri("src/billing/pay.bynk").to_string(),
5019 }],
5020 })
5021 .await
5022 .expect("will_rename_files must not error");
5023 assert!(
5024 edit.is_none(),
5025 "a rename that collides with an existing unit name must produce no edits"
5026 );
5027 }
5028
5029 #[cfg(unix)]
5037 #[tokio::test]
5038 async fn will_rename_files_tolerates_a_symlinked_project_root() {
5039 let real = scratch_project(
5040 "will_rename_symlink_real",
5041 &[
5042 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5043 (
5044 "src/billing/charge.bynk",
5045 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5046 ),
5047 ],
5048 );
5049 let alias = std::env::temp_dir().join(format!(
5050 "bynk_lsp_sliceA_will_rename_symlink_alias_{}_{:?}",
5051 std::process::id(),
5052 std::thread::current().id()
5053 ));
5054 let _ = std::fs::remove_file(&alias);
5055 std::os::unix::fs::symlink(&real.0, &alias).expect("symlink the scratch root");
5056
5057 let backend = backend_at(&alias).await;
5058 let uri = |rel: &str| Url::from_file_path(alias.join(rel)).unwrap();
5062 let old_uri = uri("src/billing/charge.bynk");
5063 let new_uri = uri("src/billing/pay.bynk"); backend.run_round().await;
5066
5067 let edit = backend
5068 .will_rename_files(RenameFilesParams {
5069 files: vec![FileRename {
5070 old_uri: old_uri.to_string(),
5071 new_uri: new_uri.to_string(),
5072 }],
5073 })
5074 .await
5075 .expect("will_rename_files must not error")
5076 .expect("must produce edits despite the symlinked root");
5077
5078 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5079 panic!("expected document-change edits");
5080 };
5081 assert_eq!(
5082 edits.len(),
5083 1,
5084 "only the moved file's own header changes here"
5085 );
5086 let OneOf::Left(e) = &edits[0].edits[0] else {
5087 panic!("expected a plain TextEdit");
5088 };
5089 assert_eq!(e.new_text, "billing.pay");
5090
5091 let _ = std::fs::remove_file(&alias);
5092 }
5093
5094 #[test]
5099 fn find_source_root_walks_up_to_the_nearest_src() {
5100 let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5101 .parent()
5102 .expect("workspace root");
5103 let make = ws.join(
5104 "bynkc/tests/fixtures/positive/\
5105 252_multi_file_commons_dotted_test/src/shipping/rates/make.bynk",
5106 );
5107 assert!(make.is_file(), "fixture present: {}", make.display());
5108
5109 let src = Backend::find_source_root(&make).expect("an ancestor src/");
5110 assert!(
5111 src.ends_with("252_multi_file_commons_dotted_test/src"),
5112 "nearest ancestor src, got {}",
5113 src.display()
5114 );
5115
5116 let (root, _config) = Backend::resolve_root(&make).expect("implicit project");
5127 assert_eq!(root, src.parent().expect("src has a parent"));
5128
5129 let found = bynk_ide::discover_files(&bynk_ide::AnalysisRoots::Project(root.clone()));
5130 let make_canon = make.canonicalize().unwrap_or(make.clone());
5131 assert!(
5132 found
5133 .iter()
5134 .any(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == make_canon),
5135 "the compiler's own discovery must reach {} from the project root {}; got {found:?}",
5136 make.display(),
5137 root.display(),
5138 );
5139 }
5140
5141 #[test]
5145 fn resolve_root_is_none_without_toml_or_src() {
5146 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
5148 assert!(p.is_file());
5149 assert!(Backend::find_source_root(&p).is_none());
5150 assert!(Backend::resolve_root(&p).is_none());
5151 }
5152
5153 #[test]
5156 fn is_scrutinee_offset_locates_the_scrutinee() {
5157 let text = " order.status is Pen";
5158 let off = is_scrutinee_offset(text, text.len()).expect("at an is-position");
5159 assert_eq!(&text[off..off + 1], "s");
5161 assert!(off < text.find(" is ").unwrap());
5162 let text2 = " x is ";
5164 let off2 = is_scrutinee_offset(text2, text2.len()).expect("at an is-position");
5165 assert_eq!(&text2[off2..off2 + 1], "x");
5166 assert!(is_scrutinee_offset(" basis ", " basis ".len()).is_none());
5168 assert!(is_scrutinee_offset(" let x = ", " let x = ".len()).is_none());
5170 }
5171
5172 #[test]
5175 fn match_scrutinee_offset_locates_the_scrutinee() {
5176 let t = "match order.status {\n ";
5178 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5179 assert_eq!(&t[off..off + 1], "s"); assert!(off < t.find(" {").unwrap());
5181
5182 let t = "match color { Re";
5184 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5185 assert_eq!(&t[off..off + 1], "r"); let t = "match c {\n Red => 1,\n Gr";
5189 let off = match_scrutinee_offset(t, t.len()).expect("at a later arm-start");
5190 assert_eq!(&t[off..off + 1], "c");
5191
5192 let t = "match c {\n Red => f(a, b),\n ";
5195 assert!(match_scrutinee_offset(t, t.len()).is_some());
5196
5197 assert!(
5199 match_scrutinee_offset("match c {\n Red => ", "match c {\n Red => ".len()).is_none()
5200 );
5201
5202 assert!(match_scrutinee_offset("fn f() {\n ", "fn f() {\n ".len()).is_none());
5204
5205 assert!(match_scrutinee_offset("match c {\n Ok(", "match c {\n Ok(".len()).is_none());
5207
5208 assert!(match_scrutinee_offset("match c ", "match c ".len()).is_none());
5210 assert!(match_scrutinee_offset("match {\n ", "match {\n ".len()).is_none());
5211 }
5212
5213 #[test]
5217 fn nested_pattern_offset_locates_the_scrutinee_and_variant() {
5218 let t = "match res {\n Some(";
5220 let (off, variant) = nested_pattern_offset(t, t.len()).expect("inside a nested pattern");
5221 assert_eq!(&t[off..off + 1], "s"); assert_eq!(variant, "Some");
5223
5224 let t = "match res {\n Ok(Po";
5226 let (off, variant) = nested_pattern_offset(t, t.len()).expect("mid partial");
5227 assert_eq!(&t[off..off + 1], "s");
5228 assert_eq!(variant, "Ok");
5229
5230 let t = "match r {\n Ok(n) => n,\n Err(";
5232 let (off, variant) = nested_pattern_offset(t, t.len()).expect("later arm");
5233 assert_eq!(&t[off..off + 1], "r");
5234 assert_eq!(variant, "Err");
5235
5236 assert!(nested_pattern_offset("match r {\n ok(", "match r {\n ok(".len()).is_none());
5239
5240 assert!(nested_pattern_offset("match c {\n ", "match c {\n ".len()).is_none());
5242 assert!(nested_pattern_offset("match c {\n Ok", "match c {\n Ok".len()).is_none());
5243
5244 let t = "match c {\n Ok(n) => g(";
5246 assert!(nested_pattern_offset(t, t.len()).is_none());
5247
5248 assert!(nested_pattern_offset("fn f() {\n h(", "fn f() {\n h(".len()).is_none());
5250 }
5251
5252 #[test]
5256 fn is_bynk_toml_matches_only_the_manifest() {
5257 let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
5262 let toml = Url::from_file_path(base.join("bynk.toml")).expect("abs path");
5263 assert!(is_bynk_toml(&toml));
5264 let nested = Url::from_file_path(base.join("sub").join("bynk.toml")).expect("abs path");
5265 assert!(is_bynk_toml(&nested));
5266
5267 let src = Url::from_file_path(base.join("src").join("main.bynk")).expect("abs path");
5269 assert!(!is_bynk_toml(&src));
5270 let decoy = Url::from_file_path(base.join("notbynk.toml")).expect("abs path");
5272 assert!(!is_bynk_toml(&decoy));
5273 let remote = Url::parse("https://example.com/bynk.toml").expect("url");
5275 assert!(!is_bynk_toml(&remote));
5276 }
5277
5278 #[test]
5281 fn advertises_code_actions_and_the_index_riders() {
5282 let caps = server_capabilities();
5283 let Some(CodeActionProviderCapability::Options(opts)) = caps.code_action_provider else {
5284 panic!("codeActionProvider not advertised with options");
5285 };
5286 assert_eq!(
5287 opts.code_action_kinds,
5288 Some(vec![
5289 CodeActionKind::QUICKFIX,
5290 CodeActionKind::REFACTOR,
5291 CodeActionKind::REFACTOR_EXTRACT,
5292 ])
5293 );
5294 assert!(matches!(
5295 caps.workspace_symbol_provider,
5296 Some(OneOf::Left(true))
5297 ));
5298 assert!(matches!(
5299 caps.document_highlight_provider,
5300 Some(OneOf::Left(true))
5301 ));
5302 }
5303
5304 #[test]
5307 fn advertises_save_notifications() {
5308 let caps = server_capabilities();
5311 let Some(TextDocumentSyncCapability::Options(opts)) = caps.text_document_sync else {
5312 panic!("textDocumentSync not advertised with options");
5313 };
5314 assert_eq!(opts.change, Some(TextDocumentSyncKind::FULL));
5315 assert!(matches!(
5316 opts.save,
5317 Some(TextDocumentSyncSaveOptions::Supported(true))
5318 ));
5319 }
5320
5321 #[test]
5322 fn advertises_inlay_hints() {
5323 let caps = server_capabilities();
5324 assert!(matches!(caps.inlay_hint_provider, Some(OneOf::Left(true))));
5325 }
5326
5327 #[test]
5329 fn advertises_type_definition() {
5330 let caps = server_capabilities();
5331 assert!(matches!(
5332 caps.type_definition_provider,
5333 Some(TypeDefinitionProviderCapability::Simple(true))
5334 ));
5335 }
5336
5337 #[test]
5339 fn advertises_document_links() {
5340 let caps = server_capabilities();
5341 assert!(caps.document_link_provider.is_some());
5342 }
5343
5344 #[test]
5346 fn advertises_will_rename_files() {
5347 let caps = server_capabilities();
5348 let file_ops = caps
5349 .workspace
5350 .as_ref()
5351 .and_then(|w| w.file_operations.as_ref())
5352 .expect("workspace.fileOperations advertised");
5353 let will_rename = file_ops
5354 .will_rename
5355 .as_ref()
5356 .expect("willRename registered");
5357 let filter = &will_rename.filters[0];
5358 assert_eq!(filter.pattern.glob, "**/*.bynk");
5359 assert_eq!(filter.pattern.matches, Some(FileOperationPatternKind::File));
5360 }
5361
5362 #[test]
5364 fn advertises_completion_with_dot_trigger_and_resolve() {
5365 let caps = server_capabilities();
5366 let opts = caps.completion_provider.expect("completion advertised");
5367 assert_eq!(opts.resolve_provider, Some(true), "resolve_provider");
5368 assert!(
5369 opts.trigger_characters
5370 .as_deref()
5371 .is_some_and(|t| t.iter().any(|c| c == ".")),
5372 "`.` trigger char"
5373 );
5374 }
5375
5376 #[test]
5379 fn advertises_semantic_tokens() {
5380 let caps = server_capabilities();
5381 let Some(SemanticTokensServerCapabilities::SemanticTokensOptions(opts)) =
5382 caps.semantic_tokens_provider
5383 else {
5384 panic!("semanticTokensProvider not advertised with options");
5385 };
5386 assert_eq!(opts.full, Some(SemanticTokensFullOptions::Bool(true)));
5387 assert_eq!(opts.range, Some(true));
5388 assert_eq!(opts.legend, crate::index_queries::semantic_tokens_legend());
5389 }
5390
5391 async fn bare_backend() -> Backend {
5396 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
5397 service.inner().clone()
5398 }
5399
5400 fn file_uri(root: &std::path::Path, rel: &str) -> Url {
5401 let abs = root.join(rel);
5402 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
5403 }
5404
5405 async fn set_folders(backend: &Backend, roots: &[&std::path::Path]) {
5406 backend.state.write().await.folders = roots
5407 .iter()
5408 .map(|r| r.canonicalize().unwrap_or_else(|_| r.to_path_buf()))
5409 .collect();
5410 }
5411
5412 async fn open(backend: &Backend, uri: &Url, text: &str) {
5413 backend
5414 .did_open(DidOpenTextDocumentParams {
5415 text_document: TextDocumentItem {
5416 uri: uri.clone(),
5417 language_id: "bynk".into(),
5418 version: 1,
5419 text: text.to_string(),
5420 },
5421 })
5422 .await;
5423 }
5424
5425 fn snapshot_keys(a: &Analysis) -> Vec<String> {
5426 let mut keys: Vec<String> = a
5427 .snapshots
5428 .keys()
5429 .map(|p| p.to_string_lossy().replace('\\', "/"))
5430 .collect();
5431 keys.sort();
5432 keys
5433 }
5434
5435 #[tokio::test]
5440 async fn two_projects_under_one_folder_are_two_projects() {
5441 let ax_src = "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n";
5442 let by_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
5443 let s = scratch_project(
5444 "d_two",
5445 &[
5446 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5447 ("a/src/x.bynk", ax_src),
5448 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5449 ("b/src/y.bynk", by_src),
5450 ],
5451 );
5452 let backend = bare_backend().await;
5453 set_folders(&backend, &[&s.0]).await;
5454 let ax = file_uri(&s.0, "a/src/x.bynk");
5455 let by = file_uri(&s.0, "b/src/y.bynk");
5456 open(&backend, &ax, ax_src).await;
5457 open(&backend, &by, by_src).await;
5458
5459 assert_ne!(
5460 Backend::root_for_uri_uncached(&ax).unwrap(),
5461 Backend::root_for_uri_uncached(&by).unwrap(),
5462 "the two files resolve to different project roots",
5463 );
5464 assert_eq!(
5465 backend.state.read().await.projects.len(),
5466 2,
5467 "one entry per project, not one for the shared folder",
5468 );
5469
5470 let a = backend.analysis_for(&ax).await.expect("A analysed");
5471 let b = backend.analysis_for(&by).await.expect("B analysed");
5472 assert_eq!(
5473 snapshot_keys(&a),
5474 vec!["src/x.bynk"],
5475 "A sees only A's file"
5476 );
5477 assert_eq!(
5478 snapshot_keys(&b),
5479 vec!["src/y.bynk"],
5480 "B sees only B's file"
5481 );
5482 }
5483
5484 #[tokio::test]
5488 async fn removing_a_folder_prunes_an_idle_project() {
5489 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5490 let s = scratch_project(
5491 "d_prune",
5492 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5493 );
5494 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5495 let backend = bare_backend().await;
5496 set_folders(&backend, &[&s.0]).await;
5497 let uri = file_uri(&s.0, "src/a.bynk");
5498 open(&backend, &uri, a).await;
5499 backend.analysis_for(&uri).await.expect("analysed");
5500 backend
5502 .did_close(DidCloseTextDocumentParams {
5503 text_document: TextDocumentIdentifier { uri: uri.clone() },
5504 })
5505 .await;
5506 assert_eq!(backend.state.read().await.projects.len(), 1);
5507
5508 backend
5509 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5510 event: WorkspaceFoldersChangeEvent {
5511 added: vec![],
5512 removed: vec![WorkspaceFolder {
5513 uri: Url::from_file_path(&folder).unwrap(),
5514 name: "p".into(),
5515 }],
5516 },
5517 })
5518 .await;
5519 assert!(
5520 backend.state.read().await.projects.is_empty(),
5521 "an idle project is pruned when its last covering folder is removed",
5522 );
5523 }
5524
5525 #[tokio::test]
5528 async fn removing_a_folder_retains_a_project_with_an_open_buffer() {
5529 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5530 let s = scratch_project(
5531 "d_retain",
5532 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5533 );
5534 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5535 let backend = bare_backend().await;
5536 set_folders(&backend, &[&s.0]).await;
5537 let uri = file_uri(&s.0, "src/a.bynk");
5538 open(&backend, &uri, a).await; backend
5541 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5542 event: WorkspaceFoldersChangeEvent {
5543 added: vec![],
5544 removed: vec![WorkspaceFolder {
5545 uri: Url::from_file_path(&folder).unwrap(),
5546 name: "p".into(),
5547 }],
5548 },
5549 })
5550 .await;
5551 assert_eq!(
5552 backend.state.read().await.projects.len(),
5553 1,
5554 "a project with an open buffer must survive folder removal",
5555 );
5556 assert!(
5557 backend.analysis_for(&uri).await.is_some(),
5558 "and it must still answer requests",
5559 );
5560 }
5561
5562 #[tokio::test]
5566 async fn closing_the_last_buffer_of_a_folder_removed_project_prunes_it() {
5567 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5568 let s = scratch_project(
5569 "d_close_prune",
5570 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5571 );
5572 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5573 let backend = bare_backend().await;
5574 set_folders(&backend, &[&s.0]).await;
5575 let uri = file_uri(&s.0, "src/a.bynk");
5576 open(&backend, &uri, a).await;
5577 backend.analysis_for(&uri).await.expect("analysed");
5578
5579 backend
5581 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5582 event: WorkspaceFoldersChangeEvent {
5583 added: vec![],
5584 removed: vec![WorkspaceFolder {
5585 uri: Url::from_file_path(&folder).unwrap(),
5586 name: "p".into(),
5587 }],
5588 },
5589 })
5590 .await;
5591 assert_eq!(
5592 backend.state.read().await.projects.len(),
5593 1,
5594 "retained while its buffer is open",
5595 );
5596
5597 backend
5599 .did_close(DidCloseTextDocumentParams {
5600 text_document: TextDocumentIdentifier { uri: uri.clone() },
5601 })
5602 .await;
5603 assert!(
5604 backend.state.read().await.projects.is_empty(),
5605 "closing the last buffer of a folder-removed project must prune it",
5606 );
5607 }
5608
5609 #[tokio::test]
5613 async fn a_rename_in_one_project_ignores_a_dirty_buffer_in_another() {
5614 let a_src = "commons a.x\n\ntype Money = Int where Positive\n\nfn charge(m: Money) -> Money {\n m\n}\n";
5615 let b_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
5616 let s = scratch_project(
5617 "d_rename_iso",
5618 &[
5619 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5620 ("a/src/x.bynk", a_src),
5621 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5622 ("b/src/y.bynk", b_src),
5623 ],
5624 );
5625 let backend = bare_backend().await;
5626 set_folders(&backend, &[&s.0]).await;
5627 let ax = file_uri(&s.0, "a/src/x.bynk");
5628 let by = file_uri(&s.0, "b/src/y.bynk");
5629 open(&backend, &ax, a_src).await;
5630 open(&backend, &by, b_src).await;
5631 backend.analysis_for(&ax).await.expect("A analysed");
5632 backend.analysis_for(&by).await.expect("B analysed");
5633
5634 backend
5636 .did_change(DidChangeTextDocumentParams {
5637 text_document: VersionedTextDocumentIdentifier {
5638 uri: by.clone(),
5639 version: 2,
5640 },
5641 content_changes: vec![TextDocumentContentChangeEvent {
5642 range: None,
5643 range_length: None,
5644 text: format!("\n{b_src}"),
5645 }],
5646 })
5647 .await;
5648
5649 let off = a_src.find("Money").unwrap();
5651 let pos = crate::position::offset_to_position(a_src, off);
5652 let edit = backend
5653 .rename(RenameParams {
5654 text_document_position: TextDocumentPositionParams {
5655 text_document: TextDocumentIdentifier { uri: ax.clone() },
5656 position: pos,
5657 },
5658 new_name: "Amount".into(),
5659 work_done_progress_params: Default::default(),
5660 })
5661 .await
5662 .expect("rename must not error");
5663 assert!(
5664 edit.is_some(),
5665 "a rename in project A must not be blocked by a dirty buffer in project B",
5666 );
5667 }
5668
5669 #[tokio::test]
5674 async fn initialize_captures_the_dynamic_watcher_capability() {
5675 let backend = bare_backend().await;
5676 let params = InitializeParams {
5677 capabilities: ClientCapabilities {
5678 workspace: Some(WorkspaceClientCapabilities {
5679 did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
5680 dynamic_registration: Some(true),
5681 relative_pattern_support: None,
5682 }),
5683 ..Default::default()
5684 }),
5685 ..Default::default()
5686 },
5687 ..Default::default()
5688 };
5689 backend.initialize(params).await.expect("initialize");
5690 assert!(
5691 backend.state.read().await.supports_dynamic_watchers,
5692 "the client's dynamic-registration support must be captured for `initialized`",
5693 );
5694 }
5695
5696 #[tokio::test]
5702 async fn initialize_captures_each_decoration_refresh_capability() {
5703 let backend = bare_backend().await;
5704 let params = InitializeParams {
5705 capabilities: ClientCapabilities {
5706 workspace: Some(WorkspaceClientCapabilities {
5707 semantic_tokens: Some(SemanticTokensWorkspaceClientCapabilities {
5709 refresh_support: Some(true),
5710 }),
5711 inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
5713 refresh_support: Some(false),
5714 }),
5715 ..Default::default()
5717 }),
5718 ..Default::default()
5719 },
5720 ..Default::default()
5721 };
5722 backend.initialize(params).await.expect("initialize");
5723 let refresh = backend.state.read().await.supports_refresh;
5724 assert!(
5725 refresh.semantic_tokens,
5726 "semantic tokens: advertised → true"
5727 );
5728 assert!(!refresh.inlay_hints, "inlay hints: withheld → false");
5729 assert!(!refresh.code_lens, "code lens: absent → false");
5730 }
5731
5732 #[tokio::test]
5735 async fn discover_projects_under_finds_nested_projects_and_skips_caches() {
5736 let s = scratch_project(
5737 "e_discover",
5738 &[
5739 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
5740 ("packages/a/src/x.bynk", "commons a.x\n"),
5741 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
5742 ("packages/b/src/y.bynk", "commons b.y\n"),
5743 ("node_modules/dep/bynk.toml", "[project]\nname=\"dep\"\n"),
5745 ],
5746 );
5747 let mut roots = Backend::discover_projects_under(&s.0);
5748 roots.sort();
5749 let names: Vec<String> = roots
5750 .iter()
5751 .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
5752 .collect();
5753 assert_eq!(
5754 names,
5755 vec!["a", "b"],
5756 "both monorepo projects found, node_modules skipped; got {roots:?}",
5757 );
5758 }
5759
5760 #[tokio::test]
5764 async fn initialized_warms_every_project_under_the_folders() {
5765 let s = scratch_project(
5766 "e_warm",
5767 &[
5768 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
5769 (
5770 "packages/a/src/x.bynk",
5771 "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n",
5772 ),
5773 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
5774 (
5775 "packages/b/src/y.bynk",
5776 "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n",
5777 ),
5778 ],
5779 );
5780 let backend = bare_backend().await;
5781 set_folders(&backend, &[&s.0]).await;
5782
5783 backend.initialized(InitializedParams {}).await;
5785
5786 assert_eq!(
5787 backend.state.read().await.projects.len(),
5788 2,
5789 "both monorepo projects are warmed at `initialized`, before any open",
5790 );
5791 let ax = file_uri(&s.0, "packages/a/src/x.bynk");
5793 assert!(
5794 backend.analysis_for(&ax).await.is_some(),
5795 "a warmed project answers index requests with no `did_open`",
5796 );
5797 }
5798
5799 #[tokio::test]
5804 async fn initialized_warms_an_implicit_src_project() {
5805 let s = scratch_project(
5806 "e_implicit",
5807 &[(
5808 "src/a.bynk",
5809 "commons demo.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
5810 )],
5811 );
5812 let backend = bare_backend().await;
5813 set_folders(&backend, &[&s.0]).await;
5814 backend.initialized(InitializedParams {}).await;
5815 assert_eq!(
5816 backend.state.read().await.projects.len(),
5817 1,
5818 "a rootless `src/` project is warmed at startup, not only on open",
5819 );
5820 }
5821
5822 #[cfg(unix)]
5826 #[tokio::test]
5827 async fn discover_projects_under_survives_a_symlink_cycle() {
5828 let s = scratch_project(
5829 "e_cycle",
5830 &[
5831 ("bynk.toml", "[project]\nname=\"p\"\n"),
5832 ("src/a.bynk", "commons p.a\n"),
5833 ],
5834 );
5835 std::os::unix::fs::symlink(&s.0, s.0.join("loop")).ok();
5837 let roots = Backend::discover_projects_under(&s.0); let canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5839 assert!(
5840 roots.contains(&canon),
5841 "the project is found and the walk terminates despite the cycle",
5842 );
5843 }
5844
5845 #[tokio::test]
5849 async fn a_created_manifest_warms_a_new_project() {
5850 let s = scratch_project("e_created", &[("src/a.bynk", "commons p.a\n")]);
5851 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
5852 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5853 let backend = bare_backend().await;
5854 set_folders(&backend, &[&s.0]).await;
5855 assert!(
5856 backend.state.read().await.projects.is_empty(),
5857 "no entry before the watcher fires",
5858 );
5859
5860 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
5861 backend
5862 .did_change_watched_files(DidChangeWatchedFilesParams {
5863 changes: vec![FileEvent {
5864 uri: toml_uri,
5865 typ: FileChangeType::CREATED,
5866 }],
5867 })
5868 .await;
5869 assert_eq!(
5870 backend.state.read().await.projects.len(),
5871 1,
5872 "a created bynk.toml warms its project via the watcher event",
5873 );
5874 }
5875
5876 #[tokio::test]
5880 async fn root_for_uri_populates_the_cache() {
5881 let s = scratch_project("g_cache_hit", &[("a.bynk", "commons demo.a\n")]);
5882 let uri = file_uri(&s.0, "a.bynk");
5883 let backend = bare_backend().await;
5884
5885 assert!(
5886 backend.root_for_uri(&uri).await.is_none(),
5887 "no bynk.toml and no src/ ancestor — routes to no project",
5888 );
5889 assert_eq!(
5890 backend.state.read().await.root_cache.get(&uri),
5891 Some(&None),
5892 "the miss is cached too",
5893 );
5894 }
5895
5896 #[tokio::test]
5900 async fn a_created_manifest_invalidates_the_cached_route() {
5901 let s = scratch_project("g_cache_invalidate", &[("a.bynk", "commons p.a\n")]);
5902 let uri = file_uri(&s.0, "a.bynk");
5903 let backend = bare_backend().await;
5904
5905 assert!(
5906 backend.root_for_uri(&uri).await.is_none(),
5907 "precondition: cached as routing to no project",
5908 );
5909
5910 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
5911 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5912 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
5913 backend
5914 .did_change_watched_files(DidChangeWatchedFilesParams {
5915 changes: vec![FileEvent {
5916 uri: toml_uri,
5917 typ: FileChangeType::CREATED,
5918 }],
5919 })
5920 .await;
5921
5922 assert_eq!(
5923 backend.root_for_uri(&uri).await,
5924 Some(root),
5925 "re-routes to the new project now the stale cache entry is gone",
5926 );
5927 }
5928
5929 #[test]
5935 fn root_cache_write_is_current_rejects_a_generation_that_moved() {
5936 assert!(
5937 Backend::root_cache_write_is_current(3, 3),
5938 "no clear happened since the read — the write-back applies",
5939 );
5940 assert!(
5941 !Backend::root_cache_write_is_current(3, 4),
5942 "a clear bumped the generation since the read — the write-back must be dropped",
5943 );
5944 }
5945
5946 #[tokio::test]
5953 async fn a_workspace_folder_change_bumps_the_root_cache_generation() {
5954 let s = scratch_project("g_race_folder", &[("bynk.toml", "[project]\nname=\"p\"\n")]);
5955 let backend = bare_backend().await;
5956 let before = backend.state.read().await.root_cache_generation;
5957
5958 backend
5959 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5960 event: WorkspaceFoldersChangeEvent {
5961 added: vec![WorkspaceFolder {
5962 uri: Url::from_file_path(&s.0).unwrap(),
5963 name: "p".into(),
5964 }],
5965 removed: vec![],
5966 },
5967 })
5968 .await;
5969
5970 assert!(
5971 backend.state.read().await.root_cache_generation > before,
5972 "a workspace-folder change must bump the generation, not just clear the cache",
5973 );
5974 }
5975
5976 #[tokio::test]
5979 async fn an_added_folder_is_warmed() {
5980 let s = scratch_project(
5981 "e_added",
5982 &[
5983 ("bynk.toml", "[project]\nname=\"p\"\n"),
5984 (
5985 "src/a.bynk",
5986 "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
5987 ),
5988 ],
5989 );
5990 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5991 let backend = bare_backend().await; assert!(backend.state.read().await.projects.is_empty());
5993
5994 backend
5995 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5996 event: WorkspaceFoldersChangeEvent {
5997 added: vec![WorkspaceFolder {
5998 uri: Url::from_file_path(&folder).unwrap(),
5999 name: "p".into(),
6000 }],
6001 removed: vec![],
6002 },
6003 })
6004 .await;
6005
6006 assert_eq!(
6007 backend.state.read().await.projects.len(),
6008 1,
6009 "an added workspace folder's project is warmed proactively",
6010 );
6011 }
6012
6013 fn change_params(uri: &Url, version: i32, text: &str) -> DidChangeTextDocumentParams {
6016 DidChangeTextDocumentParams {
6017 text_document: VersionedTextDocumentIdentifier {
6018 uri: uri.clone(),
6019 version,
6020 },
6021 content_changes: vec![TextDocumentContentChangeEvent {
6022 range: None,
6023 range_length: None,
6024 text: text.to_string(),
6025 }],
6026 }
6027 }
6028
6029 #[tokio::test]
6034 async fn a_single_file_burst_coalesces_by_generation() {
6035 let s = scratch_project("f_single", &[("a.bynk", "commons demo.a\n")]);
6037 let uri = file_uri(&s.0, "a.bynk");
6038 assert!(
6039 Backend::root_for_uri_uncached(&uri).is_none(),
6040 "precondition: the file routes to no project",
6041 );
6042 let backend = bare_backend().await;
6043 for _ in 0..3 {
6044 backend.schedule_single_file(uri.clone()).await;
6045 }
6046 assert_eq!(
6047 backend
6048 .state
6049 .read()
6050 .await
6051 .single_file_generations
6052 .get(&uri)
6053 .copied(),
6054 Some(3),
6055 "each change bumps the generation; only the third task passes its check",
6056 );
6057 }
6058
6059 #[tokio::test]
6062 async fn did_close_clears_the_single_file_generation() {
6063 let s = scratch_project("f_close", &[("a.bynk", "commons demo.a\n")]);
6064 let uri = file_uri(&s.0, "a.bynk");
6065 let backend = bare_backend().await;
6066 backend.schedule_single_file(uri.clone()).await;
6067 assert!(
6068 backend
6069 .state
6070 .read()
6071 .await
6072 .single_file_generations
6073 .contains_key(&uri),
6074 "the generation exists after scheduling",
6075 );
6076 backend
6077 .did_close(DidCloseTextDocumentParams {
6078 text_document: TextDocumentIdentifier { uri: uri.clone() },
6079 })
6080 .await;
6081 assert!(
6082 !backend
6083 .state
6084 .read()
6085 .await
6086 .single_file_generations
6087 .contains_key(&uri),
6088 "did_close clears the single-file generation",
6089 );
6090 }
6091
6092 #[tokio::test]
6097 async fn a_project_change_burst_coalesces_through_did_change() {
6098 let src = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
6099 let s = scratch_project(
6100 "f_burst",
6101 &[
6102 ("bynk.toml", "[project]\nname=\"q\"\n"),
6103 ("src/a.bynk", src),
6104 ],
6105 );
6106 let backend = backend_at(&s.0).await;
6107 let root = backend.test_root().await;
6108 let uri = file_uri(&s.0, "src/a.bynk");
6109 open(&backend, &uri, src).await;
6110
6111 let gen_before = {
6112 let state = backend.state.read().await;
6113 state.projects.get(&root).unwrap().analysis_generation
6114 };
6115 for v in 2..=5 {
6116 backend
6117 .did_change(change_params(
6118 &uri,
6119 v,
6120 &format!("{}{src}", "\n".repeat(v as usize)),
6121 ))
6122 .await;
6123 }
6124 let gen_after = {
6125 let state = backend.state.read().await;
6126 state.projects.get(&root).unwrap().analysis_generation
6127 };
6128 assert_eq!(
6129 gen_after - gen_before,
6130 4,
6131 "each of the four changes bumps the generation once — only the last \
6132 scheduled round runs (no per-change round, no stacked debounce)",
6133 );
6134 }
6135
6136 fn completion_labels(response: Option<CompletionResponse>) -> Vec<String> {
6146 match response {
6147 Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(),
6148 Some(CompletionResponse::List(list)) => {
6149 list.items.into_iter().map(|i| i.label).collect()
6150 }
6151 None => Vec::new(),
6152 }
6153 }
6154
6155 async fn complete_at(backend: &Backend, uri: &Url, text: &str, needle: &str) -> Vec<String> {
6156 let offset = text.find(needle).expect("needle present") + needle.len();
6157 let pos = crate::position::offset_to_position(text, offset);
6158 let response = backend
6159 .completion(CompletionParams {
6160 text_document_position: TextDocumentPositionParams {
6161 text_document: TextDocumentIdentifier { uri: uri.clone() },
6162 position: pos,
6163 },
6164 work_done_progress_params: Default::default(),
6165 partial_result_params: Default::default(),
6166 context: None,
6167 })
6168 .await
6169 .expect("completion must not error");
6170 completion_labels(response)
6171 }
6172
6173 #[tokio::test]
6179 async fn store_map_receiver_completion_merges_both_vocabularies() {
6180 let src = "context shop\n\nagent Inventory {\n key id: String\n store items: Map[String, Int]\n\n on call f() -> Effect[()] {\n items.\n }\n}\n";
6181 let s = scratch_project(
6182 "store_map_merge",
6183 &[
6184 ("bynk.toml", "[project]\nname=\"shop\"\n"),
6185 ("src/a.bynk", src),
6186 ],
6187 );
6188 let backend = backend_at(&s.0).await;
6189 let uri = file_uri(&s.0, "src/a.bynk");
6190 open(&backend, &uri, src).await;
6191 backend.run_round().await;
6192
6193 let labels = complete_at(&backend, &uri, src, " items.").await;
6194 assert!(
6195 labels.contains(&"filter".to_string()),
6196 "the Query kernel vocabulary: {labels:?}"
6197 );
6198 assert!(
6199 labels.contains(&"collect".to_string()),
6200 "the Query kernel vocabulary: {labels:?}"
6201 );
6202 assert!(
6203 labels.contains(&"put".to_string()),
6204 "the store entry ops: {labels:?}"
6205 );
6206 assert!(
6207 labels.contains(&"entries".to_string()),
6208 "the Map query accessors: {labels:?}"
6209 );
6210 }
6211
6212 #[tokio::test]
6223 async fn store_field_vocabulary_survives_an_unrelated_resolve_failure() {
6224 let src = "context shop\n\ntype Bad = { x: NoSuchType }\n\nagent Inventory {\n key id: String\n store items: Map[String, Int]\n\n on call f() -> Effect[()] {\n items.\n }\n}\n";
6225 let s = scratch_project(
6226 "store_map_resolve_gap",
6227 &[
6228 ("bynk.toml", "[project]\nname=\"shop\"\n"),
6229 ("src/a.bynk", src),
6230 ],
6231 );
6232 let backend = backend_at(&s.0).await;
6233 let uri = file_uri(&s.0, "src/a.bynk");
6234 open(&backend, &uri, src).await;
6235 backend.run_round().await;
6236
6237 let analysis = backend.test_analysis().await.expect("a round committed");
6241 let rel = Backend::uri_to_rel(&analysis, &uri).expect("uri resolves");
6242 assert!(
6243 analysis
6244 .diagnostics
6245 .get(&rel)
6246 .is_some_and(|ds| !ds.is_empty()),
6247 "the fixture must actually fail to check — an undeclared return \
6248 type is the trigger this test exercises",
6249 );
6250
6251 let labels = complete_at(&backend, &uri, src, " items.").await;
6252 assert!(
6258 !labels.contains(&"filter".to_string()),
6259 "the fixture must blank the typed half too, or this doesn't test \
6260 the gap: {labels:?}"
6261 );
6262 assert!(
6263 labels.contains(&"put".to_string()),
6264 "store entry ops must survive an unrelated resolve failure: {labels:?}"
6265 );
6266 assert!(
6267 labels.contains(&"entries".to_string()),
6268 "Map query accessors must survive an unrelated resolve failure: {labels:?}"
6269 );
6270 }
6271}