1pub mod architecture_request;
32pub mod capability_fixes;
33pub mod code_actions;
34pub mod completion;
35mod content;
36mod document_symbols;
37pub mod documentation_request;
38mod extract;
39pub mod hover;
40pub mod index_queries;
41mod inlay_hints;
42mod locals_nav;
43pub mod position;
44mod project;
45mod publish;
46pub mod sequence_request;
47mod signature_help;
48mod structure;
49pub mod symbols;
50pub mod transport;
51pub mod wire_contract_request;
52
53use std::path::PathBuf;
54use std::sync::Arc;
55
56use tokio::sync::RwLock;
57use tower_lsp::jsonrpc::Result as JsonRpcResult;
58use tower_lsp::lsp_types::request::{
59 GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
60 GotoTypeDefinitionResponse,
61};
62use tower_lsp::lsp_types::*;
63use tower_lsp::{Client, LanguageServer, LspService, Server};
64
65use crate::project::ProjectConfig;
66
67const SERVER_NAME: &str = "bynkc-lsp";
68const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
69
70#[derive(Debug, Clone)]
72struct DocumentState {
73 text: String,
74 version: i32,
75}
76
77#[derive(Debug)]
82struct Analysis {
83 project_root: PathBuf,
88 index: bynk_check::index::ProjectIndex,
89 snapshots: std::collections::HashMap<PathBuf, String>,
91 versions: std::collections::HashMap<PathBuf, i32>,
94 diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>>,
100 hints: bynk_check::hints::FileHints,
103 requirements: bynk_check::requirements::FileRequirements,
107 locals: bynk_check::locals::FileLocals,
111 expr_types: bynk_check::expr_types::FileExprTypes,
114 ty_intern: std::sync::Arc<bynk_check::checker::Types>,
118 unit_sources: std::collections::HashMap<String, Vec<PathBuf>>,
121 sequence_info: std::collections::HashMap<String, bynk_ide::ContextSequenceInfo>,
125 boundary_info: std::collections::HashMap<String, bynk_ide::ContextBoundaryInfo>,
129 doc_scope: std::collections::HashMap<String, Vec<String>>,
134}
135
136impl Analysis {
137 fn diag_categories(&self) -> Vec<(PathBuf, String)> {
140 self.diagnostics
141 .iter()
142 .flat_map(|(path, diags)| {
143 diags
144 .iter()
145 .map(|d| (path.clone(), d.error.category.to_string()))
146 })
147 .collect()
148 }
149}
150
151#[derive(Debug, Default)]
156struct ProjectState {
157 config: ProjectConfig,
161 analysis: Option<Arc<Analysis>>,
165 published: std::collections::HashSet<Url>,
170 analysis_generation: u64,
174 analysis_round_started: u64,
180 analysis_round_committed: u64,
182 analysis_failed: bool,
185 #[cfg(test)]
189 panic_next_round: bool,
190}
191
192#[derive(Debug, Clone, Copy, Default)]
196struct RefreshSupport {
197 semantic_tokens: bool,
198 inlay_hints: bool,
199 code_lens: bool,
200}
201
202#[derive(Debug, Default)]
205struct State {
206 projects: std::collections::HashMap<PathBuf, ProjectState>,
212 folders: Vec<PathBuf>,
217 docs: std::collections::HashMap<Url, DocumentState>,
220 supports_dynamic_watchers: bool,
226 supports_refresh: RefreshSupport,
232 single_file_generations: std::collections::HashMap<Url, u64>,
237 root_cache: std::collections::HashMap<Url, Option<PathBuf>>,
254 root_cache_generation: u64,
261}
262
263#[derive(Clone)]
264pub struct Backend {
265 client: Client,
266 state: Arc<RwLock<State>>,
267 refresh_lock: Arc<tokio::sync::Mutex<()>>,
272 shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
276}
277
278fn describe_join_error(e: tokio::task::JoinError) -> String {
281 if !e.is_panic() {
282 return format!("cancelled ({e})");
283 }
284 let payload = e.into_panic();
285 if let Some(s) = payload.downcast_ref::<&str>() {
286 format!("panicked: {s}")
287 } else if let Some(s) = payload.downcast_ref::<String>() {
288 format!("panicked: {s}")
289 } else {
290 "panicked with a non-string payload".to_string()
291 }
292}
293
294impl Backend {
295 #[cfg(test)]
296 fn new(client: Client) -> Self {
297 Self::with_shutdown_flag(client, Arc::default())
298 }
299
300 fn with_shutdown_flag(
301 client: Client,
302 shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
303 ) -> Self {
304 Self {
305 client,
306 state: Arc::new(RwLock::new(State::default())),
307 refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
308 shutdown_requested,
309 }
310 }
311
312 fn find_project_root(start: &std::path::Path) -> Option<PathBuf> {
315 let mut current = if start.is_file() {
316 start.parent()?.to_path_buf()
317 } else {
318 start.to_path_buf()
319 };
320 loop {
321 let candidate = current.join("bynk.toml");
322 if candidate.is_file() {
323 return Some(current);
324 }
325 current = current.parent()?.to_path_buf();
326 }
327 }
328
329 fn find_source_root(start: &std::path::Path) -> Option<PathBuf> {
335 let mut current = if start.is_file() {
336 start.parent()?.to_path_buf()
337 } else {
338 start.to_path_buf()
339 };
340 loop {
341 if current.file_name().and_then(|n| n.to_str()) == Some("src") {
342 return Some(current);
343 }
344 current = current.parent()?.to_path_buf();
345 }
346 }
347
348 fn resolve_root(start: &std::path::Path) -> Option<(PathBuf, project::ProjectConfig)> {
355 if let Some(root) = Self::find_project_root(start) {
356 let config = project::load_config(&root).unwrap_or_default();
357 return Some((root, config));
358 }
359 let src = Self::find_source_root(start)?;
364 let root = src.parent()?.to_path_buf();
365 Some((root, project::ProjectConfig::default()))
366 }
367
368 fn resolve_canonical(uri: &Url) -> Option<(PathBuf, project::ProjectConfig)> {
375 let path = uri.to_file_path().ok()?;
376 let (root, config) = Self::resolve_root(&path)?;
377 Some((root.canonicalize().unwrap_or(root), config))
378 }
379
380 fn root_for_uri_uncached(uri: &Url) -> Option<PathBuf> {
387 Self::resolve_canonical(uri).map(|(root, _)| root)
388 }
389
390 async fn root_for_uri(&self, uri: &Url) -> Option<PathBuf> {
407 let generation = {
408 let state = self.state.read().await;
409 if let Some(cached) = state.root_cache.get(uri) {
410 return cached.clone();
411 }
412 state.root_cache_generation
413 };
414 let root = Self::root_for_uri_uncached(uri);
415 let mut state = self.state.write().await;
416 if Self::root_cache_write_is_current(generation, state.root_cache_generation) {
417 state.root_cache.insert(uri.clone(), root.clone());
418 }
419 root
420 }
421
422 fn root_cache_write_is_current(read_generation: u64, current_generation: u64) -> bool {
430 read_generation == current_generation
431 }
432
433 fn discover_projects_under(folder: &std::path::Path) -> Vec<PathBuf> {
445 fn should_skip(name: &std::ffi::OsStr) -> bool {
446 let name = name.to_string_lossy();
447 matches!(name.as_ref(), "out" | "node_modules" | "target" | ".git")
448 || name.starts_with('.')
449 }
450 fn walk(
451 dir: &std::path::Path,
452 out: &mut Vec<PathBuf>,
453 visited: &mut std::collections::HashSet<PathBuf>,
454 ) {
455 let canon_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
458 if !visited.insert(canon_dir.clone()) {
459 return;
460 }
461 if dir.join("bynk.toml").is_file() && !out.contains(&canon_dir) {
462 out.push(canon_dir);
463 }
464 let Ok(entries) = std::fs::read_dir(dir) else {
465 return;
466 };
467 for entry in entries.flatten() {
468 let path = entry.path();
469 if path.is_dir() && !should_skip(&entry.file_name()) {
470 walk(&path, out, visited);
471 }
472 }
473 }
474 let mut roots = Vec::new();
475 if let Some((root, _)) = Self::resolve_root(folder) {
477 let canon = root.canonicalize().unwrap_or(root);
478 roots.push(canon);
479 }
480 if folder.join("src").is_dir() && !folder.join("bynk.toml").is_file() {
485 let canon = folder
486 .canonicalize()
487 .unwrap_or_else(|_| folder.to_path_buf());
488 if !roots.contains(&canon) {
489 roots.push(canon);
490 }
491 }
492 let mut visited = std::collections::HashSet::new();
493 walk(folder, &mut roots, &mut visited);
494 roots
495 }
496
497 async fn schedule_diagnostics(&self, uri: &Url) {
504 if let Some(root) = self.root_for_uri(uri).await {
510 {
511 let mut state = self.state.write().await;
512 if !state.projects.contains_key(&root) {
513 let config = project::load_config(&root).unwrap_or_default();
514 state.projects.insert(
515 root.clone(),
516 ProjectState {
517 config,
518 ..Default::default()
519 },
520 );
521 }
522 }
523 self.schedule_project_diagnostics(root).await;
524 } else {
525 self.schedule_single_file(uri.clone()).await;
526 }
527 }
528
529 async fn schedule_project_diagnostics(&self, root: PathBuf) {
539 let (generation, debounce) = {
540 let mut state = self.state.write().await;
541 let Some(ps) = state.projects.get_mut(&root) else {
542 return;
543 };
544 ps.analysis_generation += 1;
545 (ps.analysis_generation, ps.config.diagnostics_debounce_ms)
546 };
547 let this = self.clone();
548 tokio::spawn(async move {
549 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
550 let superseded = match this.state.read().await.projects.get(&root) {
551 Some(ps) => ps.analysis_generation != generation,
552 None => true, };
554 if superseded {
555 return;
556 }
557 this.run_project_diagnostics(root).await;
558 });
559 }
560
561 async fn schedule_single_file(&self, uri: Url) {
567 let debounce = ProjectConfig::default().diagnostics_debounce_ms;
568 let generation = {
569 let mut state = self.state.write().await;
570 let g = state
571 .single_file_generations
572 .entry(uri.clone())
573 .or_insert(0);
574 *g += 1;
575 *g
576 };
577 let this = self.clone();
578 tokio::spawn(async move {
579 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
580 let current = this
581 .state
582 .read()
583 .await
584 .single_file_generations
585 .get(&uri)
586 .copied();
587 if current != Some(generation) {
588 return;
589 }
590 this.diagnose_single_file(&uri).await;
591 });
592 }
593
594 async fn diagnose_single_file(&self, uri: &Url) {
598 let (text, version) = {
599 let state = self.state.read().await;
600 match state.docs.get(uri) {
601 Some(d) => (d.text.clone(), d.version),
602 None => return,
603 }
604 };
605 let positions = crate::position::PositionMap::new(&text);
606 let lsp_diags: Vec<Diagnostic> = bynk_ide::diagnose(&text)
607 .into_iter()
608 .map(|d| make_diagnostic(&d, &positions, uri))
609 .collect();
610 self.client
611 .publish_diagnostics(uri.clone(), lsp_diags, Some(version))
612 .await;
613 }
614
615 async fn report_failed_round(&self, root: &std::path::Path, reason: String) {
620 tracing::error!("analysis of {} {reason}", root.display());
621 let first_failure = {
622 let mut state = self.state.write().await;
623 match state.projects.get_mut(root) {
624 Some(ps) => !std::mem::replace(&mut ps.analysis_failed, true),
625 None => false,
626 }
627 };
628 if first_failure {
629 self.client
630 .show_message(
631 MessageType::ERROR,
632 format!(
633 "Bynk: analysing {} failed with an internal error, so its diagnostics may \
634 be out of date until a later edit analyses cleanly. Details are in \
635 ~/.bynk-lsp.log; please report it.",
636 root.display()
637 ),
638 )
639 .await;
640 }
641 }
642
643 async fn run_project_diagnostics(&self, root: PathBuf) {
648 let (round, root, canonical_root, overlay, versions, previously_dirty) = {
649 let mut state = self.state.write().await;
650 let Some(ps) = state.projects.get_mut(&root) else {
654 return;
655 };
656 ps.analysis_round_started += 1;
657 let round = ps.analysis_round_started;
658 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
662 let previously_dirty = ps.published.clone();
663 let mut overlay = std::collections::HashMap::new();
664 let mut versions = std::collections::HashMap::new();
665 for (uri, doc) in &state.docs {
671 if let Ok(p) = uri.to_file_path() {
672 let canonical = p.canonicalize().unwrap_or(p);
673 if let Ok(rel) = canonical.strip_prefix(&canonical_root) {
676 versions.insert(rel.to_path_buf(), doc.version);
677 }
678 overlay.insert(canonical, doc.text.clone());
679 }
680 }
681 (
682 round,
683 root,
684 canonical_root,
685 overlay,
686 versions,
687 previously_dirty,
688 )
689 };
690
691 let roots = bynk_ide::AnalysisRoots::Project(root.clone());
693 #[cfg(test)]
698 let inject_panic = self
699 .state
700 .write()
701 .await
702 .projects
703 .get_mut(&root)
704 .is_some_and(|ps| std::mem::take(&mut ps.panic_next_round));
705 let joined = tokio::task::spawn_blocking(move || {
706 #[cfg(test)]
707 if inject_panic {
708 panic!("injected analysis panic");
709 }
710 let content = crate::content::sweep_project_content(&roots, &overlay);
711 bynk_ide::diagnose_project_with(&roots, &content)
712 })
713 .await;
714 let result = match joined {
715 Ok(result) => result,
716 Err(e) => {
717 self.report_failed_round(&root, describe_join_error(e))
718 .await;
719 return;
720 }
721 };
722
723 let mut new_by_uri: std::collections::HashMap<Url, Vec<Diagnostic>> =
724 std::collections::HashMap::new();
725 let mut version_by_uri: std::collections::HashMap<Url, Option<i32>> =
730 std::collections::HashMap::new();
731 let mut snapshots = std::collections::HashMap::new();
732 let mut diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>> =
733 std::collections::HashMap::new();
734 for file in &result.files {
735 let abs = canonical_root.join(&file.source_path);
736 let abs = abs.canonicalize().unwrap_or(abs);
737 let Ok(uri) = Url::from_file_path(&abs) else {
738 continue;
739 };
740 let positions = crate::position::PositionMap::new(&file.text);
743 let diags: Vec<Diagnostic> = file
744 .diagnostics
745 .iter()
746 .map(|d| make_diagnostic(d, &positions, &uri))
747 .collect();
748 version_by_uri.insert(uri.clone(), versions.get(&file.source_path).copied());
749 new_by_uri.insert(uri, diags);
750 diagnostics.insert(file.source_path.clone(), file.diagnostics.clone());
751 snapshots.insert(file.source_path.clone(), file.text.clone());
752 }
753 {
757 let analysis = Arc::new(Analysis {
758 project_root: canonical_root.clone(),
759 index: result.index.clone(),
760 snapshots,
761 versions,
762 diagnostics,
763 hints: result.hints,
764 requirements: result.requirements,
765 locals: result.locals,
766 expr_types: result.expr_types,
767 ty_intern: result.ty_intern,
768 unit_sources: result.unit_sources,
769 sequence_info: result.sequence_info,
770 boundary_info: result.boundary_info,
771 doc_scope: result.doc_scope,
772 });
773 let mut state = self.state.write().await;
774 let Some(ps) = state.projects.get_mut(&root) else {
775 return; };
777 if ps.analysis_round_committed >= round {
780 return;
781 }
782 ps.analysis_round_committed = round;
783 ps.analysis_failed = false;
784 ps.analysis = Some(analysis);
785 }
786 let unattributed_anchor = {
791 let toml = root.join("bynk.toml");
792 if toml.is_file() {
793 Url::from_file_path(toml).ok()
794 } else {
795 result.files.first().and_then(|f| {
796 let abs = canonical_root.join(&f.source_path);
797 let abs = abs.canonicalize().unwrap_or(abs);
798 Url::from_file_path(abs).ok()
799 })
800 }
801 };
802 if !result.unattributed.is_empty()
803 && let Some(anchor_uri) = unattributed_anchor
804 {
805 let entry = new_by_uri.entry(anchor_uri).or_default();
806 for d in &result.unattributed {
807 entry.push(Diagnostic {
808 range: Default::default(),
809 severity: Some(match d.severity {
810 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
811 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
812 }),
813 code: Some(tower_lsp::lsp_types::NumberOrString::String(
814 d.error.category.to_string(),
815 )),
816 message: d.error.message.clone(),
817 ..Default::default()
818 });
819 }
820 }
821
822 let (publishes, dirty) = publish::publish_plan(&previously_dirty, new_by_uri);
823 for (uri, diags) in publishes {
824 let version = version_by_uri.get(&uri).copied().flatten();
829 self.client.publish_diagnostics(uri, diags, version).await;
830 }
831 let still_current = {
832 let mut state = self.state.write().await;
833 if let Some(ps) = state.projects.get_mut(&root)
834 && ps.analysis_round_committed == round
835 {
836 ps.published = dirty;
837 true
838 } else {
839 false
840 }
841 };
842 if still_current {
853 let refresh = self.state.read().await.supports_refresh;
854 if refresh.semantic_tokens || refresh.inlay_hints || refresh.code_lens {
855 let client = self.client.clone();
856 tokio::spawn(async move {
857 if refresh.semantic_tokens {
858 let _ = client.semantic_tokens_refresh().await;
859 }
860 if refresh.inlay_hints {
861 let _ = client.inlay_hint_refresh().await;
862 }
863 if refresh.code_lens {
864 let _ = client.code_lens_refresh().await;
865 }
866 });
867 }
868 }
869 }
870
871 async fn analysis_roots_for(&self, uri: &Url) -> Option<bynk_ide::AnalysisRoots> {
881 Some(bynk_ide::AnalysisRoots::Project(
882 self.root_for_uri(uri).await?,
883 ))
884 }
885
886 async fn project_content(
901 &self,
902 uri: &Url,
903 ) -> Option<Arc<std::collections::HashMap<PathBuf, String>>> {
904 let roots = self.analysis_roots_for(uri).await?;
905 let current = uri
906 .to_file_path()
907 .ok()
908 .and_then(|p| std::fs::canonicalize(&p).ok());
909 let overlay = {
910 let state = self.state.read().await;
911 let mut ov = std::collections::HashMap::new();
912 for (u, doc) in &state.docs {
913 if let Ok(p) = u.to_file_path() {
914 let canonical = p.canonicalize().unwrap_or(p);
915 ov.insert(canonical, doc.text.clone());
916 }
917 }
918 ov
919 };
920 tokio::task::spawn_blocking(move || {
921 let mut content = crate::content::sweep_project_content(&roots, &overlay);
922 if let Some(current) = current {
923 content.remove(¤t);
924 }
925 Arc::new(content)
929 })
930 .await
931 .map_err(|e| tracing::error!("project content sweep {}", describe_join_error(e)))
932 .ok()
933 }
934
935 fn local_sites(
938 &self,
939 analysis: &Analysis,
940 rel: &std::path::Path,
941 offset: usize,
942 ) -> Option<Vec<bynk_syntax::span::Span>> {
943 let text = analysis.snapshots.get(rel)?;
944 let locals = analysis.locals.get(rel)?;
945 crate::locals_nav::local_sites_at(locals, text, offset)
946 }
947
948 async fn locals_completions(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
954 let analysis = self.analysis_for(uri).await;
958 let Some(analysis) = analysis else {
959 return Vec::new();
960 };
961 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
962 return Vec::new();
963 };
964 let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
965 else {
966 return Vec::new();
967 };
968 let Some(offset) = crate::position::position_to_offset(text, pos) else {
969 return Vec::new();
970 };
971 bynk_check::locals::locals_at(locals, offset)
972 .into_iter()
973 .map(|b| CompletionItem {
974 label: b.name.clone(),
975 kind: Some(CompletionItemKind::VARIABLE),
976 detail: Some(b.ty.clone()),
977 ..Default::default()
978 })
979 .collect()
980 }
981
982 fn local_locations(
984 &self,
985 analysis: &Analysis,
986 rel: &std::path::Path,
987 spans: &[bynk_syntax::span::Span],
988 ) -> Vec<Location> {
989 let Some(text) = analysis.snapshots.get(rel) else {
990 return Vec::new();
991 };
992 let Ok(uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
993 return Vec::new();
994 };
995 spans
996 .iter()
997 .map(|s| Location {
998 uri: uri.clone(),
999 range: crate::position::span_to_range(text, *s),
1000 })
1001 .collect()
1002 }
1003
1004 async fn value_member_completions(
1023 &self,
1024 uri: &Url,
1025 text: &str,
1026 offset: usize,
1027 ) -> Vec<CompletionItem> {
1028 let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(text, offset)
1029 else {
1030 return Vec::new();
1031 };
1032 let mut items: Vec<CompletionItem> = Vec::new();
1033 if let Some((ty, tys)) = self
1034 .type_receiver(uri, rewritten.clone(), recv_offset)
1035 .await
1036 {
1037 let files = self.project_content(uri).await;
1038 items.extend(
1039 completion::value_member_candidates(ty, &tys, text, files.as_deref())
1040 .into_iter()
1041 .map(to_completion_item),
1042 );
1043 }
1044 let locals = self.fast_path_locals(uri, &rewritten).await;
1045 items.extend(
1046 completion::store_field_member_candidates(&rewritten, recv_offset, &locals)
1047 .into_iter()
1048 .map(to_completion_item),
1049 );
1050 items
1051 }
1052
1053 async fn fast_path_locals(
1059 &self,
1060 uri: &Url,
1061 rewritten: &str,
1062 ) -> Vec<bynk_check::locals::LocalBinding> {
1063 let Some(analysis) = self.project_analysis_for(uri).await else {
1064 return Vec::new();
1065 };
1066 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1067 return Vec::new();
1068 };
1069 if analysis.snapshots.get(&rel).map(String::as_str) != Some(rewritten) {
1070 return Vec::new();
1071 }
1072 analysis.locals.get(&rel).cloned().unwrap_or_default()
1073 }
1074
1075 async fn is_pattern_completions(
1080 &self,
1081 uri: &Url,
1082 text: &str,
1083 offset: usize,
1084 ) -> Vec<CompletionItem> {
1085 let Some(scrut_off) = is_scrutinee_offset(text, offset) else {
1086 return Vec::new();
1087 };
1088 self.scrutinee_variant_completions(uri, text, scrut_off)
1089 .await
1090 }
1091
1092 async fn match_arm_completions(
1096 &self,
1097 uri: &Url,
1098 text: &str,
1099 offset: usize,
1100 ) -> Vec<CompletionItem> {
1101 let Some(scrut_off) = match_scrutinee_offset(text, offset) else {
1102 return Vec::new();
1103 };
1104 self.scrutinee_variant_completions(uri, text, scrut_off)
1105 .await
1106 }
1107
1108 async fn scrutinee_variant_completions(
1115 &self,
1116 uri: &Url,
1117 text: &str,
1118 scrut_off: usize,
1119 ) -> Vec<CompletionItem> {
1120 let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1121 return Vec::new();
1122 };
1123 let files = self.project_content(uri).await;
1124 completion::variants_for_ty(ty, &tys, text, files.as_deref())
1125 .into_iter()
1126 .map(to_completion_item)
1127 .collect()
1128 }
1129
1130 async fn nested_pattern_completions(
1137 &self,
1138 uri: &Url,
1139 text: &str,
1140 offset: usize,
1141 ) -> Vec<CompletionItem> {
1142 let Some((scrut_off, variant)) = nested_pattern_offset(text, offset) else {
1143 return Vec::new();
1144 };
1145 let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1146 return Vec::new();
1147 };
1148 let files = self.project_content(uri).await;
1149 completion::nested_variant_completions(ty, &tys, &variant, text, files.as_deref())
1150 .into_iter()
1151 .map(to_completion_item)
1152 .collect()
1153 }
1154
1155 async fn type_receiver(
1160 &self,
1161 uri: &Url,
1162 rewritten: String,
1163 recv_offset: usize,
1164 ) -> Option<(
1165 bynk_check::checker::TyId,
1166 std::sync::Arc<bynk_check::checker::Types>,
1167 )> {
1168 let roots = self.analysis_roots_for(uri).await?;
1172 let project_root = roots.project_root().to_path_buf();
1173 let canonical_root = project_root
1174 .canonicalize()
1175 .unwrap_or_else(|_| project_root.clone());
1176 let cur = uri.to_file_path().ok()?;
1177 let cur = cur.canonicalize().unwrap_or(cur);
1178 let rel = cur.strip_prefix(&canonical_root).ok()?.to_path_buf();
1180 let overlay = {
1182 let state = self.state.read().await;
1183 let mut ov = std::collections::HashMap::new();
1184 for (u, doc) in &state.docs {
1185 if let Ok(p) = u.to_file_path() {
1186 let canonical = p.canonicalize().unwrap_or(p);
1187 let t = if u == uri {
1188 rewritten.clone()
1189 } else {
1190 doc.text.clone()
1191 };
1192 ov.insert(canonical, t);
1193 }
1194 }
1195 ov
1196 };
1197 if let Some(analysis) = self.project_analysis_for(uri).await
1203 && analysis.snapshots.get(&rel).map(String::as_str) == Some(rewritten.as_str())
1204 && let Some((_, entries)) = analysis.expr_types.iter().find(|(p, _)| **p == rel)
1205 {
1206 return bynk_check::expr_types::type_at_offset(entries, recv_offset)
1207 .map(|t| (t, std::sync::Arc::clone(&analysis.ty_intern)));
1208 }
1209 let result = tokio::task::spawn_blocking(move || {
1213 let content = crate::content::sweep_project_content(&roots, &overlay);
1214 bynk_ide::diagnose_project_with(&roots, &content)
1215 })
1216 .await
1217 .map_err(|e| tracing::error!("receiver-typing analysis {}", describe_join_error(e)))
1218 .ok()?;
1219 let (_, entries) = result.expr_types.iter().find(|(p, _)| **p == rel)?;
1220 bynk_check::expr_types::type_at_offset(entries, recv_offset)
1221 .map(|t| (t, std::sync::Arc::clone(&result.ty_intern)))
1222 }
1223
1224 async fn project_analysis(&self, root: &std::path::Path) -> Option<Arc<Analysis>> {
1227 self.state.read().await.projects.get(root)?.analysis.clone()
1228 }
1229
1230 async fn project_analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1234 let root = self.root_for_uri(uri).await?;
1235 self.project_analysis(&root).await
1236 }
1237
1238 async fn ensure_project_analysed(
1243 &self,
1244 root: PathBuf,
1245 config: ProjectConfig,
1246 ) -> Option<Arc<Analysis>> {
1247 {
1248 let mut state = self.state.write().await;
1249 state
1250 .projects
1251 .entry(root.clone())
1252 .or_insert_with(|| ProjectState {
1253 config,
1254 ..Default::default()
1255 });
1256 }
1257 if let Some(a) = self.project_analysis(&root).await {
1258 return Some(a);
1259 }
1260 self.refresh_now(root.clone()).await;
1261 self.project_analysis(&root).await
1262 }
1263
1264 async fn prune_orphaned_projects(&self) -> Vec<Url> {
1275 let (folders, open_uris) = {
1298 let state = self.state.read().await;
1299 (
1300 state.folders.clone(),
1301 state.docs.keys().cloned().collect::<Vec<_>>(),
1302 )
1303 };
1304 let open_roots: std::collections::HashSet<PathBuf> = open_uris
1305 .iter()
1306 .filter_map(Self::root_for_uri_uncached)
1307 .collect();
1308 let covered = |root: &std::path::Path| {
1309 folders
1310 .iter()
1311 .any(|f| f.starts_with(root) || root.starts_with(f))
1312 || open_roots.contains(root)
1313 };
1314 let mut state = self.state.write().await;
1315 let orphaned: Vec<PathBuf> = state
1316 .projects
1317 .keys()
1318 .filter(|r| !covered(r))
1319 .cloned()
1320 .collect();
1321 let mut to_clear = Vec::new();
1322 for root in orphaned {
1323 if let Some(ps) = state.projects.remove(&root) {
1324 to_clear.extend(ps.published);
1325 }
1326 }
1327 to_clear
1328 }
1329
1330 async fn warm_projects(&self, folders: &[PathBuf]) {
1337 if folders.is_empty() {
1338 return;
1339 }
1340 let folders = folders.to_vec();
1344 let roots = tokio::task::spawn_blocking(move || {
1345 let mut roots: Vec<PathBuf> = Vec::new();
1346 for folder in &folders {
1347 for root in Self::discover_projects_under(folder) {
1348 if !roots.contains(&root) {
1349 roots.push(root);
1350 }
1351 }
1352 }
1353 roots
1354 })
1355 .await
1356 .unwrap_or_else(|e| {
1357 tracing::error!("project discovery {}", describe_join_error(e));
1358 Vec::new()
1359 });
1360 for root in roots {
1361 let config = project::load_config(&root).unwrap_or_default();
1362 {
1363 let mut state = self.state.write().await;
1364 state
1365 .projects
1366 .entry(root.clone())
1367 .or_insert_with(|| ProjectState {
1368 config,
1369 ..Default::default()
1370 });
1371 }
1372 self.schedule_project_diagnostics(root).await;
1373 }
1374 }
1375
1376 async fn register_file_watchers(&self) {
1382 use tower_lsp::lsp_types::{
1383 DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, Registration,
1384 };
1385 let watchers = ["**/*.bynk", "**/bynk.toml"]
1386 .into_iter()
1387 .map(|g| FileSystemWatcher {
1388 glob_pattern: GlobPattern::String(g.to_string()),
1389 kind: None, })
1391 .collect();
1392 let registration = Registration {
1393 id: "bynk-watched-files".to_string(),
1394 method: "workspace/didChangeWatchedFiles".to_string(),
1395 register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
1396 watchers,
1397 })
1398 .ok(),
1399 };
1400 if let Err(e) = self.client.register_capability(vec![registration]).await {
1401 self.client
1402 .log_message(
1403 MessageType::WARNING,
1404 format!("bynkc-lsp: file-watcher registration failed: {e}"),
1405 )
1406 .await;
1407 }
1408 }
1409
1410 async fn config_for(&self, uri: &Url) -> ProjectConfig {
1414 let Some(root) = self.root_for_uri(uri).await else {
1415 return ProjectConfig::default();
1416 };
1417 self.state
1418 .read()
1419 .await
1420 .projects
1421 .get(&root)
1422 .map(|p| p.config.clone())
1423 .unwrap_or_default()
1424 }
1425
1426 async fn analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1446 let root = self.root_for_uri(uri).await?;
1447 let want = self.state.read().await.docs.get(uri).map(|d| d.version);
1452 let current = |a: &Arc<Analysis>| {
1453 let Some(rel) = Self::uri_to_rel(a, uri) else {
1454 return false; };
1456 if !a.snapshots.contains_key(&rel) {
1461 return false;
1462 }
1463 match want {
1464 Some(v) => a.versions.get(&rel) == Some(&v),
1467 None => true,
1470 }
1471 };
1472
1473 if let Some(a) = self.project_analysis(&root).await
1474 && current(&a)
1475 {
1476 return Some(a);
1477 }
1478
1479 let _guard = self.refresh_lock.lock().await;
1484 if let Some(a) = self.project_analysis(&root).await
1485 && current(&a)
1486 {
1487 return Some(a);
1488 }
1489 self.refresh_now(root.clone()).await;
1490 let a = self.project_analysis(&root).await?;
1491 current(&a).then_some(a)
1495 }
1496
1497 async fn committed_analysis(&self, uri: &Url) -> Option<Arc<Analysis>> {
1522 let root = self.root_for_uri(uri).await?;
1523 let a = self.project_analysis(&root).await?;
1524 let rel = Self::uri_to_rel(&a, uri)?;
1527 a.snapshots.contains_key(&rel).then_some(a)
1528 }
1529
1530 async fn analysis_covering_open_buffers(
1551 &self,
1552 root: &std::path::Path,
1553 ) -> Option<Arc<Analysis>> {
1554 let all_current =
1558 |a: &Arc<Analysis>, docs: &std::collections::HashMap<Url, DocumentState>| {
1559 docs.iter()
1560 .all(|(uri, doc)| match Self::uri_to_rel(a, uri) {
1561 Some(rel) if a.snapshots.contains_key(&rel) => {
1562 a.versions.get(&rel) == Some(&doc.version)
1563 }
1564 _ => true,
1565 })
1566 };
1567
1568 {
1569 let state = self.state.read().await;
1570 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1571 && all_current(&a, &state.docs)
1572 {
1573 return Some(a);
1574 }
1575 }
1576 let _guard = self.refresh_lock.lock().await;
1577 {
1578 let state = self.state.read().await;
1579 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1580 && all_current(&a, &state.docs)
1581 {
1582 return Some(a);
1583 }
1584 }
1585 self.refresh_now(root.to_path_buf()).await;
1586 let state = self.state.read().await;
1587 let a = state.projects.get(root).and_then(|p| p.analysis.clone())?;
1588 all_current(&a, &state.docs).then_some(a)
1589 }
1590
1591 async fn refresh_now(&self, root: PathBuf) {
1596 if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
1597 ps.analysis_generation += 1;
1598 }
1599 self.run_project_diagnostics(root).await;
1600 }
1601
1602 fn uri_to_rel(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1604 let p = uri.to_file_path().ok()?;
1605 let canonical = p.canonicalize().unwrap_or(p);
1606 canonical
1611 .strip_prefix(&analysis.project_root)
1612 .ok()
1613 .map(|r| r.to_path_buf())
1614 }
1615
1616 fn uri_to_rel_for_new_path(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1627 let p = uri.to_file_path().ok()?;
1628 let file_name = p.file_name()?;
1629 let parent = p.parent()?;
1630 let canonical_parent = parent
1631 .canonicalize()
1632 .unwrap_or_else(|_| parent.to_path_buf());
1633 canonical_parent
1634 .join(file_name)
1635 .strip_prefix(&analysis.project_root)
1636 .ok()
1637 .map(|r| r.to_path_buf())
1638 }
1639
1640 async fn unit_reference_definition(&self, uri: &Url, pos: Position) -> Option<Location> {
1646 let analysis = self.analysis_for(uri).await;
1652 let text = self
1653 .state
1654 .read()
1655 .await
1656 .docs
1657 .get(uri)
1658 .map(|d| d.text.clone());
1659 let (text, analysis) = (text?, analysis?);
1660 let offset = cursor_offset(&text, pos);
1661 for (unit, span) in crate::symbols::unit_reference_spans(&text) {
1662 if span.start <= offset && offset <= span.end {
1663 let rel = analysis.unit_sources.get(&unit)?.first()?;
1664 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
1665 return Some(Location {
1666 uri: target,
1667 range: Range::default(),
1668 });
1669 }
1670 }
1671 None
1672 }
1673
1674 fn site_to_location(
1677 analysis: &Analysis,
1678 site: &bynk_check::index::SiteRef,
1679 ) -> Option<Location> {
1680 let text = analysis.snapshots.get(&site.path)?;
1681 let abs = analysis.project_root.join(&site.path);
1682 let uri = Url::from_file_path(abs).ok()?;
1683 Some(Location {
1684 uri,
1685 range: crate::position::span_to_range(text, site.span),
1686 })
1687 }
1688
1689 fn call_hierarchy_item(
1694 analysis: &Analysis,
1695 key: &bynk_check::index::SymbolKey,
1696 def: &bynk_check::index::SiteRef,
1697 ) -> Option<CallHierarchyItem> {
1698 let location = Self::site_to_location(analysis, def)?;
1699 Some(CallHierarchyItem {
1700 name: key.name.clone(),
1701 kind: lsp_symbol_kind(key.kind),
1702 tags: None,
1703 detail: Some(key.unit.clone()),
1704 uri: location.uri,
1705 range: location.range,
1706 selection_range: location.range,
1707 data: serde_json::to_value(SerKey::from(key)).ok(),
1708 })
1709 }
1710
1711 fn call_ranges(analysis: &Analysis, sites: &[&bynk_check::index::SiteRef]) -> Vec<Range> {
1714 sites
1715 .iter()
1716 .filter_map(|s| {
1717 let text = analysis.snapshots.get(&s.path)?;
1718 Some(crate::position::span_to_range(text, s.span))
1719 })
1720 .collect()
1721 }
1722
1723 async fn semantic_tokens_for(&self, uri: &Url, range: Option<Range>) -> Vec<SemanticToken> {
1728 let analysis = self.committed_analysis(uri).await;
1732 let Some(analysis) = analysis else {
1733 return Vec::new();
1734 };
1735 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1736 return Vec::new();
1737 };
1738 let Some(text) = analysis.snapshots.get(&rel) else {
1739 return Vec::new();
1740 };
1741 let span = match range {
1742 None => None,
1743 Some(r) => {
1746 let (Some(start), Some(end)) = (
1747 crate::position::position_to_offset(text, r.start),
1748 crate::position::position_to_offset(text, r.end),
1749 ) else {
1750 return Vec::new();
1751 };
1752 Some(bynk_syntax::span::Span::new(start, end))
1753 }
1754 };
1755 let lt = analysis
1756 .locals
1757 .get(&rel)
1758 .map(|l| crate::locals_nav::local_token_sites(l, text))
1759 .unwrap_or_default();
1760 let dt = crate::symbols::handler_annotation_token_spans(text);
1764 crate::index_queries::semantic_tokens(&analysis.index, <, &dt, &rel, text, span)
1765 }
1766
1767 async fn index_position(
1770 &self,
1771 uri: &Url,
1772 position: Position,
1773 ) -> Option<(Arc<Analysis>, PathBuf, usize)> {
1774 let analysis = self.analysis_for(uri).await?;
1779 let rel = Self::uri_to_rel(&analysis, uri)?;
1780 let text = analysis.snapshots.get(&rel)?;
1781 let offset = crate::position::position_to_offset(text, position)?;
1782 Some((analysis, rel, offset))
1783 }
1784
1785 async fn identifier_at(
1789 &self,
1790 uri: &Url,
1791 position: Position,
1792 ) -> Option<(String, bynk_syntax::span::Span, String)> {
1793 let text = {
1794 let state = self.state.read().await;
1795 state.docs.get(uri)?.text.clone()
1796 };
1797 let offset = crate::position::position_to_offset(&text, position)?;
1798 let tokens = bynk_syntax::lexer::tokenize_expanding_holes(&text).ok()?;
1802 for t in &tokens {
1804 if t.span.start <= offset
1805 && offset < t.span.end
1806 && matches!(
1807 t.kind,
1808 bynk_syntax::lexer::TokenKind::Ident
1809 | bynk_syntax::lexer::TokenKind::Int
1810 | bynk_syntax::lexer::TokenKind::String
1811 | bynk_syntax::lexer::TokenKind::Bool
1812 | bynk_syntax::lexer::TokenKind::Float
1813 | bynk_syntax::lexer::TokenKind::Result
1814 | bynk_syntax::lexer::TokenKind::Option
1815 | bynk_syntax::lexer::TokenKind::Effect
1816 )
1817 {
1818 let name = text[t.span.start..t.span.end].to_string();
1819 return Some((name, t.span, text));
1820 }
1821 }
1822 None
1823 }
1824
1825 async fn sequence_model(
1832 &self,
1833 params: sequence_request::SequenceModelParams,
1834 ) -> JsonRpcResult<Option<sequence_request::WireSequenceModel>> {
1835 let uri = params.text_document.uri;
1836 let Some(analysis) = self.committed_analysis(&uri).await else {
1837 return Ok(None);
1838 };
1839 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1840 return Ok(None);
1841 };
1842 let Some(text) = analysis.snapshots.get(&rel) else {
1843 return Ok(None);
1844 };
1845 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
1846 return Ok(None);
1847 };
1848 let info = bynk_ide::symbols::own_declaration_name(text)
1849 .and_then(|(name, _)| analysis.sequence_info.get(&name));
1850 let model = sequence_request::sequence_model_at(text, offset, info);
1851 Ok(model.map(|m| sequence_request::to_wire(&m, text)))
1852 }
1853
1854 async fn documentation_model(
1863 &self,
1864 params: documentation_request::DocumentationModelParams,
1865 ) -> JsonRpcResult<Option<documentation_request::WireDocModel>> {
1866 let uri = params.text_document.uri;
1867 let Some(analysis) = self.committed_analysis(&uri).await else {
1868 return Ok(None);
1869 };
1870 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1871 return Ok(None);
1872 };
1873 let Some(text) = analysis.snapshots.get(&rel) else {
1874 return Ok(None);
1875 };
1876 let model = documentation_request::documentation_model_at(text);
1877 Ok(model.map(|m| documentation_request::to_wire(&m, text)))
1878 }
1879
1880 async fn architecture_model(
1890 &self,
1891 params: architecture_request::ArchitectureModelParams,
1892 ) -> JsonRpcResult<Option<architecture_request::WireArchModel>> {
1893 let uri = params.text_document.uri;
1894 let Some(analysis) = self.committed_analysis(&uri).await else {
1895 return Ok(None);
1896 };
1897 let model = architecture_request::architecture_model_for(
1898 &analysis.unit_sources,
1899 &analysis.snapshots,
1900 &analysis.sequence_info,
1901 );
1902 Ok(Some(architecture_request::to_wire(
1903 &model,
1904 &analysis.project_root,
1905 &analysis.snapshots,
1906 )))
1907 }
1908
1909 async fn wire_contract(
1919 &self,
1920 params: wire_contract_request::WireContractParams,
1921 ) -> JsonRpcResult<Option<wire_contract_request::WcModel>> {
1922 let uri = params.text_document.uri;
1923 let Some(analysis) = self.committed_analysis(&uri).await else {
1924 return Ok(None);
1925 };
1926 let tys = &analysis.ty_intern;
1927 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1928 return Ok(None);
1929 };
1930 let Some(text) = analysis.snapshots.get(&rel) else {
1931 return Ok(None);
1932 };
1933 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
1934 return Ok(None);
1935 };
1936 let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text) else {
1939 return Ok(None);
1940 };
1941 let Some(info) = analysis.boundary_info.get(&unit) else {
1942 return Ok(None);
1943 };
1944 let expr_types = analysis
1945 .expr_types
1946 .get(&rel)
1947 .map(|v| v.as_slice())
1948 .unwrap_or(&[]);
1949 let context_count = bynk_ide::wire_contract::real_context_count(
1950 &analysis.boundary_info,
1951 &analysis.unit_sources,
1952 );
1953 let Some(model) = bynk_ide::wire_contract::wire_contract_at(
1954 &unit,
1955 text,
1956 offset,
1957 info,
1958 expr_types,
1959 tys,
1960 context_count,
1961 ) else {
1962 return Ok(None);
1963 };
1964 let search_order = analysis
1968 .doc_scope
1969 .get(&unit)
1970 .cloned()
1971 .unwrap_or_else(|| vec![unit.clone()]);
1972 Ok(Some(wire_contract_request::to_wire(
1973 &model,
1974 &analysis.project_root,
1975 text,
1976 &info.types,
1977 &analysis.index,
1978 &analysis.snapshots,
1979 &search_order,
1980 )))
1981 }
1982}
1983
1984#[tower_lsp::async_trait]
1985impl LanguageServer for Backend {
1986 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1987 let dynamic_watchers = params
1994 .capabilities
1995 .workspace
1996 .as_ref()
1997 .and_then(|w| w.did_change_watched_files.as_ref())
1998 .and_then(|d| d.dynamic_registration)
1999 .unwrap_or(false);
2000 let ws = params.capabilities.workspace.as_ref();
2004 let supports_refresh = RefreshSupport {
2005 semantic_tokens: ws
2006 .and_then(|w| w.semantic_tokens.as_ref())
2007 .and_then(|s| s.refresh_support)
2008 .unwrap_or(false),
2009 inlay_hints: ws
2010 .and_then(|w| w.inlay_hint.as_ref())
2011 .and_then(|i| i.refresh_support)
2012 .unwrap_or(false),
2013 code_lens: ws
2014 .and_then(|w| w.code_lens.as_ref())
2015 .and_then(|c| c.refresh_support)
2016 .unwrap_or(false),
2017 };
2018 {
2019 let mut state = self.state.write().await;
2020 state.supports_dynamic_watchers = dynamic_watchers;
2021 state.supports_refresh = supports_refresh;
2022 if let Some(folders) = ¶ms.workspace_folders {
2023 state.folders = folders
2024 .iter()
2025 .filter_map(|f| f.uri.to_file_path().ok())
2026 .map(|p| p.canonicalize().unwrap_or(p))
2027 .collect();
2028 }
2029 }
2030 Ok(InitializeResult {
2031 capabilities: server_capabilities(),
2032 server_info: Some(ServerInfo {
2033 name: SERVER_NAME.into(),
2034 version: Some(SERVER_VERSION.into()),
2035 }),
2036 })
2037 }
2038
2039 async fn initialized(&self, _: InitializedParams) {
2040 let (folders, dynamic) = {
2041 let s = self.state.read().await;
2042 (s.folders.clone(), s.supports_dynamic_watchers)
2043 };
2044 if dynamic {
2050 self.register_file_watchers().await;
2051 }
2052 self.warm_projects(&folders).await;
2055 let msg = if folders.is_empty() {
2056 "bynkc-lsp: no workspace folders; single-file mode".to_string()
2057 } else {
2058 format!(
2059 "bynkc-lsp: {} workspace folder(s); projects resolved per file",
2060 folders.len()
2061 )
2062 };
2063 self.client.log_message(MessageType::INFO, msg).await;
2064 }
2065
2066 async fn shutdown(&self) -> JsonRpcResult<()> {
2067 self.shutdown_requested
2068 .store(true, std::sync::atomic::Ordering::SeqCst);
2069 Ok(())
2070 }
2071
2072 async fn did_open(&self, params: DidOpenTextDocumentParams) {
2073 let uri = params.text_document.uri.clone();
2074 {
2075 let mut state = self.state.write().await;
2076 state.docs.insert(
2077 uri.clone(),
2078 DocumentState {
2079 text: params.text_document.text,
2080 version: params.text_document.version,
2081 },
2082 );
2083 }
2084 self.schedule_diagnostics(&uri).await;
2087 }
2088
2089 async fn did_change(&self, params: DidChangeTextDocumentParams) {
2090 let uri = params.text_document.uri.clone();
2091 {
2092 let mut state = self.state.write().await;
2093 if let Some(doc) = state.docs.get_mut(&uri)
2094 && let Some(change) = params.content_changes.into_iter().next_back()
2095 {
2096 doc.text = change.text;
2097 doc.version = params.text_document.version;
2098 }
2099 }
2100 if self.config_for(&uri).await.diagnostics_mode == crate::project::DiagnosticsMode::OnSave {
2105 return;
2106 }
2107 self.schedule_diagnostics(&uri).await;
2111 }
2112
2113 async fn did_save(&self, params: DidSaveTextDocumentParams) {
2114 self.schedule_diagnostics(¶ms.text_document.uri).await;
2117 }
2118
2119 async fn did_close(&self, params: DidCloseTextDocumentParams) {
2120 let uri = params.text_document.uri;
2121 {
2122 let mut state = self.state.write().await;
2123 state.docs.remove(&uri);
2124 state.single_file_generations.remove(&uri);
2127 }
2128 for cleared in self.prune_orphaned_projects().await {
2133 self.client
2134 .publish_diagnostics(cleared, Vec::new(), None)
2135 .await;
2136 }
2137 }
2138
2139 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
2144 let uri = params.text_document_position_params.text_document.uri;
2145 let pos = params.text_document_position_params.position;
2146 let positioned = self.index_position(&uri, pos).await;
2148 let doc_text = {
2151 let state = self.state.read().await;
2152 state.docs.get(&uri).map(|d| d.text.clone())
2153 };
2154 let doc = doc_text
2155 .as_deref()
2156 .and_then(|t| Some((t, crate::position::position_to_offset(t, pos)?)));
2157 let files = self.project_content(&uri).await;
2158 let analysis = positioned
2159 .as_ref()
2160 .map(|(a, rel, offset)| crate::hover::HoverAnalysis {
2161 index: &a.index,
2162 snapshots: &a.snapshots,
2163 locals: &a.locals,
2164 expr_types: &a.expr_types,
2165 tys: &a.ty_intern,
2166 rel,
2167 offset: *offset,
2168 project_root: &a.project_root,
2169 doc_scope: &a.doc_scope,
2170 boundary_info: &a.boundary_info,
2171 context_count: bynk_ide::wire_contract::real_context_count(
2174 &a.boundary_info,
2175 &a.unit_sources,
2176 ),
2177 });
2178 let content = crate::hover::hover_content(&crate::hover::HoverInput {
2179 analysis,
2180 doc,
2181 uri: &uri,
2182 files: files.as_deref(),
2183 });
2184 Ok(content.map(|value| Hover {
2185 contents: HoverContents::Markup(MarkupContent {
2186 kind: MarkupKind::Markdown,
2187 value,
2188 }),
2189 range: None,
2190 }))
2191 }
2192
2193 async fn signature_help(
2195 &self,
2196 params: SignatureHelpParams,
2197 ) -> JsonRpcResult<Option<SignatureHelp>> {
2198 let uri = params.text_document_position_params.text_document.uri;
2199 let pos = params.text_document_position_params.position;
2200 let text = {
2201 let s = self.state.read().await;
2202 s.docs.get(&uri).map(|d| d.text.clone())
2203 };
2204 let Some(text) = text else { return Ok(None) };
2205 let offset = cursor_offset(&text, pos);
2206 let Some(ctx) = crate::signature_help::call_context(&text, offset) else {
2207 return Ok(None);
2208 };
2209 let files = self.project_content(&uri).await;
2210 let resolved_label = {
2215 let callee = ctx.callee.clone();
2216 let text = text.clone();
2217 let files = files.clone();
2218 match tokio::task::spawn_blocking(move || {
2219 crate::signature_help::resolve_label(&callee, &text, files.as_deref())
2220 })
2221 .await
2222 {
2223 Ok(l) => l,
2224 Err(e) => {
2225 tracing::error!("signature-help label task failed: {e}");
2226 None
2227 }
2228 }
2229 };
2230 let label = match resolved_label {
2231 Some(l) => Some(l),
2232 None => match crate::signature_help::value_receiver_method(&ctx.callee) {
2235 Some((_, method)) => {
2236 if let Some((rewritten, recv_offset)) =
2237 crate::signature_help::value_receiver_rewrite(
2238 &text,
2239 &ctx.callee,
2240 ctx.open_paren,
2241 offset,
2242 )
2243 && let Some((ty, tys)) =
2244 self.type_receiver(&uri, rewritten, recv_offset).await
2245 {
2246 crate::signature_help::kernel_method_signature(ty, &tys, method)
2247 } else {
2248 None
2249 }
2250 }
2251 None => None,
2252 },
2253 };
2254 let Some(label) = label else { return Ok(None) };
2255 let active = ctx.active_param as u32;
2256 let parameters: Vec<ParameterInformation> = crate::signature_help::param_ranges(&label)
2257 .into_iter()
2258 .map(|(s, e)| ParameterInformation {
2259 label: ParameterLabel::LabelOffsets([s as u32, e as u32]),
2260 documentation: None,
2261 })
2262 .collect();
2263 Ok(Some(SignatureHelp {
2264 signatures: vec![SignatureInformation {
2265 label,
2266 documentation: None,
2267 parameters: Some(parameters),
2268 active_parameter: Some(active),
2269 }],
2270 active_signature: Some(0),
2271 active_parameter: Some(active),
2272 }))
2273 }
2274
2275 async fn code_lens(&self, params: CodeLensParams) -> JsonRpcResult<Option<Vec<CodeLens>>> {
2278 let uri = params.text_document.uri;
2279 let analysis = self.committed_analysis(&uri).await;
2281 let Some(analysis) = analysis else {
2282 return Ok(Some(Vec::new()));
2283 };
2284 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2285 return Ok(Some(Vec::new()));
2286 };
2287 let Some(text) = analysis.snapshots.get(&rel) else {
2288 return Ok(Some(Vec::new()));
2289 };
2290 let show_references = |range: Range, locations: Vec<Location>, title: String| CodeLens {
2294 range,
2295 command: Some(Command {
2296 title,
2297 command: "editor.action.showReferences".to_string(),
2298 arguments: Some(vec![
2299 serde_json::to_value(&uri).unwrap_or_default(),
2300 serde_json::to_value(range.start).unwrap_or_default(),
2301 serde_json::to_value(&locations).unwrap_or_default(),
2302 ]),
2303 }),
2304 data: None,
2305 };
2306 let mut lenses: Vec<CodeLens> = crate::index_queries::code_lenses(&analysis.index, &rel)
2307 .into_iter()
2308 .map(|(def, refs)| {
2309 let range = crate::position::span_to_range(text, def.span);
2310 let locations: Vec<Location> = refs
2311 .iter()
2312 .filter_map(|r| Self::site_to_location(&analysis, r))
2313 .collect();
2314 let n = refs.len();
2315 show_references(
2316 range,
2317 locations,
2318 format!("{n} reference{}", if n == 1 { "" } else { "s" }),
2319 )
2320 })
2321 .collect();
2322 lenses.extend(
2326 crate::index_queries::capability_provider_lenses(&analysis.index, &rel)
2327 .into_iter()
2328 .map(|(def, providers)| {
2329 let range = crate::position::span_to_range(text, def.span);
2330 let locations: Vec<Location> = providers
2331 .iter()
2332 .filter_map(|r| Self::site_to_location(&analysis, r))
2333 .collect();
2334 let n = providers.len();
2335 show_references(
2336 range,
2337 locations,
2338 format!("{n} provider{}", if n == 1 { "" } else { "s" }),
2339 )
2340 }),
2341 );
2342 lenses.extend(
2346 crate::index_queries::refinement_family_lenses(&analysis.index, &rel)
2347 .into_iter()
2348 .map(|(def, base, family)| {
2349 let range = crate::position::span_to_range(text, def.span);
2350 let locations: Vec<Location> = family
2351 .iter()
2352 .filter_map(|r| Self::site_to_location(&analysis, r))
2353 .collect();
2354 let n = family.len();
2355 show_references(
2356 range,
2357 locations,
2358 format!("{n} refinements of {}", base.name()),
2359 )
2360 }),
2361 );
2362 lenses.extend(
2371 crate::sequence_request::handler_lens_sites(text)
2372 .into_iter()
2373 .map(|span| {
2374 let range = crate::position::span_to_range(text, span);
2375 CodeLens {
2376 range,
2377 command: Some(Command {
2378 title: "Show Sequence".to_string(),
2379 command: "bynk.showSequenceDiagram".to_string(),
2380 arguments: Some(vec![
2381 serde_json::to_value(&uri).unwrap_or_default(),
2382 serde_json::to_value(range.start).unwrap_or_default(),
2383 ]),
2384 }),
2385 data: None,
2386 }
2387 }),
2388 );
2389 Ok(Some(lenses))
2390 }
2391
2392 async fn prepare_call_hierarchy(
2393 &self,
2394 params: CallHierarchyPrepareParams,
2395 ) -> JsonRpcResult<Option<Vec<CallHierarchyItem>>> {
2396 let uri = params.text_document_position_params.text_document.uri;
2397 let pos = params.text_document_position_params.position;
2398 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2399 return Ok(None);
2400 };
2401 let Some((key, def)) =
2402 crate::index_queries::prepare_call_hierarchy(&analysis.index, &rel, offset)
2403 else {
2404 return Ok(None);
2405 };
2406 Ok(Self::call_hierarchy_item(&analysis, key, def).map(|item| vec![item]))
2407 }
2408
2409 async fn incoming_calls(
2410 &self,
2411 params: CallHierarchyIncomingCallsParams,
2412 ) -> JsonRpcResult<Option<Vec<CallHierarchyIncomingCall>>> {
2413 let analysis = self.analysis_for(¶ms.item.uri).await;
2414 let Some(analysis) = analysis else {
2415 return Ok(Some(Vec::new()));
2416 };
2417 let Some(key) = SerKey::read(¶ms.item.data) else {
2418 return Ok(Some(Vec::new()));
2419 };
2420 let calls = crate::index_queries::incoming_calls(&analysis.index, &key)
2421 .into_iter()
2422 .filter_map(|rel| {
2423 let from = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2424 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2425 Some(CallHierarchyIncomingCall { from, from_ranges })
2426 })
2427 .collect();
2428 Ok(Some(calls))
2429 }
2430
2431 async fn outgoing_calls(
2432 &self,
2433 params: CallHierarchyOutgoingCallsParams,
2434 ) -> JsonRpcResult<Option<Vec<CallHierarchyOutgoingCall>>> {
2435 let analysis = self.analysis_for(¶ms.item.uri).await;
2436 let Some(analysis) = analysis else {
2437 return Ok(Some(Vec::new()));
2438 };
2439 let Some(key) = SerKey::read(¶ms.item.data) else {
2440 return Ok(Some(Vec::new()));
2441 };
2442 let calls = crate::index_queries::outgoing_calls(&analysis.index, &key)
2443 .into_iter()
2444 .filter_map(|rel| {
2445 let to = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2446 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2447 Some(CallHierarchyOutgoingCall { to, from_ranges })
2448 })
2449 .collect();
2450 Ok(Some(calls))
2451 }
2452
2453 async fn goto_implementation(
2458 &self,
2459 params: GotoImplementationParams,
2460 ) -> JsonRpcResult<Option<GotoImplementationResponse>> {
2461 let uri = params.text_document_position_params.text_document.uri;
2462 let pos = params.text_document_position_params.position;
2463 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2464 return Ok(None);
2465 };
2466 let Some((key, _)) = analysis.index.symbol_at(&rel, offset) else {
2467 return Ok(None);
2468 };
2469 if key.kind != bynk_check::index::SymbolKind::Capability {
2470 return Ok(None);
2471 }
2472 let locations: Vec<Location> = crate::index_queries::implementations(&analysis.index, key)
2473 .into_iter()
2474 .filter_map(|d| Self::site_to_location(&analysis, d))
2475 .collect();
2476 if locations.is_empty() {
2477 return Ok(None);
2478 }
2479 Ok(Some(GotoDefinitionResponse::Array(locations)))
2480 }
2481
2482 async fn goto_type_definition(
2488 &self,
2489 params: GotoTypeDefinitionParams,
2490 ) -> JsonRpcResult<Option<GotoTypeDefinitionResponse>> {
2491 let uri = params.text_document_position_params.text_document.uri;
2492 let pos = params.text_document_position_params.position;
2493 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2494 return Ok(None);
2495 };
2496 let tys = &analysis.ty_intern;
2497 let Some(entries) = analysis.expr_types.get(&rel) else {
2498 return Ok(None);
2499 };
2500 let Some(ty) = bynk_check::expr_types::type_at_offset(entries, offset) else {
2501 return Ok(None);
2502 };
2503 let Some(name) = crate::index_queries::named_type_target(ty, tys) else {
2504 return Ok(None);
2505 };
2506 let locations: Vec<Location> =
2507 crate::index_queries::type_definitions_named(&analysis.index, &name)
2508 .into_iter()
2509 .filter_map(|d| Self::site_to_location(&analysis, d))
2510 .collect();
2511 if locations.is_empty() {
2512 return Ok(None);
2513 }
2514 Ok(Some(GotoDefinitionResponse::Array(locations)))
2515 }
2516
2517 async fn document_link(
2530 &self,
2531 params: DocumentLinkParams,
2532 ) -> JsonRpcResult<Option<Vec<DocumentLink>>> {
2533 let uri = params.text_document.uri;
2534 let analysis = self.committed_analysis(&uri).await;
2538 let text = self
2539 .state
2540 .read()
2541 .await
2542 .docs
2543 .get(&uri)
2544 .map(|d| d.text.clone());
2545 let (Some(text), Some(analysis)) = (text, analysis) else {
2546 return Ok(None);
2547 };
2548 let mut links: Vec<DocumentLink> = crate::symbols::unit_reference_spans(&text)
2549 .into_iter()
2550 .filter_map(|(unit, span)| {
2551 let rel = analysis.unit_sources.get(&unit)?.first()?;
2552 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
2553 Some(DocumentLink {
2554 range: crate::position::span_to_range(&text, span),
2555 target: Some(target),
2556 tooltip: Some(format!("Open unit `{unit}`")),
2557 data: None,
2558 })
2559 })
2560 .collect();
2561 if let Some((owner_unit, _)) = crate::symbols::own_declaration_name(&text) {
2565 for (name, span) in crate::symbols::doc_link_spans(&text) {
2566 let Some(def) = crate::index_queries::resolve_doc_link(
2567 &analysis.index,
2568 &analysis.doc_scope,
2569 &owner_unit,
2570 &name,
2571 ) else {
2572 continue;
2573 };
2574 let Ok(target) = Url::from_file_path(analysis.project_root.join(&def.path)) else {
2575 continue;
2576 };
2577 links.push(DocumentLink {
2578 range: crate::position::span_to_range(&text, span),
2579 target: Some(target),
2580 tooltip: Some(format!("Go to `{name}`")),
2581 data: None,
2582 });
2583 }
2584 }
2585 Ok((!links.is_empty()).then_some(links))
2586 }
2587
2588 async fn completion(
2589 &self,
2590 params: CompletionParams,
2591 ) -> JsonRpcResult<Option<CompletionResponse>> {
2592 let uri = params.text_document_position.text_document.uri;
2593 let pos = params.text_document_position.position;
2594 let text = {
2595 let s = self.state.read().await;
2596 s.docs.get(&uri).map(|d| d.text.clone())
2597 };
2598 let Some(text) = text else { return Ok(None) };
2599 let offset = cursor_offset(&text, pos);
2600 let line_prefix = text[..offset].rsplit('\n').next().unwrap_or("").to_string();
2604 let files = self.project_content(&uri).await;
2605 let candidates = {
2610 let line_prefix = line_prefix.clone();
2611 let text = text.clone();
2612 match tokio::task::spawn_blocking(move || {
2613 completion::complete(&line_prefix, &text, files.as_deref())
2614 })
2615 .await
2616 {
2617 Ok(c) => c,
2618 Err(e) => {
2623 tracing::error!("completion enumeration task failed: {e}");
2624 Vec::new()
2625 }
2626 }
2627 };
2628 let mut items: Vec<CompletionItem> =
2629 candidates.into_iter().map(to_completion_item).collect();
2630 if completion::is_keyword_position(&line_prefix)
2635 || completion::is_expression_position(&line_prefix)
2636 {
2637 items.extend(self.locals_completions(&uri, pos).await);
2638 }
2639 items.extend(contract_param_completions(&text, offset, &line_prefix));
2644 items.extend(cors_completions(&text, offset, &line_prefix));
2648 items.extend(security_completions(&text, offset, &line_prefix));
2651 items.extend(cache_completions(&text, offset, &line_prefix));
2655 items.extend(limits_completions(&text, offset, &line_prefix));
2658 items.extend(limit_completions(&text, offset, &line_prefix));
2661 let mut pattern_items = self.nested_pattern_completions(&uri, &text, offset).await;
2673 pattern_items.extend(self.match_arm_completions(&uri, &text, offset).await);
2674 if !pattern_items.is_empty() {
2675 let mut merged = pattern_items;
2676 merged.extend(items);
2677 stamp_resolve_data(&mut merged, &uri);
2678 return Ok(Some(CompletionResponse::Array(merged)));
2679 }
2680 if items.is_empty() {
2681 let is_items = self.is_pattern_completions(&uri, &text, offset).await;
2684 if !is_items.is_empty() {
2685 return Ok(Some(CompletionResponse::Array(is_items)));
2686 }
2687 let value_items = self.value_member_completions(&uri, &text, offset).await;
2691 return Ok((!value_items.is_empty()).then_some(CompletionResponse::Array(value_items)));
2692 }
2693 stamp_resolve_data(&mut items, &uri);
2695 Ok(Some(CompletionResponse::Array(items)))
2696 }
2697
2698 async fn completion_resolve(&self, mut item: CompletionItem) -> JsonRpcResult<CompletionItem> {
2705 if item.documentation.is_some() {
2706 return Ok(item);
2707 }
2708 let Some(uri) = item
2709 .data
2710 .as_ref()
2711 .and_then(|d| d.get("uri"))
2712 .and_then(serde_json::Value::as_str)
2713 .and_then(|s| Url::parse(s).ok())
2714 else {
2715 return Ok(item);
2716 };
2717 let local = {
2718 let s = self.state.read().await;
2719 s.docs.get(&uri).map(|d| d.text.clone())
2720 };
2721 let doc = match local
2722 .as_deref()
2723 .and_then(|t| crate::symbols::describe_symbol(t, &item.label))
2724 {
2725 Some(md) => Some(md),
2726 None => {
2732 let files = self.project_content(&uri).await;
2733 let uri = uri.clone();
2734 let label = item.label.clone();
2735 match tokio::task::spawn_blocking(move || {
2736 files
2737 .and_then(|files| {
2738 crate::symbols::describe_symbol_cross_file(&files, &uri, &label)
2739 })
2740 .map(|(_uri, md)| md)
2741 .or_else(|| crate::symbols::describe_firstparty_symbol(&label))
2745 })
2746 .await
2747 {
2748 Ok(md) => md,
2749 Err(e) => {
2750 tracing::error!("completion-resolve describe task failed: {e}");
2751 None
2752 }
2753 }
2754 }
2755 };
2756 if let Some(md) = doc {
2757 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
2758 kind: MarkupKind::Markdown,
2759 value: md,
2760 }));
2761 }
2762 Ok(item)
2763 }
2764
2765 async fn goto_definition(
2766 &self,
2767 params: GotoDefinitionParams,
2768 ) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
2769 let uri = params
2770 .text_document_position_params
2771 .text_document
2772 .uri
2773 .clone();
2774 let pos = params.text_document_position_params.position;
2775 if let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await {
2780 if let Some((_, def)) =
2781 crate::index_queries::definition_at(&analysis.index, &rel, offset)
2782 && let Some(location) = Self::site_to_location(&analysis, def)
2783 {
2784 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2785 }
2786 if let Some(text) = analysis.snapshots.get(&rel)
2789 && let Some(locals) = analysis.locals.get(&rel)
2790 && let Some(def) = crate::locals_nav::local_definition_at(locals, text, offset)
2791 && let Some(location) = self
2792 .local_locations(&analysis, &rel, &[def])
2793 .into_iter()
2794 .next()
2795 {
2796 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2797 }
2798 }
2799 if let Some(location) = self.unit_reference_definition(&uri, pos).await {
2804 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2805 }
2806 let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
2807 return Ok(None);
2808 };
2809 if let Some(decl_span) = crate::symbols::find_declaration_span(&text, &name) {
2810 let range = crate::position::span_to_range(&text, decl_span);
2811 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2812 uri,
2813 range,
2814 })));
2815 }
2816 if let Some(files) = self.project_content(&uri).await
2818 && let Some(found) = crate::symbols::find_declaration_cross_file(&files, &uri, &name)
2819 {
2820 let range = crate::position::span_to_range(&found.source, found.span);
2821 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2822 uri: found.uri,
2823 range,
2824 })));
2825 }
2826 Ok(None)
2827 }
2828
2829 async fn formatting(
2830 &self,
2831 params: DocumentFormattingParams,
2832 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2833 let uri = params.text_document.uri;
2834 let text = {
2835 let s = self.state.read().await;
2836 s.docs.get(&uri).map(|d| d.text.clone())
2837 };
2838 let Some(text) = text else { return Ok(None) };
2839 let opts = self.config_for(&uri).await.format_options();
2842 match bynk_fmt::format_source(&text, &opts) {
2843 Ok(formatted) => {
2844 if formatted == bynk_fmt::normalize_line_endings(&text) {
2848 Ok(Some(Vec::new()))
2849 } else {
2850 let end_pos = crate::position::end_position(&text);
2852 Ok(Some(vec![TextEdit {
2853 range: Range {
2854 start: Position::new(0, 0),
2855 end: end_pos,
2856 },
2857 new_text: formatted,
2858 }]))
2859 }
2860 }
2861 Err(_) => {
2862 Ok(Some(Vec::new()))
2865 }
2866 }
2867 }
2868
2869 async fn range_formatting(
2870 &self,
2871 params: DocumentRangeFormattingParams,
2872 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2873 self.formatting(DocumentFormattingParams {
2876 text_document: params.text_document,
2877 options: params.options,
2878 work_done_progress_params: params.work_done_progress_params,
2879 })
2880 .await
2881 }
2882
2883 async fn document_symbol(
2884 &self,
2885 params: DocumentSymbolParams,
2886 ) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
2887 let uri = params.text_document.uri;
2889 let text = {
2890 let s = self.state.read().await;
2891 s.docs.get(&uri).map(|d| d.text.clone())
2892 };
2893 let Some(text) = text else { return Ok(None) };
2894 let syms = crate::document_symbols::outline(&text);
2895 if syms.is_empty() {
2896 return Ok(None);
2897 }
2898 Ok(Some(DocumentSymbolResponse::Nested(syms)))
2899 }
2900
2901 async fn folding_range(
2904 &self,
2905 params: FoldingRangeParams,
2906 ) -> JsonRpcResult<Option<Vec<FoldingRange>>> {
2907 let uri = params.text_document.uri;
2908 let text = {
2909 let s = self.state.read().await;
2910 s.docs.get(&uri).map(|d| d.text.clone())
2911 };
2912 let Some(text) = text else { return Ok(None) };
2913 Ok(Some(crate::structure::folding_ranges(&text)))
2914 }
2915
2916 async fn selection_range(
2919 &self,
2920 params: SelectionRangeParams,
2921 ) -> JsonRpcResult<Option<Vec<SelectionRange>>> {
2922 let uri = params.text_document.uri;
2923 let text = {
2924 let s = self.state.read().await;
2925 s.docs.get(&uri).map(|d| d.text.clone())
2926 };
2927 let Some(text) = text else { return Ok(None) };
2928 Ok(Some(crate::structure::selection_ranges(
2929 &text,
2930 ¶ms.positions,
2931 )))
2932 }
2933
2934 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
2935 let uri = params.text_document_position.text_document.uri;
2936 let pos = params.text_document_position.position;
2937 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2938 return Ok(None);
2939 };
2940 let include_decl = params.context.include_declaration;
2941 if let Some(sites) =
2942 crate::index_queries::sites_for(&analysis.index, &rel, offset, include_decl)
2943 {
2944 let locations: Vec<Location> = sites
2945 .into_iter()
2946 .filter_map(|site| Self::site_to_location(&analysis, site))
2947 .collect();
2948 return Ok(Some(locations));
2949 }
2950 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
2952 let spans = if include_decl {
2953 &spans[..]
2954 } else {
2955 &spans[1..]
2956 }; let locations = self.local_locations(&analysis, &rel, spans);
2958 return Ok(Some(locations));
2959 }
2960 Ok(None)
2961 }
2962
2963 async fn code_action(
2975 &self,
2976 params: CodeActionParams,
2977 ) -> JsonRpcResult<Option<CodeActionResponse>> {
2978 let uri = params.text_document.uri;
2979 let analysis = self.committed_analysis(&uri).await;
2983 let Some(analysis) = analysis else {
2984 return Ok(Some(Vec::new()));
2985 };
2986 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2987 return Ok(Some(Vec::new()));
2988 };
2989 let (Some(text), Some(diags)) =
2990 (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
2991 else {
2992 return Ok(Some(Vec::new()));
2993 };
2994 let (Some(start), Some(end)) = (
2997 crate::position::position_to_offset(text, params.range.start),
2998 crate::position::position_to_offset(text, params.range.end),
2999 ) else {
3000 return Ok(Some(Vec::new()));
3001 };
3002 let version = analysis.versions.get(&rel).copied();
3003 let span = bynk_syntax::span::Span::new(start, end);
3004 let mut actions = crate::code_actions::quick_fixes(text, diags, span, &uri, version);
3005 actions.extend(crate::capability_fixes::header_quick_fixes(
3008 text,
3009 diags,
3010 span,
3011 &uri,
3012 version,
3013 &analysis.index,
3014 ));
3015 actions.extend(crate::extract::extract_variable(text, span, &uri, version));
3016 let empty_reqs = Vec::new();
3017 let empty_locals = Vec::new();
3018 let empty_types = Vec::new();
3019 actions.extend(crate::extract::extract_function(
3020 text,
3021 span,
3022 &uri,
3023 version,
3024 analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
3025 analysis.locals.get(&rel).unwrap_or(&empty_locals),
3026 analysis.expr_types.get(&rel).unwrap_or(&empty_types),
3027 &analysis.ty_intern,
3028 ));
3029 let actions = crate::code_actions::filter_by_only(actions, params.context.only.as_deref());
3031 Ok(Some(actions))
3032 }
3033
3034 async fn inlay_hint(&self, params: InlayHintParams) -> JsonRpcResult<Option<Vec<InlayHint>>> {
3039 let uri = params.text_document.uri;
3040 let analysis = self.committed_analysis(&uri).await;
3042 let Some(analysis) = analysis else {
3043 return Ok(Some(Vec::new()));
3044 };
3045 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3046 return Ok(Some(Vec::new()));
3047 };
3048 let Some(text) = analysis.snapshots.get(&rel) else {
3049 return Ok(Some(Vec::new()));
3050 };
3051 let (Some(start), Some(end)) = (
3054 crate::position::position_to_offset(text, params.range.start),
3055 crate::position::position_to_offset(text, params.range.end),
3056 ) else {
3057 return Ok(Some(Vec::new()));
3058 };
3059 let visible = bynk_syntax::span::Span::new(start, end);
3060 let mut hints = analysis
3064 .hints
3065 .get(&rel)
3066 .map(|h| crate::inlay_hints::inlay_hints(text, h, visible))
3067 .unwrap_or_default();
3068 if let Some(reqs) = analysis.requirements.get(&rel) {
3069 hints.extend(crate::inlay_hints::given_hints(text, reqs, visible));
3070 }
3071 Ok(Some(hints))
3072 }
3073
3074 async fn semantic_tokens_full(
3078 &self,
3079 params: SemanticTokensParams,
3080 ) -> JsonRpcResult<Option<SemanticTokensResult>> {
3081 let data = self
3082 .semantic_tokens_for(¶ms.text_document.uri, None)
3083 .await;
3084 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
3085 result_id: None,
3086 data,
3087 })))
3088 }
3089
3090 async fn semantic_tokens_range(
3093 &self,
3094 params: SemanticTokensRangeParams,
3095 ) -> JsonRpcResult<Option<SemanticTokensRangeResult>> {
3096 let data = self
3097 .semantic_tokens_for(¶ms.text_document.uri, Some(params.range))
3098 .await;
3099 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
3100 result_id: None,
3101 data,
3102 })))
3103 }
3104
3105 async fn symbol(
3115 &self,
3116 params: WorkspaceSymbolParams,
3117 ) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
3118 let candidates: Vec<(PathBuf, ProjectConfig)> = {
3119 let (mut set, folders) = {
3122 let state = self.state.read().await;
3123 let known: std::collections::HashMap<PathBuf, ProjectConfig> = state
3124 .projects
3125 .iter()
3126 .map(|(r, p)| (r.clone(), p.config.clone()))
3127 .collect();
3128 (known, state.folders.clone())
3129 };
3130 for folder in &folders {
3131 if let Some((root, config)) = Self::resolve_root(folder) {
3132 let root = root.canonicalize().unwrap_or(root);
3133 set.entry(root).or_insert(config);
3134 }
3135 }
3136 set.into_iter().collect()
3137 };
3138 let mut symbols: Vec<SymbolInformation> = Vec::new();
3139 for (root, config) in candidates {
3140 let Some(analysis) = self.ensure_project_analysed(root, config).await else {
3141 continue;
3142 };
3143 for (key, def) in
3144 crate::index_queries::workspace_symbols(&analysis.index, ¶ms.query)
3145 {
3146 let Some(location) = Self::site_to_location(&analysis, def) else {
3147 continue;
3148 };
3149 #[allow(deprecated)]
3150 symbols.push(SymbolInformation {
3151 name: key.name.clone(),
3152 kind: lsp_symbol_kind(key.kind),
3153 tags: None,
3154 deprecated: None,
3155 location,
3156 container_name: Some(key.unit.clone()),
3157 });
3158 }
3159 }
3160 symbols.sort_by(|a, b| {
3165 a.name
3166 .cmp(&b.name)
3167 .then_with(|| a.container_name.cmp(&b.container_name))
3168 });
3169 Ok(Some(symbols))
3170 }
3171
3172 async fn document_highlight(
3176 &self,
3177 params: DocumentHighlightParams,
3178 ) -> JsonRpcResult<Option<Vec<DocumentHighlight>>> {
3179 let uri = params.text_document_position_params.text_document.uri;
3180 let pos = params.text_document_position_params.position;
3181 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3182 return Ok(None);
3183 };
3184 let Some(text) = analysis.snapshots.get(&rel) else {
3185 return Ok(None);
3186 };
3187 if let Some(sites) =
3188 crate::index_queries::document_highlights(&analysis.index, &rel, offset)
3189 {
3190 let highlights: Vec<DocumentHighlight> = sites
3191 .into_iter()
3192 .map(|s| DocumentHighlight {
3193 range: crate::position::span_to_range(text, s.span),
3194 kind: None,
3195 })
3196 .collect();
3197 return Ok(Some(highlights));
3198 }
3199 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
3201 let highlights = spans
3202 .iter()
3203 .map(|s| DocumentHighlight {
3204 range: crate::position::span_to_range(text, *s),
3205 kind: None,
3206 })
3207 .collect();
3208 return Ok(Some(highlights));
3209 }
3210 Ok(None)
3211 }
3212
3213 async fn prepare_rename(
3214 &self,
3215 params: TextDocumentPositionParams,
3216 ) -> JsonRpcResult<Option<PrepareRenameResponse>> {
3217 let uri = params.text_document.uri;
3218 let pos = params.position;
3219 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3223 return Ok(None);
3224 };
3225 let Some((key, site)) = crate::index_queries::prepare_rename(&analysis.index, &rel, offset)
3226 else {
3227 return Ok(None);
3228 };
3229 let Some(text) = analysis.snapshots.get(&rel) else {
3230 return Ok(None);
3231 };
3232 Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
3233 range: crate::position::span_to_range(text, site.span),
3234 placeholder: key.name.clone(),
3235 }))
3236 }
3237
3238 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
3239 let uri = params.text_document_position.text_document.uri;
3240 let pos = params.text_document_position.position;
3241 let new_name = params.new_name;
3242 let refused = |msg: String| tower_lsp::jsonrpc::Error {
3243 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3244 message: msg.into(),
3245 data: None,
3246 };
3247 let Some(root) = self.root_for_uri(&uri).await else {
3258 return Err(refused("rename requires a project (bynk.toml)".into()));
3259 };
3260 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3261 return Err(refused("rename requires a project (bynk.toml)".into()));
3262 };
3263 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3264 return Ok(None);
3265 };
3266 let Some(text) = analysis.snapshots.get(&rel) else {
3267 return Ok(None);
3268 };
3269 let Some(offset) = crate::position::position_to_offset(text, pos) else {
3270 return Ok(None);
3271 };
3272 let plan = crate::index_queries::plan_rename(&analysis.index, &rel, offset, &new_name)
3273 .map_err(refused)?;
3274
3275 let mut overlay = std::collections::HashMap::new();
3289 for (rel_path, text) in &analysis.snapshots {
3290 let edited = match plan.edits.get(rel_path) {
3291 Some(spans) => crate::index_queries::apply_edits(text, spans, &plan.new_name),
3292 None => text.clone(),
3293 };
3294 let abs = analysis.project_root.join(rel_path);
3295 let abs = abs.canonicalize().unwrap_or(abs);
3296 overlay.insert(abs, edited);
3297 }
3298 let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
3299 let Ok(post) =
3300 tokio::task::spawn_blocking(move || bynk_ide::diagnose_project_with(&roots, &overlay))
3301 .await
3302 else {
3303 return Err(refused("rename validation failed to run".into()));
3304 };
3305
3306 let post_diags: Vec<(PathBuf, String)> = post
3308 .files
3309 .iter()
3310 .flat_map(|f| {
3311 f.diagnostics
3312 .iter()
3313 .map(|d| (f.source_path.clone(), d.error.category.to_string()))
3314 })
3315 .collect();
3316 crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags)
3317 .map_err(refused)?;
3318
3319 if !crate::index_queries::index_unchanged_modulo_rename(&analysis.index, &post.index, &plan)
3322 {
3323 return Err(refused(format!(
3324 "renaming `{}` to `{new_name}` would silently re-bind another name — refused",
3325 plan.key.name
3326 )));
3327 }
3328
3329 let mut document_edits: Vec<TextDocumentEdit> = Vec::new();
3332 for (rel_path, spans) in &plan.edits {
3333 let Some(text) = analysis.snapshots.get(rel_path) else {
3334 continue;
3335 };
3336 let abs = analysis.project_root.join(rel_path);
3337 let Ok(file_uri) = Url::from_file_path(&abs) else {
3338 continue;
3339 };
3340 let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = spans
3341 .iter()
3342 .map(|span| {
3343 OneOf::Left(TextEdit {
3344 range: crate::position::span_to_range(text, *span),
3345 new_text: plan.new_name.clone(),
3346 })
3347 })
3348 .collect();
3349 document_edits.push(TextDocumentEdit {
3350 text_document: OptionalVersionedTextDocumentIdentifier {
3351 uri: file_uri,
3352 version: analysis.versions.get(rel_path).copied(),
3353 },
3354 edits,
3355 });
3356 }
3357 Ok(Some(WorkspaceEdit {
3358 changes: None,
3359 document_changes: Some(DocumentChanges::Edits(document_edits)),
3360 change_annotations: None,
3361 }))
3362 }
3363
3364 async fn will_rename_files(
3390 &self,
3391 params: RenameFilesParams,
3392 ) -> JsonRpcResult<Option<WorkspaceEdit>> {
3393 let mut combined: std::collections::HashMap<Url, (Option<i32>, Vec<TextEdit>)> =
3394 std::collections::HashMap::new();
3395 for fr in ¶ms.files {
3396 let (Ok(old_uri), Ok(new_uri)) = (Url::parse(&fr.old_uri), Url::parse(&fr.new_uri))
3397 else {
3398 continue;
3399 };
3400 let Some(root) = self.root_for_uri(&old_uri).await else {
3401 continue;
3402 };
3403 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3404 continue;
3405 };
3406 let Some(old_rel) = Self::uri_to_rel(&analysis, &old_uri) else {
3407 continue;
3408 };
3409 let Some(new_rel) = Self::uri_to_rel_for_new_path(&analysis, &new_uri) else {
3418 continue;
3419 };
3420 let Some(text) = analysis.snapshots.get(&old_rel) else {
3421 continue;
3422 };
3423 let Some((old_name, name_span)) = crate::symbols::own_declaration_name(text) else {
3424 continue;
3425 };
3426 let Some(new_name) = bynk_ide::renamed_unit_name(&old_rel, &old_name, &new_rel) else {
3427 continue;
3428 };
3429 if new_name == old_name {
3430 continue;
3431 }
3432 if analysis.unit_sources.contains_key(&new_name) {
3435 continue;
3436 }
3437 let Ok(old_file_uri) = Url::from_file_path(analysis.project_root.join(&old_rel)) else {
3444 continue;
3445 };
3446 combined
3449 .entry(old_file_uri)
3450 .or_insert_with(|| (analysis.versions.get(&old_rel).copied(), Vec::new()))
3451 .1
3452 .push(TextEdit {
3453 range: crate::position::span_to_range(text, name_span),
3454 new_text: new_name.clone(),
3455 });
3456 for (rel, snap_text) in &analysis.snapshots {
3458 if *rel == old_rel {
3459 continue;
3460 }
3461 let edits: Vec<TextEdit> = crate::symbols::unit_reference_spans(snap_text)
3462 .into_iter()
3463 .filter(|(unit, _)| *unit == old_name)
3464 .map(|(_, span)| TextEdit {
3465 range: crate::position::span_to_range(snap_text, span),
3466 new_text: new_name.clone(),
3467 })
3468 .collect();
3469 if edits.is_empty() {
3470 continue;
3471 }
3472 let Ok(file_uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
3473 continue;
3474 };
3475 combined
3476 .entry(file_uri)
3477 .or_insert_with(|| (analysis.versions.get(rel).copied(), Vec::new()))
3478 .1
3479 .extend(edits);
3480 }
3481 }
3482 if combined.is_empty() {
3483 return Ok(None);
3484 }
3485 let document_edits: Vec<TextDocumentEdit> = combined
3486 .into_iter()
3487 .map(|(uri, (version, edits))| TextDocumentEdit {
3488 text_document: OptionalVersionedTextDocumentIdentifier { uri, version },
3489 edits: edits.into_iter().map(OneOf::Left).collect(),
3490 })
3491 .collect();
3492 Ok(Some(WorkspaceEdit {
3493 changes: None,
3494 document_changes: Some(DocumentChanges::Edits(document_edits)),
3495 change_annotations: None,
3496 }))
3497 }
3498
3499 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
3500 if params.changes.iter().any(|ev| is_bynk_toml(&ev.uri)) {
3507 let mut state = self.state.write().await;
3508 state.root_cache.clear();
3509 state.root_cache_generation += 1;
3510 }
3511 let mut uris_to_refresh = Vec::new();
3516 let mut roots_to_reanalyse: std::collections::HashSet<PathBuf> =
3519 std::collections::HashSet::new();
3520 let mut config_changed_roots: std::collections::HashSet<PathBuf> =
3526 std::collections::HashSet::new();
3527 let open_docs: std::collections::HashSet<Url> =
3531 self.state.read().await.docs.keys().cloned().collect();
3532 for ev in ¶ms.changes {
3533 if is_bynk_toml(&ev.uri) {
3534 if let Ok(p) = ev.uri.to_file_path()
3536 && let Some(dir) = p.parent()
3537 {
3538 let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
3539 config_changed_roots.insert(root.clone());
3540 roots_to_reanalyse.insert(root);
3541 }
3542 } else if open_docs.contains(&ev.uri) {
3543 uris_to_refresh.push(ev.uri.clone());
3544 } else if ev.uri.path().ends_with(".bynk")
3545 && let Some(root) = self.root_for_uri(&ev.uri).await
3546 {
3547 roots_to_reanalyse.insert(root);
3548 }
3549 }
3550 for root in &config_changed_roots {
3555 let config = project::load_config(root).unwrap_or_default();
3556 let mut state = self.state.write().await;
3557 state
3558 .projects
3559 .entry(root.clone())
3560 .and_modify(|ps| ps.config = config.clone())
3561 .or_insert_with(|| ProjectState {
3562 config,
3563 ..Default::default()
3564 });
3565 }
3566 for uri in uris_to_refresh {
3567 self.schedule_diagnostics(&uri).await;
3568 }
3569 for root in roots_to_reanalyse {
3574 self.schedule_project_diagnostics(root).await;
3575 }
3576 }
3577
3578 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
3579 let added_dirs: Vec<PathBuf> = {
3584 let mut state = self.state.write().await;
3585 let mut added = Vec::new();
3586 for a in ¶ms.event.added {
3587 if let Ok(p) = a.uri.to_file_path() {
3588 let dir = p.canonicalize().unwrap_or(p);
3589 if !state.folders.contains(&dir) {
3590 state.folders.push(dir.clone());
3591 added.push(dir);
3592 }
3593 }
3594 }
3595 for removed in ¶ms.event.removed {
3596 if let Ok(p) = removed.uri.to_file_path() {
3597 let dir = p.canonicalize().unwrap_or(p);
3598 state.folders.retain(|f| f != &dir);
3599 }
3600 }
3601 state.root_cache.clear();
3608 state.root_cache_generation += 1;
3609 added
3610 };
3611 self.warm_projects(&added_dirs).await;
3614 for uri in self.prune_orphaned_projects().await {
3617 self.client.publish_diagnostics(uri, Vec::new(), None).await;
3618 }
3619 }
3620}
3621
3622fn server_capabilities() -> ServerCapabilities {
3625 ServerCapabilities {
3626 text_document_sync: Some(TextDocumentSyncCapability::Options(
3629 TextDocumentSyncOptions {
3630 open_close: Some(true),
3631 change: Some(TextDocumentSyncKind::FULL),
3632 save: Some(TextDocumentSyncSaveOptions::Supported(true)),
3633 ..Default::default()
3634 },
3635 )),
3636 hover_provider: Some(HoverProviderCapability::Simple(true)),
3637 definition_provider: Some(OneOf::Left(true)),
3638 completion_provider: Some(CompletionOptions {
3643 trigger_characters: Some(vec![
3644 " ".to_string(),
3645 "{".to_string(),
3646 ",".to_string(),
3647 ".".to_string(),
3648 ]),
3649 resolve_provider: Some(true),
3652 ..Default::default()
3653 }),
3654 signature_help_provider: Some(SignatureHelpOptions {
3656 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
3657 retrigger_characters: Some(vec![",".to_string()]),
3658 ..Default::default()
3659 }),
3660 code_lens_provider: Some(CodeLensOptions {
3662 resolve_provider: Some(false),
3663 }),
3664 call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
3666 implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
3668 type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
3670 document_link_provider: Some(DocumentLinkOptions {
3672 resolve_provider: Some(false),
3673 work_done_progress_options: Default::default(),
3674 }),
3675 document_formatting_provider: Some(OneOf::Left(true)),
3676 document_range_formatting_provider: Some(OneOf::Left(true)),
3677 document_symbol_provider: Some(OneOf::Left(true)),
3678 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
3680 selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
3681 references_provider: Some(OneOf::Left(true)),
3684 rename_provider: Some(OneOf::Right(RenameOptions {
3685 prepare_provider: Some(true),
3686 work_done_progress_options: Default::default(),
3687 })),
3688 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
3691 code_action_kinds: Some(vec![
3692 CodeActionKind::QUICKFIX,
3693 CodeActionKind::REFACTOR,
3694 CodeActionKind::REFACTOR_EXTRACT,
3695 ]),
3696 ..Default::default()
3697 })),
3698 inlay_hint_provider: Some(OneOf::Left(true)),
3701 semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions(
3705 SemanticTokensOptions {
3706 legend: crate::index_queries::semantic_tokens_legend(),
3707 full: Some(SemanticTokensFullOptions::Bool(true)),
3708 range: Some(true),
3709 ..Default::default()
3710 },
3711 )),
3712 workspace_symbol_provider: Some(OneOf::Left(true)),
3714 document_highlight_provider: Some(OneOf::Left(true)),
3715 workspace: Some(WorkspaceServerCapabilities {
3716 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
3717 supported: Some(true),
3718 change_notifications: Some(OneOf::Left(true)),
3719 }),
3720 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
3724 will_rename: Some(FileOperationRegistrationOptions {
3725 filters: vec![FileOperationFilter {
3726 scheme: Some("file".to_string()),
3727 pattern: FileOperationPattern {
3728 glob: "**/*.bynk".to_string(),
3729 matches: Some(FileOperationPatternKind::File),
3730 options: None,
3731 },
3732 }],
3733 }),
3734 ..Default::default()
3735 }),
3736 }),
3737 experimental: Some(serde_json::json!({
3742 "sequenceModel": true,
3743 "documentationModel": true,
3744 "architectureModel": true,
3745 "wireContract": true,
3746 })),
3747 ..Default::default()
3748 }
3749}
3750
3751fn stamp_resolve_data(items: &mut [CompletionItem], uri: &Url) {
3759 let data = serde_json::json!({ "uri": uri.to_string() });
3760 for item in items.iter_mut() {
3761 item.data = Some(data.clone());
3762 }
3763}
3764
3765fn cors_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3774 if completion::in_cors_field_position(text, offset) {
3775 return completion::CORS_FIELDS
3776 .iter()
3777 .map(|(name, doc)| CompletionItem {
3778 label: name.to_string(),
3779 kind: Some(CompletionItemKind::FIELD),
3780 detail: Some((*doc).to_string()),
3781 insert_text: Some(format!("{name}: ")),
3782 ..Default::default()
3783 })
3784 .collect();
3785 }
3786 if completion::in_service_body_item_position(text, offset, line) {
3787 return vec![CompletionItem {
3788 label: "cors".to_string(),
3789 kind: Some(CompletionItemKind::KEYWORD),
3790 detail: Some("a cross-origin (CORS) policy for this HTTP service".to_string()),
3791 insert_text: Some("cors {\n\torigins: [$0],\n}".to_string()),
3792 insert_text_format: Some(InsertTextFormat::SNIPPET),
3793 ..Default::default()
3794 }];
3795 }
3796 Vec::new()
3797}
3798
3799fn security_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3804 if completion::in_security_field_position(text, offset) {
3805 return completion::SECURITY_FIELDS
3806 .iter()
3807 .map(|(name, doc)| CompletionItem {
3808 label: name.to_string(),
3809 kind: Some(CompletionItemKind::FIELD),
3810 detail: Some((*doc).to_string()),
3811 insert_text: Some(format!("{name}: ")),
3812 ..Default::default()
3813 })
3814 .collect();
3815 }
3816 if completion::in_service_body_item_position(text, offset, line) {
3817 return vec![CompletionItem {
3818 label: "security".to_string(),
3819 kind: Some(CompletionItemKind::KEYWORD),
3820 detail: Some("security response headers for this HTTP service".to_string()),
3821 insert_text: Some("security {\n\tnosniff: $0,\n}".to_string()),
3822 insert_text_format: Some(InsertTextFormat::SNIPPET),
3823 ..Default::default()
3824 }];
3825 }
3826 Vec::new()
3827}
3828
3829fn cache_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3834 if completion::in_cache_arg_position(text, offset) {
3835 return completion::CACHE_ARGS
3836 .iter()
3837 .map(|(name, doc)| CompletionItem {
3838 label: name.to_string(),
3839 kind: Some(CompletionItemKind::FIELD),
3840 detail: Some((*doc).to_string()),
3841 insert_text: Some(format!("{name}: ")),
3842 ..Default::default()
3843 })
3844 .collect();
3845 }
3846 if completion::in_service_body_item_position(text, offset, line) {
3847 return vec![CompletionItem {
3848 label: "@cache".to_string(),
3849 kind: Some(CompletionItemKind::SNIPPET),
3850 detail: Some(
3851 "cache a GET read — a synthesised ETag/304 revalidation with a freshness window"
3852 .to_string(),
3853 ),
3854 insert_text: Some("@cache(maxAge: ${1:5.minutes})".to_string()),
3855 insert_text_format: Some(InsertTextFormat::SNIPPET),
3856 ..Default::default()
3857 }];
3858 }
3859 Vec::new()
3860}
3861
3862fn limits_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3867 if completion::in_limits_field_position(text, offset) {
3868 return completion::LIMITS_FIELDS
3869 .iter()
3870 .map(|(name, doc)| CompletionItem {
3871 label: name.to_string(),
3872 kind: Some(CompletionItemKind::FIELD),
3873 detail: Some((*doc).to_string()),
3874 insert_text: Some(format!("{name}: ")),
3875 ..Default::default()
3876 })
3877 .collect();
3878 }
3879 if completion::in_service_body_item_position(text, offset, line) {
3880 return vec![CompletionItem {
3881 label: "limits".to_string(),
3882 kind: Some(CompletionItemKind::KEYWORD),
3883 detail: Some("request limits for this HTTP service".to_string()),
3884 insert_text: Some("limits {\n\tmaxBody: $0,\n}".to_string()),
3885 insert_text_format: Some(InsertTextFormat::SNIPPET),
3886 ..Default::default()
3887 }];
3888 }
3889 Vec::new()
3890}
3891
3892fn limit_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3897 if completion::in_limit_arg_position(text, offset) {
3898 return completion::LIMIT_ARGS
3899 .iter()
3900 .map(|(name, doc)| CompletionItem {
3901 label: name.to_string(),
3902 kind: Some(CompletionItemKind::FIELD),
3903 detail: Some((*doc).to_string()),
3904 insert_text: Some(format!("{name}: ")),
3905 ..Default::default()
3906 })
3907 .collect();
3908 }
3909 if completion::in_service_body_item_position(text, offset, line) {
3910 return vec![CompletionItem {
3911 label: "@limit".to_string(),
3912 kind: Some(CompletionItemKind::SNIPPET),
3913 detail: Some(
3914 "cap the request body size — a `413` synthesised before the body is read"
3915 .to_string(),
3916 ),
3917 insert_text: Some("@limit(maxBody: ${1:1048576})".to_string()),
3918 insert_text_format: Some(InsertTextFormat::SNIPPET),
3919 ..Default::default()
3920 }];
3921 }
3922 Vec::new()
3923}
3924
3925fn contract_param_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3926 use bynk_syntax::ast::{CommonsItem, SourceUnit};
3927 let Some(is_ensures) = completion::contract_clause_kind(line) else {
3928 return Vec::new();
3929 };
3930 let Ok(tokens) = bynk_syntax::lexer::tokenize(text) else {
3931 return Vec::new();
3932 };
3933 let (Some(unit), _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text) else {
3934 return Vec::new();
3935 };
3936 let items = match &unit {
3937 SourceUnit::Commons(c) => &c.items,
3938 SourceUnit::Context(c) => &c.items,
3939 SourceUnit::Adapter(a) => &a.items,
3940 _ => return Vec::new(),
3941 };
3942 for item in items {
3943 if let CommonsItem::Fn(f) = item
3946 && f.span.start <= offset
3947 && offset <= f.body.span.start
3948 {
3949 let mut out: Vec<CompletionItem> = f
3952 .params
3953 .iter()
3954 .filter(|p| p.name.name != "_")
3955 .map(|p| CompletionItem {
3956 label: p.name.name.clone(),
3957 kind: Some(CompletionItemKind::VARIABLE),
3958 detail: Some(format!(
3959 "parameter: {}",
3960 crate::symbols::type_ref_str(&p.type_ref)
3961 )),
3962 ..Default::default()
3963 })
3964 .collect();
3965 if is_ensures {
3966 out.push(CompletionItem {
3967 label: "result".to_string(),
3968 kind: Some(CompletionItemKind::VARIABLE),
3969 detail: Some("the function's return value".to_string()),
3970 ..Default::default()
3971 });
3972 }
3973 return out;
3974 }
3975 }
3976 Vec::new()
3977}
3978
3979fn is_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
3984 let before = text.get(..cursor)?;
3985 let before = before
3987 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
3988 .trim_end();
3989 let before = before.strip_suffix("is")?;
3990 if !before.ends_with(char::is_whitespace) {
3991 return None;
3992 }
3993 let before = before.trim_end();
3994 (!before.is_empty()).then(|| before.len() - 1)
3995}
3996
3997fn match_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
4004 let before = text.get(..cursor)?;
4005 let brace = innermost_open_brace(before)?;
4007 let arm_start = arm_start_offset(before, brace);
4010 let arm = before.get(arm_start..)?;
4011 if arm.contains("=>") {
4012 return None;
4013 }
4014 if !arm
4017 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4018 .trim()
4019 .is_empty()
4020 {
4021 return None;
4022 }
4023 match_head_scrutinee_offset(before, brace)
4024}
4025
4026fn nested_pattern_offset(text: &str, cursor: usize) -> Option<(usize, String)> {
4033 let before = text.get(..cursor)?;
4034 let brace = innermost_open_brace(before)?;
4035 let arm_start = arm_start_offset(before, brace);
4036 let arm = before.get(arm_start..)?;
4037 if arm.contains("=>") {
4038 return None; }
4040 let paren = innermost_open_paren(arm)?;
4042 let head = arm.get(..paren)?.trim_end();
4045 let variant: String = head
4046 .chars()
4047 .rev()
4048 .take_while(|c| c.is_alphanumeric() || *c == '_')
4049 .collect::<Vec<_>>()
4050 .into_iter()
4051 .rev()
4052 .collect();
4053 if !variant.chars().next().is_some_and(char::is_uppercase) {
4054 return None;
4055 }
4056 let after = arm.get(paren + 1..)?;
4059 if !after
4060 .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '.')
4061 .trim()
4062 .is_empty()
4063 {
4064 return None;
4065 }
4066 let scrut_off = match_head_scrutinee_offset(before, brace)?;
4067 Some((scrut_off, variant))
4068}
4069
4070fn match_head_scrutinee_offset(before: &str, brace: usize) -> Option<usize> {
4076 let head = before.get(..brace)?.trim_end();
4077 let m = head.rfind("match")?;
4078 if head[..m]
4079 .chars()
4080 .next_back()
4081 .is_some_and(|c| c.is_alphanumeric() || c == '_')
4082 {
4083 return None; }
4085 let after = head.get(m + "match".len()..)?;
4086 if !after.starts_with(char::is_whitespace) {
4087 return None;
4088 }
4089 let scrut = after.trim();
4090 if scrut.is_empty() || scrut.contains(['{', '}']) || scrut.contains("=>") {
4091 return None;
4092 }
4093 Some(head.len() - 1)
4095}
4096
4097fn innermost_open_paren(arm: &str) -> Option<usize> {
4101 let mut stack: Vec<usize> = Vec::new();
4102 for (i, c) in arm.char_indices() {
4103 match c {
4104 '(' => stack.push(i),
4105 ')' => {
4106 stack.pop();
4107 }
4108 _ => {}
4109 }
4110 }
4111 stack.pop()
4112}
4113
4114fn innermost_open_brace(before: &str) -> Option<usize> {
4117 let mut stack: Vec<usize> = Vec::new();
4118 for (i, c) in before.char_indices() {
4119 match c {
4120 '{' => stack.push(i),
4121 '}' => {
4122 stack.pop();
4123 }
4124 _ => {}
4125 }
4126 }
4127 stack.pop()
4128}
4129
4130fn arm_start_offset(before: &str, brace: usize) -> usize {
4134 let mut depth = 0i32;
4135 let mut start = brace + 1; for (rel, c) in before[brace + 1..].char_indices() {
4137 match c {
4138 '{' | '(' | '[' => depth += 1,
4139 '}' | ')' | ']' => depth -= 1,
4140 ',' if depth == 0 => start = brace + 1 + rel + c.len_utf8(),
4141 _ => {}
4142 }
4143 }
4144 start
4145}
4146
4147fn to_completion_item(c: completion::Completion) -> CompletionItem {
4148 CompletionItem {
4149 kind: Some(match c.kind {
4150 completion::CompletionKind::Unit => CompletionItemKind::MODULE,
4151 completion::CompletionKind::Capability => CompletionItemKind::INTERFACE,
4152 completion::CompletionKind::Type => CompletionItemKind::STRUCT,
4153 completion::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
4154 completion::CompletionKind::Snippet => CompletionItemKind::SNIPPET,
4155 completion::CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
4156 completion::CompletionKind::Member => CompletionItemKind::METHOD,
4157 completion::CompletionKind::Field => CompletionItemKind::FIELD,
4158 completion::CompletionKind::Constructor => CompletionItemKind::CONSTRUCTOR,
4159 completion::CompletionKind::Function => CompletionItemKind::FUNCTION,
4160 }),
4161 insert_text_format: c.insert_text.as_ref().map(|_| InsertTextFormat::SNIPPET),
4164 insert_text: c.insert_text,
4165 label: c.label,
4166 detail: c.detail,
4167 ..Default::default()
4168 }
4169}
4170
4171fn cursor_offset(text: &str, pos: Position) -> usize {
4177 crate::position::position_to_offset(text, pos).unwrap_or(text.len())
4178}
4179
4180#[derive(serde::Serialize, serde::Deserialize)]
4184struct SerKey {
4185 unit: String,
4186 kind: String,
4187 name: String,
4188}
4189
4190impl From<&bynk_check::index::SymbolKey> for SerKey {
4191 fn from(k: &bynk_check::index::SymbolKey) -> Self {
4192 SerKey {
4193 unit: k.unit.clone(),
4194 kind: k.kind.display().to_string(),
4195 name: k.name.clone(),
4196 }
4197 }
4198}
4199
4200impl SerKey {
4201 fn read(data: &Option<serde_json::Value>) -> Option<bynk_check::index::SymbolKey> {
4205 let sk: SerKey = serde_json::from_value(data.as_ref()?.clone()).ok()?;
4206 let kind = match sk.kind.as_str() {
4207 "type" => bynk_check::index::SymbolKind::Type,
4208 "fn" => bynk_check::index::SymbolKind::Fn,
4209 "capability" => bynk_check::index::SymbolKind::Capability,
4210 "service" => bynk_check::index::SymbolKind::Service,
4211 "agent" => bynk_check::index::SymbolKind::Agent,
4212 "provider" => bynk_check::index::SymbolKind::Provider,
4213 _ => return None,
4214 };
4215 Some(bynk_check::index::SymbolKey {
4216 unit: sk.unit,
4217 kind,
4218 name: sk.name,
4219 })
4220 }
4221}
4222
4223fn lsp_symbol_kind(kind: bynk_check::index::SymbolKind) -> SymbolKind {
4224 match kind {
4225 bynk_check::index::SymbolKind::Type => SymbolKind::STRUCT,
4226 bynk_check::index::SymbolKind::Fn => SymbolKind::FUNCTION,
4227 bynk_check::index::SymbolKind::Capability => SymbolKind::INTERFACE,
4228 bynk_check::index::SymbolKind::Service | bynk_check::index::SymbolKind::Agent => {
4229 SymbolKind::CLASS
4230 }
4231 bynk_check::index::SymbolKind::Provider => SymbolKind::OBJECT,
4232 bynk_check::index::SymbolKind::Method => SymbolKind::METHOD,
4233 bynk_check::index::SymbolKind::CapabilityOp => SymbolKind::METHOD,
4234 bynk_check::index::SymbolKind::Field => SymbolKind::FIELD,
4235 bynk_check::index::SymbolKind::Actor => SymbolKind::INTERFACE,
4236 bynk_check::index::SymbolKind::Handler => SymbolKind::METHOD,
4237 bynk_check::index::SymbolKind::Messages => SymbolKind::STRUCT,
4238 }
4239}
4240
4241fn is_bynk_toml(uri: &Url) -> bool {
4245 let Ok(path) = uri.to_file_path() else {
4246 return false;
4247 };
4248 path.file_name().and_then(|n| n.to_str()) == Some("bynk.toml")
4249}
4250
4251fn code_description(code: &str) -> Option<CodeDescription> {
4257 let href = Url::parse(&bynk_syntax::diagnostics::explain(code)?.href()).ok()?;
4258 Some(CodeDescription { href })
4259}
4260
4261#[cfg(test)]
4262mod code_description_tests {
4263 use super::code_description;
4264
4265 #[test]
4266 fn mapped_code_gets_a_valid_book_link() {
4267 let cd = code_description("bynk.resolve.unknown_type")
4268 .expect("a curated code produces a codeDescription");
4269 assert_eq!(cd.href.scheme(), "https");
4270 assert_eq!(cd.href.host_str(), Some("bynk-lang.org"));
4271 assert!(cd.href.path().starts_with("/book/"));
4272 }
4273
4274 #[test]
4275 fn uncurated_code_gets_no_link() {
4276 assert!(code_description("bynk.resolve.duplicate_type").is_none());
4279 assert!(code_description("bynk.not.a_real_code").is_none());
4280 }
4281
4282 #[test]
4283 fn every_curated_explanation_yields_a_parseable_url() {
4284 for e in bynk_syntax::diagnostics::EXPLANATIONS {
4287 assert!(
4288 code_description(e.code).is_some(),
4289 "curated explanation `{}` produced no codeDescription — its href \
4290 `{}` did not parse as a URL",
4291 e.code,
4292 e.href()
4293 );
4294 }
4295 }
4296}
4297
4298fn make_diagnostic(
4299 d: &bynk_ide::Diagnostic,
4300 positions: &crate::position::PositionMap,
4301 uri: &Url,
4302) -> Diagnostic {
4303 let range = positions.range(d.error.span);
4304 let severity = match d.severity {
4305 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
4306 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
4307 };
4308 let related_information: Vec<DiagnosticRelatedInformation> = d
4309 .error
4310 .labels
4311 .iter()
4312 .map(|(span, msg)| DiagnosticRelatedInformation {
4313 location: Location {
4314 uri: uri.clone(),
4318 range: positions.range(*span),
4319 },
4320 message: msg.clone(),
4321 })
4322 .collect();
4323 let mut message = d.error.message.clone();
4324 for note in &d.error.notes {
4325 message.push_str("\n\n");
4326 message.push_str("note: ");
4327 message.push_str(note);
4328 }
4329 Diagnostic {
4330 range,
4331 severity: Some(severity),
4332 code: Some(NumberOrString::String(d.error.category.to_string())),
4333 code_description: code_description(d.error.category),
4338 source: Some(SERVER_NAME.to_string()),
4339 message,
4340 related_information: if related_information.is_empty() {
4341 None
4342 } else {
4343 Some(related_information)
4344 },
4345 tags: None,
4346 data: None,
4347 }
4348}
4349
4350pub async fn run() -> std::process::ExitCode {
4357 if std::env::args()
4361 .skip(1)
4362 .any(|a| a == "--version" || a == "-V")
4363 {
4364 println!("{SERVER_NAME} {SERVER_VERSION}");
4365 return std::process::ExitCode::SUCCESS;
4366 }
4367 if let Some(home) = std::env::var_os("HOME") {
4370 let path: PathBuf = PathBuf::from(home).join(".bynk-lsp.log");
4371 if let Ok(file) = std::fs::OpenOptions::new()
4372 .create(true)
4373 .append(true)
4374 .open(&path)
4375 {
4376 use tracing_subscriber::prelude::*;
4377 let env_filter = tracing_subscriber::EnvFilter::try_from_env("BYNK_LSP_LOG")
4378 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
4379 let file_layer = tracing_subscriber::fmt::layer()
4380 .with_writer(std::sync::Mutex::new(file))
4381 .with_ansi(false);
4382 tracing_subscriber::registry()
4383 .with(env_filter)
4384 .with(file_layer)
4385 .try_init()
4386 .ok();
4387 }
4388 }
4389 tracing::info!("bynkc-lsp v{} starting", SERVER_VERSION);
4390 let stdout = tokio::io::stdout();
4391 let (server_input, pump_output) = tokio::io::duplex(1 << 16);
4394 tokio::spawn(transport::pump(tokio::io::stdin(), pump_output));
4395 let shutdown_requested = Arc::new(std::sync::atomic::AtomicBool::new(false));
4396 let flag = Arc::clone(&shutdown_requested);
4397 let (service, socket) =
4402 LspService::build(move |client| Backend::with_shutdown_flag(client, Arc::clone(&flag)))
4403 .custom_method("bynk/sequenceModel", Backend::sequence_model)
4404 .custom_method("bynk/documentationModel", Backend::documentation_model)
4405 .custom_method("bynk/architectureModel", Backend::architecture_model)
4406 .custom_method("bynk/wireContract", Backend::wire_contract)
4407 .finish();
4408 Server::new(server_input, stdout, socket)
4409 .serve(service)
4410 .await;
4411 if shutdown_requested.load(std::sync::atomic::Ordering::SeqCst) {
4412 std::process::ExitCode::SUCCESS
4413 } else {
4414 eprintln!(
4415 "bynkc-lsp: the session ended without a `shutdown` request \
4416 (the client closed the connection, or sent `exit` early); see ~/.bynk-lsp.log"
4417 );
4418 std::process::ExitCode::FAILURE
4419 }
4420}
4421
4422#[cfg(test)]
4423mod tests {
4424 use super::*;
4425
4426 struct Scratch(PathBuf);
4442 impl Drop for Scratch {
4443 fn drop(&mut self) {
4444 let _ = std::fs::remove_dir_all(&self.0);
4445 }
4446 }
4447
4448 fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
4449 let dir = std::env::temp_dir().join(format!(
4450 "bynk_lsp_sliceA_{tag}_{}_{:?}",
4451 std::process::id(),
4452 std::thread::current().id()
4453 ));
4454 let _ = std::fs::remove_dir_all(&dir);
4455 for (rel, body) in files {
4456 let p = dir.join(rel);
4457 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
4458 std::fs::write(&p, body).unwrap();
4459 }
4460 Scratch(dir)
4461 }
4462
4463 async fn backend_at(root: &std::path::Path) -> Backend {
4469 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4470 let backend = service.inner().clone();
4471 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
4474 {
4475 let mut state = backend.state.write().await;
4476 state.folders.push(canonical.clone());
4477 state.projects.insert(
4478 canonical.clone(),
4479 ProjectState {
4480 config: project::load_config(&canonical).unwrap_or_default(),
4481 ..Default::default()
4482 },
4483 );
4484 }
4485 backend
4486 }
4487
4488 #[tokio::test]
4492 async fn a_panicking_round_tells_the_client_once_and_clears_on_success() {
4493 use futures::StreamExt;
4494 use tower::{Service, ServiceExt};
4495
4496 let s = scratch_project(
4497 "panic",
4498 &[
4499 ("bynk.toml", "[project]\nname = \"panic\"\n"),
4500 ("src/thing.bynk", "context thing\n"),
4501 ],
4502 );
4503 let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
4504 let initialize = tower_lsp::jsonrpc::Request::build("initialize")
4506 .params(serde_json::json!({ "capabilities": {} }))
4507 .id(1)
4508 .finish();
4509 service
4510 .ready()
4511 .await
4512 .unwrap()
4513 .call(initialize)
4514 .await
4515 .unwrap();
4516 let initialized = tower_lsp::jsonrpc::Request::build("initialized")
4517 .params(serde_json::json!({}))
4518 .finish();
4519 service
4520 .ready()
4521 .await
4522 .unwrap()
4523 .call(initialized)
4524 .await
4525 .unwrap();
4526 let shown = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
4530 let drain = {
4531 let shown = Arc::clone(&shown);
4532 tokio::spawn(socket.for_each(move |message| {
4533 if message.method() == "window/showMessage" {
4534 shown
4535 .lock()
4536 .unwrap()
4537 .push(format!("{:?}", message.params()));
4538 }
4539 async {}
4540 }))
4541 };
4542 let backend = service.inner().clone();
4543 let canonical = s.0.canonicalize().unwrap();
4544 backend
4545 .state
4546 .write()
4547 .await
4548 .projects
4549 .insert(canonical.clone(), ProjectState::default());
4550 let failed = |b: &Backend| {
4551 let b = b.clone();
4552 let root = canonical.clone();
4553 async move { b.state.read().await.projects[&root].analysis_failed }
4554 };
4555
4556 let inject = |b: &Backend| {
4557 let b = b.clone();
4558 let root = canonical.clone();
4559 async move {
4560 b.state
4561 .write()
4562 .await
4563 .projects
4564 .get_mut(&root)
4565 .unwrap()
4566 .panic_next_round = true;
4567 }
4568 };
4569 inject(&backend).await;
4570 backend.run_project_diagnostics(canonical.clone()).await;
4571 assert!(failed(&backend).await, "the failed round is recorded");
4572 inject(&backend).await;
4574 backend.run_project_diagnostics(canonical.clone()).await;
4575
4576 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4577 {
4578 let shown = shown.lock().unwrap();
4579 assert_eq!(shown.len(), 1, "told once per streak: {shown:?}");
4580 assert!(shown[0].contains("internal error"), "{shown:?}");
4581 }
4582
4583 backend.run_project_diagnostics(canonical.clone()).await;
4584 assert!(!failed(&backend).await, "a clean round ends the streak");
4585 inject(&backend).await;
4587 backend.run_project_diagnostics(canonical.clone()).await;
4588 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4589 assert_eq!(shown.lock().unwrap().len(), 2, "a new streak is told again");
4590 drain.abort();
4591 }
4592
4593 impl Backend {
4597 async fn test_root(&self) -> PathBuf {
4598 self.state
4599 .read()
4600 .await
4601 .projects
4602 .keys()
4603 .next()
4604 .cloned()
4605 .expect("a test project entry")
4606 }
4607 async fn run_round(&self) {
4608 let root = self.test_root().await;
4609 self.run_project_diagnostics(root).await;
4610 }
4611 async fn test_analysis(&self) -> Option<Arc<Analysis>> {
4612 let root = self.test_root().await;
4613 self.project_analysis(&root).await
4614 }
4615 async fn test_round_started(&self) -> u64 {
4616 let root = self.test_root().await;
4617 self.state
4618 .read()
4619 .await
4620 .projects
4621 .get(&root)
4622 .map(|p| p.analysis_round_started)
4623 .unwrap_or(0)
4624 }
4625 }
4626
4627 #[tokio::test]
4632 async fn a_round_covers_every_include_tree() {
4633 let s = scratch_project(
4634 "round",
4635 &[
4636 ("bynk.toml", "[project]\nname = \"round\"\n"),
4637 ("src/thing.bynk", "context thing\n"),
4638 ("tests/thing.bynk", "suite thing\n"),
4640 ],
4641 );
4642 let backend = backend_at(&s.0).await;
4643 backend.run_round().await;
4644
4645 let analysis = backend.test_analysis().await.expect("a round committed");
4646 let mut keys: Vec<String> = analysis
4647 .snapshots
4648 .keys()
4649 .map(|p| p.to_string_lossy().replace('\\', "/"))
4650 .collect();
4651 keys.sort();
4652 assert_eq!(
4653 keys,
4654 vec!["src/thing.bynk", "tests/thing.bynk"],
4655 "the round must cover both include trees, with distinct identities",
4656 );
4657 }
4658
4659 #[tokio::test]
4663 async fn a_uri_in_any_include_tree_resolves_to_its_analysed_file() {
4664 let s = scratch_project(
4665 "uri",
4666 &[
4667 ("bynk.toml", "[project]\nname = \"uri\"\n"),
4668 ("src/thing.bynk", "context thing\n"),
4669 ("tests/thing.bynk", "suite thing\n"),
4670 ],
4671 );
4672 let backend = backend_at(&s.0).await;
4673 backend.run_round().await;
4674 let analysis = backend.test_analysis().await.expect("round");
4675
4676 for (rel, label) in [
4677 ("src/thing.bynk", "primary"),
4678 ("tests/thing.bynk", "secondary"),
4679 ] {
4680 let abs = s.0.join(rel);
4681 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4682 let resolved = Backend::uri_to_rel(&analysis, &uri)
4683 .unwrap_or_else(|| panic!("{label} root URI must resolve"));
4684 assert_eq!(
4685 resolved.to_string_lossy().replace('\\', "/"),
4686 rel,
4687 "a {label}-tree URI must name its own analysed file",
4688 );
4689 assert!(
4690 analysis.snapshots.contains_key(&resolved),
4691 "…and that file must be in the round",
4692 );
4693 }
4694 }
4695
4696 #[tokio::test]
4702 async fn project_content_excludes_the_calling_file() {
4703 let s = scratch_project(
4704 "self_excl",
4705 &[
4706 ("bynk.toml", "[project]\nname = \"self_excl\"\n"),
4707 ("a.bynk", "context a\n"),
4708 ("b.bynk", "context b\n"),
4709 ],
4710 );
4711 let backend = backend_at(&s.0).await;
4712 let abs = s.0.join("a.bynk");
4713 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4714 let content = backend
4715 .project_content(&uri)
4716 .await
4717 .expect("a project root resolves to a content map");
4718 assert!(
4719 !content.keys().any(|p| p.file_name().unwrap() == "a.bynk"),
4720 "the calling file's own path must not be in its own project content map: {content:?}"
4721 );
4722 assert!(
4723 content.keys().any(|p| p.file_name().unwrap() == "b.bynk"),
4724 "a sibling project file must still be present: {content:?}"
4725 );
4726 assert_eq!(
4727 content
4728 .iter()
4729 .find(|(p, _)| p.file_name().unwrap() == "b.bynk")
4730 .map(|(_, text)| text.as_str()),
4731 Some("context b\n"),
4732 "the sibling's content must be the real file content, not just its path"
4733 );
4734 }
4735
4736 #[tokio::test]
4740 async fn an_excluded_tree_is_not_analysed() {
4741 let s = scratch_project(
4742 "excl",
4743 &[
4744 (
4745 "bynk.toml",
4746 "[project]\nname = \"excl\"\n\n[paths]\ninclude = [\".\"]\nexclude = [\"generated\"]\n",
4747 ),
4748 ("a.bynk", "context a\n"),
4749 ("generated/gen.bynk", "context gen\n"),
4750 ],
4751 );
4752 let backend = backend_at(&s.0).await;
4753 backend.run_round().await;
4754 let analysis = backend.test_analysis().await.expect("round");
4755 let keys: Vec<String> = analysis
4756 .snapshots
4757 .keys()
4758 .map(|p| p.to_string_lossy().replace('\\', "/"))
4759 .collect();
4760 assert_eq!(keys, vec!["a.bynk"], "excluded trees stay out of the round");
4761 }
4762
4763 #[tokio::test]
4768 async fn references_resolve_in_the_vscode_fixture_layout() {
4769 let s = scratch_project(
4770 "vsc",
4771 &[
4772 (
4773 "bynk.toml",
4774 "[project]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[paths]\nsrc = \"src\"\ntests = \"tests\"\n",
4775 ),
4776 (
4777 "src/text.bynk",
4778 "commons fixture.text\n\nfn shout(s: String) -> String {\n s\n}\n\nfn greet(name: String) -> String {\n \"Hi, \\(shout(name))!\"\n}\n",
4779 ),
4780 ],
4781 );
4782 let (root, config) = Backend::resolve_root(&s.0).expect("bynk.toml is present");
4784 assert_eq!(root, s.0, "the manifest's directory is the project root");
4785
4786 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4787 let backend = service.inner().clone();
4788 {
4789 let mut st = backend.state.write().await;
4790 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
4793 st.projects.insert(
4794 canonical,
4795 ProjectState {
4796 config,
4797 ..Default::default()
4798 },
4799 );
4800 }
4801 backend.run_round().await;
4802
4803 let analysis = backend.test_analysis().await.expect("a round committed");
4804 let keys: Vec<String> = analysis
4805 .snapshots
4806 .keys()
4807 .map(|p| p.to_string_lossy().replace('\\', "/"))
4808 .collect();
4809 assert_eq!(keys, vec!["src/text.bynk"], "the fixture file is analysed");
4810
4811 let abs = s.0.join("src/text.bynk");
4813 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4814 let rel = Backend::uri_to_rel(&analysis, &uri).expect("URI resolves into the round");
4815 assert_eq!(rel, PathBuf::from("src/text.bynk"));
4816
4817 let text = analysis.snapshots.get(&rel).expect("snapshot present");
4819 let decl = text.find("shout").expect("`shout` in source");
4820 let pos = crate::position::offset_to_position(text, decl);
4821
4822 let refs = backend
4823 .references(ReferenceParams {
4824 text_document_position: TextDocumentPositionParams {
4825 text_document: TextDocumentIdentifier { uri: uri.clone() },
4826 position: pos,
4827 },
4828 work_done_progress_params: Default::default(),
4829 partial_result_params: Default::default(),
4830 context: ReferenceContext {
4831 include_declaration: true,
4832 },
4833 })
4834 .await
4835 .expect("references must not error");
4836 let found = refs.unwrap_or_default();
4837 assert!(
4838 !found.is_empty(),
4839 "`shout` is referenced by `greet` — references must resolve; got none",
4840 );
4841 }
4842
4843 async fn open_round_edit(
4853 backend: &Backend,
4854 root: &std::path::Path,
4855 v1_text: &str,
4856 v2_text: &str,
4857 ) -> (Url, PathBuf) {
4858 let abs = root.join("src/a.bynk");
4859 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4860
4861 backend
4862 .did_open(DidOpenTextDocumentParams {
4863 text_document: TextDocumentItem {
4864 uri: uri.clone(),
4865 language_id: "bynk".into(),
4866 version: 1,
4867 text: v1_text.to_string(),
4868 },
4869 })
4870 .await;
4871 backend.run_round().await;
4872
4873 let a1 = backend.test_analysis().await.expect("round 1");
4875 let rel = Backend::uri_to_rel(&a1, &uri).expect("uri resolves");
4876 assert_eq!(a1.versions.get(&rel), Some(&1), "round 1 is version 1");
4877
4878 backend
4881 .did_change(DidChangeTextDocumentParams {
4882 text_document: VersionedTextDocumentIdentifier {
4883 uri: uri.clone(),
4884 version: 2,
4885 },
4886 content_changes: vec![TextDocumentContentChangeEvent {
4887 range: None,
4888 range_length: None,
4889 text: v2_text.to_string(),
4890 }],
4891 })
4892 .await;
4893 (uri, rel)
4894 }
4895
4896 #[tokio::test]
4902 async fn a_position_after_an_edit_resolves_against_the_new_text() {
4903 let v1 = "commons q.a\n\nfn target(x: Int) -> Int {\n x\n}\n";
4904 let v2 = format!("\n{v1}");
4906 let s = scratch_project(
4907 "fresh_hd",
4908 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4909 );
4910 let backend = backend_at(&s.0).await;
4911 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4912
4913 let a = backend.analysis_for(&uri).await.expect("current analysis");
4915 assert_eq!(a.versions.get(&rel), Some(&2), "gate refreshed to the edit");
4916 assert_eq!(a.snapshots.get(&rel).map(String::as_str), Some(v2.as_str()));
4917
4918 let off_v2 = v2.find("target").unwrap();
4920 let new_pos = crate::position::offset_to_position(&v2, off_v2);
4921 let (a2, rel2, off) = backend
4922 .index_position(&uri, new_pos)
4923 .await
4924 .expect("position resolves");
4925 assert!(
4926 a2.snapshots.get(&rel2).unwrap()[off..].starts_with("target"),
4927 "the new position must land on `target` in the current snapshot — \
4928 the whole point of refreshing",
4929 );
4930 }
4931
4932 #[tokio::test]
4936 async fn a_stale_round_is_never_served() {
4937 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4938 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
4939 let s = scratch_project(
4940 "fresh_stale",
4941 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4942 );
4943 let backend = backend_at(&s.0).await;
4944 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4945
4946 let cached = backend.test_analysis().await.unwrap();
4948 assert_eq!(cached.versions.get(&rel), Some(&1), "cached round is stale");
4949
4950 let a = backend.analysis_for(&uri).await.unwrap();
4952 assert_eq!(
4953 a.versions.get(&rel),
4954 Some(&2),
4955 "analysis_for must refresh past a stale cached round, never serve it",
4956 );
4957 }
4958
4959 #[tokio::test]
4966 async fn committed_analysis_serves_the_stale_round_without_refreshing() {
4967 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4968 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
4969 let s = scratch_project(
4970 "fresh_committed",
4971 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4972 );
4973 let backend = backend_at(&s.0).await;
4974 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4975
4976 assert_eq!(
4978 backend.test_analysis().await.unwrap().versions.get(&rel),
4979 Some(&1),
4980 "cached round is stale",
4981 );
4982
4983 let a = backend
4985 .committed_analysis(&uri)
4986 .await
4987 .expect("committed round");
4988 assert_eq!(
4989 a.versions.get(&rel),
4990 Some(&1),
4991 "committed_analysis serves the committed round, stale and all",
4992 );
4993 assert_eq!(
4995 backend.test_analysis().await.unwrap().versions.get(&rel),
4996 Some(&1),
4997 "committed_analysis must not trigger a refresh",
4998 );
4999 }
5000
5001 #[tokio::test]
5005 async fn concurrent_requests_after_one_edit_share_one_round() {
5006 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
5007 let v2 = format!("\n{v1}");
5008 let s = scratch_project(
5009 "fresh_coal",
5010 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
5011 );
5012 let backend = backend_at(&s.0).await;
5013 let (uri, _rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
5014
5015 let started_before = backend.test_round_started().await;
5016
5017 let calls = (0..5).map(|_| {
5019 let b = backend.clone();
5020 let u = uri.clone();
5021 tokio::spawn(async move { b.analysis_for(&u).await.is_some() })
5022 });
5023 for c in calls {
5024 assert!(
5025 c.await.unwrap(),
5026 "each concurrent request must get an analysis"
5027 );
5028 }
5029
5030 let started_after = backend.test_round_started().await;
5031 assert_eq!(
5032 started_after - started_before,
5033 1,
5034 "five concurrent requests after one edit must share ONE round, not run five",
5035 );
5036 }
5037
5038 #[tokio::test]
5041 async fn a_file_outside_the_project_is_declined() {
5042 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
5043 let s = scratch_project(
5044 "fresh_out",
5045 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
5046 );
5047 let backend = backend_at(&s.0).await;
5048 backend.run_round().await;
5050
5051 let outside = s.0.join("elsewhere.bynk");
5053 std::fs::write(&outside, v1).unwrap();
5054 let uri = Url::from_file_path(outside.canonicalize().unwrap()).unwrap();
5055 backend
5056 .did_open(DidOpenTextDocumentParams {
5057 text_document: TextDocumentItem {
5058 uri: uri.clone(),
5059 language_id: "bynk".into(),
5060 version: 1,
5061 text: v1.to_string(),
5062 },
5063 })
5064 .await;
5065
5066 assert!(
5067 backend.analysis_for(&uri).await.is_none(),
5068 "a file outside the include roots is never a snapshot key — decline, \
5069 don't serve a round that doesn't cover it",
5070 );
5071 }
5072
5073 #[tokio::test]
5081 async fn a_multi_file_rename_stamps_a_dirty_non_cursor_file_at_its_current_version() {
5082 let util = "commons demo.util\n\ntype Money = Int where Positive\n";
5083 let thing = "commons demo.thing\n\nuses demo.util\n\nfn f(m: Money) -> Money {\n m\n}\n";
5084 let s = scratch_project(
5085 "rename_multi",
5086 &[
5087 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5088 ("src/demo/util.bynk", util),
5089 ("src/demo/thing.bynk", thing),
5090 ],
5091 );
5092 let backend = backend_at(&s.0).await;
5093 let uri = |rel: &str| {
5094 let abs = s.0.join(rel);
5095 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
5096 };
5097 let util_uri = uri("src/demo/util.bynk");
5098 let thing_uri = uri("src/demo/thing.bynk");
5099
5100 for (u, text) in [(&util_uri, util), (&thing_uri, thing)] {
5101 backend
5102 .did_open(DidOpenTextDocumentParams {
5103 text_document: TextDocumentItem {
5104 uri: u.clone(),
5105 language_id: "bynk".into(),
5106 version: 1,
5107 text: text.to_string(),
5108 },
5109 })
5110 .await;
5111 }
5112 backend.run_round().await;
5113
5114 let thing_v2 = format!("\n{thing}");
5117 backend
5118 .did_change(DidChangeTextDocumentParams {
5119 text_document: VersionedTextDocumentIdentifier {
5120 uri: thing_uri.clone(),
5121 version: 2,
5122 },
5123 content_changes: vec![TextDocumentContentChangeEvent {
5124 range: None,
5125 range_length: None,
5126 text: thing_v2.clone(),
5127 }],
5128 })
5129 .await;
5130
5131 let money_off = util.find("Money").unwrap();
5133 let pos = crate::position::offset_to_position(util, money_off);
5134 let edit = backend
5135 .rename(RenameParams {
5136 text_document_position: TextDocumentPositionParams {
5137 text_document: TextDocumentIdentifier {
5138 uri: util_uri.clone(),
5139 },
5140 position: pos,
5141 },
5142 new_name: "Amount".into(),
5143 work_done_progress_params: Default::default(),
5144 })
5145 .await
5146 .expect("rename must not error")
5147 .expect("rename must produce edits");
5148
5149 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5150 panic!("expected document-change edits");
5151 };
5152 let thing_edit = edits
5153 .iter()
5154 .find(|e| e.text_document.uri == thing_uri)
5155 .expect("the rename must edit `thing`, which references the symbol");
5156 assert_eq!(
5157 thing_edit.text_document.version,
5158 Some(2),
5159 "the dirty non-cursor file's edit must carry its current version (2), \
5160 not the stale round's (1) — else the client rejects the whole rename",
5161 );
5162 }
5163
5164 #[tokio::test]
5169 async fn will_rename_files_updates_the_declaration_and_every_reference() {
5170 let charge = "commons billing.charge\n\ntype ChargeId = Int where Positive\n";
5171 let main = "context app.main\n\nuses billing.charge\n";
5172 let s = scratch_project(
5173 "will_rename_basic",
5174 &[
5175 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5176 ("src/billing/charge.bynk", charge),
5177 ("src/app/main.bynk", main),
5178 ],
5179 );
5180 let backend = backend_at(&s.0).await;
5181 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5182 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5183 let old_uri = uri("src/billing/charge.bynk");
5184 let new_uri = uri("src/billing/pay.bynk");
5185 let main_uri = uri("src/app/main.bynk");
5186
5187 backend.run_round().await;
5188
5189 let edit = backend
5190 .will_rename_files(RenameFilesParams {
5191 files: vec![FileRename {
5192 old_uri: old_uri.to_string(),
5193 new_uri: new_uri.to_string(),
5194 }],
5195 })
5196 .await
5197 .expect("will_rename_files must not error")
5198 .expect("must produce edits");
5199
5200 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5201 panic!("expected document-change edits");
5202 };
5203
5204 let own = edits
5205 .iter()
5206 .find(|e| e.text_document.uri == old_uri)
5207 .expect("the moved file's own declaration must be rewritten");
5208 assert_eq!(own.edits.len(), 1);
5209 let OneOf::Left(own_edit) = &own.edits[0] else {
5210 panic!("expected a plain TextEdit");
5211 };
5212 assert_eq!(own_edit.new_text, "billing.pay");
5213
5214 let referencer = edits
5215 .iter()
5216 .find(|e| e.text_document.uri == main_uri)
5217 .expect("the referencing file must be rewritten");
5218 assert_eq!(referencer.edits.len(), 1);
5219 let OneOf::Left(ref_edit) = &referencer.edits[0] else {
5220 panic!("expected a plain TextEdit");
5221 };
5222 assert_eq!(ref_edit.new_text, "billing.pay");
5223 }
5224
5225 #[tokio::test]
5229 async fn will_rename_files_is_a_noop_for_a_multi_file_unit_member() {
5230 let s = scratch_project(
5231 "will_rename_multi_file_noop",
5232 &[
5233 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5234 (
5235 "src/billing/charge/one.bynk",
5236 "context billing.charge\n\ntype ChargeId = Int where Positive\n",
5237 ),
5238 (
5239 "src/billing/charge/two.bynk",
5240 "context billing.charge\n\ntype PaymentId = Int where Positive\n",
5241 ),
5242 ],
5243 );
5244 let backend = backend_at(&s.0).await;
5245 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5246 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5247
5248 backend.run_round().await;
5249
5250 let edit = backend
5251 .will_rename_files(RenameFilesParams {
5252 files: vec![FileRename {
5253 old_uri: uri("src/billing/charge/one.bynk").to_string(),
5254 new_uri: uri("src/billing/charge/renamed.bynk").to_string(),
5255 }],
5256 })
5257 .await
5258 .expect("will_rename_files must not error");
5259 assert!(
5260 edit.is_none(),
5261 "renaming a member file within the same directory must not edit anything"
5262 );
5263 }
5264
5265 #[tokio::test]
5269 async fn will_rename_files_is_a_noop_for_a_suite() {
5270 let s = scratch_project(
5271 "will_rename_suite_noop",
5272 &[
5273 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5274 (
5275 "src/billing/charge.bynk",
5276 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5277 ),
5278 ("tests/billing_charge.bynk", "suite billing.charge\n"),
5279 ],
5280 );
5281 let backend = backend_at(&s.0).await;
5282 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5283 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5284
5285 backend.run_round().await;
5286
5287 let edit = backend
5288 .will_rename_files(RenameFilesParams {
5289 files: vec![FileRename {
5290 old_uri: uri("tests/billing_charge.bynk").to_string(),
5291 new_uri: uri("tests/billing_charge_renamed.bynk").to_string(),
5292 }],
5293 })
5294 .await
5295 .expect("will_rename_files must not error");
5296 assert!(edit.is_none(), "a suite rename must produce no edits");
5297 }
5298
5299 #[tokio::test]
5304 async fn will_rename_files_updates_a_suite_s_target_reference() {
5305 let s = scratch_project(
5306 "will_rename_suite_target",
5307 &[
5308 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5309 (
5310 "src/billing/charge.bynk",
5311 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5312 ),
5313 ("tests/billing_charge.bynk", "suite billing.charge\n"),
5314 ],
5315 );
5316 let backend = backend_at(&s.0).await;
5317 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5318 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5319 let suite_uri = uri("tests/billing_charge.bynk");
5320
5321 backend.run_round().await;
5322
5323 let edit = backend
5324 .will_rename_files(RenameFilesParams {
5325 files: vec![FileRename {
5326 old_uri: uri("src/billing/charge.bynk").to_string(),
5327 new_uri: uri("src/billing/pay.bynk").to_string(),
5328 }],
5329 })
5330 .await
5331 .expect("will_rename_files must not error")
5332 .expect("must produce edits");
5333
5334 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5335 panic!("expected document-change edits");
5336 };
5337 let suite_edit = edits
5338 .iter()
5339 .find(|e| e.text_document.uri == suite_uri)
5340 .expect("the suite's own `suite <target>` header must be rewritten");
5341 assert_eq!(suite_edit.edits.len(), 1);
5342 let OneOf::Left(e) = &suite_edit.edits[0] else {
5343 panic!("expected a plain TextEdit");
5344 };
5345 assert_eq!(e.new_text, "billing.pay");
5346 }
5347
5348 #[tokio::test]
5353 async fn will_rename_files_refuses_a_rename_that_collides_with_an_existing_unit() {
5354 let s = scratch_project(
5355 "will_rename_collision",
5356 &[
5357 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5358 (
5359 "src/billing/charge.bynk",
5360 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5361 ),
5362 (
5363 "src/billing/pay.bynk",
5364 "commons billing.pay\n\ntype PaymentId = Int where Positive\n",
5365 ),
5366 ],
5367 );
5368 let backend = backend_at(&s.0).await;
5369 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5370 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5371
5372 backend.run_round().await;
5373
5374 let edit = backend
5377 .will_rename_files(RenameFilesParams {
5378 files: vec![FileRename {
5379 old_uri: uri("src/billing/charge.bynk").to_string(),
5380 new_uri: uri("src/billing/pay.bynk").to_string(),
5381 }],
5382 })
5383 .await
5384 .expect("will_rename_files must not error");
5385 assert!(
5386 edit.is_none(),
5387 "a rename that collides with an existing unit name must produce no edits"
5388 );
5389 }
5390
5391 #[cfg(unix)]
5399 #[tokio::test]
5400 async fn will_rename_files_tolerates_a_symlinked_project_root() {
5401 let real = scratch_project(
5402 "will_rename_symlink_real",
5403 &[
5404 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5405 (
5406 "src/billing/charge.bynk",
5407 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5408 ),
5409 ],
5410 );
5411 let alias = std::env::temp_dir().join(format!(
5412 "bynk_lsp_sliceA_will_rename_symlink_alias_{}_{:?}",
5413 std::process::id(),
5414 std::thread::current().id()
5415 ));
5416 let _ = std::fs::remove_file(&alias);
5417 std::os::unix::fs::symlink(&real.0, &alias).expect("symlink the scratch root");
5418
5419 let backend = backend_at(&alias).await;
5420 let uri = |rel: &str| Url::from_file_path(alias.join(rel)).unwrap();
5424 let old_uri = uri("src/billing/charge.bynk");
5425 let new_uri = uri("src/billing/pay.bynk"); backend.run_round().await;
5428
5429 let edit = backend
5430 .will_rename_files(RenameFilesParams {
5431 files: vec![FileRename {
5432 old_uri: old_uri.to_string(),
5433 new_uri: new_uri.to_string(),
5434 }],
5435 })
5436 .await
5437 .expect("will_rename_files must not error")
5438 .expect("must produce edits despite the symlinked root");
5439
5440 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5441 panic!("expected document-change edits");
5442 };
5443 assert_eq!(
5444 edits.len(),
5445 1,
5446 "only the moved file's own header changes here"
5447 );
5448 let OneOf::Left(e) = &edits[0].edits[0] else {
5449 panic!("expected a plain TextEdit");
5450 };
5451 assert_eq!(e.new_text, "billing.pay");
5452
5453 let _ = std::fs::remove_file(&alias);
5454 }
5455
5456 #[test]
5461 fn find_source_root_walks_up_to_the_nearest_src() {
5462 let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5463 .parent()
5464 .expect("workspace root");
5465 let make = ws.join(
5466 "bynkc/tests/fixtures/positive/\
5467 252_multi_file_commons_dotted_test/src/shipping/rates/make.bynk",
5468 );
5469 assert!(make.is_file(), "fixture present: {}", make.display());
5470
5471 let src = Backend::find_source_root(&make).expect("an ancestor src/");
5472 assert!(
5473 src.ends_with("252_multi_file_commons_dotted_test/src"),
5474 "nearest ancestor src, got {}",
5475 src.display()
5476 );
5477
5478 let (root, _config) = Backend::resolve_root(&make).expect("implicit project");
5489 assert_eq!(root, src.parent().expect("src has a parent"));
5490
5491 let found = bynk_ide::discover_files(
5495 &bynk_ide::AnalysisRoots::Project(root.clone()),
5496 &std::collections::HashMap::new(),
5497 );
5498 let make_canon = make.canonicalize().unwrap_or(make.clone());
5499 assert!(
5500 found
5501 .iter()
5502 .any(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == make_canon),
5503 "the compiler's own discovery must reach {} from the project root {}; got {found:?}",
5504 make.display(),
5505 root.display(),
5506 );
5507 }
5508
5509 #[test]
5513 fn resolve_root_is_none_without_toml_or_src() {
5514 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
5516 assert!(p.is_file());
5517 assert!(Backend::find_source_root(&p).is_none());
5518 assert!(Backend::resolve_root(&p).is_none());
5519 }
5520
5521 #[test]
5524 fn is_scrutinee_offset_locates_the_scrutinee() {
5525 let text = " order.status is Pen";
5526 let off = is_scrutinee_offset(text, text.len()).expect("at an is-position");
5527 assert_eq!(&text[off..off + 1], "s");
5529 assert!(off < text.find(" is ").unwrap());
5530 let text2 = " x is ";
5532 let off2 = is_scrutinee_offset(text2, text2.len()).expect("at an is-position");
5533 assert_eq!(&text2[off2..off2 + 1], "x");
5534 assert!(is_scrutinee_offset(" basis ", " basis ".len()).is_none());
5536 assert!(is_scrutinee_offset(" let x = ", " let x = ".len()).is_none());
5538 }
5539
5540 #[test]
5543 fn match_scrutinee_offset_locates_the_scrutinee() {
5544 let t = "match order.status {\n ";
5546 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5547 assert_eq!(&t[off..off + 1], "s"); assert!(off < t.find(" {").unwrap());
5549
5550 let t = "match color { Re";
5552 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5553 assert_eq!(&t[off..off + 1], "r"); let t = "match c {\n Red => 1,\n Gr";
5557 let off = match_scrutinee_offset(t, t.len()).expect("at a later arm-start");
5558 assert_eq!(&t[off..off + 1], "c");
5559
5560 let t = "match c {\n Red => f(a, b),\n ";
5563 assert!(match_scrutinee_offset(t, t.len()).is_some());
5564
5565 assert!(
5567 match_scrutinee_offset("match c {\n Red => ", "match c {\n Red => ".len()).is_none()
5568 );
5569
5570 assert!(match_scrutinee_offset("fn f() {\n ", "fn f() {\n ".len()).is_none());
5572
5573 assert!(match_scrutinee_offset("match c {\n Ok(", "match c {\n Ok(".len()).is_none());
5575
5576 assert!(match_scrutinee_offset("match c ", "match c ".len()).is_none());
5578 assert!(match_scrutinee_offset("match {\n ", "match {\n ".len()).is_none());
5579 }
5580
5581 #[test]
5585 fn nested_pattern_offset_locates_the_scrutinee_and_variant() {
5586 let t = "match res {\n Some(";
5588 let (off, variant) = nested_pattern_offset(t, t.len()).expect("inside a nested pattern");
5589 assert_eq!(&t[off..off + 1], "s"); assert_eq!(variant, "Some");
5591
5592 let t = "match res {\n Ok(Po";
5594 let (off, variant) = nested_pattern_offset(t, t.len()).expect("mid partial");
5595 assert_eq!(&t[off..off + 1], "s");
5596 assert_eq!(variant, "Ok");
5597
5598 let t = "match r {\n Ok(n) => n,\n Err(";
5600 let (off, variant) = nested_pattern_offset(t, t.len()).expect("later arm");
5601 assert_eq!(&t[off..off + 1], "r");
5602 assert_eq!(variant, "Err");
5603
5604 assert!(nested_pattern_offset("match r {\n ok(", "match r {\n ok(".len()).is_none());
5607
5608 assert!(nested_pattern_offset("match c {\n ", "match c {\n ".len()).is_none());
5610 assert!(nested_pattern_offset("match c {\n Ok", "match c {\n Ok".len()).is_none());
5611
5612 let t = "match c {\n Ok(n) => g(";
5614 assert!(nested_pattern_offset(t, t.len()).is_none());
5615
5616 assert!(nested_pattern_offset("fn f() {\n h(", "fn f() {\n h(".len()).is_none());
5618 }
5619
5620 #[test]
5624 fn is_bynk_toml_matches_only_the_manifest() {
5625 let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
5630 let toml = Url::from_file_path(base.join("bynk.toml")).expect("abs path");
5631 assert!(is_bynk_toml(&toml));
5632 let nested = Url::from_file_path(base.join("sub").join("bynk.toml")).expect("abs path");
5633 assert!(is_bynk_toml(&nested));
5634
5635 let src = Url::from_file_path(base.join("src").join("main.bynk")).expect("abs path");
5637 assert!(!is_bynk_toml(&src));
5638 let decoy = Url::from_file_path(base.join("notbynk.toml")).expect("abs path");
5640 assert!(!is_bynk_toml(&decoy));
5641 let remote = Url::parse("https://example.com/bynk.toml").expect("url");
5643 assert!(!is_bynk_toml(&remote));
5644 }
5645
5646 #[test]
5649 fn advertises_code_actions_and_the_index_riders() {
5650 let caps = server_capabilities();
5651 let Some(CodeActionProviderCapability::Options(opts)) = caps.code_action_provider else {
5652 panic!("codeActionProvider not advertised with options");
5653 };
5654 assert_eq!(
5655 opts.code_action_kinds,
5656 Some(vec![
5657 CodeActionKind::QUICKFIX,
5658 CodeActionKind::REFACTOR,
5659 CodeActionKind::REFACTOR_EXTRACT,
5660 ])
5661 );
5662 assert!(matches!(
5663 caps.workspace_symbol_provider,
5664 Some(OneOf::Left(true))
5665 ));
5666 assert!(matches!(
5667 caps.document_highlight_provider,
5668 Some(OneOf::Left(true))
5669 ));
5670 }
5671
5672 #[test]
5675 fn advertises_save_notifications() {
5676 let caps = server_capabilities();
5679 let Some(TextDocumentSyncCapability::Options(opts)) = caps.text_document_sync else {
5680 panic!("textDocumentSync not advertised with options");
5681 };
5682 assert_eq!(opts.change, Some(TextDocumentSyncKind::FULL));
5683 assert!(matches!(
5684 opts.save,
5685 Some(TextDocumentSyncSaveOptions::Supported(true))
5686 ));
5687 }
5688
5689 #[test]
5690 fn advertises_inlay_hints() {
5691 let caps = server_capabilities();
5692 assert!(matches!(caps.inlay_hint_provider, Some(OneOf::Left(true))));
5693 }
5694
5695 #[test]
5697 fn advertises_type_definition() {
5698 let caps = server_capabilities();
5699 assert!(matches!(
5700 caps.type_definition_provider,
5701 Some(TypeDefinitionProviderCapability::Simple(true))
5702 ));
5703 }
5704
5705 #[test]
5707 fn advertises_document_links() {
5708 let caps = server_capabilities();
5709 assert!(caps.document_link_provider.is_some());
5710 }
5711
5712 #[test]
5714 fn advertises_will_rename_files() {
5715 let caps = server_capabilities();
5716 let file_ops = caps
5717 .workspace
5718 .as_ref()
5719 .and_then(|w| w.file_operations.as_ref())
5720 .expect("workspace.fileOperations advertised");
5721 let will_rename = file_ops
5722 .will_rename
5723 .as_ref()
5724 .expect("willRename registered");
5725 let filter = &will_rename.filters[0];
5726 assert_eq!(filter.pattern.glob, "**/*.bynk");
5727 assert_eq!(filter.pattern.matches, Some(FileOperationPatternKind::File));
5728 }
5729
5730 #[test]
5732 fn advertises_completion_with_dot_trigger_and_resolve() {
5733 let caps = server_capabilities();
5734 let opts = caps.completion_provider.expect("completion advertised");
5735 assert_eq!(opts.resolve_provider, Some(true), "resolve_provider");
5736 assert!(
5737 opts.trigger_characters
5738 .as_deref()
5739 .is_some_and(|t| t.iter().any(|c| c == ".")),
5740 "`.` trigger char"
5741 );
5742 }
5743
5744 #[test]
5747 fn advertises_semantic_tokens() {
5748 let caps = server_capabilities();
5749 let Some(SemanticTokensServerCapabilities::SemanticTokensOptions(opts)) =
5750 caps.semantic_tokens_provider
5751 else {
5752 panic!("semanticTokensProvider not advertised with options");
5753 };
5754 assert_eq!(opts.full, Some(SemanticTokensFullOptions::Bool(true)));
5755 assert_eq!(opts.range, Some(true));
5756 assert_eq!(opts.legend, crate::index_queries::semantic_tokens_legend());
5757 }
5758
5759 async fn bare_backend() -> Backend {
5764 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
5765 service.inner().clone()
5766 }
5767
5768 fn file_uri(root: &std::path::Path, rel: &str) -> Url {
5769 let abs = root.join(rel);
5770 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
5771 }
5772
5773 async fn set_folders(backend: &Backend, roots: &[&std::path::Path]) {
5774 backend.state.write().await.folders = roots
5775 .iter()
5776 .map(|r| r.canonicalize().unwrap_or_else(|_| r.to_path_buf()))
5777 .collect();
5778 }
5779
5780 async fn open(backend: &Backend, uri: &Url, text: &str) {
5781 backend
5782 .did_open(DidOpenTextDocumentParams {
5783 text_document: TextDocumentItem {
5784 uri: uri.clone(),
5785 language_id: "bynk".into(),
5786 version: 1,
5787 text: text.to_string(),
5788 },
5789 })
5790 .await;
5791 }
5792
5793 fn snapshot_keys(a: &Analysis) -> Vec<String> {
5794 let mut keys: Vec<String> = a
5795 .snapshots
5796 .keys()
5797 .map(|p| p.to_string_lossy().replace('\\', "/"))
5798 .collect();
5799 keys.sort();
5800 keys
5801 }
5802
5803 #[tokio::test]
5808 async fn two_projects_under_one_folder_are_two_projects() {
5809 let ax_src = "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n";
5810 let by_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
5811 let s = scratch_project(
5812 "d_two",
5813 &[
5814 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5815 ("a/src/x.bynk", ax_src),
5816 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5817 ("b/src/y.bynk", by_src),
5818 ],
5819 );
5820 let backend = bare_backend().await;
5821 set_folders(&backend, &[&s.0]).await;
5822 let ax = file_uri(&s.0, "a/src/x.bynk");
5823 let by = file_uri(&s.0, "b/src/y.bynk");
5824 open(&backend, &ax, ax_src).await;
5825 open(&backend, &by, by_src).await;
5826
5827 assert_ne!(
5828 Backend::root_for_uri_uncached(&ax).unwrap(),
5829 Backend::root_for_uri_uncached(&by).unwrap(),
5830 "the two files resolve to different project roots",
5831 );
5832 assert_eq!(
5833 backend.state.read().await.projects.len(),
5834 2,
5835 "one entry per project, not one for the shared folder",
5836 );
5837
5838 let a = backend.analysis_for(&ax).await.expect("A analysed");
5839 let b = backend.analysis_for(&by).await.expect("B analysed");
5840 assert_eq!(
5841 snapshot_keys(&a),
5842 vec!["src/x.bynk"],
5843 "A sees only A's file"
5844 );
5845 assert_eq!(
5846 snapshot_keys(&b),
5847 vec!["src/y.bynk"],
5848 "B sees only B's file"
5849 );
5850 }
5851
5852 #[tokio::test]
5856 async fn removing_a_folder_prunes_an_idle_project() {
5857 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5858 let s = scratch_project(
5859 "d_prune",
5860 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5861 );
5862 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5863 let backend = bare_backend().await;
5864 set_folders(&backend, &[&s.0]).await;
5865 let uri = file_uri(&s.0, "src/a.bynk");
5866 open(&backend, &uri, a).await;
5867 backend.analysis_for(&uri).await.expect("analysed");
5868 backend
5870 .did_close(DidCloseTextDocumentParams {
5871 text_document: TextDocumentIdentifier { uri: uri.clone() },
5872 })
5873 .await;
5874 assert_eq!(backend.state.read().await.projects.len(), 1);
5875
5876 backend
5877 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5878 event: WorkspaceFoldersChangeEvent {
5879 added: vec![],
5880 removed: vec![WorkspaceFolder {
5881 uri: Url::from_file_path(&folder).unwrap(),
5882 name: "p".into(),
5883 }],
5884 },
5885 })
5886 .await;
5887 assert!(
5888 backend.state.read().await.projects.is_empty(),
5889 "an idle project is pruned when its last covering folder is removed",
5890 );
5891 }
5892
5893 #[tokio::test]
5896 async fn removing_a_folder_retains_a_project_with_an_open_buffer() {
5897 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5898 let s = scratch_project(
5899 "d_retain",
5900 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5901 );
5902 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5903 let backend = bare_backend().await;
5904 set_folders(&backend, &[&s.0]).await;
5905 let uri = file_uri(&s.0, "src/a.bynk");
5906 open(&backend, &uri, a).await; backend
5909 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5910 event: WorkspaceFoldersChangeEvent {
5911 added: vec![],
5912 removed: vec![WorkspaceFolder {
5913 uri: Url::from_file_path(&folder).unwrap(),
5914 name: "p".into(),
5915 }],
5916 },
5917 })
5918 .await;
5919 assert_eq!(
5920 backend.state.read().await.projects.len(),
5921 1,
5922 "a project with an open buffer must survive folder removal",
5923 );
5924 assert!(
5925 backend.analysis_for(&uri).await.is_some(),
5926 "and it must still answer requests",
5927 );
5928 }
5929
5930 #[tokio::test]
5934 async fn closing_the_last_buffer_of_a_folder_removed_project_prunes_it() {
5935 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5936 let s = scratch_project(
5937 "d_close_prune",
5938 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5939 );
5940 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5941 let backend = bare_backend().await;
5942 set_folders(&backend, &[&s.0]).await;
5943 let uri = file_uri(&s.0, "src/a.bynk");
5944 open(&backend, &uri, a).await;
5945 backend.analysis_for(&uri).await.expect("analysed");
5946
5947 backend
5949 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5950 event: WorkspaceFoldersChangeEvent {
5951 added: vec![],
5952 removed: vec![WorkspaceFolder {
5953 uri: Url::from_file_path(&folder).unwrap(),
5954 name: "p".into(),
5955 }],
5956 },
5957 })
5958 .await;
5959 assert_eq!(
5960 backend.state.read().await.projects.len(),
5961 1,
5962 "retained while its buffer is open",
5963 );
5964
5965 backend
5967 .did_close(DidCloseTextDocumentParams {
5968 text_document: TextDocumentIdentifier { uri: uri.clone() },
5969 })
5970 .await;
5971 assert!(
5972 backend.state.read().await.projects.is_empty(),
5973 "closing the last buffer of a folder-removed project must prune it",
5974 );
5975 }
5976
5977 #[tokio::test]
5981 async fn a_rename_in_one_project_ignores_a_dirty_buffer_in_another() {
5982 let a_src = "commons a.x\n\ntype Money = Int where Positive\n\nfn charge(m: Money) -> Money {\n m\n}\n";
5983 let b_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
5984 let s = scratch_project(
5985 "d_rename_iso",
5986 &[
5987 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5988 ("a/src/x.bynk", a_src),
5989 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5990 ("b/src/y.bynk", b_src),
5991 ],
5992 );
5993 let backend = bare_backend().await;
5994 set_folders(&backend, &[&s.0]).await;
5995 let ax = file_uri(&s.0, "a/src/x.bynk");
5996 let by = file_uri(&s.0, "b/src/y.bynk");
5997 open(&backend, &ax, a_src).await;
5998 open(&backend, &by, b_src).await;
5999 backend.analysis_for(&ax).await.expect("A analysed");
6000 backend.analysis_for(&by).await.expect("B analysed");
6001
6002 backend
6004 .did_change(DidChangeTextDocumentParams {
6005 text_document: VersionedTextDocumentIdentifier {
6006 uri: by.clone(),
6007 version: 2,
6008 },
6009 content_changes: vec![TextDocumentContentChangeEvent {
6010 range: None,
6011 range_length: None,
6012 text: format!("\n{b_src}"),
6013 }],
6014 })
6015 .await;
6016
6017 let off = a_src.find("Money").unwrap();
6019 let pos = crate::position::offset_to_position(a_src, off);
6020 let edit = backend
6021 .rename(RenameParams {
6022 text_document_position: TextDocumentPositionParams {
6023 text_document: TextDocumentIdentifier { uri: ax.clone() },
6024 position: pos,
6025 },
6026 new_name: "Amount".into(),
6027 work_done_progress_params: Default::default(),
6028 })
6029 .await
6030 .expect("rename must not error");
6031 assert!(
6032 edit.is_some(),
6033 "a rename in project A must not be blocked by a dirty buffer in project B",
6034 );
6035 }
6036
6037 #[tokio::test]
6042 async fn initialize_captures_the_dynamic_watcher_capability() {
6043 let backend = bare_backend().await;
6044 let params = InitializeParams {
6045 capabilities: ClientCapabilities {
6046 workspace: Some(WorkspaceClientCapabilities {
6047 did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
6048 dynamic_registration: Some(true),
6049 relative_pattern_support: None,
6050 }),
6051 ..Default::default()
6052 }),
6053 ..Default::default()
6054 },
6055 ..Default::default()
6056 };
6057 backend.initialize(params).await.expect("initialize");
6058 assert!(
6059 backend.state.read().await.supports_dynamic_watchers,
6060 "the client's dynamic-registration support must be captured for `initialized`",
6061 );
6062 }
6063
6064 #[tokio::test]
6070 async fn initialize_captures_each_decoration_refresh_capability() {
6071 let backend = bare_backend().await;
6072 let params = InitializeParams {
6073 capabilities: ClientCapabilities {
6074 workspace: Some(WorkspaceClientCapabilities {
6075 semantic_tokens: Some(SemanticTokensWorkspaceClientCapabilities {
6077 refresh_support: Some(true),
6078 }),
6079 inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
6081 refresh_support: Some(false),
6082 }),
6083 ..Default::default()
6085 }),
6086 ..Default::default()
6087 },
6088 ..Default::default()
6089 };
6090 backend.initialize(params).await.expect("initialize");
6091 let refresh = backend.state.read().await.supports_refresh;
6092 assert!(
6093 refresh.semantic_tokens,
6094 "semantic tokens: advertised → true"
6095 );
6096 assert!(!refresh.inlay_hints, "inlay hints: withheld → false");
6097 assert!(!refresh.code_lens, "code lens: absent → false");
6098 }
6099
6100 #[tokio::test]
6103 async fn discover_projects_under_finds_nested_projects_and_skips_caches() {
6104 let s = scratch_project(
6105 "e_discover",
6106 &[
6107 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
6108 ("packages/a/src/x.bynk", "commons a.x\n"),
6109 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
6110 ("packages/b/src/y.bynk", "commons b.y\n"),
6111 ("node_modules/dep/bynk.toml", "[project]\nname=\"dep\"\n"),
6113 ],
6114 );
6115 let mut roots = Backend::discover_projects_under(&s.0);
6116 roots.sort();
6117 let names: Vec<String> = roots
6118 .iter()
6119 .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
6120 .collect();
6121 assert_eq!(
6122 names,
6123 vec!["a", "b"],
6124 "both monorepo projects found, node_modules skipped; got {roots:?}",
6125 );
6126 }
6127
6128 #[tokio::test]
6132 async fn initialized_warms_every_project_under_the_folders() {
6133 let s = scratch_project(
6134 "e_warm",
6135 &[
6136 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
6137 (
6138 "packages/a/src/x.bynk",
6139 "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n",
6140 ),
6141 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
6142 (
6143 "packages/b/src/y.bynk",
6144 "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n",
6145 ),
6146 ],
6147 );
6148 let backend = bare_backend().await;
6149 set_folders(&backend, &[&s.0]).await;
6150
6151 backend.initialized(InitializedParams {}).await;
6153
6154 assert_eq!(
6155 backend.state.read().await.projects.len(),
6156 2,
6157 "both monorepo projects are warmed at `initialized`, before any open",
6158 );
6159 let ax = file_uri(&s.0, "packages/a/src/x.bynk");
6161 assert!(
6162 backend.analysis_for(&ax).await.is_some(),
6163 "a warmed project answers index requests with no `did_open`",
6164 );
6165 }
6166
6167 #[tokio::test]
6172 async fn initialized_warms_an_implicit_src_project() {
6173 let s = scratch_project(
6174 "e_implicit",
6175 &[(
6176 "src/a.bynk",
6177 "commons demo.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
6178 )],
6179 );
6180 let backend = bare_backend().await;
6181 set_folders(&backend, &[&s.0]).await;
6182 backend.initialized(InitializedParams {}).await;
6183 assert_eq!(
6184 backend.state.read().await.projects.len(),
6185 1,
6186 "a rootless `src/` project is warmed at startup, not only on open",
6187 );
6188 }
6189
6190 #[cfg(unix)]
6194 #[tokio::test]
6195 async fn discover_projects_under_survives_a_symlink_cycle() {
6196 let s = scratch_project(
6197 "e_cycle",
6198 &[
6199 ("bynk.toml", "[project]\nname=\"p\"\n"),
6200 ("src/a.bynk", "commons p.a\n"),
6201 ],
6202 );
6203 std::os::unix::fs::symlink(&s.0, s.0.join("loop")).ok();
6205 let roots = Backend::discover_projects_under(&s.0); let canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6207 assert!(
6208 roots.contains(&canon),
6209 "the project is found and the walk terminates despite the cycle",
6210 );
6211 }
6212
6213 #[tokio::test]
6217 async fn a_created_manifest_warms_a_new_project() {
6218 let s = scratch_project("e_created", &[("src/a.bynk", "commons p.a\n")]);
6219 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
6220 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6221 let backend = bare_backend().await;
6222 set_folders(&backend, &[&s.0]).await;
6223 assert!(
6224 backend.state.read().await.projects.is_empty(),
6225 "no entry before the watcher fires",
6226 );
6227
6228 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
6229 backend
6230 .did_change_watched_files(DidChangeWatchedFilesParams {
6231 changes: vec![FileEvent {
6232 uri: toml_uri,
6233 typ: FileChangeType::CREATED,
6234 }],
6235 })
6236 .await;
6237 assert_eq!(
6238 backend.state.read().await.projects.len(),
6239 1,
6240 "a created bynk.toml warms its project via the watcher event",
6241 );
6242 }
6243
6244 #[tokio::test]
6248 async fn root_for_uri_populates_the_cache() {
6249 let s = scratch_project("g_cache_hit", &[("a.bynk", "commons demo.a\n")]);
6250 let uri = file_uri(&s.0, "a.bynk");
6251 let backend = bare_backend().await;
6252
6253 assert!(
6254 backend.root_for_uri(&uri).await.is_none(),
6255 "no bynk.toml and no src/ ancestor — routes to no project",
6256 );
6257 assert_eq!(
6258 backend.state.read().await.root_cache.get(&uri),
6259 Some(&None),
6260 "the miss is cached too",
6261 );
6262 }
6263
6264 #[tokio::test]
6268 async fn a_created_manifest_invalidates_the_cached_route() {
6269 let s = scratch_project("g_cache_invalidate", &[("a.bynk", "commons p.a\n")]);
6270 let uri = file_uri(&s.0, "a.bynk");
6271 let backend = bare_backend().await;
6272
6273 assert!(
6274 backend.root_for_uri(&uri).await.is_none(),
6275 "precondition: cached as routing to no project",
6276 );
6277
6278 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
6279 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6280 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
6281 backend
6282 .did_change_watched_files(DidChangeWatchedFilesParams {
6283 changes: vec![FileEvent {
6284 uri: toml_uri,
6285 typ: FileChangeType::CREATED,
6286 }],
6287 })
6288 .await;
6289
6290 assert_eq!(
6291 backend.root_for_uri(&uri).await,
6292 Some(root),
6293 "re-routes to the new project now the stale cache entry is gone",
6294 );
6295 }
6296
6297 #[test]
6303 fn root_cache_write_is_current_rejects_a_generation_that_moved() {
6304 assert!(
6305 Backend::root_cache_write_is_current(3, 3),
6306 "no clear happened since the read — the write-back applies",
6307 );
6308 assert!(
6309 !Backend::root_cache_write_is_current(3, 4),
6310 "a clear bumped the generation since the read — the write-back must be dropped",
6311 );
6312 }
6313
6314 #[tokio::test]
6321 async fn a_workspace_folder_change_bumps_the_root_cache_generation() {
6322 let s = scratch_project("g_race_folder", &[("bynk.toml", "[project]\nname=\"p\"\n")]);
6323 let backend = bare_backend().await;
6324 let before = backend.state.read().await.root_cache_generation;
6325
6326 backend
6327 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
6328 event: WorkspaceFoldersChangeEvent {
6329 added: vec![WorkspaceFolder {
6330 uri: Url::from_file_path(&s.0).unwrap(),
6331 name: "p".into(),
6332 }],
6333 removed: vec![],
6334 },
6335 })
6336 .await;
6337
6338 assert!(
6339 backend.state.read().await.root_cache_generation > before,
6340 "a workspace-folder change must bump the generation, not just clear the cache",
6341 );
6342 }
6343
6344 #[tokio::test]
6347 async fn an_added_folder_is_warmed() {
6348 let s = scratch_project(
6349 "e_added",
6350 &[
6351 ("bynk.toml", "[project]\nname=\"p\"\n"),
6352 (
6353 "src/a.bynk",
6354 "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
6355 ),
6356 ],
6357 );
6358 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6359 let backend = bare_backend().await; assert!(backend.state.read().await.projects.is_empty());
6361
6362 backend
6363 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
6364 event: WorkspaceFoldersChangeEvent {
6365 added: vec![WorkspaceFolder {
6366 uri: Url::from_file_path(&folder).unwrap(),
6367 name: "p".into(),
6368 }],
6369 removed: vec![],
6370 },
6371 })
6372 .await;
6373
6374 assert_eq!(
6375 backend.state.read().await.projects.len(),
6376 1,
6377 "an added workspace folder's project is warmed proactively",
6378 );
6379 }
6380
6381 fn change_params(uri: &Url, version: i32, text: &str) -> DidChangeTextDocumentParams {
6384 DidChangeTextDocumentParams {
6385 text_document: VersionedTextDocumentIdentifier {
6386 uri: uri.clone(),
6387 version,
6388 },
6389 content_changes: vec![TextDocumentContentChangeEvent {
6390 range: None,
6391 range_length: None,
6392 text: text.to_string(),
6393 }],
6394 }
6395 }
6396
6397 #[tokio::test]
6402 async fn a_single_file_burst_coalesces_by_generation() {
6403 let s = scratch_project("f_single", &[("a.bynk", "commons demo.a\n")]);
6405 let uri = file_uri(&s.0, "a.bynk");
6406 assert!(
6407 Backend::root_for_uri_uncached(&uri).is_none(),
6408 "precondition: the file routes to no project",
6409 );
6410 let backend = bare_backend().await;
6411 for _ in 0..3 {
6412 backend.schedule_single_file(uri.clone()).await;
6413 }
6414 assert_eq!(
6415 backend
6416 .state
6417 .read()
6418 .await
6419 .single_file_generations
6420 .get(&uri)
6421 .copied(),
6422 Some(3),
6423 "each change bumps the generation; only the third task passes its check",
6424 );
6425 }
6426
6427 #[tokio::test]
6430 async fn did_close_clears_the_single_file_generation() {
6431 let s = scratch_project("f_close", &[("a.bynk", "commons demo.a\n")]);
6432 let uri = file_uri(&s.0, "a.bynk");
6433 let backend = bare_backend().await;
6434 backend.schedule_single_file(uri.clone()).await;
6435 assert!(
6436 backend
6437 .state
6438 .read()
6439 .await
6440 .single_file_generations
6441 .contains_key(&uri),
6442 "the generation exists after scheduling",
6443 );
6444 backend
6445 .did_close(DidCloseTextDocumentParams {
6446 text_document: TextDocumentIdentifier { uri: uri.clone() },
6447 })
6448 .await;
6449 assert!(
6450 !backend
6451 .state
6452 .read()
6453 .await
6454 .single_file_generations
6455 .contains_key(&uri),
6456 "did_close clears the single-file generation",
6457 );
6458 }
6459
6460 #[tokio::test]
6465 async fn a_project_change_burst_coalesces_through_did_change() {
6466 let src = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
6467 let s = scratch_project(
6468 "f_burst",
6469 &[
6470 ("bynk.toml", "[project]\nname=\"q\"\n"),
6471 ("src/a.bynk", src),
6472 ],
6473 );
6474 let backend = backend_at(&s.0).await;
6475 let root = backend.test_root().await;
6476 let uri = file_uri(&s.0, "src/a.bynk");
6477 open(&backend, &uri, src).await;
6478
6479 let gen_before = {
6480 let state = backend.state.read().await;
6481 state.projects.get(&root).unwrap().analysis_generation
6482 };
6483 for v in 2..=5 {
6484 backend
6485 .did_change(change_params(
6486 &uri,
6487 v,
6488 &format!("{}{src}", "\n".repeat(v as usize)),
6489 ))
6490 .await;
6491 }
6492 let gen_after = {
6493 let state = backend.state.read().await;
6494 state.projects.get(&root).unwrap().analysis_generation
6495 };
6496 assert_eq!(
6497 gen_after - gen_before,
6498 4,
6499 "each of the four changes bumps the generation once — only the last \
6500 scheduled round runs (no per-change round, no stacked debounce)",
6501 );
6502 }
6503
6504 fn completion_labels(response: Option<CompletionResponse>) -> Vec<String> {
6514 match response {
6515 Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(),
6516 Some(CompletionResponse::List(list)) => {
6517 list.items.into_iter().map(|i| i.label).collect()
6518 }
6519 None => Vec::new(),
6520 }
6521 }
6522
6523 async fn complete_at(backend: &Backend, uri: &Url, text: &str, needle: &str) -> Vec<String> {
6524 let offset = text.find(needle).expect("needle present") + needle.len();
6525 let pos = crate::position::offset_to_position(text, offset);
6526 let response = backend
6527 .completion(CompletionParams {
6528 text_document_position: TextDocumentPositionParams {
6529 text_document: TextDocumentIdentifier { uri: uri.clone() },
6530 position: pos,
6531 },
6532 work_done_progress_params: Default::default(),
6533 partial_result_params: Default::default(),
6534 context: None,
6535 })
6536 .await
6537 .expect("completion must not error");
6538 completion_labels(response)
6539 }
6540
6541 #[tokio::test]
6547 async fn store_map_receiver_completion_merges_both_vocabularies() {
6548 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";
6549 let s = scratch_project(
6550 "store_map_merge",
6551 &[
6552 ("bynk.toml", "[project]\nname=\"shop\"\n"),
6553 ("src/a.bynk", src),
6554 ],
6555 );
6556 let backend = backend_at(&s.0).await;
6557 let uri = file_uri(&s.0, "src/a.bynk");
6558 open(&backend, &uri, src).await;
6559 backend.run_round().await;
6560
6561 let labels = complete_at(&backend, &uri, src, " items.").await;
6562 assert!(
6563 labels.contains(&"filter".to_string()),
6564 "the Query kernel vocabulary: {labels:?}"
6565 );
6566 assert!(
6567 labels.contains(&"collect".to_string()),
6568 "the Query kernel vocabulary: {labels:?}"
6569 );
6570 assert!(
6571 labels.contains(&"put".to_string()),
6572 "the store entry ops: {labels:?}"
6573 );
6574 assert!(
6575 labels.contains(&"entries".to_string()),
6576 "the Map query accessors: {labels:?}"
6577 );
6578 }
6579
6580 #[tokio::test]
6589 async fn store_field_vocabulary_survives_an_unrelated_resolve_failure() {
6590 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";
6591 let s = scratch_project(
6592 "store_map_resolve_gap",
6593 &[
6594 ("bynk.toml", "[project]\nname=\"shop\"\n"),
6595 ("src/a.bynk", src),
6596 ],
6597 );
6598 let backend = backend_at(&s.0).await;
6599 let uri = file_uri(&s.0, "src/a.bynk");
6600 open(&backend, &uri, src).await;
6601 backend.run_round().await;
6602
6603 let analysis = backend.test_analysis().await.expect("a round committed");
6607 let rel = Backend::uri_to_rel(&analysis, &uri).expect("uri resolves");
6608 assert!(
6609 analysis
6610 .diagnostics
6611 .get(&rel)
6612 .is_some_and(|ds| !ds.is_empty()),
6613 "the fixture must actually fail to check — an undeclared return \
6614 type is the trigger this test exercises",
6615 );
6616
6617 let labels = complete_at(&backend, &uri, src, " items.").await;
6618 assert!(
6621 labels.contains(&"filter".to_string()),
6622 "an unrelated resolve error must not blank the typed half: {labels:?}"
6623 );
6624 assert!(
6625 labels.contains(&"put".to_string()),
6626 "store entry ops must survive an unrelated resolve failure: {labels:?}"
6627 );
6628 assert!(
6629 labels.contains(&"entries".to_string()),
6630 "Map query accessors must survive an unrelated resolve failure: {labels:?}"
6631 );
6632 }
6633
6634 #[tokio::test]
6648 async fn an_unsaved_edit_in_one_file_is_visible_to_completion_in_another() {
6649 let widget_v1 = "commons shared.widget\n\ntype Widget = { size: Int }\n";
6650 let widget_v2 = "commons shared.widget\n\ntype Widget = { size: Int, weight: Int }\n";
6651 let use_src =
6652 "commons app.use\n\nuses shared.widget\n\nfn make() -> Widget {\n Widget { \n}\n";
6653 let s = scratch_project(
6654 "cross_file_unsaved",
6655 &[
6656 (
6657 "bynk.toml",
6658 "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
6659 ),
6660 ("shared/widget.bynk", widget_v1),
6661 ("app/use.bynk", use_src),
6662 ],
6663 );
6664 let backend = backend_at(&s.0).await;
6665 let widget_uri = file_uri(&s.0, "shared/widget.bynk");
6666 let use_uri = file_uri(&s.0, "app/use.bynk");
6667 open(&backend, &widget_uri, widget_v1).await;
6668 open(&backend, &use_uri, use_src).await;
6669 backend.run_round().await;
6670
6671 let before = complete_at(&backend, &use_uri, use_src, " Widget { ").await;
6673 assert!(before.contains(&"size".to_string()), "{before:?}");
6674 assert!(
6675 !before.contains(&"weight".to_string()),
6676 "the fixture must not already have `weight` on disk: {before:?}"
6677 );
6678
6679 backend
6681 .did_change(DidChangeTextDocumentParams {
6682 text_document: VersionedTextDocumentIdentifier {
6683 uri: widget_uri.clone(),
6684 version: 2,
6685 },
6686 content_changes: vec![TextDocumentContentChangeEvent {
6687 range: None,
6688 range_length: None,
6689 text: widget_v2.to_string(),
6690 }],
6691 })
6692 .await;
6693
6694 let after = complete_at(&backend, &use_uri, use_src, " Widget { ").await;
6695 assert!(
6696 after.contains(&"weight".to_string()),
6697 "an unsaved edit to `widget.bynk` must be visible to `use.bynk`'s \
6698 cross-file completion in the same round: {after:?}"
6699 );
6700 assert!(after.contains(&"size".to_string()), "{after:?}");
6701
6702 assert_eq!(
6704 std::fs::read_to_string(s.0.join("shared/widget.bynk")).unwrap(),
6705 widget_v1,
6706 "the edit must never have been saved to disk"
6707 );
6708 }
6709
6710 #[tokio::test]
6722 async fn type_receivers_slow_path_sees_a_closed_files_disk_content() {
6723 let widget_src = "commons shared.widget\n\ntype Widget = { size: Int }\n";
6724 let use_src =
6725 "commons app.use\n\nuses shared.widget\n\nfn area(w: Widget) -> Int {\n w.\n}\n";
6726 let s = scratch_project(
6727 "type_receiver_slow_path",
6728 &[
6729 (
6730 "bynk.toml",
6731 "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
6732 ),
6733 ("shared/widget.bynk", widget_src),
6734 ("app/use.bynk", use_src),
6735 ],
6736 );
6737 let backend = backend_at(&s.0).await;
6738 let use_uri = file_uri(&s.0, "app/use.bynk");
6739 open(&backend, &use_uri, use_src).await;
6741
6742 let labels = complete_at(&backend, &use_uri, use_src, " w.").await;
6743 assert!(
6744 labels.contains(&"size".to_string()),
6745 "type_receiver's slow path must resolve `w: Widget` off the \
6746 closed widget.bynk's real disk content: {labels:?}"
6747 );
6748 }
6749}