1pub mod agent_state_request;
32pub mod architecture_request;
33pub mod capability_fixes;
34pub mod code_actions;
35pub mod completion;
36mod content;
37mod document_symbols;
38pub mod documentation_request;
39pub mod explain_request;
40pub mod extract;
41pub mod fill_arms;
42pub mod fix_all;
43pub mod hover;
44pub mod index_queries;
45mod inlay_hints;
46pub mod inline;
47mod locals_nav;
48pub mod organise;
49pub mod position;
50mod project;
51pub mod provider_stub;
52mod publish;
53mod schema_lock;
54pub mod sequence_request;
55mod signature_help;
56mod structure;
57pub mod symbols;
58pub mod transport;
59pub mod wire_contract_request;
60
61use std::path::PathBuf;
62use std::sync::Arc;
63
64use tokio::sync::RwLock;
65use tower_lsp::jsonrpc::Result as JsonRpcResult;
66use tower_lsp::lsp_types::request::{
67 GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
68 GotoTypeDefinitionResponse,
69};
70use tower_lsp::lsp_types::*;
71use tower_lsp::{Client, LanguageServer, LspService, Server};
72
73use crate::project::ProjectConfig;
74
75const SERVER_NAME: &str = "bynkc-lsp";
76const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
77
78#[derive(Debug, Clone)]
80struct DocumentState {
81 text: String,
82 version: i32,
83}
84
85#[derive(Debug)]
90struct Analysis {
91 project_root: PathBuf,
96 index: bynk_check::index::ProjectIndex,
97 snapshots: std::collections::HashMap<PathBuf, String>,
99 versions: std::collections::HashMap<PathBuf, i32>,
102 diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>>,
108 hints: bynk_check::hints::FileHints,
111 requirements: bynk_check::requirements::FileRequirements,
115 locals: bynk_check::locals::FileLocals,
119 expr_types: bynk_check::expr_types::FileExprTypes,
122 ty_intern: std::sync::Arc<bynk_check::checker::Types>,
126 unit_sources: std::collections::HashMap<String, Vec<PathBuf>>,
129 sequence_info: std::collections::HashMap<String, bynk_ide::ContextSequenceInfo>,
133 boundary_info: std::collections::HashMap<String, bynk_ide::ContextBoundaryInfo>,
137 doc_scope: std::collections::HashMap<String, Vec<String>>,
142}
143
144impl Analysis {
145 fn diag_categories(&self) -> Vec<(PathBuf, String)> {
148 self.diagnostics
149 .iter()
150 .flat_map(|(path, diags)| {
151 diags
152 .iter()
153 .map(|d| (path.clone(), d.error.category.to_string()))
154 })
155 .collect()
156 }
157}
158
159#[derive(Debug, Default)]
164struct ProjectState {
165 config: ProjectConfig,
169 analysis: Option<Arc<Analysis>>,
173 published: std::collections::HashSet<Url>,
178 analysis_generation: u64,
182 analysis_round_started: u64,
188 analysis_round_committed: u64,
190 analysis_failed: bool,
193 #[cfg(test)]
197 panic_next_round: bool,
198 schema_lock: schema_lock::SchemaLockCache,
203}
204
205#[derive(Debug, Clone, Copy, Default)]
209struct RefreshSupport {
210 semantic_tokens: bool,
211 inlay_hints: bool,
212 code_lens: bool,
213}
214
215#[derive(Debug, Default)]
218struct State {
219 projects: std::collections::HashMap<PathBuf, ProjectState>,
225 folders: Vec<PathBuf>,
230 docs: std::collections::HashMap<Url, DocumentState>,
233 supports_dynamic_watchers: bool,
239 supports_refresh: RefreshSupport,
245 supports_explain_command: bool,
250 supports_code_action_disabled: bool,
254 single_file_generations: std::collections::HashMap<Url, u64>,
259 root_cache: std::collections::HashMap<Url, Option<PathBuf>>,
276 root_cache_generation: u64,
283}
284
285#[derive(Clone)]
286pub struct Backend {
287 client: Client,
288 state: Arc<RwLock<State>>,
289 refresh_lock: Arc<tokio::sync::Mutex<()>>,
294 shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
298}
299
300fn describe_join_error(e: tokio::task::JoinError) -> String {
303 if !e.is_panic() {
304 return format!("cancelled ({e})");
305 }
306 let payload = e.into_panic();
307 if let Some(s) = payload.downcast_ref::<&str>() {
308 format!("panicked: {s}")
309 } else if let Some(s) = payload.downcast_ref::<String>() {
310 format!("panicked: {s}")
311 } else {
312 "panicked with a non-string payload".to_string()
313 }
314}
315
316impl Backend {
317 #[cfg(test)]
318 fn new(client: Client) -> Self {
319 Self::with_shutdown_flag(client, Arc::default())
320 }
321
322 fn with_shutdown_flag(
323 client: Client,
324 shutdown_requested: Arc<std::sync::atomic::AtomicBool>,
325 ) -> Self {
326 Self {
327 client,
328 state: Arc::new(RwLock::new(State::default())),
329 refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
330 shutdown_requested,
331 }
332 }
333
334 fn find_project_root(start: &std::path::Path) -> Option<PathBuf> {
337 let mut current = if start.is_file() {
338 start.parent()?.to_path_buf()
339 } else {
340 start.to_path_buf()
341 };
342 loop {
343 let candidate = current.join("bynk.toml");
344 if candidate.is_file() {
345 return Some(current);
346 }
347 current = current.parent()?.to_path_buf();
348 }
349 }
350
351 fn find_source_root(start: &std::path::Path) -> Option<PathBuf> {
357 let mut current = if start.is_file() {
358 start.parent()?.to_path_buf()
359 } else {
360 start.to_path_buf()
361 };
362 loop {
363 if current.file_name().and_then(|n| n.to_str()) == Some("src") {
364 return Some(current);
365 }
366 current = current.parent()?.to_path_buf();
367 }
368 }
369
370 fn resolve_root(start: &std::path::Path) -> Option<(PathBuf, project::ProjectConfig)> {
377 if let Some(root) = Self::find_project_root(start) {
378 let config = project::load_config(&root).unwrap_or_default();
379 return Some((root, config));
380 }
381 let src = Self::find_source_root(start)?;
386 let root = src.parent()?.to_path_buf();
387 Some((root, project::ProjectConfig::default()))
388 }
389
390 fn resolve_canonical(uri: &Url) -> Option<(PathBuf, project::ProjectConfig)> {
397 let path = uri.to_file_path().ok()?;
398 let (root, config) = Self::resolve_root(&path)?;
399 Some((root.canonicalize().unwrap_or(root), config))
400 }
401
402 fn root_for_uri_uncached(uri: &Url) -> Option<PathBuf> {
409 Self::resolve_canonical(uri).map(|(root, _)| root)
410 }
411
412 async fn root_for_uri(&self, uri: &Url) -> Option<PathBuf> {
429 let generation = {
430 let state = self.state.read().await;
431 if let Some(cached) = state.root_cache.get(uri) {
432 return cached.clone();
433 }
434 state.root_cache_generation
435 };
436 let root = Self::root_for_uri_uncached(uri);
437 let mut state = self.state.write().await;
438 if Self::root_cache_write_is_current(generation, state.root_cache_generation) {
439 state.root_cache.insert(uri.clone(), root.clone());
440 }
441 root
442 }
443
444 fn root_cache_write_is_current(read_generation: u64, current_generation: u64) -> bool {
452 read_generation == current_generation
453 }
454
455 fn discover_projects_under(folder: &std::path::Path) -> Vec<PathBuf> {
467 fn should_skip(name: &std::ffi::OsStr) -> bool {
468 let name = name.to_string_lossy();
469 matches!(name.as_ref(), "out" | "node_modules" | "target" | ".git")
470 || name.starts_with('.')
471 }
472 fn walk(
473 dir: &std::path::Path,
474 out: &mut Vec<PathBuf>,
475 visited: &mut std::collections::HashSet<PathBuf>,
476 ) {
477 let canon_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
480 if !visited.insert(canon_dir.clone()) {
481 return;
482 }
483 if dir.join("bynk.toml").is_file() && !out.contains(&canon_dir) {
484 out.push(canon_dir);
485 }
486 let Ok(entries) = std::fs::read_dir(dir) else {
487 return;
488 };
489 for entry in entries.flatten() {
490 let path = entry.path();
491 if path.is_dir() && !should_skip(&entry.file_name()) {
492 walk(&path, out, visited);
493 }
494 }
495 }
496 let mut roots = Vec::new();
497 if let Some((root, _)) = Self::resolve_root(folder) {
499 let canon = root.canonicalize().unwrap_or(root);
500 roots.push(canon);
501 }
502 if folder.join("src").is_dir() && !folder.join("bynk.toml").is_file() {
507 let canon = folder
508 .canonicalize()
509 .unwrap_or_else(|_| folder.to_path_buf());
510 if !roots.contains(&canon) {
511 roots.push(canon);
512 }
513 }
514 let mut visited = std::collections::HashSet::new();
515 walk(folder, &mut roots, &mut visited);
516 roots.retain(|root| !bynk_ide::is_workspace_root(root));
520 roots
521 }
522
523 async fn schedule_diagnostics(&self, uri: &Url) {
530 if let Some(root) = self.root_for_uri(uri).await {
536 {
537 let mut state = self.state.write().await;
538 if !state.projects.contains_key(&root) {
539 let config = project::load_config(&root).unwrap_or_default();
540 state.projects.insert(
541 root.clone(),
542 ProjectState {
543 config,
544 ..Default::default()
545 },
546 );
547 }
548 }
549 self.schedule_project_diagnostics(root).await;
550 } else {
551 self.schedule_single_file(uri.clone()).await;
552 }
553 }
554
555 async fn schedule_project_diagnostics(&self, root: PathBuf) {
565 let (generation, debounce) = {
566 let mut state = self.state.write().await;
567 let Some(ps) = state.projects.get_mut(&root) else {
568 return;
569 };
570 ps.analysis_generation += 1;
571 (ps.analysis_generation, ps.config.diagnostics_debounce_ms)
572 };
573 let this = self.clone();
574 tokio::spawn(async move {
575 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
576 let superseded = match this.state.read().await.projects.get(&root) {
577 Some(ps) => ps.analysis_generation != generation,
578 None => true, };
580 if superseded {
581 return;
582 }
583 this.run_project_diagnostics(root).await;
584 });
585 }
586
587 async fn schedule_single_file(&self, uri: Url) {
593 let debounce = ProjectConfig::default().diagnostics_debounce_ms;
594 let generation = {
595 let mut state = self.state.write().await;
596 let g = state
597 .single_file_generations
598 .entry(uri.clone())
599 .or_insert(0);
600 *g += 1;
601 *g
602 };
603 let this = self.clone();
604 tokio::spawn(async move {
605 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
606 let current = this
607 .state
608 .read()
609 .await
610 .single_file_generations
611 .get(&uri)
612 .copied();
613 if current != Some(generation) {
614 return;
615 }
616 this.diagnose_single_file(&uri).await;
617 });
618 }
619
620 async fn diagnose_single_file(&self, uri: &Url) {
624 let (text, version) = {
625 let state = self.state.read().await;
626 match state.docs.get(uri) {
627 Some(d) => (d.text.clone(), d.version),
628 None => return,
629 }
630 };
631 let positions = crate::position::PositionMap::new(&text);
632 let lsp_diags: Vec<Diagnostic> = bynk_ide::diagnose(&text)
633 .into_iter()
634 .map(|d| make_diagnostic(&d, &positions, uri, &ProjectFiles::new()))
635 .collect();
636 self.client
637 .publish_diagnostics(uri.clone(), lsp_diags, Some(version))
638 .await;
639 }
640
641 async fn report_failed_round(&self, root: &std::path::Path, reason: String) {
646 tracing::error!("analysis of {} {reason}", root.display());
647 let first_failure = {
648 let mut state = self.state.write().await;
649 match state.projects.get_mut(root) {
650 Some(ps) => !std::mem::replace(&mut ps.analysis_failed, true),
651 None => false,
652 }
653 };
654 if first_failure {
655 self.client
656 .show_message(
657 MessageType::ERROR,
658 format!(
659 "Bynk: analysing {} failed with an internal error, so its diagnostics may \
660 be out of date until a later edit analyses cleanly. Details are in \
661 ~/.bynk-lsp.log; please report it.",
662 root.display()
663 ),
664 )
665 .await;
666 }
667 }
668
669 async fn schema_lock_for(&self, root: &std::path::Path) -> bynk_ide::SchemaLock {
674 let mut cache = self
675 .state
676 .read()
677 .await
678 .projects
679 .get(root)
680 .map(|ps| ps.schema_lock.clone())
681 .unwrap_or_default();
682 let lock = cache.current(root);
683 if let Some(ps) = self.state.write().await.projects.get_mut(root) {
684 ps.schema_lock = cache;
685 }
686 lock
687 }
688
689 async fn run_project_diagnostics(&self, root: PathBuf) {
694 let (round, root, canonical_root, overlay, versions, previously_dirty) = {
695 let mut state = self.state.write().await;
696 let Some(ps) = state.projects.get_mut(&root) else {
700 return;
701 };
702 ps.analysis_round_started += 1;
703 let round = ps.analysis_round_started;
704 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
708 let previously_dirty = ps.published.clone();
709 let mut overlay = std::collections::HashMap::new();
710 let mut versions = std::collections::HashMap::new();
711 for (uri, doc) in &state.docs {
717 if let Ok(p) = uri.to_file_path() {
718 let canonical = p.canonicalize().unwrap_or(p);
719 if let Ok(rel) = canonical.strip_prefix(&canonical_root) {
722 versions.insert(rel.to_path_buf(), doc.version);
723 }
724 overlay.insert(canonical, doc.text.clone());
725 }
726 }
727 (
728 round,
729 root,
730 canonical_root,
731 overlay,
732 versions,
733 previously_dirty,
734 )
735 };
736
737 let roots = bynk_ide::AnalysisRoots::Project(root.clone());
739 #[cfg(test)]
744 let inject_panic = self
745 .state
746 .write()
747 .await
748 .projects
749 .get_mut(&root)
750 .is_some_and(|ps| std::mem::take(&mut ps.panic_next_round));
751 let schema_lock = self.schema_lock_for(&root).await;
754 let joined = tokio::task::spawn_blocking(move || {
755 #[cfg(test)]
756 if inject_panic {
757 panic!("injected analysis panic");
758 }
759 let content = crate::content::sweep_project_content(&roots, &overlay);
760 bynk_ide::diagnose_project_with(&roots, &content, &schema_lock)
761 })
762 .await;
763 let result = match joined {
764 Ok(result) => result,
765 Err(e) => {
766 self.report_failed_round(&root, describe_join_error(e))
767 .await;
768 return;
769 }
770 };
771
772 let mut new_by_uri: std::collections::HashMap<Url, Vec<Diagnostic>> =
773 std::collections::HashMap::new();
774 let mut version_by_uri: std::collections::HashMap<Url, Option<i32>> =
779 std::collections::HashMap::new();
780 let mut snapshots = std::collections::HashMap::new();
781 let mut diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>> =
782 std::collections::HashMap::new();
783 let files: ProjectFiles<'_> = result
787 .files
788 .iter()
789 .filter_map(|file| {
790 let abs = canonical_root.join(&file.source_path);
791 let abs = abs.canonicalize().unwrap_or(abs);
792 let uri = Url::from_file_path(&abs).ok()?;
793 let positions = crate::position::PositionMap::new(&file.text);
794 Some((file.source_path.clone(), (uri, positions)))
795 })
796 .collect();
797 for file in &result.files {
798 let Some((uri, positions)) = files.get(&file.source_path) else {
799 continue;
800 };
801 let diags: Vec<Diagnostic> = file
802 .diagnostics
803 .iter()
804 .map(|d| make_diagnostic(d, positions, uri, &files))
805 .collect();
806 version_by_uri.insert(uri.clone(), versions.get(&file.source_path).copied());
807 new_by_uri.insert(uri.clone(), diags);
808 diagnostics.insert(file.source_path.clone(), file.diagnostics.clone());
809 snapshots.insert(file.source_path.clone(), file.text.clone());
810 }
811 {
815 let analysis = Arc::new(Analysis {
816 project_root: canonical_root.clone(),
817 index: result.index.clone(),
818 snapshots,
819 versions,
820 diagnostics,
821 hints: result.hints,
822 requirements: result.requirements,
823 locals: result.locals,
824 expr_types: result.expr_types,
825 ty_intern: result.ty_intern,
826 unit_sources: result.unit_sources,
827 sequence_info: result.sequence_info,
828 boundary_info: result.boundary_info,
829 doc_scope: result.doc_scope,
830 });
831 let mut state = self.state.write().await;
832 let Some(ps) = state.projects.get_mut(&root) else {
833 return; };
835 if ps.analysis_round_committed >= round {
838 return;
839 }
840 ps.analysis_round_committed = round;
841 ps.analysis_failed = false;
842 ps.analysis = Some(analysis);
843 }
844 let unattributed_anchor = {
849 let toml = root.join("bynk.toml");
850 if toml.is_file() {
851 Url::from_file_path(toml).ok()
852 } else {
853 result.files.first().and_then(|f| {
854 let abs = canonical_root.join(&f.source_path);
855 let abs = abs.canonicalize().unwrap_or(abs);
856 Url::from_file_path(abs).ok()
857 })
858 }
859 };
860 if !result.unattributed.is_empty()
861 && let Some(anchor_uri) = unattributed_anchor
862 {
863 let entry = new_by_uri.entry(anchor_uri).or_default();
864 for d in &result.unattributed {
865 entry.push(Diagnostic {
866 range: Default::default(),
867 severity: Some(match d.severity {
868 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
869 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
870 }),
871 code: Some(tower_lsp::lsp_types::NumberOrString::String(
872 d.error.category.to_string(),
873 )),
874 message: d.error.message.clone(),
875 ..Default::default()
876 });
877 }
878 }
879
880 let (publishes, dirty) = publish::publish_plan(&previously_dirty, new_by_uri);
881 for (uri, diags) in publishes {
882 let version = version_by_uri.get(&uri).copied().flatten();
887 self.client.publish_diagnostics(uri, diags, version).await;
888 }
889 let still_current = {
890 let mut state = self.state.write().await;
891 if let Some(ps) = state.projects.get_mut(&root)
892 && ps.analysis_round_committed == round
893 {
894 ps.published = dirty;
895 true
896 } else {
897 false
898 }
899 };
900 if still_current {
911 let refresh = self.state.read().await.supports_refresh;
912 if refresh.semantic_tokens || refresh.inlay_hints || refresh.code_lens {
913 let client = self.client.clone();
914 tokio::spawn(async move {
915 if refresh.semantic_tokens {
916 let _ = client.semantic_tokens_refresh().await;
917 }
918 if refresh.inlay_hints {
919 let _ = client.inlay_hint_refresh().await;
920 }
921 if refresh.code_lens {
922 let _ = client.code_lens_refresh().await;
923 }
924 });
925 }
926 }
927 }
928
929 async fn analysis_roots_for(&self, uri: &Url) -> Option<bynk_ide::AnalysisRoots> {
939 Some(bynk_ide::AnalysisRoots::Project(
940 self.root_for_uri(uri).await?,
941 ))
942 }
943
944 async fn project_content(
959 &self,
960 uri: &Url,
961 ) -> Option<Arc<std::collections::HashMap<PathBuf, String>>> {
962 let roots = self.analysis_roots_for(uri).await?;
963 let current = uri
964 .to_file_path()
965 .ok()
966 .and_then(|p| std::fs::canonicalize(&p).ok());
967 let overlay = {
968 let state = self.state.read().await;
969 let mut ov = std::collections::HashMap::new();
970 for (u, doc) in &state.docs {
971 if let Ok(p) = u.to_file_path() {
972 let canonical = p.canonicalize().unwrap_or(p);
973 ov.insert(canonical, doc.text.clone());
974 }
975 }
976 ov
977 };
978 tokio::task::spawn_blocking(move || {
979 let mut content = crate::content::sweep_project_content(&roots, &overlay);
980 if let Some(current) = current {
981 content.remove(¤t);
982 }
983 Arc::new(content)
987 })
988 .await
989 .map_err(|e| tracing::error!("project content sweep {}", describe_join_error(e)))
990 .ok()
991 }
992
993 fn local_sites(
996 &self,
997 analysis: &Analysis,
998 rel: &std::path::Path,
999 offset: usize,
1000 ) -> Option<Vec<bynk_syntax::span::Span>> {
1001 let text = analysis.snapshots.get(rel)?;
1002 let locals = analysis.locals.get(rel)?;
1003 crate::locals_nav::local_sites_at(locals, text, offset)
1004 }
1005
1006 async fn locals_completions(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
1012 let analysis = self.analysis_for(uri).await;
1016 let Some(analysis) = analysis else {
1017 return Vec::new();
1018 };
1019 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1020 return Vec::new();
1021 };
1022 let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
1023 else {
1024 return Vec::new();
1025 };
1026 let Some(offset) = crate::position::position_to_offset(text, pos) else {
1027 return Vec::new();
1028 };
1029 bynk_check::locals::locals_at(locals, offset)
1030 .into_iter()
1031 .map(|b| CompletionItem {
1032 label: b.name.clone(),
1033 kind: Some(CompletionItemKind::VARIABLE),
1034 detail: Some(b.ty.clone()),
1035 ..Default::default()
1036 })
1037 .collect()
1038 }
1039
1040 fn local_locations(
1042 &self,
1043 analysis: &Analysis,
1044 rel: &std::path::Path,
1045 spans: &[bynk_syntax::span::Span],
1046 ) -> Vec<Location> {
1047 let Some(text) = analysis.snapshots.get(rel) else {
1048 return Vec::new();
1049 };
1050 let Ok(uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
1051 return Vec::new();
1052 };
1053 spans
1054 .iter()
1055 .map(|s| Location {
1056 uri: uri.clone(),
1057 range: crate::position::span_to_range(text, *s),
1058 })
1059 .collect()
1060 }
1061
1062 async fn value_member_completions(
1084 &self,
1085 uri: &Url,
1086 text: &str,
1087 offset: usize,
1088 ) -> Vec<CompletionItem> {
1089 let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(text, offset)
1090 else {
1091 return Vec::new();
1092 };
1093 let mut items: Vec<CompletionItem> = Vec::new();
1094 if let Some((ty, tys)) = self
1095 .type_receiver(uri, rewritten.clone(), recv_offset)
1096 .await
1097 {
1098 let files = self.project_content(uri).await;
1099 items.extend(
1100 completion::value_member_candidates(ty, &tys, text, files.as_deref())
1101 .into_iter()
1102 .map(to_completion_item),
1103 );
1104 }
1105 if items.is_empty() {
1107 items.extend(
1108 completion::run_id_member_candidates(&rewritten, recv_offset)
1109 .into_iter()
1110 .map(to_completion_item),
1111 );
1112 }
1113 let locals = self.fast_path_locals(uri, &rewritten).await;
1114 items.extend(
1115 completion::store_field_member_candidates(&rewritten, recv_offset, &locals)
1116 .into_iter()
1117 .map(to_completion_item),
1118 );
1119 items
1120 }
1121
1122 async fn fast_path_locals(
1128 &self,
1129 uri: &Url,
1130 rewritten: &str,
1131 ) -> Vec<bynk_check::locals::LocalBinding> {
1132 let Some(analysis) = self.project_analysis_for(uri).await else {
1133 return Vec::new();
1134 };
1135 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1136 return Vec::new();
1137 };
1138 if analysis.snapshots.get(&rel).map(String::as_str) != Some(rewritten) {
1139 return Vec::new();
1140 }
1141 analysis.locals.get(&rel).cloned().unwrap_or_default()
1142 }
1143
1144 async fn is_pattern_completions(
1149 &self,
1150 uri: &Url,
1151 text: &str,
1152 offset: usize,
1153 ) -> Vec<CompletionItem> {
1154 let Some(scrut_off) = is_scrutinee_offset(text, offset) else {
1155 return Vec::new();
1156 };
1157 self.scrutinee_variant_completions(uri, text, scrut_off)
1158 .await
1159 }
1160
1161 async fn match_arm_completions(
1165 &self,
1166 uri: &Url,
1167 text: &str,
1168 offset: usize,
1169 ) -> Vec<CompletionItem> {
1170 let Some(scrut_off) = match_scrutinee_offset(text, offset) else {
1171 return self.let_else_match_completions(uri, text, offset).await;
1172 };
1173 self.scrutinee_variant_completions(uri, text, scrut_off)
1174 .await
1175 }
1176
1177 async fn let_else_match_completions(
1181 &self,
1182 uri: &Url,
1183 text: &str,
1184 offset: usize,
1185 ) -> Vec<CompletionItem> {
1186 let Some(head) = let_else_match_head(text, offset) else {
1187 return Vec::new();
1188 };
1189 let Some((ty, tys)) = self
1193 .type_receiver(uri, text.to_string(), head.value_end + 1)
1194 .await
1195 else {
1196 return Vec::new();
1197 };
1198 let ty = if head.effectful {
1199 match &*tys.get(ty) {
1200 bynk_check::checker::Ty::Effect(inner) => *inner,
1201 _ => return Vec::new(),
1202 }
1203 } else {
1204 ty
1205 };
1206 let files = self.project_content(uri).await;
1207 completion::variants_for_ty(ty, &tys, text, files.as_deref())
1208 .into_iter()
1209 .filter(|c| head.covered.as_deref() != Some(c.label.as_str()))
1210 .map(to_completion_item)
1211 .collect()
1212 }
1213
1214 async fn scrutinee_variant_completions(
1222 &self,
1223 uri: &Url,
1224 text: &str,
1225 scrut_off: usize,
1226 ) -> Vec<CompletionItem> {
1227 let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1228 return Vec::new();
1229 };
1230 let files = self.project_content(uri).await;
1231 completion::variants_for_ty(ty, &tys, text, files.as_deref())
1232 .into_iter()
1233 .map(to_completion_item)
1234 .collect()
1235 }
1236
1237 async fn nested_pattern_completions(
1244 &self,
1245 uri: &Url,
1246 text: &str,
1247 offset: usize,
1248 ) -> Vec<CompletionItem> {
1249 let Some((scrut_off, variant)) = nested_pattern_offset(text, offset) else {
1250 return Vec::new();
1251 };
1252 let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1253 return Vec::new();
1254 };
1255 let files = self.project_content(uri).await;
1256 completion::nested_variant_completions(ty, &tys, &variant, text, files.as_deref())
1257 .into_iter()
1258 .map(to_completion_item)
1259 .collect()
1260 }
1261
1262 async fn type_receiver(
1268 &self,
1269 uri: &Url,
1270 rewritten: String,
1271 recv_offset: usize,
1272 ) -> Option<(
1273 bynk_check::checker::TyId,
1274 std::sync::Arc<bynk_check::checker::Types>,
1275 )> {
1276 let roots = self.analysis_roots_for(uri).await?;
1280 let project_root = roots.project_root().to_path_buf();
1281 let canonical_root = project_root
1282 .canonicalize()
1283 .unwrap_or_else(|_| project_root.clone());
1284 let cur = uri.to_file_path().ok()?;
1285 let cur = cur.canonicalize().unwrap_or(cur);
1286 let rel = cur.strip_prefix(&canonical_root).ok()?.to_path_buf();
1288 let overlay = {
1290 let state = self.state.read().await;
1291 let mut ov = std::collections::HashMap::new();
1292 for (u, doc) in &state.docs {
1293 if let Ok(p) = u.to_file_path() {
1294 let canonical = p.canonicalize().unwrap_or(p);
1295 let t = if u == uri {
1296 rewritten.clone()
1297 } else {
1298 doc.text.clone()
1299 };
1300 ov.insert(canonical, t);
1301 }
1302 }
1303 ov
1304 };
1305 if let Some(analysis) = self.project_analysis_for(uri).await
1311 && analysis.snapshots.get(&rel).map(String::as_str) == Some(rewritten.as_str())
1312 && let Some((_, entries)) = analysis.expr_types.iter().find(|(p, _)| **p == rel)
1313 {
1314 return bynk_check::expr_types::type_at_offset(
1315 entries,
1316 recv_offset,
1317 &analysis.ty_intern,
1318 )
1319 .map(|t| (t, std::sync::Arc::clone(&analysis.ty_intern)));
1320 }
1321 let result = tokio::task::spawn_blocking(move || {
1327 let content = crate::content::sweep_project_content(&roots, &overlay);
1328 bynk_ide::diagnose_project_with(&roots, &content, &bynk_ide::SchemaLock::Off)
1329 })
1330 .await
1331 .map_err(|e| tracing::error!("receiver-typing analysis {}", describe_join_error(e)))
1332 .ok()?;
1333 let (_, entries) = result.expr_types.iter().find(|(p, _)| **p == rel)?;
1334 bynk_check::expr_types::type_at_offset(entries, recv_offset, &result.ty_intern)
1335 .map(|t| (t, std::sync::Arc::clone(&result.ty_intern)))
1336 }
1337
1338 async fn project_analysis(&self, root: &std::path::Path) -> Option<Arc<Analysis>> {
1341 self.state.read().await.projects.get(root)?.analysis.clone()
1342 }
1343
1344 async fn project_analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1348 let root = self.root_for_uri(uri).await?;
1349 self.project_analysis(&root).await
1350 }
1351
1352 async fn ensure_project_analysed(
1357 &self,
1358 root: PathBuf,
1359 config: ProjectConfig,
1360 ) -> Option<Arc<Analysis>> {
1361 {
1362 let mut state = self.state.write().await;
1363 state
1364 .projects
1365 .entry(root.clone())
1366 .or_insert_with(|| ProjectState {
1367 config,
1368 ..Default::default()
1369 });
1370 }
1371 if let Some(a) = self.project_analysis(&root).await {
1372 return Some(a);
1373 }
1374 self.refresh_now(root.clone()).await;
1375 self.project_analysis(&root).await
1376 }
1377
1378 async fn prune_orphaned_projects(&self) -> Vec<Url> {
1389 let (folders, open_uris) = {
1412 let state = self.state.read().await;
1413 (
1414 state.folders.clone(),
1415 state.docs.keys().cloned().collect::<Vec<_>>(),
1416 )
1417 };
1418 let open_roots: std::collections::HashSet<PathBuf> = open_uris
1419 .iter()
1420 .filter_map(Self::root_for_uri_uncached)
1421 .collect();
1422 let covered = |root: &std::path::Path| {
1423 folders
1424 .iter()
1425 .any(|f| f.starts_with(root) || root.starts_with(f))
1426 || open_roots.contains(root)
1427 };
1428 let mut state = self.state.write().await;
1429 let orphaned: Vec<PathBuf> = state
1430 .projects
1431 .keys()
1432 .filter(|r| !covered(r))
1433 .cloned()
1434 .collect();
1435 let mut to_clear = Vec::new();
1436 for root in orphaned {
1437 if let Some(ps) = state.projects.remove(&root) {
1438 to_clear.extend(ps.published);
1439 }
1440 }
1441 to_clear
1442 }
1443
1444 async fn warm_projects(&self, folders: &[PathBuf]) {
1451 if folders.is_empty() {
1452 return;
1453 }
1454 let folders = folders.to_vec();
1458 let roots = tokio::task::spawn_blocking(move || {
1459 let mut roots: Vec<PathBuf> = Vec::new();
1460 for folder in &folders {
1461 for root in Self::discover_projects_under(folder) {
1462 if !roots.contains(&root) {
1463 roots.push(root);
1464 }
1465 }
1466 }
1467 roots
1468 })
1469 .await
1470 .unwrap_or_else(|e| {
1471 tracing::error!("project discovery {}", describe_join_error(e));
1472 Vec::new()
1473 });
1474 for root in roots {
1475 let config = project::load_config(&root).unwrap_or_default();
1476 {
1477 let mut state = self.state.write().await;
1478 state
1479 .projects
1480 .entry(root.clone())
1481 .or_insert_with(|| ProjectState {
1482 config,
1483 ..Default::default()
1484 });
1485 }
1486 self.schedule_project_diagnostics(root).await;
1487 }
1488 }
1489
1490 async fn register_file_watchers(&self) {
1497 use tower_lsp::lsp_types::{
1498 DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, Registration,
1499 };
1500 let watchers = ["**/*.bynk", "**/bynk.toml", "**/bynk.schema.lock"]
1501 .into_iter()
1502 .map(|g| FileSystemWatcher {
1503 glob_pattern: GlobPattern::String(g.to_string()),
1504 kind: None, })
1506 .collect();
1507 let registration = Registration {
1508 id: "bynk-watched-files".to_string(),
1509 method: "workspace/didChangeWatchedFiles".to_string(),
1510 register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
1511 watchers,
1512 })
1513 .ok(),
1514 };
1515 if let Err(e) = self.client.register_capability(vec![registration]).await {
1516 self.client
1517 .log_message(
1518 MessageType::WARNING,
1519 format!("bynkc-lsp: file-watcher registration failed: {e}"),
1520 )
1521 .await;
1522 }
1523 }
1524
1525 async fn config_for(&self, uri: &Url) -> ProjectConfig {
1529 let Some(root) = self.root_for_uri(uri).await else {
1530 return ProjectConfig::default();
1531 };
1532 self.state
1533 .read()
1534 .await
1535 .projects
1536 .get(&root)
1537 .map(|p| p.config.clone())
1538 .unwrap_or_default()
1539 }
1540
1541 async fn analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1561 let root = self.root_for_uri(uri).await?;
1562 let want = self.state.read().await.docs.get(uri).map(|d| d.version);
1567 let current = |a: &Arc<Analysis>| {
1568 let Some(rel) = Self::uri_to_rel(a, uri) else {
1569 return false; };
1571 if !a.snapshots.contains_key(&rel) {
1576 return false;
1577 }
1578 match want {
1579 Some(v) => a.versions.get(&rel) == Some(&v),
1582 None => true,
1585 }
1586 };
1587
1588 if let Some(a) = self.project_analysis(&root).await
1589 && current(&a)
1590 {
1591 return Some(a);
1592 }
1593
1594 let _guard = self.refresh_lock.lock().await;
1599 if let Some(a) = self.project_analysis(&root).await
1600 && current(&a)
1601 {
1602 return Some(a);
1603 }
1604 self.refresh_now(root.clone()).await;
1605 let a = self.project_analysis(&root).await?;
1606 current(&a).then_some(a)
1610 }
1611
1612 async fn committed_analysis(&self, uri: &Url) -> Option<Arc<Analysis>> {
1637 let root = self.root_for_uri(uri).await?;
1638 let a = self.project_analysis(&root).await?;
1639 let rel = Self::uri_to_rel(&a, uri)?;
1642 a.snapshots.contains_key(&rel).then_some(a)
1643 }
1644
1645 async fn organise_action(
1651 &self,
1652 analysis: &Arc<Analysis>,
1653 rel: &std::path::Path,
1654 uri: &Url,
1655 version: Option<i32>,
1656 ) -> Option<CodeActionOrCommand> {
1657 use crate::organise::{Mode, Project};
1658 let text = analysis.snapshots.get(rel)?;
1659 let project = Project {
1660 index: &analysis.index,
1661 snapshots: &analysis.snapshots,
1662 unit_sources: &analysis.unit_sources,
1663 };
1664 let mut tried: Option<String> = None;
1665 for mode in [Mode::Full, Mode::SortOnly] {
1666 let Some(edit) = crate::organise::organise(text, rel, mode, &project) else {
1667 continue;
1668 };
1669 let edited = crate::organise::apply(text, &edit);
1670 if tried.as_deref() == Some(edited.as_str()) {
1671 continue;
1672 }
1673 if self.rechecks_clean(analysis, rel, edited.clone()).await {
1674 return Some(crate::organise::action(text, edit, uri, version));
1675 }
1676 tried = Some(edited);
1677 }
1678 None
1679 }
1680
1681 async fn resolve_inline(
1685 &self,
1686 action: CodeAction,
1687 target: crate::inline::Target,
1688 ) -> JsonRpcResult<CodeAction> {
1689 let refused = |msg: &str| tower_lsp::jsonrpc::Error {
1690 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
1691 message: msg.to_string().into(),
1692 data: None,
1693 };
1694 let stale = "the document changed since the inline was offered";
1695 let analysis = self
1696 .committed_analysis(&target.uri)
1697 .await
1698 .ok_or_else(|| refused(stale))?;
1699 let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
1700 let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
1701 else {
1702 return Err(refused(stale));
1703 };
1704 let version = analysis.versions.get(&rel).copied();
1705 if version != target.version {
1706 return Err(refused(stale));
1707 }
1708 let plan = crate::inline::plan(text, target.def, locals)
1709 .ok_or_else(|| refused("this binding can't be inlined"))?;
1710 let inlined = crate::fix_all::apply(text, &plan.edits);
1711 if !self.rechecks_clean(&analysis, &rel, inlined).await {
1712 return Err(refused(&format!(
1713 "inlining `{}` would introduce a new problem",
1714 plan.name
1715 )));
1716 }
1717 Ok(crate::fill_arms::resolve(
1718 action,
1719 text,
1720 &plan.edits,
1721 &target.uri,
1722 version,
1723 ))
1724 }
1725
1726 async fn resolve_provider_stub(
1730 &self,
1731 action: CodeAction,
1732 target: crate::provider_stub::Target,
1733 ) -> JsonRpcResult<CodeAction> {
1734 let refused = |msg: &str| tower_lsp::jsonrpc::Error {
1735 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
1736 message: msg.to_string().into(),
1737 data: None,
1738 };
1739 let stale = "the document changed since the provider stub was offered";
1740 let analysis = self
1741 .committed_analysis(&target.uri)
1742 .await
1743 .ok_or_else(|| refused(stale))?;
1744 let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
1745 let Some(text) = analysis.snapshots.get(&rel) else {
1746 return Err(refused(stale));
1747 };
1748 let version = analysis.versions.get(&rel).copied();
1749 if version != target.version {
1750 return Err(refused(stale));
1751 }
1752 let plan = crate::provider_stub::plan(text, &target.capability, &analysis.index)
1753 .ok_or_else(|| refused("a provider stub can't be generated here"))?;
1754 let stubbed = crate::fix_all::apply(text, &plan.edits);
1755 if !self.rechecks_clean(&analysis, &rel, stubbed).await {
1756 return Err(refused(&format!(
1757 "a provider stub for `{}` would introduce a new problem",
1758 plan.capability
1759 )));
1760 }
1761 Ok(crate::fill_arms::resolve(
1762 action,
1763 text,
1764 &plan.edits,
1765 &target.uri,
1766 version,
1767 ))
1768 }
1769
1770 async fn resolve_extract_function(
1774 &self,
1775 action: CodeAction,
1776 target: crate::extract::FunctionTarget,
1777 ) -> JsonRpcResult<CodeAction> {
1778 let refused = |msg: &str| tower_lsp::jsonrpc::Error {
1779 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
1780 message: msg.to_string().into(),
1781 data: None,
1782 };
1783 let stale = "the document changed since the extraction was offered";
1784 let analysis = self
1785 .committed_analysis(&target.uri)
1786 .await
1787 .ok_or_else(|| refused(stale))?;
1788 let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
1789 let Some(text) = analysis.snapshots.get(&rel) else {
1790 return Err(refused(stale));
1791 };
1792 let version = analysis.versions.get(&rel).copied();
1793 if version != target.version {
1794 return Err(refused(stale));
1795 }
1796 let empty_reqs = Vec::new();
1797 let empty_locals = Vec::new();
1798 let empty_types = Vec::new();
1799 let plan = crate::extract::plan_function(
1800 text,
1801 target.requested,
1802 analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
1803 analysis.locals.get(&rel).unwrap_or(&empty_locals),
1804 analysis.expr_types.get(&rel).unwrap_or(&empty_types),
1805 &analysis.ty_intern,
1806 )
1807 .map_err(|r| {
1808 refused(
1809 r.reason
1810 .as_deref()
1811 .unwrap_or("the selection can't be extracted into a function"),
1812 )
1813 })?;
1814 let extracted = crate::fix_all::apply(text, &plan.edits);
1815 if !self.rechecks_clean(&analysis, &rel, extracted).await {
1816 return Err(refused(&format!(
1817 "extracting `{}` would introduce a new problem",
1818 plan.name
1819 )));
1820 }
1821 Ok(crate::fill_arms::resolve(
1822 action,
1823 text,
1824 &plan.edits,
1825 &target.uri,
1826 version,
1827 ))
1828 }
1829
1830 async fn rechecks_clean(
1836 &self,
1837 analysis: &Arc<Analysis>,
1838 rel: &std::path::Path,
1839 edited: String,
1840 ) -> bool {
1841 let mut overlay = std::collections::HashMap::new();
1842 for (rel_path, text) in &analysis.snapshots {
1843 let abs = analysis.project_root.join(rel_path);
1844 let abs = abs.canonicalize().unwrap_or(abs);
1845 let text = if rel_path == rel {
1846 edited.clone()
1847 } else {
1848 text.clone()
1849 };
1850 overlay.insert(abs, text);
1851 }
1852 let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
1853 let Ok(post) = tokio::task::spawn_blocking(move || {
1856 bynk_ide::diagnose_project_with(&roots, &overlay, &bynk_ide::SchemaLock::Off)
1857 })
1858 .await
1859 else {
1860 return false;
1861 };
1862 let post_diags: Vec<(PathBuf, String)> = post
1863 .files
1864 .iter()
1865 .flat_map(|f| {
1866 f.diagnostics
1867 .iter()
1868 .map(|d| (f.source_path.clone(), d.error.category.to_string()))
1869 })
1870 .collect();
1871 crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags).is_ok()
1872 }
1873
1874 async fn analysis_covering_open_buffers(
1895 &self,
1896 root: &std::path::Path,
1897 ) -> Option<Arc<Analysis>> {
1898 let all_current =
1902 |a: &Arc<Analysis>, docs: &std::collections::HashMap<Url, DocumentState>| {
1903 docs.iter()
1904 .all(|(uri, doc)| match Self::uri_to_rel(a, uri) {
1905 Some(rel) if a.snapshots.contains_key(&rel) => {
1906 a.versions.get(&rel) == Some(&doc.version)
1907 }
1908 _ => true,
1909 })
1910 };
1911
1912 {
1913 let state = self.state.read().await;
1914 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1915 && all_current(&a, &state.docs)
1916 {
1917 return Some(a);
1918 }
1919 }
1920 let _guard = self.refresh_lock.lock().await;
1921 {
1922 let state = self.state.read().await;
1923 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1924 && all_current(&a, &state.docs)
1925 {
1926 return Some(a);
1927 }
1928 }
1929 self.refresh_now(root.to_path_buf()).await;
1930 let state = self.state.read().await;
1931 let a = state.projects.get(root).and_then(|p| p.analysis.clone())?;
1932 all_current(&a, &state.docs).then_some(a)
1933 }
1934
1935 async fn refresh_now(&self, root: PathBuf) {
1940 if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
1941 ps.analysis_generation += 1;
1942 }
1943 self.run_project_diagnostics(root).await;
1944 }
1945
1946 fn uri_to_rel(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1948 let p = uri.to_file_path().ok()?;
1949 let canonical = p.canonicalize().unwrap_or(p);
1950 canonical
1955 .strip_prefix(&analysis.project_root)
1956 .ok()
1957 .map(|r| r.to_path_buf())
1958 }
1959
1960 fn uri_to_rel_for_new_path(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1971 let p = uri.to_file_path().ok()?;
1972 let file_name = p.file_name()?;
1973 let parent = p.parent()?;
1974 let canonical_parent = parent
1975 .canonicalize()
1976 .unwrap_or_else(|_| parent.to_path_buf());
1977 canonical_parent
1978 .join(file_name)
1979 .strip_prefix(&analysis.project_root)
1980 .ok()
1981 .map(|r| r.to_path_buf())
1982 }
1983
1984 fn expand_rename(analysis: &Analysis, old_uri: &Url, new_uri: &Url) -> Vec<(PathBuf, PathBuf)> {
1993 let (Some(old_rel), Some(new_rel)) = (
1994 Self::uri_to_rel(analysis, old_uri),
1995 Self::uri_to_rel_for_new_path(analysis, new_uri),
1996 ) else {
1997 return Vec::new();
1998 };
1999 if analysis.snapshots.contains_key(&old_rel) {
2000 return vec![(old_rel, new_rel)];
2001 }
2002 let mut pairs: Vec<(PathBuf, PathBuf)> = analysis
2003 .snapshots
2004 .keys()
2005 .filter_map(|rel| {
2006 let suffix = rel.strip_prefix(&old_rel).ok()?;
2007 (!suffix.as_os_str().is_empty()).then(|| (rel.clone(), new_rel.join(suffix)))
2008 })
2009 .collect();
2010 pairs.sort();
2011 pairs
2012 }
2013
2014 fn new_path_root(new_uri: &Url) -> Option<PathBuf> {
2020 let p = new_uri.to_file_path().ok()?;
2021 let parent = p.parent()?;
2022 let canonical = parent
2023 .canonicalize()
2024 .unwrap_or_else(|_| parent.to_path_buf())
2025 .join(p.file_name()?);
2026 let (root, _) = Self::resolve_root(&canonical)?;
2027 Some(root.canonicalize().unwrap_or(root))
2028 }
2029
2030 async fn unit_reference_definition(&self, uri: &Url, pos: Position) -> Option<Location> {
2036 let analysis = self.analysis_for(uri).await;
2042 let text = self
2043 .state
2044 .read()
2045 .await
2046 .docs
2047 .get(uri)
2048 .map(|d| d.text.clone());
2049 let (text, analysis) = (text?, analysis?);
2050 let offset = cursor_offset(&text, pos);
2051 for (unit, span) in crate::symbols::unit_reference_spans(&text) {
2052 if span.start <= offset && offset <= span.end {
2053 let view = if analysis.unit_sources.contains_key(&unit) {
2057 None
2058 } else {
2059 let files = self.project_content(uri).await;
2060 let empty = std::collections::HashMap::new();
2061 bynk_ide::packages::DependencyView::for_root(
2062 &analysis.project_root,
2063 files.as_deref().unwrap_or(&empty),
2064 )
2065 };
2066 let rel = bynk_ide::packages::unit_sources_for(
2067 &analysis.unit_sources,
2068 view.as_ref(),
2069 &unit,
2070 )?
2071 .first()?;
2072 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
2073 return Some(Location {
2074 uri: target,
2075 range: Range::default(),
2076 });
2077 }
2078 }
2079 None
2080 }
2081
2082 fn site_to_location(
2085 analysis: &Analysis,
2086 site: &bynk_check::index::SiteRef,
2087 ) -> Option<Location> {
2088 let text = analysis.snapshots.get(&site.path)?;
2089 let abs = analysis.project_root.join(&site.path);
2090 let uri = Url::from_file_path(abs).ok()?;
2091 Some(Location {
2092 uri,
2093 range: crate::position::span_to_range(text, site.span),
2094 })
2095 }
2096
2097 fn call_hierarchy_item(
2102 analysis: &Analysis,
2103 key: &bynk_check::index::SymbolKey,
2104 def: &bynk_check::index::SiteRef,
2105 ) -> Option<CallHierarchyItem> {
2106 let location = Self::site_to_location(analysis, def)?;
2107 Some(CallHierarchyItem {
2108 name: key.name.clone(),
2109 kind: lsp_symbol_kind(key.kind),
2110 tags: None,
2111 detail: Some(key.unit.clone()),
2112 uri: location.uri,
2113 range: location.range,
2114 selection_range: location.range,
2115 data: serde_json::to_value(SerKey::from(key)).ok(),
2116 })
2117 }
2118
2119 fn call_ranges(analysis: &Analysis, sites: &[&bynk_check::index::SiteRef]) -> Vec<Range> {
2122 sites
2123 .iter()
2124 .filter_map(|s| {
2125 let text = analysis.snapshots.get(&s.path)?;
2126 Some(crate::position::span_to_range(text, s.span))
2127 })
2128 .collect()
2129 }
2130
2131 async fn semantic_tokens_for(&self, uri: &Url, range: Option<Range>) -> Vec<SemanticToken> {
2136 let analysis = self.committed_analysis(uri).await;
2140 let Some(analysis) = analysis else {
2141 return Vec::new();
2142 };
2143 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
2144 return Vec::new();
2145 };
2146 let Some(text) = analysis.snapshots.get(&rel) else {
2147 return Vec::new();
2148 };
2149 let span = match range {
2150 None => None,
2151 Some(r) => {
2154 let (Some(start), Some(end)) = (
2155 crate::position::position_to_offset(text, r.start),
2156 crate::position::position_to_offset(text, r.end),
2157 ) else {
2158 return Vec::new();
2159 };
2160 Some(bynk_syntax::span::Span::new(start, end))
2161 }
2162 };
2163 let lt = analysis
2164 .locals
2165 .get(&rel)
2166 .map(|l| crate::locals_nav::local_token_sites(l, text))
2167 .unwrap_or_default();
2168 let dt = crate::symbols::handler_annotation_token_spans(text);
2172 crate::index_queries::semantic_tokens(&analysis.index, <, &dt, &rel, text, span)
2173 }
2174
2175 async fn index_position(
2178 &self,
2179 uri: &Url,
2180 position: Position,
2181 ) -> Option<(Arc<Analysis>, PathBuf, usize)> {
2182 let analysis = self.analysis_for(uri).await?;
2187 let rel = Self::uri_to_rel(&analysis, uri)?;
2188 let text = analysis.snapshots.get(&rel)?;
2189 let offset = crate::position::position_to_offset(text, position)?;
2190 Some((analysis, rel, offset))
2191 }
2192
2193 async fn identifier_at(
2197 &self,
2198 uri: &Url,
2199 position: Position,
2200 ) -> Option<(String, bynk_syntax::span::Span, String)> {
2201 let text = {
2202 let state = self.state.read().await;
2203 state.docs.get(uri)?.text.clone()
2204 };
2205 let offset = crate::position::position_to_offset(&text, position)?;
2206 let tokens = bynk_syntax::lexer::tokenize_expanding_holes(&text).ok()?;
2210 for t in &tokens {
2212 if t.span.start <= offset
2213 && offset < t.span.end
2214 && matches!(
2215 t.kind,
2216 bynk_syntax::lexer::TokenKind::Ident
2217 | bynk_syntax::lexer::TokenKind::Int
2218 | bynk_syntax::lexer::TokenKind::String
2219 | bynk_syntax::lexer::TokenKind::Bool
2220 | bynk_syntax::lexer::TokenKind::Float
2221 | bynk_syntax::lexer::TokenKind::Result
2222 | bynk_syntax::lexer::TokenKind::Option
2223 | bynk_syntax::lexer::TokenKind::Effect
2224 )
2225 {
2226 let name = text[t.span.start..t.span.end].to_string();
2227 return Some((name, t.span, text));
2228 }
2229 }
2230 None
2231 }
2232
2233 async fn sequence_model(
2240 &self,
2241 params: sequence_request::SequenceModelParams,
2242 ) -> JsonRpcResult<Option<sequence_request::WireSequenceModel>> {
2243 let uri = params.text_document.uri;
2244 let Some(analysis) = self.committed_analysis(&uri).await else {
2245 return Ok(None);
2246 };
2247 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2248 return Ok(None);
2249 };
2250 let Some(text) = analysis.snapshots.get(&rel) else {
2251 return Ok(None);
2252 };
2253 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
2254 return Ok(None);
2255 };
2256 let info = bynk_ide::symbols::own_declaration_name(text)
2257 .and_then(|(name, _)| analysis.sequence_info.get(&name));
2258 let model = sequence_request::sequence_model_at(text, offset, info);
2259 Ok(model.map(|m| sequence_request::to_wire(&m, text)))
2260 }
2261
2262 async fn agent_state_model(
2269 &self,
2270 params: agent_state_request::AgentStateModelParams,
2271 ) -> JsonRpcResult<Option<agent_state_request::WireAgentStateModel>> {
2272 let uri = params.text_document.uri;
2273 let Some(analysis) = self.committed_analysis(&uri).await else {
2274 return Ok(None);
2275 };
2276 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2277 return Ok(None);
2278 };
2279 let Some(text) = analysis.snapshots.get(&rel) else {
2280 return Ok(None);
2281 };
2282 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
2283 return Ok(None);
2284 };
2285 let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text) else {
2286 return Ok(None);
2287 };
2288 let Some(info) = analysis.boundary_info.get(&unit) else {
2289 return Ok(None);
2290 };
2291 let agents: std::collections::HashSet<String> = analysis
2293 .sequence_info
2294 .get(&unit)
2295 .map(|s| s.agents.keys().cloned().collect())
2296 .unwrap_or_default();
2297 Ok(
2298 agent_state_request::agent_state_at(text, offset, &info.types).map(|(agent, model)| {
2299 let effects = agent_state_request::effects_of(&agent, &agents);
2300 agent_state_request::to_wire(&agent, &model, &effects, text)
2301 }),
2302 )
2303 }
2304
2305 async fn documentation_model(
2315 &self,
2316 params: documentation_request::DocumentationModelParams,
2317 ) -> JsonRpcResult<Option<documentation_request::WireDocModel>> {
2318 let uri = params.text_document.uri;
2319 let Some(analysis) = self.committed_analysis(&uri).await else {
2320 return Ok(None);
2321 };
2322 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2323 return Ok(None);
2324 };
2325 Ok(documentation_request::documentation_model_for(
2326 &rel,
2327 &analysis.snapshots,
2328 &analysis.unit_sources,
2329 &analysis.project_root,
2330 ))
2331 }
2332
2333 async fn explain(
2339 &self,
2340 params: explain_request::ExplainParams,
2341 ) -> JsonRpcResult<Option<explain_request::WireExplanation>> {
2342 Ok(explain_request::explanation_for(¶ms.code))
2343 }
2344
2345 async fn architecture_model(
2355 &self,
2356 params: architecture_request::ArchitectureModelParams,
2357 ) -> JsonRpcResult<Option<architecture_request::WireArchModel>> {
2358 let uri = params.text_document.uri;
2359 let Some(analysis) = self.committed_analysis(&uri).await else {
2360 return Ok(None);
2361 };
2362 let model = architecture_request::architecture_model_for(
2363 &analysis.unit_sources,
2364 &analysis.snapshots,
2365 &analysis.sequence_info,
2366 );
2367 Ok(Some(architecture_request::to_wire(
2368 &model,
2369 &analysis.project_root,
2370 &analysis.snapshots,
2371 )))
2372 }
2373
2374 async fn wire_contract(
2384 &self,
2385 params: wire_contract_request::WireContractParams,
2386 ) -> JsonRpcResult<Option<wire_contract_request::WcModel>> {
2387 let uri = params.text_document.uri;
2388 let Some(analysis) = self.committed_analysis(&uri).await else {
2389 return Ok(None);
2390 };
2391 let tys = &analysis.ty_intern;
2392 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2393 return Ok(None);
2394 };
2395 let Some(text) = analysis.snapshots.get(&rel) else {
2396 return Ok(None);
2397 };
2398 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
2399 return Ok(None);
2400 };
2401 let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text) else {
2404 return Ok(None);
2405 };
2406 let Some(info) = analysis.boundary_info.get(&unit) else {
2407 return Ok(None);
2408 };
2409 let expr_types = analysis
2410 .expr_types
2411 .get(&rel)
2412 .map(|v| v.as_slice())
2413 .unwrap_or(&[]);
2414 let context_count = bynk_ide::wire_contract::real_context_count(
2415 &analysis.boundary_info,
2416 &analysis.unit_sources,
2417 );
2418 let Some(model) = bynk_ide::wire_contract::wire_contract_at(
2419 &unit,
2420 text,
2421 offset,
2422 info,
2423 expr_types,
2424 tys,
2425 context_count,
2426 ) else {
2427 return Ok(None);
2428 };
2429 let search_order = analysis
2433 .doc_scope
2434 .get(&unit)
2435 .cloned()
2436 .unwrap_or_else(|| vec![unit.clone()]);
2437 Ok(Some(wire_contract_request::to_wire(
2438 &model,
2439 &analysis.project_root,
2440 text,
2441 &info.types,
2442 &analysis.index,
2443 &analysis.snapshots,
2444 &search_order,
2445 )))
2446 }
2447}
2448
2449#[tower_lsp::async_trait]
2450impl LanguageServer for Backend {
2451 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
2452 let dynamic_watchers = params
2459 .capabilities
2460 .workspace
2461 .as_ref()
2462 .and_then(|w| w.did_change_watched_files.as_ref())
2463 .and_then(|d| d.dynamic_registration)
2464 .unwrap_or(false);
2465 let ws = params.capabilities.workspace.as_ref();
2469 let supports_refresh = RefreshSupport {
2470 semantic_tokens: ws
2471 .and_then(|w| w.semantic_tokens.as_ref())
2472 .and_then(|s| s.refresh_support)
2473 .unwrap_or(false),
2474 inlay_hints: ws
2475 .and_then(|w| w.inlay_hint.as_ref())
2476 .and_then(|i| i.refresh_support)
2477 .unwrap_or(false),
2478 code_lens: ws
2479 .and_then(|w| w.code_lens.as_ref())
2480 .and_then(|c| c.refresh_support)
2481 .unwrap_or(false),
2482 };
2483 {
2484 let mut state = self.state.write().await;
2485 state.supports_dynamic_watchers = dynamic_watchers;
2486 state.supports_refresh = supports_refresh;
2487 state.supports_explain_command = explain_request::client_supports_explain_command(
2488 params.capabilities.experimental.as_ref(),
2489 );
2490 state.supports_code_action_disabled = params
2491 .capabilities
2492 .text_document
2493 .as_ref()
2494 .and_then(|t| t.code_action.as_ref())
2495 .and_then(|c| c.disabled_support)
2496 .unwrap_or(false);
2497 if let Some(folders) = ¶ms.workspace_folders {
2498 state.folders = folders
2499 .iter()
2500 .filter_map(|f| f.uri.to_file_path().ok())
2501 .map(|p| p.canonicalize().unwrap_or(p))
2502 .collect();
2503 }
2504 }
2505 Ok(InitializeResult {
2506 capabilities: server_capabilities(),
2507 server_info: Some(ServerInfo {
2508 name: SERVER_NAME.into(),
2509 version: Some(SERVER_VERSION.into()),
2510 }),
2511 })
2512 }
2513
2514 async fn initialized(&self, _: InitializedParams) {
2515 let (folders, dynamic) = {
2516 let s = self.state.read().await;
2517 (s.folders.clone(), s.supports_dynamic_watchers)
2518 };
2519 if dynamic {
2525 self.register_file_watchers().await;
2526 }
2527 self.warm_projects(&folders).await;
2530 let msg = if folders.is_empty() {
2531 "bynkc-lsp: no workspace folders; single-file mode".to_string()
2532 } else {
2533 format!(
2534 "bynkc-lsp: {} workspace folder(s); projects resolved per file",
2535 folders.len()
2536 )
2537 };
2538 self.client.log_message(MessageType::INFO, msg).await;
2539 }
2540
2541 async fn shutdown(&self) -> JsonRpcResult<()> {
2542 self.shutdown_requested
2543 .store(true, std::sync::atomic::Ordering::SeqCst);
2544 Ok(())
2545 }
2546
2547 async fn did_open(&self, params: DidOpenTextDocumentParams) {
2548 let uri = params.text_document.uri.clone();
2549 {
2550 let mut state = self.state.write().await;
2551 state.docs.insert(
2552 uri.clone(),
2553 DocumentState {
2554 text: params.text_document.text,
2555 version: params.text_document.version,
2556 },
2557 );
2558 }
2559 self.schedule_diagnostics(&uri).await;
2562 }
2563
2564 async fn did_change(&self, params: DidChangeTextDocumentParams) {
2565 let uri = params.text_document.uri.clone();
2566 {
2567 let mut state = self.state.write().await;
2568 if let Some(doc) = state.docs.get_mut(&uri)
2569 && let Some(change) = params.content_changes.into_iter().next_back()
2570 {
2571 doc.text = change.text;
2572 doc.version = params.text_document.version;
2573 }
2574 }
2575 if self.config_for(&uri).await.diagnostics_mode == crate::project::DiagnosticsMode::OnSave {
2580 return;
2581 }
2582 self.schedule_diagnostics(&uri).await;
2586 }
2587
2588 async fn did_save(&self, params: DidSaveTextDocumentParams) {
2589 self.schedule_diagnostics(¶ms.text_document.uri).await;
2592 }
2593
2594 async fn did_close(&self, params: DidCloseTextDocumentParams) {
2595 let uri = params.text_document.uri;
2596 {
2597 let mut state = self.state.write().await;
2598 state.docs.remove(&uri);
2599 state.single_file_generations.remove(&uri);
2602 }
2603 for cleared in self.prune_orphaned_projects().await {
2608 self.client
2609 .publish_diagnostics(cleared, Vec::new(), None)
2610 .await;
2611 }
2612 }
2613
2614 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
2619 let uri = params.text_document_position_params.text_document.uri;
2620 let pos = params.text_document_position_params.position;
2621 let positioned = self.index_position(&uri, pos).await;
2623 let doc_text = {
2626 let state = self.state.read().await;
2627 state.docs.get(&uri).map(|d| d.text.clone())
2628 };
2629 let doc = doc_text
2630 .as_deref()
2631 .and_then(|t| Some((t, crate::position::position_to_offset(t, pos)?)));
2632 let files = self.project_content(&uri).await;
2633 let analysis = positioned
2634 .as_ref()
2635 .map(|(a, rel, offset)| crate::hover::HoverAnalysis {
2636 index: &a.index,
2637 snapshots: &a.snapshots,
2638 locals: &a.locals,
2639 expr_types: &a.expr_types,
2640 tys: &a.ty_intern,
2641 rel,
2642 offset: *offset,
2643 project_root: &a.project_root,
2644 doc_scope: &a.doc_scope,
2645 boundary_info: &a.boundary_info,
2646 context_count: bynk_ide::wire_contract::real_context_count(
2649 &a.boundary_info,
2650 &a.unit_sources,
2651 ),
2652 });
2653 let content = crate::hover::hover_content(&crate::hover::HoverInput {
2654 analysis,
2655 doc,
2656 uri: &uri,
2657 files: files.as_deref(),
2658 });
2659 Ok(content.map(|value| Hover {
2660 contents: HoverContents::Markup(MarkupContent {
2661 kind: MarkupKind::Markdown,
2662 value,
2663 }),
2664 range: None,
2665 }))
2666 }
2667
2668 async fn signature_help(
2670 &self,
2671 params: SignatureHelpParams,
2672 ) -> JsonRpcResult<Option<SignatureHelp>> {
2673 let uri = params.text_document_position_params.text_document.uri;
2674 let pos = params.text_document_position_params.position;
2675 let text = {
2676 let s = self.state.read().await;
2677 s.docs.get(&uri).map(|d| d.text.clone())
2678 };
2679 let Some(text) = text else { return Ok(None) };
2680 let offset = cursor_offset(&text, pos);
2681 let Some(ctx) = crate::signature_help::call_context(&text, offset) else {
2682 return Ok(None);
2683 };
2684 let files = self.project_content(&uri).await;
2685 let resolved_label = {
2690 let callee = ctx.callee.clone();
2691 let text = text.clone();
2692 let files = files.clone();
2693 match tokio::task::spawn_blocking(move || {
2694 crate::signature_help::resolve_label(&callee, &text, files.as_deref())
2695 })
2696 .await
2697 {
2698 Ok(l) => l,
2699 Err(e) => {
2700 tracing::error!("signature-help label task failed: {e}");
2701 None
2702 }
2703 }
2704 };
2705 let label = match resolved_label {
2706 Some(l) => Some(l),
2707 None => match crate::signature_help::value_receiver_method(&ctx.callee) {
2710 Some((_, method)) => {
2711 if let Some((rewritten, recv_offset)) =
2712 crate::signature_help::value_receiver_rewrite(
2713 &text,
2714 &ctx.callee,
2715 ctx.open_paren,
2716 offset,
2717 )
2718 && let Some((ty, tys)) =
2719 self.type_receiver(&uri, rewritten, recv_offset).await
2720 {
2721 crate::signature_help::kernel_method_signature(ty, &tys, method)
2722 } else {
2723 None
2724 }
2725 }
2726 None => None,
2727 },
2728 };
2729 let Some(label) = label else { return Ok(None) };
2730 let active = ctx.active_param as u32;
2731 let parameters: Vec<ParameterInformation> = crate::signature_help::param_ranges(&label)
2732 .into_iter()
2733 .map(|(s, e)| ParameterInformation {
2734 label: ParameterLabel::LabelOffsets([s as u32, e as u32]),
2735 documentation: None,
2736 })
2737 .collect();
2738 Ok(Some(SignatureHelp {
2739 signatures: vec![SignatureInformation {
2740 label,
2741 documentation: None,
2742 parameters: Some(parameters),
2743 active_parameter: Some(active),
2744 }],
2745 active_signature: Some(0),
2746 active_parameter: Some(active),
2747 }))
2748 }
2749
2750 async fn code_lens(&self, params: CodeLensParams) -> JsonRpcResult<Option<Vec<CodeLens>>> {
2753 let uri = params.text_document.uri;
2754 let analysis = self.committed_analysis(&uri).await;
2756 let Some(analysis) = analysis else {
2757 return Ok(Some(Vec::new()));
2758 };
2759 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2760 return Ok(Some(Vec::new()));
2761 };
2762 let Some(text) = analysis.snapshots.get(&rel) else {
2763 return Ok(Some(Vec::new()));
2764 };
2765 let show_references = |range: Range, locations: Vec<Location>, title: String| CodeLens {
2769 range,
2770 command: Some(Command {
2771 title,
2772 command: "editor.action.showReferences".to_string(),
2773 arguments: Some(vec![
2774 serde_json::to_value(&uri).unwrap_or_default(),
2775 serde_json::to_value(range.start).unwrap_or_default(),
2776 serde_json::to_value(&locations).unwrap_or_default(),
2777 ]),
2778 }),
2779 data: None,
2780 };
2781 let mut lenses: Vec<CodeLens> = crate::index_queries::code_lenses(&analysis.index, &rel)
2782 .into_iter()
2783 .map(|(def, refs)| {
2784 let range = crate::position::span_to_range(text, def.span);
2785 let locations: Vec<Location> = refs
2786 .iter()
2787 .filter_map(|r| Self::site_to_location(&analysis, r))
2788 .collect();
2789 let n = refs.len();
2790 show_references(
2791 range,
2792 locations,
2793 format!("{n} reference{}", if n == 1 { "" } else { "s" }),
2794 )
2795 })
2796 .collect();
2797 lenses.extend(
2801 crate::index_queries::capability_provider_lenses(&analysis.index, &rel)
2802 .into_iter()
2803 .map(|(def, providers)| {
2804 let range = crate::position::span_to_range(text, def.span);
2805 let locations: Vec<Location> = providers
2806 .iter()
2807 .filter_map(|r| Self::site_to_location(&analysis, r))
2808 .collect();
2809 let n = providers.len();
2810 show_references(
2811 range,
2812 locations,
2813 format!("{n} provider{}", if n == 1 { "" } else { "s" }),
2814 )
2815 }),
2816 );
2817 lenses.extend(
2821 crate::index_queries::refinement_family_lenses(&analysis.index, &rel)
2822 .into_iter()
2823 .map(|(def, base, family)| {
2824 let range = crate::position::span_to_range(text, def.span);
2825 let locations: Vec<Location> = family
2826 .iter()
2827 .filter_map(|r| Self::site_to_location(&analysis, r))
2828 .collect();
2829 let n = family.len();
2830 show_references(
2831 range,
2832 locations,
2833 format!("{n} refinements of {}", base.name()),
2834 )
2835 }),
2836 );
2837 lenses.extend(
2846 crate::sequence_request::handler_lens_sites(text)
2847 .into_iter()
2848 .map(|span| {
2849 let range = crate::position::span_to_range(text, span);
2850 CodeLens {
2851 range,
2852 command: Some(Command {
2853 title: "Show Sequence".to_string(),
2854 command: "bynk.showSequenceDiagram".to_string(),
2855 arguments: Some(vec![
2856 serde_json::to_value(&uri).unwrap_or_default(),
2857 serde_json::to_value(range.start).unwrap_or_default(),
2858 ]),
2859 }),
2860 data: None,
2861 }
2862 }),
2863 );
2864 lenses.extend(
2867 crate::agent_state_request::agent_lens_sites(text)
2868 .into_iter()
2869 .map(|span| {
2870 let range = crate::position::span_to_range(text, span);
2871 CodeLens {
2872 range,
2873 command: Some(Command {
2874 title: "Show State".to_string(),
2875 command: "bynk.showAgentState".to_string(),
2876 arguments: Some(vec![
2877 serde_json::to_value(&uri).unwrap_or_default(),
2878 serde_json::to_value(range.start).unwrap_or_default(),
2879 ]),
2880 }),
2881 data: None,
2882 }
2883 }),
2884 );
2885 Ok(Some(lenses))
2886 }
2887
2888 async fn prepare_call_hierarchy(
2889 &self,
2890 params: CallHierarchyPrepareParams,
2891 ) -> JsonRpcResult<Option<Vec<CallHierarchyItem>>> {
2892 let uri = params.text_document_position_params.text_document.uri;
2893 let pos = params.text_document_position_params.position;
2894 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2895 return Ok(None);
2896 };
2897 let Some((key, def)) =
2898 crate::index_queries::prepare_call_hierarchy(&analysis.index, &rel, offset)
2899 else {
2900 return Ok(None);
2901 };
2902 Ok(Self::call_hierarchy_item(&analysis, key, def).map(|item| vec![item]))
2903 }
2904
2905 async fn incoming_calls(
2906 &self,
2907 params: CallHierarchyIncomingCallsParams,
2908 ) -> JsonRpcResult<Option<Vec<CallHierarchyIncomingCall>>> {
2909 let analysis = self.analysis_for(¶ms.item.uri).await;
2910 let Some(analysis) = analysis else {
2911 return Ok(Some(Vec::new()));
2912 };
2913 let Some(key) = SerKey::read(¶ms.item.data) else {
2914 return Ok(Some(Vec::new()));
2915 };
2916 let calls = crate::index_queries::incoming_calls(&analysis.index, &key)
2917 .into_iter()
2918 .filter_map(|rel| {
2919 let from = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2920 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2921 Some(CallHierarchyIncomingCall { from, from_ranges })
2922 })
2923 .collect();
2924 Ok(Some(calls))
2925 }
2926
2927 async fn outgoing_calls(
2928 &self,
2929 params: CallHierarchyOutgoingCallsParams,
2930 ) -> JsonRpcResult<Option<Vec<CallHierarchyOutgoingCall>>> {
2931 let analysis = self.analysis_for(¶ms.item.uri).await;
2932 let Some(analysis) = analysis else {
2933 return Ok(Some(Vec::new()));
2934 };
2935 let Some(key) = SerKey::read(¶ms.item.data) else {
2936 return Ok(Some(Vec::new()));
2937 };
2938 let calls = crate::index_queries::outgoing_calls(&analysis.index, &key)
2939 .into_iter()
2940 .filter_map(|rel| {
2941 let to = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2942 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2943 Some(CallHierarchyOutgoingCall { to, from_ranges })
2944 })
2945 .collect();
2946 Ok(Some(calls))
2947 }
2948
2949 async fn goto_implementation(
2954 &self,
2955 params: GotoImplementationParams,
2956 ) -> JsonRpcResult<Option<GotoImplementationResponse>> {
2957 let uri = params.text_document_position_params.text_document.uri;
2958 let pos = params.text_document_position_params.position;
2959 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2960 return Ok(None);
2961 };
2962 let Some((key, _)) = analysis.index.symbol_at(&rel, offset) else {
2963 return Ok(None);
2964 };
2965 if key.kind != bynk_check::index::SymbolKind::Capability {
2966 return Ok(None);
2967 }
2968 let locations: Vec<Location> = crate::index_queries::implementations(&analysis.index, key)
2969 .into_iter()
2970 .filter_map(|d| Self::site_to_location(&analysis, d))
2971 .collect();
2972 if locations.is_empty() {
2973 return Ok(None);
2974 }
2975 Ok(Some(GotoDefinitionResponse::Array(locations)))
2976 }
2977
2978 async fn goto_type_definition(
2984 &self,
2985 params: GotoTypeDefinitionParams,
2986 ) -> JsonRpcResult<Option<GotoTypeDefinitionResponse>> {
2987 let uri = params.text_document_position_params.text_document.uri;
2988 let pos = params.text_document_position_params.position;
2989 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2990 return Ok(None);
2991 };
2992 let tys = &analysis.ty_intern;
2993 let Some(entries) = analysis.expr_types.get(&rel) else {
2994 return Ok(None);
2995 };
2996 let Some(ty) = bynk_check::expr_types::type_at_offset(entries, offset, tys) else {
2997 return Ok(None);
2998 };
2999 let Some(name) = crate::index_queries::named_type_target(ty, tys) else {
3000 return Ok(None);
3001 };
3002 let locations: Vec<Location> =
3003 crate::index_queries::type_definitions_named(&analysis.index, &name)
3004 .into_iter()
3005 .filter_map(|d| Self::site_to_location(&analysis, d))
3006 .collect();
3007 if locations.is_empty() {
3008 return Ok(None);
3009 }
3010 Ok(Some(GotoDefinitionResponse::Array(locations)))
3011 }
3012
3013 async fn document_link(
3026 &self,
3027 params: DocumentLinkParams,
3028 ) -> JsonRpcResult<Option<Vec<DocumentLink>>> {
3029 let uri = params.text_document.uri;
3030 let analysis = self.committed_analysis(&uri).await;
3034 let text = self
3035 .state
3036 .read()
3037 .await
3038 .docs
3039 .get(&uri)
3040 .map(|d| d.text.clone());
3041 let (Some(text), Some(analysis)) = (text, analysis) else {
3042 return Ok(None);
3043 };
3044 let mut links: Vec<DocumentLink> = crate::symbols::unit_reference_spans(&text)
3045 .into_iter()
3046 .filter_map(|(unit, span)| {
3047 let rel = analysis.unit_sources.get(&unit)?.first()?;
3048 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
3049 Some(DocumentLink {
3050 range: crate::position::span_to_range(&text, span),
3051 target: Some(target),
3052 tooltip: Some(format!("Open unit `{unit}`")),
3053 data: None,
3054 })
3055 })
3056 .collect();
3057 if let Some((owner_unit, _)) = crate::symbols::own_declaration_name(&text) {
3061 for (name, span) in crate::symbols::doc_link_spans(&text) {
3062 let Some(def) = crate::index_queries::resolve_doc_link(
3063 &analysis.index,
3064 &analysis.doc_scope,
3065 &owner_unit,
3066 &name,
3067 ) else {
3068 continue;
3069 };
3070 let Ok(target) = Url::from_file_path(analysis.project_root.join(&def.path)) else {
3071 continue;
3072 };
3073 links.push(DocumentLink {
3074 range: crate::position::span_to_range(&text, span),
3075 target: Some(target),
3076 tooltip: Some(format!("Go to `{name}`")),
3077 data: None,
3078 });
3079 }
3080 }
3081 Ok((!links.is_empty()).then_some(links))
3082 }
3083
3084 async fn completion(
3085 &self,
3086 params: CompletionParams,
3087 ) -> JsonRpcResult<Option<CompletionResponse>> {
3088 let uri = params.text_document_position.text_document.uri;
3089 let pos = params.text_document_position.position;
3090 let text = {
3091 let s = self.state.read().await;
3092 s.docs.get(&uri).map(|d| d.text.clone())
3093 };
3094 let Some(text) = text else { return Ok(None) };
3095 let offset = cursor_offset(&text, pos);
3096 let line_prefix = text[..offset].rsplit('\n').next().unwrap_or("").to_string();
3100 let files = self.project_content(&uri).await;
3101 if completion::in_env_name_position(&text, offset) {
3104 let items = completion::env_name_candidates(&text, files.as_deref())
3105 .into_iter()
3106 .map(to_completion_item)
3107 .collect();
3108 return Ok(Some(CompletionResponse::Array(items)));
3109 }
3110 let candidates = {
3115 let line_prefix = line_prefix.clone();
3116 let text = text.clone();
3117 let root = self.root_for_uri(&uri).await;
3118 match tokio::task::spawn_blocking(move || {
3119 completion::complete_in(root.as_deref(), &line_prefix, &text, files.as_deref())
3120 })
3121 .await
3122 {
3123 Ok(c) => c,
3124 Err(e) => {
3129 tracing::error!("completion enumeration task failed: {e}");
3130 Vec::new()
3131 }
3132 }
3133 };
3134 let mut items: Vec<CompletionItem> =
3135 candidates.into_iter().map(to_completion_item).collect();
3136 if completion::is_keyword_position(&line_prefix)
3141 || completion::is_expression_position(&line_prefix)
3142 {
3143 items.extend(self.locals_completions(&uri, pos).await);
3144 }
3145 items.extend(contract_param_completions(&text, offset, &line_prefix));
3150 items.extend(cors_completions(&text, offset, &line_prefix));
3154 items.extend(security_completions(&text, offset, &line_prefix));
3157 items.extend(cache_completions(&text, offset, &line_prefix));
3161 items.extend(limits_completions(&text, offset, &line_prefix));
3164 items.extend(limit_completions(&text, offset, &line_prefix));
3167 items.extend(raw_completions(&text, offset, &line_prefix));
3170 items.extend(
3173 completion::instance_declaration_completions(&line_prefix)
3174 .into_iter()
3175 .map(|(label, detail, insert)| CompletionItem {
3176 label,
3177 kind: Some(CompletionItemKind::SNIPPET),
3178 detail: Some(detail),
3179 insert_text: Some(insert),
3180 insert_text_format: Some(InsertTextFormat::SNIPPET),
3181 ..Default::default()
3182 }),
3183 );
3184 items.extend(
3187 completion::instance_option_values(&text, offset)
3188 .iter()
3189 .map(|(value, doc)| CompletionItem {
3190 label: value.to_string(),
3191 kind: Some(CompletionItemKind::VALUE),
3192 detail: Some((*doc).to_string()),
3193 ..Default::default()
3194 }),
3195 );
3196 let mut pattern_items = self.nested_pattern_completions(&uri, &text, offset).await;
3208 pattern_items.extend(self.match_arm_completions(&uri, &text, offset).await);
3209 if !pattern_items.is_empty() {
3210 let mut merged = pattern_items;
3211 merged.extend(items);
3212 stamp_resolve_data(&mut merged, &uri);
3213 return Ok(Some(CompletionResponse::Array(merged)));
3214 }
3215 if items.is_empty() {
3216 let is_items = self.is_pattern_completions(&uri, &text, offset).await;
3219 if !is_items.is_empty() {
3220 return Ok(Some(CompletionResponse::Array(is_items)));
3221 }
3222 let value_items = self.value_member_completions(&uri, &text, offset).await;
3226 return Ok((!value_items.is_empty()).then_some(CompletionResponse::Array(value_items)));
3227 }
3228 stamp_resolve_data(&mut items, &uri);
3230 Ok(Some(CompletionResponse::Array(items)))
3231 }
3232
3233 async fn completion_resolve(&self, mut item: CompletionItem) -> JsonRpcResult<CompletionItem> {
3240 if item.documentation.is_some() {
3241 return Ok(item);
3242 }
3243 let Some(uri) = item
3244 .data
3245 .as_ref()
3246 .and_then(|d| d.get("uri"))
3247 .and_then(serde_json::Value::as_str)
3248 .and_then(|s| Url::parse(s).ok())
3249 else {
3250 return Ok(item);
3251 };
3252 let local = {
3253 let s = self.state.read().await;
3254 s.docs.get(&uri).map(|d| d.text.clone())
3255 };
3256 let doc = match local
3257 .as_deref()
3258 .and_then(|t| crate::symbols::describe_symbol(t, &item.label))
3259 {
3260 Some(md) => Some(md),
3261 None => {
3267 let files = self.project_content(&uri).await;
3268 let uri = uri.clone();
3269 let label = item.label.clone();
3270 match tokio::task::spawn_blocking(move || {
3271 files
3272 .and_then(|files| {
3273 crate::symbols::describe_symbol_cross_file(&files, &uri, &label)
3274 })
3275 .map(|(_uri, md)| md)
3276 .or_else(|| crate::symbols::describe_firstparty_symbol(&label))
3280 })
3281 .await
3282 {
3283 Ok(md) => md,
3284 Err(e) => {
3285 tracing::error!("completion-resolve describe task failed: {e}");
3286 None
3287 }
3288 }
3289 }
3290 };
3291 if let Some(md) = doc {
3292 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
3293 kind: MarkupKind::Markdown,
3294 value: md,
3295 }));
3296 }
3297 Ok(item)
3298 }
3299
3300 async fn goto_definition(
3301 &self,
3302 params: GotoDefinitionParams,
3303 ) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
3304 let uri = params
3305 .text_document_position_params
3306 .text_document
3307 .uri
3308 .clone();
3309 let pos = params.text_document_position_params.position;
3310 if let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await {
3315 if let Some((_, def)) =
3316 crate::index_queries::definition_at(&analysis.index, &rel, offset)
3317 && let Some(location) = Self::site_to_location(&analysis, def)
3318 {
3319 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
3320 }
3321 if let Some(text) = analysis.snapshots.get(&rel)
3324 && let Some(locals) = analysis.locals.get(&rel)
3325 && let Some(def) = crate::locals_nav::local_definition_at(locals, text, offset)
3326 && let Some(location) = self
3327 .local_locations(&analysis, &rel, &[def])
3328 .into_iter()
3329 .next()
3330 {
3331 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
3332 }
3333 }
3334 if let Some(location) = self.unit_reference_definition(&uri, pos).await {
3339 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
3340 }
3341 let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
3342 return Ok(None);
3343 };
3344 if let Some(decl_span) = crate::symbols::find_declaration_span(&text, &name) {
3345 let range = crate::position::span_to_range(&text, decl_span);
3346 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
3347 uri,
3348 range,
3349 })));
3350 }
3351 if let Some(files) = self.project_content(&uri).await
3353 && let Some(found) = crate::symbols::find_declaration_cross_file(&files, &uri, &name)
3354 {
3355 let range = crate::position::span_to_range(&found.source, found.span);
3356 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
3357 uri: found.uri,
3358 range,
3359 })));
3360 }
3361 Ok(None)
3362 }
3363
3364 async fn formatting(
3365 &self,
3366 params: DocumentFormattingParams,
3367 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
3368 let uri = params.text_document.uri;
3369 let text = {
3370 let s = self.state.read().await;
3371 s.docs.get(&uri).map(|d| d.text.clone())
3372 };
3373 let Some(text) = text else { return Ok(None) };
3374 let opts = self.config_for(&uri).await.format_options();
3377 match bynk_fmt::format_source(&text, &opts) {
3378 Ok(formatted) => {
3379 if formatted == bynk_fmt::normalize_line_endings(&text) {
3383 Ok(Some(Vec::new()))
3384 } else {
3385 let end_pos = crate::position::end_position(&text);
3387 Ok(Some(vec![TextEdit {
3388 range: Range {
3389 start: Position::new(0, 0),
3390 end: end_pos,
3391 },
3392 new_text: formatted,
3393 }]))
3394 }
3395 }
3396 Err(_) => {
3397 Ok(Some(Vec::new()))
3400 }
3401 }
3402 }
3403
3404 async fn range_formatting(
3405 &self,
3406 params: DocumentRangeFormattingParams,
3407 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
3408 self.formatting(DocumentFormattingParams {
3411 text_document: params.text_document,
3412 options: params.options,
3413 work_done_progress_params: params.work_done_progress_params,
3414 })
3415 .await
3416 }
3417
3418 async fn document_symbol(
3419 &self,
3420 params: DocumentSymbolParams,
3421 ) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
3422 let uri = params.text_document.uri;
3424 let text = {
3425 let s = self.state.read().await;
3426 s.docs.get(&uri).map(|d| d.text.clone())
3427 };
3428 let Some(text) = text else { return Ok(None) };
3429 let syms = crate::document_symbols::outline(&text);
3430 if syms.is_empty() {
3431 return Ok(None);
3432 }
3433 Ok(Some(DocumentSymbolResponse::Nested(syms)))
3434 }
3435
3436 async fn folding_range(
3439 &self,
3440 params: FoldingRangeParams,
3441 ) -> JsonRpcResult<Option<Vec<FoldingRange>>> {
3442 let uri = params.text_document.uri;
3443 let text = {
3444 let s = self.state.read().await;
3445 s.docs.get(&uri).map(|d| d.text.clone())
3446 };
3447 let Some(text) = text else { return Ok(None) };
3448 Ok(Some(crate::structure::folding_ranges(&text)))
3449 }
3450
3451 async fn selection_range(
3454 &self,
3455 params: SelectionRangeParams,
3456 ) -> JsonRpcResult<Option<Vec<SelectionRange>>> {
3457 let uri = params.text_document.uri;
3458 let text = {
3459 let s = self.state.read().await;
3460 s.docs.get(&uri).map(|d| d.text.clone())
3461 };
3462 let Some(text) = text else { return Ok(None) };
3463 Ok(Some(crate::structure::selection_ranges(
3464 &text,
3465 ¶ms.positions,
3466 )))
3467 }
3468
3469 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
3470 let uri = params.text_document_position.text_document.uri;
3471 let pos = params.text_document_position.position;
3472 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3473 return Ok(None);
3474 };
3475 let include_decl = params.context.include_declaration;
3476 if let Some(sites) =
3477 crate::index_queries::sites_for(&analysis.index, &rel, offset, include_decl)
3478 {
3479 let locations: Vec<Location> = sites
3480 .into_iter()
3481 .filter_map(|site| Self::site_to_location(&analysis, site))
3482 .collect();
3483 return Ok(Some(locations));
3484 }
3485 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
3487 let spans = if include_decl {
3488 &spans[..]
3489 } else {
3490 &spans[1..]
3491 }; let locations = self.local_locations(&analysis, &rel, spans);
3493 return Ok(Some(locations));
3494 }
3495 Ok(None)
3496 }
3497
3498 async fn code_action(
3510 &self,
3511 params: CodeActionParams,
3512 ) -> JsonRpcResult<Option<CodeActionResponse>> {
3513 let uri = params.text_document.uri;
3514 let analysis = self.committed_analysis(&uri).await;
3518 let Some(analysis) = analysis else {
3519 return Ok(Some(Vec::new()));
3520 };
3521 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3522 return Ok(Some(Vec::new()));
3523 };
3524 let (Some(text), Some(diags)) =
3525 (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
3526 else {
3527 return Ok(Some(Vec::new()));
3528 };
3529 let (Some(start), Some(end)) = (
3532 crate::position::position_to_offset(text, params.range.start),
3533 crate::position::position_to_offset(text, params.range.end),
3534 ) else {
3535 return Ok(Some(Vec::new()));
3536 };
3537 let version = analysis.versions.get(&rel).copied();
3538 let span = bynk_syntax::span::Span::new(start, end);
3539 let mut actions = crate::code_actions::quick_fixes(text, diags, span, &uri, version);
3540 actions.extend(crate::capability_fixes::header_quick_fixes(
3543 text,
3544 diags,
3545 span,
3546 &uri,
3547 version,
3548 &analysis.index,
3549 ));
3550 for match_span in crate::fill_arms::fillable(diags, span) {
3553 actions.push(crate::fill_arms::listed(diags, match_span, &uri, version));
3554 }
3555 if let Some(plan) = analysis
3558 .locals
3559 .get(&rel)
3560 .and_then(|locals| crate::inline::offerable(text, span.start, locals))
3561 {
3562 actions.push(crate::inline::listed(&plan, &uri, version));
3563 }
3564 for request in crate::provider_stub::requested(text, diags, span) {
3567 if let Some(plan) =
3568 crate::provider_stub::plan(text, &request.capability, &analysis.index)
3569 {
3570 actions.push(crate::provider_stub::listed(
3571 &plan,
3572 request.quick_fix,
3573 &uri,
3574 version,
3575 ));
3576 }
3577 }
3578 actions.extend(crate::extract::extract_variable(text, span, &uri, version));
3579 let empty_reqs = Vec::new();
3580 let empty_locals = Vec::new();
3581 let empty_types = Vec::new();
3582 let planned = crate::extract::plan_function(
3587 text,
3588 span,
3589 analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
3590 analysis.locals.get(&rel).unwrap_or(&empty_locals),
3591 analysis.expr_types.get(&rel).unwrap_or(&empty_types),
3592 &analysis.ty_intern,
3593 );
3594 let disabled_support = self.state.read().await.supports_code_action_disabled;
3595 actions.extend(crate::extract::function_listed(
3596 &planned,
3597 span,
3598 &uri,
3599 version,
3600 disabled_support,
3601 ));
3602 let only = params.context.only.as_deref();
3608 let wants = |kind: &CodeActionKind| {
3609 only.is_some_and(|only| {
3610 only.iter()
3611 .any(|requested| crate::code_actions::kind_matches(kind, requested))
3612 })
3613 };
3614 if wants(&CodeActionKind::SOURCE_ORGANIZE_IMPORTS)
3615 && let Some(action) = self.organise_action(&analysis, &rel, &uri, version).await
3616 {
3617 actions.push(action);
3618 }
3619 if wants(&crate::fix_all::kind()) {
3620 let edits = crate::fix_all::edits(text, diags);
3621 if !edits.is_empty()
3622 && self
3623 .rechecks_clean(&analysis, &rel, crate::fix_all::apply(text, &edits))
3624 .await
3625 {
3626 actions.push(crate::fix_all::action(text, &edits, &uri, version));
3627 }
3628 }
3629 if self.state.read().await.supports_explain_command {
3632 actions.extend(crate::explain_request::explain_actions(diags, span));
3633 }
3634 let actions = crate::code_actions::filter_by_only(actions, only);
3636 Ok(Some(actions))
3637 }
3638
3639 async fn code_action_resolve(&self, action: CodeAction) -> JsonRpcResult<CodeAction> {
3646 if let Some(target) = crate::inline::Target::of(&action) {
3647 return self.resolve_inline(action, target).await;
3648 }
3649 if let Some(target) = crate::provider_stub::Target::of(&action) {
3650 return self.resolve_provider_stub(action, target).await;
3651 }
3652 if let Some(target) = crate::extract::FunctionTarget::of(&action) {
3653 return self.resolve_extract_function(action, target).await;
3654 }
3655 let Some(target) = crate::fill_arms::Target::of(&action) else {
3656 return Ok(action);
3657 };
3658 let refused = |msg: &str| tower_lsp::jsonrpc::Error {
3659 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3660 message: msg.to_string().into(),
3661 data: None,
3662 };
3663 let stale = "the document changed since the match arms were offered";
3664 let analysis = self
3665 .committed_analysis(&target.uri)
3666 .await
3667 .ok_or_else(|| refused(stale))?;
3668 let rel = Self::uri_to_rel(&analysis, &target.uri).ok_or_else(|| refused(stale))?;
3669 let (Some(text), Some(diags)) =
3670 (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
3671 else {
3672 return Err(refused(stale));
3673 };
3674 let version = analysis.versions.get(&rel).copied();
3675 if version != target.version {
3676 return Err(refused(stale));
3677 }
3678 let edits = crate::fill_arms::edits(text, diags, target.match_span)
3679 .ok_or_else(|| refused("the missing match arms can't be filled here"))?;
3680 let filled = crate::fix_all::apply(text, &edits);
3681 if !self.rechecks_clean(&analysis, &rel, filled).await {
3682 return Err(refused(
3683 "filling the missing match arms would introduce a new problem",
3684 ));
3685 }
3686 Ok(crate::fill_arms::resolve(
3687 action,
3688 text,
3689 &edits,
3690 &target.uri,
3691 version,
3692 ))
3693 }
3694
3695 async fn inlay_hint(&self, params: InlayHintParams) -> JsonRpcResult<Option<Vec<InlayHint>>> {
3700 let uri = params.text_document.uri;
3701 let analysis = self.committed_analysis(&uri).await;
3703 let Some(analysis) = analysis else {
3704 return Ok(Some(Vec::new()));
3705 };
3706 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3707 return Ok(Some(Vec::new()));
3708 };
3709 let Some(text) = analysis.snapshots.get(&rel) else {
3710 return Ok(Some(Vec::new()));
3711 };
3712 let (Some(start), Some(end)) = (
3715 crate::position::position_to_offset(text, params.range.start),
3716 crate::position::position_to_offset(text, params.range.end),
3717 ) else {
3718 return Ok(Some(Vec::new()));
3719 };
3720 let visible = bynk_syntax::span::Span::new(start, end);
3721 let mut hints = analysis
3725 .hints
3726 .get(&rel)
3727 .map(|h| crate::inlay_hints::inlay_hints(text, h, visible))
3728 .unwrap_or_default();
3729 if let Some(reqs) = analysis.requirements.get(&rel) {
3730 hints.extend(crate::inlay_hints::given_hints(text, reqs, visible));
3731 }
3732 Ok(Some(hints))
3733 }
3734
3735 async fn semantic_tokens_full(
3739 &self,
3740 params: SemanticTokensParams,
3741 ) -> JsonRpcResult<Option<SemanticTokensResult>> {
3742 let data = self
3743 .semantic_tokens_for(¶ms.text_document.uri, None)
3744 .await;
3745 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
3746 result_id: None,
3747 data,
3748 })))
3749 }
3750
3751 async fn semantic_tokens_range(
3754 &self,
3755 params: SemanticTokensRangeParams,
3756 ) -> JsonRpcResult<Option<SemanticTokensRangeResult>> {
3757 let data = self
3758 .semantic_tokens_for(¶ms.text_document.uri, Some(params.range))
3759 .await;
3760 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
3761 result_id: None,
3762 data,
3763 })))
3764 }
3765
3766 async fn symbol(
3776 &self,
3777 params: WorkspaceSymbolParams,
3778 ) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
3779 let candidates: Vec<(PathBuf, ProjectConfig)> = {
3780 let (mut set, folders) = {
3783 let state = self.state.read().await;
3784 let known: std::collections::HashMap<PathBuf, ProjectConfig> = state
3785 .projects
3786 .iter()
3787 .map(|(r, p)| (r.clone(), p.config.clone()))
3788 .collect();
3789 (known, state.folders.clone())
3790 };
3791 for folder in &folders {
3792 if let Some((root, config)) = Self::resolve_root(folder) {
3793 let root = root.canonicalize().unwrap_or(root);
3794 set.entry(root).or_insert(config);
3795 }
3796 }
3797 set.into_iter().collect()
3798 };
3799 let mut symbols: Vec<SymbolInformation> = Vec::new();
3800 for (root, config) in candidates {
3801 let Some(analysis) = self.ensure_project_analysed(root, config).await else {
3802 continue;
3803 };
3804 for (key, def) in
3805 crate::index_queries::workspace_symbols(&analysis.index, ¶ms.query)
3806 {
3807 let Some(location) = Self::site_to_location(&analysis, def) else {
3808 continue;
3809 };
3810 #[allow(deprecated)]
3811 symbols.push(SymbolInformation {
3812 name: key.name.clone(),
3813 kind: lsp_symbol_kind(key.kind),
3814 tags: None,
3815 deprecated: None,
3816 location,
3817 container_name: Some(key.unit.clone()),
3818 });
3819 }
3820 }
3821 symbols.sort_by(|a, b| {
3826 a.name
3827 .cmp(&b.name)
3828 .then_with(|| a.container_name.cmp(&b.container_name))
3829 });
3830 Ok(Some(symbols))
3831 }
3832
3833 async fn document_highlight(
3837 &self,
3838 params: DocumentHighlightParams,
3839 ) -> JsonRpcResult<Option<Vec<DocumentHighlight>>> {
3840 let uri = params.text_document_position_params.text_document.uri;
3841 let pos = params.text_document_position_params.position;
3842 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3843 return Ok(None);
3844 };
3845 let Some(text) = analysis.snapshots.get(&rel) else {
3846 return Ok(None);
3847 };
3848 if let Some(sites) =
3849 crate::index_queries::document_highlights(&analysis.index, &rel, offset)
3850 {
3851 let highlights: Vec<DocumentHighlight> = sites
3852 .into_iter()
3853 .map(|s| DocumentHighlight {
3854 range: crate::position::span_to_range(text, s.span),
3855 kind: None,
3856 })
3857 .collect();
3858 return Ok(Some(highlights));
3859 }
3860 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
3862 let highlights = spans
3863 .iter()
3864 .map(|s| DocumentHighlight {
3865 range: crate::position::span_to_range(text, *s),
3866 kind: None,
3867 })
3868 .collect();
3869 return Ok(Some(highlights));
3870 }
3871 Ok(None)
3872 }
3873
3874 async fn prepare_rename(
3875 &self,
3876 params: TextDocumentPositionParams,
3877 ) -> JsonRpcResult<Option<PrepareRenameResponse>> {
3878 let uri = params.text_document.uri;
3879 let pos = params.position;
3880 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3884 return Ok(None);
3885 };
3886 let Some((key, site)) = crate::index_queries::prepare_rename(&analysis.index, &rel, offset)
3887 else {
3888 return Ok(None);
3889 };
3890 let Some(text) = analysis.snapshots.get(&rel) else {
3891 return Ok(None);
3892 };
3893 Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
3894 range: crate::position::span_to_range(text, site.span),
3895 placeholder: key.name.clone(),
3896 }))
3897 }
3898
3899 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
3900 let uri = params.text_document_position.text_document.uri;
3901 let pos = params.text_document_position.position;
3902 let new_name = params.new_name;
3903 let refused = |msg: String| tower_lsp::jsonrpc::Error {
3904 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3905 message: msg.into(),
3906 data: None,
3907 };
3908 let Some(root) = self.root_for_uri(&uri).await else {
3919 return Err(refused("rename requires a project (bynk.toml)".into()));
3920 };
3921 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3922 return Err(refused("rename requires a project (bynk.toml)".into()));
3923 };
3924 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3925 return Ok(None);
3926 };
3927 let Some(text) = analysis.snapshots.get(&rel) else {
3928 return Ok(None);
3929 };
3930 let Some(offset) = crate::position::position_to_offset(text, pos) else {
3931 return Ok(None);
3932 };
3933 let plan = crate::index_queries::plan_rename(&analysis.index, &rel, offset, &new_name)
3934 .map_err(refused)?;
3935
3936 let mut overlay = std::collections::HashMap::new();
3950 for (rel_path, text) in &analysis.snapshots {
3951 let edited = match plan.edits.get(rel_path) {
3952 Some(spans) => crate::index_queries::apply_edits(text, spans, &plan.new_name),
3953 None => text.clone(),
3954 };
3955 let abs = analysis.project_root.join(rel_path);
3956 let abs = abs.canonicalize().unwrap_or(abs);
3957 overlay.insert(abs, edited);
3958 }
3959 let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
3960 let Ok(post) = tokio::task::spawn_blocking(move || {
3967 bynk_ide::diagnose_project_with(&roots, &overlay, &bynk_ide::SchemaLock::Off)
3968 })
3969 .await
3970 else {
3971 return Err(refused("rename validation failed to run".into()));
3972 };
3973
3974 let post_diags: Vec<(PathBuf, String)> = post
3976 .files
3977 .iter()
3978 .flat_map(|f| {
3979 f.diagnostics
3980 .iter()
3981 .map(|d| (f.source_path.clone(), d.error.category.to_string()))
3982 })
3983 .collect();
3984 crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags)
3985 .map_err(refused)?;
3986
3987 if !crate::index_queries::index_unchanged_modulo_rename(&analysis.index, &post.index, &plan)
3990 {
3991 return Err(refused(format!(
3992 "renaming `{}` to `{new_name}` would silently re-bind another name — refused",
3993 plan.key.name
3994 )));
3995 }
3996
3997 let mut document_edits: Vec<TextDocumentEdit> = Vec::new();
4000 for (rel_path, spans) in &plan.edits {
4001 let Some(text) = analysis.snapshots.get(rel_path) else {
4002 continue;
4003 };
4004 let abs = analysis.project_root.join(rel_path);
4005 let Ok(file_uri) = Url::from_file_path(&abs) else {
4006 continue;
4007 };
4008 let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = spans
4009 .iter()
4010 .map(|span| {
4011 OneOf::Left(TextEdit {
4012 range: crate::position::span_to_range(text, *span),
4013 new_text: plan.new_name.clone(),
4014 })
4015 })
4016 .collect();
4017 document_edits.push(TextDocumentEdit {
4018 text_document: OptionalVersionedTextDocumentIdentifier {
4019 uri: file_uri,
4020 version: analysis.versions.get(rel_path).copied(),
4021 },
4022 edits,
4023 });
4024 }
4025 Ok(Some(WorkspaceEdit {
4026 changes: None,
4027 document_changes: Some(DocumentChanges::Edits(document_edits)),
4028 change_annotations: None,
4029 }))
4030 }
4031
4032 async fn will_rename_files(
4068 &self,
4069 params: RenameFilesParams,
4070 ) -> JsonRpcResult<Option<WorkspaceEdit>> {
4071 let mut combined: std::collections::HashMap<Url, (Option<i32>, Vec<TextEdit>)> =
4072 std::collections::HashMap::new();
4073 let mut handled_files: std::collections::HashSet<(PathBuf, PathBuf)> =
4077 std::collections::HashSet::new();
4078 let mut renamed_units: std::collections::HashSet<(PathBuf, String)> =
4079 std::collections::HashSet::new();
4080 for fr in ¶ms.files {
4081 let (Ok(old_uri), Ok(new_uri)) = (Url::parse(&fr.old_uri), Url::parse(&fr.new_uri))
4082 else {
4083 continue;
4084 };
4085 let Some(root) = self.root_for_uri(&old_uri).await else {
4086 continue;
4087 };
4088 if Self::new_path_root(&new_uri).as_ref() != Some(&root) {
4091 tracing::info!(
4092 "willRenameFiles: {} -> {} leaves project {}; no reference edits",
4093 fr.old_uri,
4094 fr.new_uri,
4095 root.display()
4096 );
4097 continue;
4098 }
4099 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
4100 continue;
4101 };
4102 for (old_rel, new_rel) in Self::expand_rename(&analysis, &old_uri, &new_uri) {
4103 if !handled_files.insert((root.clone(), old_rel.clone())) {
4104 continue;
4105 }
4106 let Some(text) = analysis.snapshots.get(&old_rel) else {
4107 continue;
4108 };
4109 let Some((old_name, name_span)) = crate::symbols::own_declaration_name(text) else {
4110 continue;
4111 };
4112 let Some(new_name) = bynk_ide::renamed_unit_name(&old_rel, &old_name, &new_rel)
4113 else {
4114 tracing::info!(
4117 "willRenameFiles: {} -> {}: no unit name follows from the new path \
4118 for `{old_name}`; left unchanged",
4119 old_rel.display(),
4120 new_rel.display()
4121 );
4122 continue;
4123 };
4124 if new_name == old_name {
4125 continue;
4126 }
4127 if analysis.unit_sources.contains_key(&new_name) {
4130 tracing::info!(
4131 "willRenameFiles: {} -> {}: `{new_name}` is already declared; \
4132 `{old_name}` left unchanged",
4133 old_rel.display(),
4134 new_rel.display()
4135 );
4136 continue;
4137 }
4138 let Ok(old_file_uri) = Url::from_file_path(analysis.project_root.join(&old_rel))
4145 else {
4146 continue;
4147 };
4148 combined
4151 .entry(old_file_uri)
4152 .or_insert_with(|| (analysis.versions.get(&old_rel).copied(), Vec::new()))
4153 .1
4154 .push(TextEdit {
4155 range: crate::position::span_to_range(text, name_span),
4156 new_text: new_name.clone(),
4157 });
4158 if !renamed_units.insert((root.clone(), old_name.clone())) {
4162 continue;
4163 }
4164 for (rel, snap_text) in &analysis.snapshots {
4166 if *rel == old_rel {
4167 continue;
4168 }
4169 let edits: Vec<TextEdit> = crate::symbols::unit_reference_spans(snap_text)
4170 .into_iter()
4171 .filter(|(unit, _)| *unit == old_name)
4172 .map(|(_, span)| TextEdit {
4173 range: crate::position::span_to_range(snap_text, span),
4174 new_text: new_name.clone(),
4175 })
4176 .collect();
4177 if edits.is_empty() {
4178 continue;
4179 }
4180 let Ok(file_uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
4181 continue;
4182 };
4183 combined
4184 .entry(file_uri)
4185 .or_insert_with(|| (analysis.versions.get(rel).copied(), Vec::new()))
4186 .1
4187 .extend(edits);
4188 }
4189 }
4190 }
4191 if combined.is_empty() {
4192 return Ok(None);
4193 }
4194 let document_edits: Vec<TextDocumentEdit> = combined
4195 .into_iter()
4196 .map(|(uri, (version, edits))| TextDocumentEdit {
4197 text_document: OptionalVersionedTextDocumentIdentifier { uri, version },
4198 edits: edits.into_iter().map(OneOf::Left).collect(),
4199 })
4200 .collect();
4201 Ok(Some(WorkspaceEdit {
4202 changes: None,
4203 document_changes: Some(DocumentChanges::Edits(document_edits)),
4204 change_annotations: None,
4205 }))
4206 }
4207
4208 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
4209 if params.changes.iter().any(|ev| is_bynk_toml(&ev.uri)) {
4216 let mut state = self.state.write().await;
4217 state.root_cache.clear();
4218 state.root_cache_generation += 1;
4219 }
4220 let mut uris_to_refresh = Vec::new();
4225 let mut roots_to_reanalyse: std::collections::HashSet<PathBuf> =
4228 std::collections::HashSet::new();
4229 let mut config_changed_roots: std::collections::HashSet<PathBuf> =
4235 std::collections::HashSet::new();
4236 let open_docs: std::collections::HashSet<Url> =
4240 self.state.read().await.docs.keys().cloned().collect();
4241 for ev in ¶ms.changes {
4242 if is_bynk_toml(&ev.uri) {
4243 if let Ok(p) = ev.uri.to_file_path()
4245 && let Some(dir) = p.parent()
4246 {
4247 let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
4248 config_changed_roots.insert(root.clone());
4249 roots_to_reanalyse.insert(root);
4250 }
4251 } else if schema_lock::is_schema_lock(&ev.uri) {
4252 if let Ok(p) = ev.uri.to_file_path()
4256 && let Some(dir) = p.parent()
4257 {
4258 let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
4259 if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
4260 ps.schema_lock.invalidate();
4261 }
4262 roots_to_reanalyse.insert(root);
4263 }
4264 } else if open_docs.contains(&ev.uri) {
4265 uris_to_refresh.push(ev.uri.clone());
4266 } else if ev.uri.path().ends_with(".bynk")
4267 && let Some(root) = self.root_for_uri(&ev.uri).await
4268 {
4269 roots_to_reanalyse.insert(root);
4270 }
4271 }
4272 for root in &config_changed_roots {
4277 let config = project::load_config(root).unwrap_or_default();
4278 let mut state = self.state.write().await;
4279 state
4280 .projects
4281 .entry(root.clone())
4282 .and_modify(|ps| ps.config = config.clone())
4283 .or_insert_with(|| ProjectState {
4284 config,
4285 ..Default::default()
4286 });
4287 }
4288 for uri in uris_to_refresh {
4289 self.schedule_diagnostics(&uri).await;
4290 }
4291 for root in roots_to_reanalyse {
4296 self.schedule_project_diagnostics(root).await;
4297 }
4298 }
4299
4300 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
4301 let added_dirs: Vec<PathBuf> = {
4306 let mut state = self.state.write().await;
4307 let mut added = Vec::new();
4308 for a in ¶ms.event.added {
4309 if let Ok(p) = a.uri.to_file_path() {
4310 let dir = p.canonicalize().unwrap_or(p);
4311 if !state.folders.contains(&dir) {
4312 state.folders.push(dir.clone());
4313 added.push(dir);
4314 }
4315 }
4316 }
4317 for removed in ¶ms.event.removed {
4318 if let Ok(p) = removed.uri.to_file_path() {
4319 let dir = p.canonicalize().unwrap_or(p);
4320 state.folders.retain(|f| f != &dir);
4321 }
4322 }
4323 state.root_cache.clear();
4330 state.root_cache_generation += 1;
4331 added
4332 };
4333 self.warm_projects(&added_dirs).await;
4336 for uri in self.prune_orphaned_projects().await {
4339 self.client.publish_diagnostics(uri, Vec::new(), None).await;
4340 }
4341 }
4342}
4343
4344fn server_capabilities() -> ServerCapabilities {
4347 ServerCapabilities {
4348 text_document_sync: Some(TextDocumentSyncCapability::Options(
4351 TextDocumentSyncOptions {
4352 open_close: Some(true),
4353 change: Some(TextDocumentSyncKind::FULL),
4354 save: Some(TextDocumentSyncSaveOptions::Supported(true)),
4355 ..Default::default()
4356 },
4357 )),
4358 hover_provider: Some(HoverProviderCapability::Simple(true)),
4359 definition_provider: Some(OneOf::Left(true)),
4360 completion_provider: Some(CompletionOptions {
4365 trigger_characters: Some(vec![
4366 " ".to_string(),
4367 "{".to_string(),
4368 ",".to_string(),
4369 ".".to_string(),
4370 ]),
4371 resolve_provider: Some(true),
4374 ..Default::default()
4375 }),
4376 signature_help_provider: Some(SignatureHelpOptions {
4378 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
4379 retrigger_characters: Some(vec![",".to_string()]),
4380 ..Default::default()
4381 }),
4382 code_lens_provider: Some(CodeLensOptions {
4384 resolve_provider: Some(false),
4385 }),
4386 call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
4388 implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
4390 type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
4392 document_link_provider: Some(DocumentLinkOptions {
4394 resolve_provider: Some(false),
4395 work_done_progress_options: Default::default(),
4396 }),
4397 document_formatting_provider: Some(OneOf::Left(true)),
4398 document_range_formatting_provider: Some(OneOf::Left(true)),
4399 document_symbol_provider: Some(OneOf::Left(true)),
4400 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
4402 selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
4403 references_provider: Some(OneOf::Left(true)),
4406 rename_provider: Some(OneOf::Right(RenameOptions {
4407 prepare_provider: Some(true),
4408 work_done_progress_options: Default::default(),
4409 })),
4410 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
4413 code_action_kinds: Some(vec![
4414 CodeActionKind::QUICKFIX,
4415 CodeActionKind::REFACTOR,
4416 CodeActionKind::REFACTOR_EXTRACT,
4417 CodeActionKind::REFACTOR_INLINE,
4418 CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
4419 crate::fix_all::kind(),
4420 ]),
4421 resolve_provider: Some(true),
4423 ..Default::default()
4424 })),
4425 inlay_hint_provider: Some(OneOf::Left(true)),
4428 semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions(
4432 SemanticTokensOptions {
4433 legend: crate::index_queries::semantic_tokens_legend(),
4434 full: Some(SemanticTokensFullOptions::Bool(true)),
4435 range: Some(true),
4436 ..Default::default()
4437 },
4438 )),
4439 workspace_symbol_provider: Some(OneOf::Left(true)),
4441 document_highlight_provider: Some(OneOf::Left(true)),
4442 workspace: Some(WorkspaceServerCapabilities {
4443 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
4444 supported: Some(true),
4445 change_notifications: Some(OneOf::Left(true)),
4446 }),
4447 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
4453 will_rename: Some(FileOperationRegistrationOptions {
4454 filters: vec![
4455 FileOperationFilter {
4456 scheme: Some("file".to_string()),
4457 pattern: FileOperationPattern {
4458 glob: "**/*.bynk".to_string(),
4459 matches: Some(FileOperationPatternKind::File),
4460 options: None,
4461 },
4462 },
4463 FileOperationFilter {
4464 scheme: Some("file".to_string()),
4465 pattern: FileOperationPattern {
4466 glob: "**".to_string(),
4467 matches: Some(FileOperationPatternKind::Folder),
4468 options: None,
4469 },
4470 },
4471 ],
4472 }),
4473 ..Default::default()
4474 }),
4475 }),
4476 experimental: Some(serde_json::json!({
4481 "sequenceModel": true,
4482 "agentStateModel": true,
4483 "documentationModel": true,
4484 "architectureModel": true,
4485 "wireContract": true,
4486 "explain": true,
4487 })),
4488 ..Default::default()
4489 }
4490}
4491
4492fn stamp_resolve_data(items: &mut [CompletionItem], uri: &Url) {
4500 let data = serde_json::json!({ "uri": uri.to_string() });
4501 for item in items.iter_mut() {
4502 item.data = Some(data.clone());
4503 }
4504}
4505
4506fn cors_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4515 if completion::in_cors_field_position(text, offset) {
4516 return completion::CORS_FIELDS
4517 .iter()
4518 .map(|(name, doc)| CompletionItem {
4519 label: name.to_string(),
4520 kind: Some(CompletionItemKind::FIELD),
4521 detail: Some((*doc).to_string()),
4522 insert_text: Some(format!("{name}: ")),
4523 ..Default::default()
4524 })
4525 .collect();
4526 }
4527 if completion::in_service_body_item_position(text, offset, line) {
4528 return vec![CompletionItem {
4529 label: "cors".to_string(),
4530 kind: Some(CompletionItemKind::KEYWORD),
4531 detail: Some("a cross-origin (CORS) policy for this HTTP service".to_string()),
4532 insert_text: Some("cors {\n\torigins: [$0],\n}".to_string()),
4533 insert_text_format: Some(InsertTextFormat::SNIPPET),
4534 ..Default::default()
4535 }];
4536 }
4537 Vec::new()
4538}
4539
4540fn security_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4545 if completion::in_security_field_position(text, offset) {
4546 return completion::SECURITY_FIELDS
4547 .iter()
4548 .map(|(name, doc)| CompletionItem {
4549 label: name.to_string(),
4550 kind: Some(CompletionItemKind::FIELD),
4551 detail: Some((*doc).to_string()),
4552 insert_text: Some(format!("{name}: ")),
4553 ..Default::default()
4554 })
4555 .collect();
4556 }
4557 if completion::in_service_body_item_position(text, offset, line) {
4558 return vec![CompletionItem {
4559 label: "security".to_string(),
4560 kind: Some(CompletionItemKind::KEYWORD),
4561 detail: Some("security response headers for this HTTP service".to_string()),
4562 insert_text: Some("security {\n\tnosniff: $0,\n}".to_string()),
4563 insert_text_format: Some(InsertTextFormat::SNIPPET),
4564 ..Default::default()
4565 }];
4566 }
4567 Vec::new()
4568}
4569
4570fn cache_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4575 if completion::in_cache_arg_position(text, offset) {
4576 return completion::CACHE_ARGS
4577 .iter()
4578 .map(|(name, doc)| CompletionItem {
4579 label: name.to_string(),
4580 kind: Some(CompletionItemKind::FIELD),
4581 detail: Some((*doc).to_string()),
4582 insert_text: Some(format!("{name}: ")),
4583 ..Default::default()
4584 })
4585 .collect();
4586 }
4587 if completion::in_service_body_item_position(text, offset, line) {
4588 return vec![CompletionItem {
4589 label: "@cache".to_string(),
4590 kind: Some(CompletionItemKind::SNIPPET),
4591 detail: Some(
4592 "cache a GET read — a synthesised ETag/304 revalidation with a freshness window"
4593 .to_string(),
4594 ),
4595 insert_text: Some("@cache(maxAge: ${1:5.minutes})".to_string()),
4596 insert_text_format: Some(InsertTextFormat::SNIPPET),
4597 ..Default::default()
4598 }];
4599 }
4600 Vec::new()
4601}
4602
4603fn limits_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4608 if completion::in_limits_field_position(text, offset) {
4609 return completion::LIMITS_FIELDS
4610 .iter()
4611 .map(|(name, doc)| CompletionItem {
4612 label: name.to_string(),
4613 kind: Some(CompletionItemKind::FIELD),
4614 detail: Some((*doc).to_string()),
4615 insert_text: Some(format!("{name}: ")),
4616 ..Default::default()
4617 })
4618 .collect();
4619 }
4620 if completion::in_service_body_item_position(text, offset, line) {
4621 return vec![CompletionItem {
4622 label: "limits".to_string(),
4623 kind: Some(CompletionItemKind::KEYWORD),
4624 detail: Some("request limits for this HTTP service".to_string()),
4625 insert_text: Some("limits {\n\tmaxBody: $0,\n}".to_string()),
4626 insert_text_format: Some(InsertTextFormat::SNIPPET),
4627 ..Default::default()
4628 }];
4629 }
4630 Vec::new()
4631}
4632
4633fn limit_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4638 if completion::in_limit_arg_position(text, offset) {
4639 return completion::LIMIT_ARGS
4640 .iter()
4641 .map(|(name, doc)| CompletionItem {
4642 label: name.to_string(),
4643 kind: Some(CompletionItemKind::FIELD),
4644 detail: Some((*doc).to_string()),
4645 insert_text: Some(format!("{name}: ")),
4646 ..Default::default()
4647 })
4648 .collect();
4649 }
4650 if completion::in_service_body_item_position(text, offset, line) {
4651 return vec![CompletionItem {
4652 label: "@limit".to_string(),
4653 kind: Some(CompletionItemKind::SNIPPET),
4654 detail: Some(
4655 "cap the request body size — a `413` synthesised before the body is read"
4656 .to_string(),
4657 ),
4658 insert_text: Some("@limit(maxBody: ${1:1048576})".to_string()),
4659 insert_text_format: Some(InsertTextFormat::SNIPPET),
4660 ..Default::default()
4661 }];
4662 }
4663 Vec::new()
4664}
4665
4666fn raw_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4670 if completion::in_raw_arg_position(text, offset) {
4671 return completion::RAW_ARGS
4672 .iter()
4673 .map(|(name, doc)| CompletionItem {
4674 label: name.to_string(),
4675 kind: Some(CompletionItemKind::FIELD),
4676 detail: Some((*doc).to_string()),
4677 insert_text: Some(format!("{name}: ")),
4678 ..Default::default()
4679 })
4680 .collect();
4681 }
4682 if completion::in_service_body_item_position(text, offset, line) {
4683 return vec![CompletionItem {
4684 label: "@raw".to_string(),
4685 kind: Some(CompletionItemKind::SNIPPET),
4686 detail: Some(
4687 "take `body: Bytes` as the raw request body — a `415` for any other content type"
4688 .to_string(),
4689 ),
4690 insert_text: Some("@raw(contentType: \"${1:application/octet-stream}\")".to_string()),
4691 insert_text_format: Some(InsertTextFormat::SNIPPET),
4692 ..Default::default()
4693 }];
4694 }
4695 Vec::new()
4696}
4697
4698fn contract_param_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
4699 use bynk_syntax::ast::{CommonsItem, SourceUnit};
4700 let Some(is_ensures) = completion::contract_clause_kind(line) else {
4701 return Vec::new();
4702 };
4703 let Ok(tokens) = bynk_syntax::lexer::tokenize(text) else {
4704 return Vec::new();
4705 };
4706 let (Some(unit), _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text) else {
4707 return Vec::new();
4708 };
4709 let items = match &unit {
4710 SourceUnit::Commons(c) => &c.items,
4711 SourceUnit::Context(c) => &c.items,
4712 SourceUnit::Adapter(a) => &a.items,
4713 _ => return Vec::new(),
4714 };
4715 for item in items {
4716 if let CommonsItem::Fn(f) = item
4719 && f.span.start <= offset
4720 && offset <= f.body.span.start
4721 {
4722 let mut out: Vec<CompletionItem> = f
4725 .params
4726 .iter()
4727 .filter(|p| p.name.name != "_")
4728 .map(|p| CompletionItem {
4729 label: p.name.name.clone(),
4730 kind: Some(CompletionItemKind::VARIABLE),
4731 detail: Some(format!(
4732 "parameter: {}",
4733 crate::symbols::type_ref_str(&p.type_ref)
4734 )),
4735 ..Default::default()
4736 })
4737 .collect();
4738 if is_ensures {
4739 out.push(CompletionItem {
4740 label: "result".to_string(),
4741 kind: Some(CompletionItemKind::VARIABLE),
4742 detail: Some("the function's return value".to_string()),
4743 ..Default::default()
4744 });
4745 }
4746 return out;
4747 }
4748 }
4749 Vec::new()
4750}
4751
4752fn is_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
4757 let before = text.get(..cursor)?;
4758 let before = before
4760 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4761 .trim_end();
4762 let before = before.strip_suffix("is")?;
4763 if !before.ends_with(char::is_whitespace) {
4764 return None;
4765 }
4766 let before = before.trim_end();
4767 (!before.is_empty()).then(|| before.len() - 1)
4768}
4769
4770fn match_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
4777 let before = text.get(..cursor)?;
4778 let brace = innermost_open_brace(before)?;
4780 let arm_start = arm_start_offset(before, brace);
4783 let arm = before.get(arm_start..)?;
4784 if arm.contains("=>") {
4785 return None;
4786 }
4787 if !arm
4790 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4791 .trim()
4792 .is_empty()
4793 {
4794 return None;
4795 }
4796 match_head_scrutinee_offset(before, brace)
4797}
4798
4799fn nested_pattern_offset(text: &str, cursor: usize) -> Option<(usize, String)> {
4806 let before = text.get(..cursor)?;
4807 let brace = innermost_open_brace(before)?;
4808 let arm_start = arm_start_offset(before, brace);
4809 let arm = before.get(arm_start..)?;
4810 if arm.contains("=>") {
4811 return None; }
4813 let paren = innermost_open_paren(arm)?;
4815 let head = arm.get(..paren)?.trim_end();
4818 let variant: String = head
4819 .chars()
4820 .rev()
4821 .take_while(|c| c.is_alphanumeric() || *c == '_')
4822 .collect::<Vec<_>>()
4823 .into_iter()
4824 .rev()
4825 .collect();
4826 if !variant.chars().next().is_some_and(char::is_uppercase) {
4827 return None;
4828 }
4829 let after = arm.get(paren + 1..)?;
4832 if !after
4833 .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '.')
4834 .trim()
4835 .is_empty()
4836 {
4837 return None;
4838 }
4839 let scrut_off = match_head_scrutinee_offset(before, brace)?;
4840 Some((scrut_off, variant))
4841}
4842
4843fn match_head_scrutinee_offset(before: &str, brace: usize) -> Option<usize> {
4849 let head = before.get(..brace)?.trim_end();
4850 let m = head.rfind("match")?;
4851 if head[..m]
4852 .chars()
4853 .next_back()
4854 .is_some_and(|c| c.is_alphanumeric() || c == '_')
4855 {
4856 return None; }
4858 let after = head.get(m + "match".len()..)?;
4859 if !after.starts_with(char::is_whitespace) {
4860 return None;
4861 }
4862 let scrut = after.trim();
4863 if scrut.is_empty() || scrut.contains(['{', '}']) || scrut.contains("=>") {
4864 return None;
4865 }
4866 Some(head.len() - 1)
4868}
4869
4870#[derive(Debug, PartialEq, Eq)]
4874struct LetElseMatchHead {
4875 value_end: usize,
4877 effectful: bool,
4879 covered: Option<String>,
4883}
4884
4885fn let_else_match_head(text: &str, cursor: usize) -> Option<LetElseMatchHead> {
4888 let before = text.get(..cursor)?;
4889 let brace = innermost_open_brace(before)?;
4890 let arm = before.get(arm_start_offset(before, brace)..)?;
4891 if arm.contains("=>")
4892 || !arm
4893 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
4894 .trim()
4895 .is_empty()
4896 {
4897 return None;
4898 }
4899 let is_ident = |c: char| c.is_alphanumeric() || c == '_';
4900 let strip_word = |s: &'_ str, word: &str| -> Option<usize> {
4901 let rest = s.strip_suffix(word)?;
4902 (!rest.ends_with(is_ident)).then_some(rest.trim_end().len())
4903 };
4904 let head = before.get(..brace)?.trim_end();
4905 let head = &head[..strip_word(head, "match")?];
4906 let head = &head[..strip_word(head, "else")?];
4907 let value_end = head.len().checked_sub(1)?;
4908 let let_at = head
4910 .match_indices("let")
4911 .map(|(i, _)| i)
4912 .filter(|&i| {
4913 !head[..i].ends_with(is_ident) && head[i + 3..].starts_with(char::is_whitespace)
4914 })
4915 .last()?;
4916 let rest = &head[let_at + 3..];
4917 let mut depth = 0i32;
4919 let mut split = None;
4920 let bytes = rest.as_bytes();
4921 for (i, &b) in bytes.iter().enumerate() {
4922 match b {
4923 b'(' | b'[' | b'{' => depth += 1,
4924 b')' | b']' | b'}' => depth -= 1,
4925 b'<' if depth == 0 && bytes.get(i + 1) == Some(&b'-') => {
4926 split = Some((i, true));
4927 break;
4928 }
4929 b'=' if depth == 0
4930 && !matches!(bytes.get(i + 1), Some(b'=' | b'>'))
4931 && !matches!(
4932 i.checked_sub(1).map(|j| bytes[j]),
4933 Some(b'=' | b'!' | b'<' | b'>')
4934 ) =>
4935 {
4936 split = Some((i, false));
4937 break;
4938 }
4939 _ => {}
4940 }
4941 }
4942 let (at, effectful) = split?;
4943 let pattern = rest[..at].trim();
4944 let (name, payload) = match pattern.split_once('(') {
4945 Some((name, payload)) => (name, Some(payload.trim_end_matches(')'))),
4946 None => (pattern, None),
4947 };
4948 let name = name.rsplit('.').next()?.trim();
4949 let whole_variant = name.starts_with(char::is_uppercase)
4950 && payload.is_none_or(|p| {
4951 p.split(',').all(|part| {
4952 let binder = part.rsplit(':').next().unwrap_or(part).trim();
4953 binder == "_"
4954 || binder.starts_with(char::is_lowercase) && binder.chars().all(is_ident)
4955 })
4956 });
4957 Some(LetElseMatchHead {
4958 value_end,
4959 effectful,
4960 covered: whole_variant.then(|| name.to_string()),
4961 })
4962}
4963
4964fn innermost_open_paren(arm: &str) -> Option<usize> {
4968 let mut stack: Vec<usize> = Vec::new();
4969 for (i, c) in arm.char_indices() {
4970 match c {
4971 '(' => stack.push(i),
4972 ')' => {
4973 stack.pop();
4974 }
4975 _ => {}
4976 }
4977 }
4978 stack.pop()
4979}
4980
4981fn innermost_open_brace(before: &str) -> Option<usize> {
4984 let mut stack: Vec<usize> = Vec::new();
4985 for (i, c) in before.char_indices() {
4986 match c {
4987 '{' => stack.push(i),
4988 '}' => {
4989 stack.pop();
4990 }
4991 _ => {}
4992 }
4993 }
4994 stack.pop()
4995}
4996
4997fn arm_start_offset(before: &str, brace: usize) -> usize {
5001 let mut depth = 0i32;
5002 let mut start = brace + 1; for (rel, c) in before[brace + 1..].char_indices() {
5004 match c {
5005 '{' | '(' | '[' => depth += 1,
5006 '}' | ')' | ']' => depth -= 1,
5007 ',' if depth == 0 => start = brace + 1 + rel + c.len_utf8(),
5008 _ => {}
5009 }
5010 }
5011 start
5012}
5013
5014fn to_completion_item(c: completion::Completion) -> CompletionItem {
5015 CompletionItem {
5016 kind: Some(match c.kind {
5017 completion::CompletionKind::Unit => CompletionItemKind::MODULE,
5018 completion::CompletionKind::Capability => CompletionItemKind::INTERFACE,
5019 completion::CompletionKind::Type => CompletionItemKind::STRUCT,
5020 completion::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
5021 completion::CompletionKind::Snippet => CompletionItemKind::SNIPPET,
5022 completion::CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
5023 completion::CompletionKind::Member => CompletionItemKind::METHOD,
5024 completion::CompletionKind::Field => CompletionItemKind::FIELD,
5025 completion::CompletionKind::Constructor => CompletionItemKind::CONSTRUCTOR,
5026 completion::CompletionKind::Function => CompletionItemKind::FUNCTION,
5027 completion::CompletionKind::Constant => CompletionItemKind::CONSTANT,
5028 }),
5029 insert_text_format: c.insert_text.as_ref().map(|_| InsertTextFormat::SNIPPET),
5032 insert_text: c.insert_text,
5033 label: c.label,
5034 detail: c.detail,
5035 documentation: c.documentation.map(|value| {
5037 Documentation::MarkupContent(MarkupContent {
5038 kind: MarkupKind::Markdown,
5039 value,
5040 })
5041 }),
5042 ..Default::default()
5043 }
5044}
5045
5046fn cursor_offset(text: &str, pos: Position) -> usize {
5052 crate::position::position_to_offset(text, pos).unwrap_or(text.len())
5053}
5054
5055#[derive(serde::Serialize, serde::Deserialize)]
5059struct SerKey {
5060 unit: String,
5061 kind: String,
5062 name: String,
5063}
5064
5065impl From<&bynk_check::index::SymbolKey> for SerKey {
5066 fn from(k: &bynk_check::index::SymbolKey) -> Self {
5067 SerKey {
5068 unit: k.unit.clone(),
5069 kind: k.kind.display().to_string(),
5070 name: k.name.clone(),
5071 }
5072 }
5073}
5074
5075impl SerKey {
5076 fn read(data: &Option<serde_json::Value>) -> Option<bynk_check::index::SymbolKey> {
5080 let sk: SerKey = serde_json::from_value(data.as_ref()?.clone()).ok()?;
5081 let kind = match sk.kind.as_str() {
5082 "type" => bynk_check::index::SymbolKind::Type,
5083 "fn" => bynk_check::index::SymbolKind::Fn,
5084 "capability" => bynk_check::index::SymbolKind::Capability,
5085 "service" => bynk_check::index::SymbolKind::Service,
5086 "agent" => bynk_check::index::SymbolKind::Agent,
5087 "provider" => bynk_check::index::SymbolKind::Provider,
5088 _ => return None,
5089 };
5090 Some(bynk_check::index::SymbolKey {
5091 unit: sk.unit,
5092 kind,
5093 name: sk.name,
5094 })
5095 }
5096}
5097
5098fn lsp_symbol_kind(kind: bynk_check::index::SymbolKind) -> SymbolKind {
5099 match kind {
5100 bynk_check::index::SymbolKind::Type => SymbolKind::STRUCT,
5101 bynk_check::index::SymbolKind::Fn => SymbolKind::FUNCTION,
5102 bynk_check::index::SymbolKind::Capability => SymbolKind::INTERFACE,
5103 bynk_check::index::SymbolKind::Service | bynk_check::index::SymbolKind::Agent => {
5104 SymbolKind::CLASS
5105 }
5106 bynk_check::index::SymbolKind::Provider => SymbolKind::OBJECT,
5107 bynk_check::index::SymbolKind::Method => SymbolKind::METHOD,
5108 bynk_check::index::SymbolKind::CapabilityOp => SymbolKind::METHOD,
5109 bynk_check::index::SymbolKind::Field => SymbolKind::FIELD,
5110 bynk_check::index::SymbolKind::Actor => SymbolKind::INTERFACE,
5111 bynk_check::index::SymbolKind::Handler => SymbolKind::METHOD,
5112 bynk_check::index::SymbolKind::Messages => SymbolKind::STRUCT,
5113 bynk_check::index::SymbolKind::Asset => SymbolKind::CONSTANT,
5116 bynk_check::index::SymbolKind::Embed => SymbolKind::CONSTANT,
5118 }
5119}
5120
5121fn is_bynk_toml(uri: &Url) -> bool {
5125 let Ok(path) = uri.to_file_path() else {
5126 return false;
5127 };
5128 path.file_name().and_then(|n| n.to_str()) == Some("bynk.toml")
5129}
5130
5131fn code_description(code: &str) -> Option<CodeDescription> {
5137 let href = Url::parse(&bynk_syntax::diagnostics::explain(code)?.href()).ok()?;
5138 Some(CodeDescription { href })
5139}
5140
5141#[cfg(test)]
5142mod code_description_tests {
5143 use super::code_description;
5144
5145 #[test]
5146 fn mapped_code_gets_a_valid_book_link() {
5147 let cd = code_description("bynk.resolve.unknown_type")
5148 .expect("a curated code produces a codeDescription");
5149 assert_eq!(cd.href.scheme(), "https");
5150 assert_eq!(cd.href.host_str(), Some("bynk-lang.org"));
5151 assert!(cd.href.path().starts_with("/book/"));
5152 }
5153
5154 #[test]
5155 fn uncurated_code_gets_no_link() {
5156 assert!(code_description("bynk.resolve.duplicate_type").is_none());
5159 assert!(code_description("bynk.not.a_real_code").is_none());
5160 }
5161
5162 #[test]
5163 fn every_curated_explanation_yields_a_parseable_url() {
5164 for e in bynk_syntax::diagnostics::EXPLANATIONS {
5167 assert!(
5168 code_description(e.code).is_some(),
5169 "curated explanation `{}` produced no codeDescription — its href \
5170 `{}` did not parse as a URL",
5171 e.code,
5172 e.href()
5173 );
5174 }
5175 }
5176}
5177
5178type ProjectFiles<'a> = std::collections::HashMap<PathBuf, (Url, crate::position::PositionMap<'a>)>;
5182
5183fn make_diagnostic(
5184 d: &bynk_ide::Diagnostic,
5185 positions: &crate::position::PositionMap,
5186 uri: &Url,
5187 files: &ProjectFiles<'_>,
5188) -> Diagnostic {
5189 let range = positions.range(d.error.span);
5190 let severity = match d.severity {
5191 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
5192 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
5193 };
5194 let mut related_information: Vec<DiagnosticRelatedInformation> = Vec::new();
5195 let mut label_notes: Vec<&str> = Vec::new();
5196 for (i, (span, msg)) in d.error.labels.iter().enumerate() {
5197 let (label_uri, label_positions) = match d.label_file(i) {
5202 bynk_ide::LabelFile::Primary => (uri, positions),
5203 bynk_ide::LabelFile::Other(path) => match files.get(path) {
5204 Some((u, p)) => (u, p),
5205 None => {
5206 label_notes.push(msg);
5207 continue;
5208 }
5209 },
5210 bynk_ide::LabelFile::FirstParty { .. } | bynk_ide::LabelFile::Unavailable => {
5212 label_notes.push(msg);
5213 continue;
5214 }
5215 };
5216 related_information.push(DiagnosticRelatedInformation {
5217 location: Location {
5218 uri: label_uri.clone(),
5219 range: label_positions.range(*span),
5220 },
5221 message: msg.clone(),
5222 });
5223 }
5224 let mut message = d.error.message.clone();
5225 for note in d.error.notes.iter().map(String::as_str).chain(label_notes) {
5226 message.push_str("\n\n");
5227 message.push_str("note: ");
5228 message.push_str(note);
5229 }
5230 Diagnostic {
5231 range,
5232 severity: Some(severity),
5233 code: Some(NumberOrString::String(d.error.category.to_string())),
5234 code_description: code_description(d.error.category),
5239 source: Some(SERVER_NAME.to_string()),
5240 message,
5241 related_information: if related_information.is_empty() {
5242 None
5243 } else {
5244 Some(related_information)
5245 },
5246 tags: None,
5247 data: None,
5248 }
5249}
5250
5251pub async fn run() -> std::process::ExitCode {
5258 if std::env::args()
5262 .skip(1)
5263 .any(|a| a == "--version" || a == "-V")
5264 {
5265 println!("{SERVER_NAME} {SERVER_VERSION}");
5266 return std::process::ExitCode::SUCCESS;
5267 }
5268 if let Some(home) = std::env::var_os("HOME") {
5271 let path: PathBuf = PathBuf::from(home).join(".bynk-lsp.log");
5272 if let Ok(file) = std::fs::OpenOptions::new()
5273 .create(true)
5274 .append(true)
5275 .open(&path)
5276 {
5277 use tracing_subscriber::prelude::*;
5278 let env_filter = tracing_subscriber::EnvFilter::try_from_env("BYNK_LSP_LOG")
5279 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
5280 let file_layer = tracing_subscriber::fmt::layer()
5281 .with_writer(std::sync::Mutex::new(file))
5282 .with_ansi(false);
5283 tracing_subscriber::registry()
5284 .with(env_filter)
5285 .with(file_layer)
5286 .try_init()
5287 .ok();
5288 }
5289 }
5290 tracing::info!("bynkc-lsp v{} starting", SERVER_VERSION);
5291 let stdout = tokio::io::stdout();
5292 let (server_input, pump_output) = tokio::io::duplex(1 << 16);
5295 tokio::spawn(transport::pump(tokio::io::stdin(), pump_output));
5296 let shutdown_requested = Arc::new(std::sync::atomic::AtomicBool::new(false));
5297 let flag = Arc::clone(&shutdown_requested);
5298 let (service, socket) =
5303 LspService::build(move |client| Backend::with_shutdown_flag(client, Arc::clone(&flag)))
5304 .custom_method("bynk/sequenceModel", Backend::sequence_model)
5305 .custom_method("bynk/agentStateModel", Backend::agent_state_model)
5306 .custom_method("bynk/documentationModel", Backend::documentation_model)
5307 .custom_method("bynk/architectureModel", Backend::architecture_model)
5308 .custom_method("bynk/wireContract", Backend::wire_contract)
5309 .custom_method("bynk/explain", Backend::explain)
5310 .finish();
5311 Server::new(server_input, stdout, socket)
5312 .serve(service)
5313 .await;
5314 if shutdown_requested.load(std::sync::atomic::Ordering::SeqCst) {
5315 std::process::ExitCode::SUCCESS
5316 } else {
5317 eprintln!(
5318 "bynkc-lsp: the session ended without a `shutdown` request \
5319 (the client closed the connection, or sent `exit` early); see ~/.bynk-lsp.log"
5320 );
5321 std::process::ExitCode::FAILURE
5322 }
5323}
5324
5325#[cfg(test)]
5326mod tests {
5327 use super::*;
5328
5329 struct Scratch(PathBuf);
5345 impl Drop for Scratch {
5346 fn drop(&mut self) {
5347 let _ = std::fs::remove_dir_all(&self.0);
5348 }
5349 }
5350
5351 fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
5352 let dir = std::env::temp_dir().join(format!(
5353 "bynk_lsp_sliceA_{tag}_{}_{:?}",
5354 std::process::id(),
5355 std::thread::current().id()
5356 ));
5357 let _ = std::fs::remove_dir_all(&dir);
5358 for (rel, body) in files {
5359 let p = dir.join(rel);
5360 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
5361 std::fs::write(&p, body).unwrap();
5362 }
5363 Scratch(dir)
5364 }
5365
5366 async fn backend_at(root: &std::path::Path) -> Backend {
5372 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
5373 let backend = service.inner().clone();
5374 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
5377 {
5378 let mut state = backend.state.write().await;
5379 state.folders.push(canonical.clone());
5380 state.projects.insert(
5381 canonical.clone(),
5382 ProjectState {
5383 config: project::load_config(&canonical).unwrap_or_default(),
5384 ..Default::default()
5385 },
5386 );
5387 }
5388 backend
5389 }
5390
5391 #[tokio::test]
5395 async fn a_panicking_round_tells_the_client_once_and_clears_on_success() {
5396 use futures::StreamExt;
5397 use tower::{Service, ServiceExt};
5398
5399 let s = scratch_project(
5400 "panic",
5401 &[
5402 ("bynk.toml", "[project]\nname = \"panic\"\n"),
5403 ("src/thing.bynk", "context thing\n"),
5404 ],
5405 );
5406 let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5407 let initialize = tower_lsp::jsonrpc::Request::build("initialize")
5409 .params(serde_json::json!({ "capabilities": {} }))
5410 .id(1)
5411 .finish();
5412 service
5413 .ready()
5414 .await
5415 .unwrap()
5416 .call(initialize)
5417 .await
5418 .unwrap();
5419 let initialized = tower_lsp::jsonrpc::Request::build("initialized")
5420 .params(serde_json::json!({}))
5421 .finish();
5422 service
5423 .ready()
5424 .await
5425 .unwrap()
5426 .call(initialized)
5427 .await
5428 .unwrap();
5429 let shown = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
5433 let drain = {
5434 let shown = Arc::clone(&shown);
5435 tokio::spawn(socket.for_each(move |message| {
5436 if message.method() == "window/showMessage" {
5437 shown
5438 .lock()
5439 .unwrap()
5440 .push(format!("{:?}", message.params()));
5441 }
5442 async {}
5443 }))
5444 };
5445 let backend = service.inner().clone();
5446 let canonical = s.0.canonicalize().unwrap();
5447 backend
5448 .state
5449 .write()
5450 .await
5451 .projects
5452 .insert(canonical.clone(), ProjectState::default());
5453 let failed = |b: &Backend| {
5454 let b = b.clone();
5455 let root = canonical.clone();
5456 async move { b.state.read().await.projects[&root].analysis_failed }
5457 };
5458
5459 let inject = |b: &Backend| {
5460 let b = b.clone();
5461 let root = canonical.clone();
5462 async move {
5463 b.state
5464 .write()
5465 .await
5466 .projects
5467 .get_mut(&root)
5468 .unwrap()
5469 .panic_next_round = true;
5470 }
5471 };
5472 inject(&backend).await;
5473 backend.run_project_diagnostics(canonical.clone()).await;
5474 assert!(failed(&backend).await, "the failed round is recorded");
5475 inject(&backend).await;
5477 backend.run_project_diagnostics(canonical.clone()).await;
5478
5479 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5480 {
5481 let shown = shown.lock().unwrap();
5482 assert_eq!(shown.len(), 1, "told once per streak: {shown:?}");
5483 assert!(shown[0].contains("internal error"), "{shown:?}");
5484 }
5485
5486 backend.run_project_diagnostics(canonical.clone()).await;
5487 assert!(!failed(&backend).await, "a clean round ends the streak");
5488 inject(&backend).await;
5490 backend.run_project_diagnostics(canonical.clone()).await;
5491 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5492 assert_eq!(shown.lock().unwrap().len(), 2, "a new streak is told again");
5493 drain.abort();
5494 }
5495
5496 #[tokio::test]
5502 async fn a_label_in_a_sibling_file_points_at_that_file() {
5503 use futures::StreamExt;
5504 use tower::{Service, ServiceExt};
5505
5506 let service_src =
5507 "service api {\n on call() -> Effect[Int] {\n Effect.pure(1)\n }\n}\n";
5508 let s = scratch_project(
5509 "sibling_label",
5510 &[
5511 ("bynk.toml", "[project]\nname = \"demo\"\n"),
5512 (
5513 "src/app/a_web.bynk",
5514 &format!("context app\n\n{service_src}"),
5515 ),
5516 (
5517 "src/app/z_more.bynk",
5518 &format!("context app\n\n\n\n\n{service_src}"),
5519 ),
5520 ],
5521 );
5522 let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5523 for request in [
5524 tower_lsp::jsonrpc::Request::build("initialize")
5525 .params(serde_json::json!({ "capabilities": {} }))
5526 .id(1)
5527 .finish(),
5528 tower_lsp::jsonrpc::Request::build("initialized")
5529 .params(serde_json::json!({}))
5530 .finish(),
5531 ] {
5532 service.ready().await.unwrap().call(request).await.unwrap();
5533 }
5534 let published = Arc::new(std::sync::Mutex::new(Vec::<PublishDiagnosticsParams>::new()));
5535 let drain = {
5536 let published = Arc::clone(&published);
5537 tokio::spawn(socket.for_each(move |message| {
5538 if message.method() == "textDocument/publishDiagnostics"
5539 && let Some(params) = message.params()
5540 {
5541 published
5542 .lock()
5543 .unwrap()
5544 .push(serde_json::from_value(params.clone()).unwrap());
5545 }
5546 async {}
5547 }))
5548 };
5549 let backend = service.inner().clone();
5550 let canonical = s.0.canonicalize().unwrap();
5551 backend
5552 .state
5553 .write()
5554 .await
5555 .projects
5556 .insert(canonical.clone(), ProjectState::default());
5557 backend.run_project_diagnostics(canonical.clone()).await;
5558 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5559 drain.abort();
5560
5561 let a_web = Url::from_file_path(canonical.join("src/app/a_web.bynk")).unwrap();
5562 let z_more = Url::from_file_path(canonical.join("src/app/z_more.bynk")).unwrap();
5563 let published = published.lock().unwrap();
5564 let duplicate = published
5565 .iter()
5566 .filter(|p| p.uri == z_more)
5567 .flat_map(|p| &p.diagnostics)
5568 .find(|d| {
5569 d.code
5570 == Some(NumberOrString::String(
5571 "bynk.resolve.duplicate_service".into(),
5572 ))
5573 })
5574 .unwrap_or_else(|| panic!("z_more.bynk must report the duplicate: {published:?}"));
5575 let related = duplicate
5576 .related_information
5577 .as_deref()
5578 .expect("the duplicate carries its \"previously declared here\" label");
5579 assert_eq!(related.len(), 1, "{related:?}");
5580 assert_eq!(related[0].message, "previously declared here");
5581 assert_eq!(
5582 related[0].location.uri, a_web,
5583 "the label lies in a_web.bynk, not the diagnostic's own file"
5584 );
5585 assert_eq!(
5587 related[0].location.range,
5588 Range::new(Position::new(2, 8), Position::new(2, 11)),
5589 );
5590 }
5591
5592 #[tokio::test]
5598 async fn a_round_reports_a_non_additive_event_change_against_the_lock() {
5599 use futures::StreamExt;
5600 use tower::{Service, ServiceExt};
5601
5602 const CODE: &str = "bynk.event.non_additive_schema_change";
5603 const LOCK_V1: &str = "version = 1\n\n\
5604 [events.\"commerce.order.PaymentConfirmed\"]\nschema = 1\n\n\
5605 [[events.\"commerce.order.PaymentConfirmed\".fields]]\n\
5606 name = \"orderId\"\ntype = \"String\"\ndefault = false\n";
5607 const LOCK_V2: &str = "version = 1\n\n\
5609 [events.\"commerce.order.PaymentConfirmed\"]\nschema = 1\n\n\
5610 [[events.\"commerce.order.PaymentConfirmed\".fields]]\n\
5611 name = \"orderId\"\ntype = \"String\"\ndefault = false\n\n\
5612 [[events.\"commerce.order.PaymentConfirmed\".fields]]\n\
5613 name = \"region\"\ntype = \"String\"\ndefault = false\n";
5614 const SOURCE: &str = "context commerce.order\n\n\
5616 consumes bynk { Events }\n\n\
5617 event PaymentConfirmed = {\n orderId: String,\n region: String,\n}\n\n\
5618 service markPaid {\n on call(orderId: String) -> Effect[()] given Events {\n \
5619 Events.emit[PaymentConfirmed](PaymentConfirmed { orderId: orderId, region: \"US\" })\n \
5620 }\n}\n";
5621
5622 let s = scratch_project(
5623 "schema_lock",
5624 &[
5625 ("bynk.toml", "[project]\nname = \"shop\"\n"),
5626 ("src/commerce/order.bynk", SOURCE),
5627 ("bynk.schema.lock", LOCK_V1),
5628 ],
5629 );
5630 let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5631 for request in [
5632 tower_lsp::jsonrpc::Request::build("initialize")
5633 .params(serde_json::json!({ "capabilities": {} }))
5634 .id(1)
5635 .finish(),
5636 tower_lsp::jsonrpc::Request::build("initialized")
5637 .params(serde_json::json!({}))
5638 .finish(),
5639 ] {
5640 service.ready().await.unwrap().call(request).await.unwrap();
5641 }
5642 let published = Arc::new(std::sync::Mutex::new(Vec::<PublishDiagnosticsParams>::new()));
5643 let drain = {
5644 let published = Arc::clone(&published);
5645 tokio::spawn(socket.for_each(move |message| {
5646 if message.method() == "textDocument/publishDiagnostics"
5647 && let Some(params) = message.params()
5648 {
5649 published
5650 .lock()
5651 .unwrap()
5652 .push(serde_json::from_value(params.clone()).unwrap());
5653 }
5654 async {}
5655 }))
5656 };
5657 let backend = service.inner().clone();
5658 let canonical = s.0.canonicalize().unwrap();
5659 backend
5660 .state
5661 .write()
5662 .await
5663 .projects
5664 .insert(canonical.clone(), ProjectState::default());
5665 let manifest = Url::from_file_path(canonical.join("bynk.toml")).unwrap();
5667 let reported = |published: &[PublishDiagnosticsParams]| {
5668 published
5669 .iter()
5670 .rfind(|p| p.uri == manifest)
5671 .is_some_and(|p| {
5672 p.diagnostics
5673 .iter()
5674 .any(|d| d.code == Some(NumberOrString::String(CODE.into())))
5675 })
5676 };
5677
5678 backend.run_project_diagnostics(canonical.clone()).await;
5679 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5680 assert!(
5681 reported(&published.lock().unwrap()),
5682 "the round must report {CODE} against the committed lock: {:?}",
5683 published.lock().unwrap()
5684 );
5685 assert_eq!(
5686 std::fs::read_to_string(canonical.join("bynk.schema.lock")).unwrap(),
5687 LOCK_V1,
5688 "the editor must never write the lock"
5689 );
5690
5691 std::fs::write(canonical.join("bynk.schema.lock"), LOCK_V2).unwrap();
5694 backend.run_project_diagnostics(canonical.clone()).await;
5695 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5696 assert!(
5697 !reported(&published.lock().unwrap()),
5698 "a lock matching the source must clear {CODE}: {:?}",
5699 published.lock().unwrap()
5700 );
5701
5702 std::fs::write(canonical.join("bynk.schema.lock"), LOCK_V1).unwrap();
5704 backend.run_project_diagnostics(canonical.clone()).await;
5705 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5706 assert!(
5707 reported(&published.lock().unwrap()),
5708 "the old lock is read again"
5709 );
5710 std::fs::remove_file(canonical.join("bynk.schema.lock")).unwrap();
5712 backend.run_project_diagnostics(canonical.clone()).await;
5713 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5714 drain.abort();
5715 assert!(
5716 !reported(&published.lock().unwrap()),
5717 "with no lock there is nothing to report: {:?}",
5718 published.lock().unwrap()
5719 );
5720 assert!(
5721 !canonical.join("bynk.schema.lock").exists(),
5722 "the editor must not create a lock"
5723 );
5724 }
5725
5726 #[tokio::test]
5731 async fn a_round_reports_a_non_additive_agent_state_change_against_the_lock() {
5732 use futures::StreamExt;
5733 use tower::{Service, ServiceExt};
5734
5735 const CODE: &str = "bynk.agent.non_additive_state_change";
5736 const LOCK: &str = "version = 2\n\n[events]\n\n\
5737 [agents.\"shop.Cart\"]\nversion = 1\nfloor = 1\n\n\
5738 [[agents.\"shop.Cart\".shapes]]\nversion = 1\nadditive = false\n\n\
5739 [[agents.\"shop.Cart\".shapes.fields]]\n\
5740 name = \"owner\"\nkind = \"Cell\"\ntype = \"String\"\nrefinement = \"String\"\n";
5741 const SOURCE: &str = "context shop\n\nagent Cart {\n key id: String\n\n \
5742 store ownerName: Cell[String]\n\n on call ping() -> Effect[Int] {\n 1\n }\n}\n";
5743
5744 let s = scratch_project(
5745 "agent_schema_lock",
5746 &[
5747 ("bynk.toml", "[project]\nname = \"shop\"\n"),
5748 ("src/shop/cart.bynk", SOURCE),
5749 ("bynk.schema.lock", LOCK),
5750 ],
5751 );
5752 let (mut service, socket) = tower_lsp::LspService::new(Backend::new);
5753 for request in [
5754 tower_lsp::jsonrpc::Request::build("initialize")
5755 .params(serde_json::json!({ "capabilities": {} }))
5756 .id(1)
5757 .finish(),
5758 tower_lsp::jsonrpc::Request::build("initialized")
5759 .params(serde_json::json!({}))
5760 .finish(),
5761 ] {
5762 service.ready().await.unwrap().call(request).await.unwrap();
5763 }
5764 let published = Arc::new(std::sync::Mutex::new(Vec::<PublishDiagnosticsParams>::new()));
5765 let drain = {
5766 let published = Arc::clone(&published);
5767 tokio::spawn(socket.for_each(move |message| {
5768 if message.method() == "textDocument/publishDiagnostics"
5769 && let Some(params) = message.params()
5770 {
5771 published
5772 .lock()
5773 .unwrap()
5774 .push(serde_json::from_value(params.clone()).unwrap());
5775 }
5776 async {}
5777 }))
5778 };
5779 let backend = service.inner().clone();
5780 let canonical = s.0.canonicalize().unwrap();
5781 backend
5782 .state
5783 .write()
5784 .await
5785 .projects
5786 .insert(canonical.clone(), ProjectState::default());
5787 let source = Url::from_file_path(canonical.join("src/shop/cart.bynk")).unwrap();
5788
5789 backend.run_project_diagnostics(canonical.clone()).await;
5790 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5791 drain.abort();
5792 let published = published.lock().unwrap();
5793 assert!(
5794 published
5795 .iter()
5796 .rfind(|p| p.uri == source)
5797 .is_some_and(|p| p
5798 .diagnostics
5799 .iter()
5800 .any(|d| d.code == Some(NumberOrString::String(CODE.into())))),
5801 "the round must report {CODE} on the agent's file: {published:?}"
5802 );
5803 assert_eq!(
5804 std::fs::read_to_string(canonical.join("bynk.schema.lock")).unwrap(),
5805 LOCK,
5806 "the editor must never write the lock"
5807 );
5808 }
5809
5810 #[test]
5814 fn a_label_with_no_file_to_point_at_becomes_a_note() {
5815 let text = "context app\n";
5816 let positions = crate::position::PositionMap::new(text);
5817 let uri = Url::parse("file:///z.bynk").unwrap();
5818 let d = bynk_ide::Diagnostic {
5819 severity: bynk_syntax::Severity::Error,
5820 error: bynk_syntax::CompileError::new(
5821 "bynk.test",
5822 bynk_syntax::span::Span::new(0, 7),
5823 "msg",
5824 )
5825 .with_label(bynk_syntax::span::Span::new(0, 1), "in an injected unit")
5826 .with_label(bynk_syntax::span::Span::new(0, 1), "in an unanalysed file")
5827 .with_label(bynk_syntax::span::Span::new(0, 1), "in no known file"),
5828 label_files: vec![
5829 bynk_ide::LabelFile::FirstParty {
5830 identity_path: "bynk.bynk",
5831 source: "commons bynk\n",
5832 },
5833 bynk_ide::LabelFile::Other(PathBuf::from("src/missing.bynk")),
5834 bynk_ide::LabelFile::Unavailable,
5835 ],
5836 };
5837 let diagnostic = make_diagnostic(&d, &positions, &uri, &ProjectFiles::new());
5838 assert!(
5839 diagnostic.related_information.is_none(),
5840 "{:?}",
5841 diagnostic.related_information
5842 );
5843 assert_eq!(
5844 diagnostic.message,
5845 "msg\n\nnote: in an injected unit\n\nnote: in an unanalysed file\n\nnote: in no known file"
5846 );
5847 }
5848
5849 impl Backend {
5853 async fn test_root(&self) -> PathBuf {
5854 self.state
5855 .read()
5856 .await
5857 .projects
5858 .keys()
5859 .next()
5860 .cloned()
5861 .expect("a test project entry")
5862 }
5863 async fn run_round(&self) {
5864 let root = self.test_root().await;
5865 self.run_project_diagnostics(root).await;
5866 }
5867 async fn test_analysis(&self) -> Option<Arc<Analysis>> {
5868 let root = self.test_root().await;
5869 self.project_analysis(&root).await
5870 }
5871 async fn test_round_started(&self) -> u64 {
5872 let root = self.test_root().await;
5873 self.state
5874 .read()
5875 .await
5876 .projects
5877 .get(&root)
5878 .map(|p| p.analysis_round_started)
5879 .unwrap_or(0)
5880 }
5881 }
5882
5883 #[tokio::test]
5888 async fn a_round_covers_every_include_tree() {
5889 let s = scratch_project(
5890 "round",
5891 &[
5892 ("bynk.toml", "[project]\nname = \"round\"\n"),
5893 ("src/thing.bynk", "context thing\n"),
5894 ("tests/thing.bynk", "suite thing\n"),
5896 ],
5897 );
5898 let backend = backend_at(&s.0).await;
5899 backend.run_round().await;
5900
5901 let analysis = backend.test_analysis().await.expect("a round committed");
5902 let mut keys: Vec<String> = analysis
5903 .snapshots
5904 .keys()
5905 .map(|p| p.to_string_lossy().replace('\\', "/"))
5906 .collect();
5907 keys.sort();
5908 assert_eq!(
5909 keys,
5910 vec!["src/thing.bynk", "tests/thing.bynk"],
5911 "the round must cover both include trees, with distinct identities",
5912 );
5913 }
5914
5915 #[tokio::test]
5919 async fn a_uri_in_any_include_tree_resolves_to_its_analysed_file() {
5920 let s = scratch_project(
5921 "uri",
5922 &[
5923 ("bynk.toml", "[project]\nname = \"uri\"\n"),
5924 ("src/thing.bynk", "context thing\n"),
5925 ("tests/thing.bynk", "suite thing\n"),
5926 ],
5927 );
5928 let backend = backend_at(&s.0).await;
5929 backend.run_round().await;
5930 let analysis = backend.test_analysis().await.expect("round");
5931
5932 for (rel, label) in [
5933 ("src/thing.bynk", "primary"),
5934 ("tests/thing.bynk", "secondary"),
5935 ] {
5936 let abs = s.0.join(rel);
5937 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
5938 let resolved = Backend::uri_to_rel(&analysis, &uri)
5939 .unwrap_or_else(|| panic!("{label} root URI must resolve"));
5940 assert_eq!(
5941 resolved.to_string_lossy().replace('\\', "/"),
5942 rel,
5943 "a {label}-tree URI must name its own analysed file",
5944 );
5945 assert!(
5946 analysis.snapshots.contains_key(&resolved),
5947 "…and that file must be in the round",
5948 );
5949 }
5950 }
5951
5952 #[tokio::test]
5958 async fn project_content_excludes_the_calling_file() {
5959 let s = scratch_project(
5960 "self_excl",
5961 &[
5962 ("bynk.toml", "[project]\nname = \"self_excl\"\n"),
5963 ("a.bynk", "context a\n"),
5964 ("b.bynk", "context b\n"),
5965 ],
5966 );
5967 let backend = backend_at(&s.0).await;
5968 let abs = s.0.join("a.bynk");
5969 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
5970 let content = backend
5971 .project_content(&uri)
5972 .await
5973 .expect("a project root resolves to a content map");
5974 assert!(
5975 !content.keys().any(|p| p.file_name().unwrap() == "a.bynk"),
5976 "the calling file's own path must not be in its own project content map: {content:?}"
5977 );
5978 assert!(
5979 content.keys().any(|p| p.file_name().unwrap() == "b.bynk"),
5980 "a sibling project file must still be present: {content:?}"
5981 );
5982 assert_eq!(
5983 content
5984 .iter()
5985 .find(|(p, _)| p.file_name().unwrap() == "b.bynk")
5986 .map(|(_, text)| text.as_str()),
5987 Some("context b\n"),
5988 "the sibling's content must be the real file content, not just its path"
5989 );
5990 }
5991
5992 #[tokio::test]
5996 async fn an_excluded_tree_is_not_analysed() {
5997 let s = scratch_project(
5998 "excl",
5999 &[
6000 (
6001 "bynk.toml",
6002 "[project]\nname = \"excl\"\n\n[paths]\ninclude = [\".\"]\nexclude = [\"generated\"]\n",
6003 ),
6004 ("a.bynk", "context a\n"),
6005 ("generated/gen.bynk", "context gen\n"),
6006 ],
6007 );
6008 let backend = backend_at(&s.0).await;
6009 backend.run_round().await;
6010 let analysis = backend.test_analysis().await.expect("round");
6011 let keys: Vec<String> = analysis
6012 .snapshots
6013 .keys()
6014 .map(|p| p.to_string_lossy().replace('\\', "/"))
6015 .collect();
6016 assert_eq!(keys, vec!["a.bynk"], "excluded trees stay out of the round");
6017 }
6018
6019 #[tokio::test]
6024 async fn references_resolve_in_the_vscode_fixture_layout() {
6025 let s = scratch_project(
6026 "vsc",
6027 &[
6028 (
6029 "bynk.toml",
6030 "[project]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[paths]\nsrc = \"src\"\ntests = \"tests\"\n",
6031 ),
6032 (
6033 "src/text.bynk",
6034 "commons fixture.text\n\nfn shout(s: String) -> String {\n s\n}\n\nfn greet(name: String) -> String {\n \"Hi, \\(shout(name))!\"\n}\n",
6035 ),
6036 ],
6037 );
6038 let (root, config) = Backend::resolve_root(&s.0).expect("bynk.toml is present");
6040 assert_eq!(root, s.0, "the manifest's directory is the project root");
6041
6042 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
6043 let backend = service.inner().clone();
6044 {
6045 let mut st = backend.state.write().await;
6046 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
6049 st.projects.insert(
6050 canonical,
6051 ProjectState {
6052 config,
6053 ..Default::default()
6054 },
6055 );
6056 }
6057 backend.run_round().await;
6058
6059 let analysis = backend.test_analysis().await.expect("a round committed");
6060 let keys: Vec<String> = analysis
6061 .snapshots
6062 .keys()
6063 .map(|p| p.to_string_lossy().replace('\\', "/"))
6064 .collect();
6065 assert_eq!(keys, vec!["src/text.bynk"], "the fixture file is analysed");
6066
6067 let abs = s.0.join("src/text.bynk");
6069 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
6070 let rel = Backend::uri_to_rel(&analysis, &uri).expect("URI resolves into the round");
6071 assert_eq!(rel, PathBuf::from("src/text.bynk"));
6072
6073 let text = analysis.snapshots.get(&rel).expect("snapshot present");
6075 let decl = text.find("shout").expect("`shout` in source");
6076 let pos = crate::position::offset_to_position(text, decl);
6077
6078 let refs = backend
6079 .references(ReferenceParams {
6080 text_document_position: TextDocumentPositionParams {
6081 text_document: TextDocumentIdentifier { uri: uri.clone() },
6082 position: pos,
6083 },
6084 work_done_progress_params: Default::default(),
6085 partial_result_params: Default::default(),
6086 context: ReferenceContext {
6087 include_declaration: true,
6088 },
6089 })
6090 .await
6091 .expect("references must not error");
6092 let found = refs.unwrap_or_default();
6093 assert!(
6094 !found.is_empty(),
6095 "`shout` is referenced by `greet` — references must resolve; got none",
6096 );
6097 }
6098
6099 async fn open_round_edit(
6109 backend: &Backend,
6110 root: &std::path::Path,
6111 v1_text: &str,
6112 v2_text: &str,
6113 ) -> (Url, PathBuf) {
6114 let abs = root.join("src/a.bynk");
6115 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
6116
6117 backend
6118 .did_open(DidOpenTextDocumentParams {
6119 text_document: TextDocumentItem {
6120 uri: uri.clone(),
6121 language_id: "bynk".into(),
6122 version: 1,
6123 text: v1_text.to_string(),
6124 },
6125 })
6126 .await;
6127 backend.run_round().await;
6128
6129 let a1 = backend.test_analysis().await.expect("round 1");
6131 let rel = Backend::uri_to_rel(&a1, &uri).expect("uri resolves");
6132 assert_eq!(a1.versions.get(&rel), Some(&1), "round 1 is version 1");
6133
6134 backend
6137 .did_change(DidChangeTextDocumentParams {
6138 text_document: VersionedTextDocumentIdentifier {
6139 uri: uri.clone(),
6140 version: 2,
6141 },
6142 content_changes: vec![TextDocumentContentChangeEvent {
6143 range: None,
6144 range_length: None,
6145 text: v2_text.to_string(),
6146 }],
6147 })
6148 .await;
6149 (uri, rel)
6150 }
6151
6152 #[tokio::test]
6158 async fn a_position_after_an_edit_resolves_against_the_new_text() {
6159 let v1 = "commons q.a\n\nfn target(x: Int) -> Int {\n x\n}\n";
6160 let v2 = format!("\n{v1}");
6162 let s = scratch_project(
6163 "fresh_hd",
6164 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6165 );
6166 let backend = backend_at(&s.0).await;
6167 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6168
6169 let a = backend.analysis_for(&uri).await.expect("current analysis");
6171 assert_eq!(a.versions.get(&rel), Some(&2), "gate refreshed to the edit");
6172 assert_eq!(a.snapshots.get(&rel).map(String::as_str), Some(v2.as_str()));
6173
6174 let off_v2 = v2.find("target").unwrap();
6176 let new_pos = crate::position::offset_to_position(&v2, off_v2);
6177 let (a2, rel2, off) = backend
6178 .index_position(&uri, new_pos)
6179 .await
6180 .expect("position resolves");
6181 assert!(
6182 a2.snapshots.get(&rel2).unwrap()[off..].starts_with("target"),
6183 "the new position must land on `target` in the current snapshot — \
6184 the whole point of refreshing",
6185 );
6186 }
6187
6188 #[tokio::test]
6192 async fn a_stale_round_is_never_served() {
6193 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
6194 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
6195 let s = scratch_project(
6196 "fresh_stale",
6197 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6198 );
6199 let backend = backend_at(&s.0).await;
6200 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6201
6202 let cached = backend.test_analysis().await.unwrap();
6204 assert_eq!(cached.versions.get(&rel), Some(&1), "cached round is stale");
6205
6206 let a = backend.analysis_for(&uri).await.unwrap();
6208 assert_eq!(
6209 a.versions.get(&rel),
6210 Some(&2),
6211 "analysis_for must refresh past a stale cached round, never serve it",
6212 );
6213 }
6214
6215 #[tokio::test]
6222 async fn committed_analysis_serves_the_stale_round_without_refreshing() {
6223 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
6224 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
6225 let s = scratch_project(
6226 "fresh_committed",
6227 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6228 );
6229 let backend = backend_at(&s.0).await;
6230 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6231
6232 assert_eq!(
6234 backend.test_analysis().await.unwrap().versions.get(&rel),
6235 Some(&1),
6236 "cached round is stale",
6237 );
6238
6239 let a = backend
6241 .committed_analysis(&uri)
6242 .await
6243 .expect("committed round");
6244 assert_eq!(
6245 a.versions.get(&rel),
6246 Some(&1),
6247 "committed_analysis serves the committed round, stale and all",
6248 );
6249 assert_eq!(
6251 backend.test_analysis().await.unwrap().versions.get(&rel),
6252 Some(&1),
6253 "committed_analysis must not trigger a refresh",
6254 );
6255 }
6256
6257 #[tokio::test]
6261 async fn concurrent_requests_after_one_edit_share_one_round() {
6262 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
6263 let v2 = format!("\n{v1}");
6264 let s = scratch_project(
6265 "fresh_coal",
6266 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6267 );
6268 let backend = backend_at(&s.0).await;
6269 let (uri, _rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
6270
6271 let started_before = backend.test_round_started().await;
6272
6273 let calls = (0..5).map(|_| {
6275 let b = backend.clone();
6276 let u = uri.clone();
6277 tokio::spawn(async move { b.analysis_for(&u).await.is_some() })
6278 });
6279 for c in calls {
6280 assert!(
6281 c.await.unwrap(),
6282 "each concurrent request must get an analysis"
6283 );
6284 }
6285
6286 let started_after = backend.test_round_started().await;
6287 assert_eq!(
6288 started_after - started_before,
6289 1,
6290 "five concurrent requests after one edit must share ONE round, not run five",
6291 );
6292 }
6293
6294 #[tokio::test]
6297 async fn a_file_outside_the_project_is_declined() {
6298 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
6299 let s = scratch_project(
6300 "fresh_out",
6301 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
6302 );
6303 let backend = backend_at(&s.0).await;
6304 backend.run_round().await;
6306
6307 let outside = s.0.join("elsewhere.bynk");
6309 std::fs::write(&outside, v1).unwrap();
6310 let uri = Url::from_file_path(outside.canonicalize().unwrap()).unwrap();
6311 backend
6312 .did_open(DidOpenTextDocumentParams {
6313 text_document: TextDocumentItem {
6314 uri: uri.clone(),
6315 language_id: "bynk".into(),
6316 version: 1,
6317 text: v1.to_string(),
6318 },
6319 })
6320 .await;
6321
6322 assert!(
6323 backend.analysis_for(&uri).await.is_none(),
6324 "a file outside the include roots is never a snapshot key — decline, \
6325 don't serve a round that doesn't cover it",
6326 );
6327 }
6328
6329 #[tokio::test]
6337 async fn a_multi_file_rename_stamps_a_dirty_non_cursor_file_at_its_current_version() {
6338 let util = "commons demo.util\n\ntype Money = Int where Positive\n";
6339 let thing = "commons demo.thing\n\nuses demo.util\n\nfn f(m: Money) -> Money {\n m\n}\n";
6340 let s = scratch_project(
6341 "rename_multi",
6342 &[
6343 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6344 ("src/demo/util.bynk", util),
6345 ("src/demo/thing.bynk", thing),
6346 ],
6347 );
6348 let backend = backend_at(&s.0).await;
6349 let uri = |rel: &str| {
6350 let abs = s.0.join(rel);
6351 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
6352 };
6353 let util_uri = uri("src/demo/util.bynk");
6354 let thing_uri = uri("src/demo/thing.bynk");
6355
6356 for (u, text) in [(&util_uri, util), (&thing_uri, thing)] {
6357 backend
6358 .did_open(DidOpenTextDocumentParams {
6359 text_document: TextDocumentItem {
6360 uri: u.clone(),
6361 language_id: "bynk".into(),
6362 version: 1,
6363 text: text.to_string(),
6364 },
6365 })
6366 .await;
6367 }
6368 backend.run_round().await;
6369
6370 let thing_v2 = format!("\n{thing}");
6373 backend
6374 .did_change(DidChangeTextDocumentParams {
6375 text_document: VersionedTextDocumentIdentifier {
6376 uri: thing_uri.clone(),
6377 version: 2,
6378 },
6379 content_changes: vec![TextDocumentContentChangeEvent {
6380 range: None,
6381 range_length: None,
6382 text: thing_v2.clone(),
6383 }],
6384 })
6385 .await;
6386
6387 let money_off = util.find("Money").unwrap();
6389 let pos = crate::position::offset_to_position(util, money_off);
6390 let edit = backend
6391 .rename(RenameParams {
6392 text_document_position: TextDocumentPositionParams {
6393 text_document: TextDocumentIdentifier {
6394 uri: util_uri.clone(),
6395 },
6396 position: pos,
6397 },
6398 new_name: "Amount".into(),
6399 work_done_progress_params: Default::default(),
6400 })
6401 .await
6402 .expect("rename must not error")
6403 .expect("rename must produce edits");
6404
6405 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6406 panic!("expected document-change edits");
6407 };
6408 let thing_edit = edits
6409 .iter()
6410 .find(|e| e.text_document.uri == thing_uri)
6411 .expect("the rename must edit `thing`, which references the symbol");
6412 assert_eq!(
6413 thing_edit.text_document.version,
6414 Some(2),
6415 "the dirty non-cursor file's edit must carry its current version (2), \
6416 not the stale round's (1) — else the client rejects the whole rename",
6417 );
6418 }
6419
6420 #[tokio::test]
6425 async fn will_rename_files_updates_the_declaration_and_every_reference() {
6426 let charge = "commons billing.charge\n\ntype ChargeId = Int where Positive\n";
6427 let main = "context app.main\n\nuses billing.charge\n";
6428 let s = scratch_project(
6429 "will_rename_basic",
6430 &[
6431 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6432 ("src/billing/charge.bynk", charge),
6433 ("src/app/main.bynk", main),
6434 ],
6435 );
6436 let backend = backend_at(&s.0).await;
6437 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6438 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6439 let old_uri = uri("src/billing/charge.bynk");
6440 let new_uri = uri("src/billing/pay.bynk");
6441 let main_uri = uri("src/app/main.bynk");
6442
6443 backend.run_round().await;
6444
6445 let edit = backend
6446 .will_rename_files(RenameFilesParams {
6447 files: vec![FileRename {
6448 old_uri: old_uri.to_string(),
6449 new_uri: new_uri.to_string(),
6450 }],
6451 })
6452 .await
6453 .expect("will_rename_files must not error")
6454 .expect("must produce edits");
6455
6456 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6457 panic!("expected document-change edits");
6458 };
6459
6460 let own = edits
6461 .iter()
6462 .find(|e| e.text_document.uri == old_uri)
6463 .expect("the moved file's own declaration must be rewritten");
6464 assert_eq!(own.edits.len(), 1);
6465 let OneOf::Left(own_edit) = &own.edits[0] else {
6466 panic!("expected a plain TextEdit");
6467 };
6468 assert_eq!(own_edit.new_text, "billing.pay");
6469
6470 let referencer = edits
6471 .iter()
6472 .find(|e| e.text_document.uri == main_uri)
6473 .expect("the referencing file must be rewritten");
6474 assert_eq!(referencer.edits.len(), 1);
6475 let OneOf::Left(ref_edit) = &referencer.edits[0] else {
6476 panic!("expected a plain TextEdit");
6477 };
6478 assert_eq!(ref_edit.new_text, "billing.pay");
6479 }
6480
6481 #[tokio::test]
6485 async fn will_rename_files_is_a_noop_for_a_multi_file_unit_member() {
6486 let s = scratch_project(
6487 "will_rename_multi_file_noop",
6488 &[
6489 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6490 (
6491 "src/billing/charge/one.bynk",
6492 "context billing.charge\n\ntype ChargeId = Int where Positive\n",
6493 ),
6494 (
6495 "src/billing/charge/two.bynk",
6496 "context billing.charge\n\ntype PaymentId = Int where Positive\n",
6497 ),
6498 ],
6499 );
6500 let backend = backend_at(&s.0).await;
6501 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6502 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6503
6504 backend.run_round().await;
6505
6506 let edit = backend
6507 .will_rename_files(RenameFilesParams {
6508 files: vec![FileRename {
6509 old_uri: uri("src/billing/charge/one.bynk").to_string(),
6510 new_uri: uri("src/billing/charge/renamed.bynk").to_string(),
6511 }],
6512 })
6513 .await
6514 .expect("will_rename_files must not error");
6515 assert!(
6516 edit.is_none(),
6517 "renaming a member file within the same directory must not edit anything"
6518 );
6519 }
6520
6521 #[tokio::test]
6525 async fn will_rename_files_is_a_noop_for_a_suite() {
6526 let s = scratch_project(
6527 "will_rename_suite_noop",
6528 &[
6529 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6530 (
6531 "src/billing/charge.bynk",
6532 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6533 ),
6534 ("tests/billing_charge.bynk", "suite billing.charge\n"),
6535 ],
6536 );
6537 let backend = backend_at(&s.0).await;
6538 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6539 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6540
6541 backend.run_round().await;
6542
6543 let edit = backend
6544 .will_rename_files(RenameFilesParams {
6545 files: vec![FileRename {
6546 old_uri: uri("tests/billing_charge.bynk").to_string(),
6547 new_uri: uri("tests/billing_charge_renamed.bynk").to_string(),
6548 }],
6549 })
6550 .await
6551 .expect("will_rename_files must not error");
6552 assert!(edit.is_none(), "a suite rename must produce no edits");
6553 }
6554
6555 #[tokio::test]
6560 async fn will_rename_files_updates_a_suite_s_target_reference() {
6561 let s = scratch_project(
6562 "will_rename_suite_target",
6563 &[
6564 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6565 (
6566 "src/billing/charge.bynk",
6567 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6568 ),
6569 ("tests/billing_charge.bynk", "suite billing.charge\n"),
6570 ],
6571 );
6572 let backend = backend_at(&s.0).await;
6573 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6574 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6575 let suite_uri = uri("tests/billing_charge.bynk");
6576
6577 backend.run_round().await;
6578
6579 let edit = backend
6580 .will_rename_files(RenameFilesParams {
6581 files: vec![FileRename {
6582 old_uri: uri("src/billing/charge.bynk").to_string(),
6583 new_uri: uri("src/billing/pay.bynk").to_string(),
6584 }],
6585 })
6586 .await
6587 .expect("will_rename_files must not error")
6588 .expect("must produce edits");
6589
6590 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6591 panic!("expected document-change edits");
6592 };
6593 let suite_edit = edits
6594 .iter()
6595 .find(|e| e.text_document.uri == suite_uri)
6596 .expect("the suite's own `suite <target>` header must be rewritten");
6597 assert_eq!(suite_edit.edits.len(), 1);
6598 let OneOf::Left(e) = &suite_edit.edits[0] else {
6599 panic!("expected a plain TextEdit");
6600 };
6601 assert_eq!(e.new_text, "billing.pay");
6602 }
6603
6604 #[tokio::test]
6609 async fn will_rename_files_refuses_a_rename_that_collides_with_an_existing_unit() {
6610 let s = scratch_project(
6611 "will_rename_collision",
6612 &[
6613 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6614 (
6615 "src/billing/charge.bynk",
6616 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6617 ),
6618 (
6619 "src/billing/pay.bynk",
6620 "commons billing.pay\n\ntype PaymentId = Int where Positive\n",
6621 ),
6622 ],
6623 );
6624 let backend = backend_at(&s.0).await;
6625 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6626 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
6627
6628 backend.run_round().await;
6629
6630 let edit = backend
6633 .will_rename_files(RenameFilesParams {
6634 files: vec![FileRename {
6635 old_uri: uri("src/billing/charge.bynk").to_string(),
6636 new_uri: uri("src/billing/pay.bynk").to_string(),
6637 }],
6638 })
6639 .await
6640 .expect("will_rename_files must not error");
6641 assert!(
6642 edit.is_none(),
6643 "a rename that collides with an existing unit name must produce no edits"
6644 );
6645 }
6646
6647 #[cfg(unix)]
6655 #[tokio::test]
6656 async fn will_rename_files_tolerates_a_symlinked_project_root() {
6657 let real = scratch_project(
6658 "will_rename_symlink_real",
6659 &[
6660 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6661 (
6662 "src/billing/charge.bynk",
6663 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6664 ),
6665 ],
6666 );
6667 let alias = std::env::temp_dir().join(format!(
6668 "bynk_lsp_sliceA_will_rename_symlink_alias_{}_{:?}",
6669 std::process::id(),
6670 std::thread::current().id()
6671 ));
6672 let _ = std::fs::remove_file(&alias);
6673 std::os::unix::fs::symlink(&real.0, &alias).expect("symlink the scratch root");
6674
6675 let backend = backend_at(&alias).await;
6676 let uri = |rel: &str| Url::from_file_path(alias.join(rel)).unwrap();
6680 let old_uri = uri("src/billing/charge.bynk");
6681 let new_uri = uri("src/billing/pay.bynk"); backend.run_round().await;
6684
6685 let edit = backend
6686 .will_rename_files(RenameFilesParams {
6687 files: vec![FileRename {
6688 old_uri: old_uri.to_string(),
6689 new_uri: new_uri.to_string(),
6690 }],
6691 })
6692 .await
6693 .expect("will_rename_files must not error")
6694 .expect("must produce edits despite the symlinked root");
6695
6696 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6697 panic!("expected document-change edits");
6698 };
6699 assert_eq!(
6700 edits.len(),
6701 1,
6702 "only the moved file's own header changes here"
6703 );
6704 let OneOf::Left(e) = &edits[0].edits[0] else {
6705 panic!("expected a plain TextEdit");
6706 };
6707 assert_eq!(e.new_text, "billing.pay");
6708
6709 let _ = std::fs::remove_file(&alias);
6710 }
6711
6712 async fn folder_rename(
6718 tag: &str,
6719 files: &[(&str, &str)],
6720 renames: &[(&str, &str)],
6721 ) -> Option<std::collections::BTreeMap<String, Vec<String>>> {
6722 let s = scratch_project(tag, files);
6723 let backend = backend_at(&s.0).await;
6724 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6725 let uri = |rel: &str| {
6729 Url::from_file_path(root_canon.join(rel.split('/').collect::<PathBuf>())).unwrap()
6730 };
6731 let fixture_of = |edited: &Url| -> String {
6735 let edited = edited.to_file_path().unwrap();
6736 files
6737 .iter()
6738 .map(|(rel, _)| *rel)
6739 .find(|rel| uri(rel).to_file_path().ok().as_ref() == Some(&edited))
6740 .unwrap_or_else(|| panic!("edit to a non-fixture file {}", edited.display()))
6741 .to_string()
6742 };
6743 backend.run_round().await;
6744 let edit = backend
6745 .will_rename_files(RenameFilesParams {
6746 files: renames
6747 .iter()
6748 .map(|(old, new)| FileRename {
6749 old_uri: uri(old).to_string(),
6750 new_uri: uri(new).to_string(),
6751 })
6752 .collect(),
6753 })
6754 .await
6755 .expect("will_rename_files must not error")?;
6756 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
6757 panic!("expected document-change edits");
6758 };
6759 let mut out = std::collections::BTreeMap::new();
6760 for doc in edits {
6761 let rel = fixture_of(&doc.text_document.uri);
6762 let mut ranges: Vec<Range> = Vec::new();
6763 let mut texts = Vec::new();
6764 for e in doc.edits {
6765 let OneOf::Left(e) = e else {
6766 panic!("expected a plain TextEdit");
6767 };
6768 assert!(
6769 !ranges
6770 .iter()
6771 .any(|r| r.start <= e.range.end && e.range.start <= r.end),
6772 "overlapping edits in {rel}: {:?} and {ranges:?}",
6773 e.range
6774 );
6775 ranges.push(e.range);
6776 texts.push(e.new_text);
6777 }
6778 texts.sort();
6779 assert!(
6780 out.insert(rel.clone(), texts).is_none(),
6781 "{rel} edited twice"
6782 );
6783 }
6784 Some(out)
6785 }
6786
6787 fn expect_edits(pairs: &[(&str, &[&str])]) -> std::collections::BTreeMap<String, Vec<String>> {
6788 pairs
6789 .iter()
6790 .map(|(f, texts)| (f.to_string(), texts.iter().map(|t| t.to_string()).collect()))
6791 .collect()
6792 }
6793
6794 const BILLING: &[(&str, &str)] = &[
6797 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6798 (
6799 "src/billing/charge.bynk",
6800 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
6801 ),
6802 (
6803 "src/billing/refund.bynk",
6804 "commons billing.refund\n\nuses billing.charge\n\ntype RefundId = Int where Positive\n",
6805 ),
6806 (
6807 "src/app/main.bynk",
6808 "context app.main\n\nuses billing.charge\nuses billing.refund\n",
6809 ),
6810 ];
6811
6812 #[tokio::test]
6815 async fn will_rename_files_renames_a_folder_of_units() {
6816 let edits = folder_rename(
6817 "will_rename_folder",
6818 BILLING,
6819 &[("src/billing", "src/payments")],
6820 )
6821 .await
6822 .expect("a folder rename must produce edits");
6823 assert_eq!(
6824 edits,
6825 expect_edits(&[
6826 ("src/app/main.bynk", &["payments.charge", "payments.refund"]),
6827 ("src/billing/charge.bynk", &["payments.charge"]),
6828 (
6829 "src/billing/refund.bynk",
6830 &["payments.charge", "payments.refund"]
6831 ),
6832 ])
6833 );
6834 }
6835
6836 #[tokio::test]
6839 async fn will_rename_files_moves_a_folder_with_nested_units() {
6840 let mut files = BILLING.to_vec();
6841 files.push((
6842 "src/billing/core/ledger.bynk",
6843 "commons billing.core.ledger\n\ntype Entry = Int where Positive\n",
6844 ));
6845 files.push((
6846 "src/app/books.bynk",
6847 "context app.books\n\nuses billing.core.ledger\n",
6848 ));
6849 files.push((
6850 "src/finance/tax.bynk",
6851 "commons finance.tax\n\ntype Rate = Int where Positive\n",
6852 ));
6853 let edits = folder_rename(
6854 "will_rename_folder_move",
6855 &files,
6856 &[("src/billing", "src/finance/billing")],
6857 )
6858 .await
6859 .expect("a folder move must produce edits");
6860 assert_eq!(
6861 edits,
6862 expect_edits(&[
6863 ("src/app/books.bynk", &["finance.billing.core.ledger"]),
6864 (
6865 "src/app/main.bynk",
6866 &["finance.billing.charge", "finance.billing.refund"]
6867 ),
6868 ("src/billing/charge.bynk", &["finance.billing.charge"]),
6869 (
6870 "src/billing/core/ledger.bynk",
6871 &["finance.billing.core.ledger"]
6872 ),
6873 (
6874 "src/billing/refund.bynk",
6875 &["finance.billing.charge", "finance.billing.refund"]
6876 ),
6877 ])
6878 );
6879 }
6880
6881 #[tokio::test]
6885 async fn will_rename_files_renames_only_the_nested_folder() {
6886 let edits = folder_rename(
6887 "will_rename_folder_nested",
6888 &[
6889 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6890 (
6891 "src/billing/core/ledger.bynk",
6892 "commons billing.core.ledger\n",
6893 ),
6894 (
6895 "src/billing/corex/extra.bynk",
6896 "commons billing.corex.extra\n",
6897 ),
6898 (
6899 "src/app/main.bynk",
6900 "context app.main\n\nuses billing.core.ledger\nuses billing.corex.extra\n",
6901 ),
6902 ],
6903 &[("src/billing/core", "src/billing/base")],
6904 )
6905 .await
6906 .expect("a nested folder rename must produce edits");
6907 assert_eq!(
6908 edits,
6909 expect_edits(&[
6910 ("src/app/main.bynk", &["billing.base.ledger"]),
6911 ("src/billing/core/ledger.bynk", &["billing.base.ledger"]),
6912 ])
6913 );
6914 }
6915
6916 #[tokio::test]
6920 async fn will_rename_files_renames_a_multi_file_unit_folder_once() {
6921 let edits = folder_rename(
6922 "will_rename_folder_multi_file",
6923 &[
6924 ("bynk.toml", "[project]\nname=\"demo\"\n"),
6925 (
6926 "src/billing/charge/one.bynk",
6927 "context billing.charge\n\ntype ChargeId = Int where Positive\n",
6928 ),
6929 (
6930 "src/billing/charge/two.bynk",
6931 "context billing.charge\n\ntype PaymentId = Int where Positive\n",
6932 ),
6933 (
6934 "src/app/main.bynk",
6935 "context app.main\n\nconsumes billing.charge\n",
6936 ),
6937 ],
6938 &[("src/billing/charge", "src/billing/payment")],
6939 )
6940 .await
6941 .expect("a multi-file unit folder rename must produce edits");
6942 assert_eq!(
6943 edits,
6944 expect_edits(&[
6945 ("src/app/main.bynk", &["billing.payment"]),
6946 ("src/billing/charge/one.bynk", &["billing.payment"]),
6947 ("src/billing/charge/two.bynk", &["billing.payment"]),
6948 ])
6949 );
6950 }
6951
6952 #[tokio::test]
6954 async fn will_rename_files_is_a_noop_for_a_folder_without_units() {
6955 let mut files = BILLING.to_vec();
6956 files.push(("src/assets/logo.txt", "not bynk\n"));
6957 let edits = folder_rename(
6958 "will_rename_folder_empty",
6959 &files,
6960 &[("src/assets", "src/static")],
6961 )
6962 .await;
6963 assert!(edits.is_none(), "{edits:?}");
6964 }
6965
6966 #[tokio::test]
6969 async fn will_rename_files_deduplicates_a_file_inside_a_renamed_folder() {
6970 let edits = folder_rename(
6971 "will_rename_folder_mixed",
6972 BILLING,
6973 &[
6974 ("src/billing/charge.bynk", "src/payments/charge.bynk"),
6975 ("src/billing", "src/payments"),
6976 ],
6977 )
6978 .await
6979 .expect("a mixed rename must produce edits");
6980 assert_eq!(
6981 edits,
6982 expect_edits(&[
6983 ("src/app/main.bynk", &["payments.charge", "payments.refund"]),
6984 ("src/billing/charge.bynk", &["payments.charge"]),
6985 (
6986 "src/billing/refund.bynk",
6987 &["payments.charge", "payments.refund"]
6988 ),
6989 ])
6990 );
6991 }
6992
6993 #[tokio::test]
6997 async fn will_rename_files_refuses_a_move_into_another_project() {
6998 let mut files = BILLING.to_vec();
6999 files.push(("vendor/bynk.toml", "[project]\nname=\"vendor\"\n"));
7000 files.push(("vendor/src/keep.bynk", "commons keep\n"));
7001 let folder = folder_rename(
7002 "will_rename_folder_cross_project",
7003 &files,
7004 &[("src/billing", "vendor/src/billing")],
7005 )
7006 .await;
7007 assert!(folder.is_none(), "{folder:?}");
7008 let file = folder_rename(
7009 "will_rename_file_cross_project",
7010 &files,
7011 &[("src/billing/charge.bynk", "vendor/src/charge.bynk")],
7012 )
7013 .await;
7014 assert!(file.is_none(), "{file:?}");
7015 }
7016
7017 #[test]
7022 fn find_source_root_walks_up_to_the_nearest_src() {
7023 let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
7024 .parent()
7025 .expect("workspace root");
7026 let make = ws.join(
7027 "bynkc/tests/fixtures/positive/\
7028 252_multi_file_commons_dotted_test/src/shipping/rates/make.bynk",
7029 );
7030 assert!(make.is_file(), "fixture present: {}", make.display());
7031
7032 let src = Backend::find_source_root(&make).expect("an ancestor src/");
7033 assert!(
7034 src.ends_with("252_multi_file_commons_dotted_test/src"),
7035 "nearest ancestor src, got {}",
7036 src.display()
7037 );
7038
7039 let (root, _config) = Backend::resolve_root(&make).expect("implicit project");
7050 assert_eq!(root, src.parent().expect("src has a parent"));
7051
7052 let found = bynk_ide::discover_files(
7056 &bynk_ide::AnalysisRoots::Project(root.clone()),
7057 &std::collections::HashMap::new(),
7058 );
7059 let make_canon = make.canonicalize().unwrap_or(make.clone());
7060 assert!(
7061 found
7062 .iter()
7063 .any(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == make_canon),
7064 "the compiler's own discovery must reach {} from the project root {}; got {found:?}",
7065 make.display(),
7066 root.display(),
7067 );
7068 }
7069
7070 #[test]
7074 fn resolve_root_is_none_without_toml_or_src() {
7075 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
7077 assert!(p.is_file());
7078 assert!(Backend::find_source_root(&p).is_none());
7079 assert!(Backend::resolve_root(&p).is_none());
7080 }
7081
7082 #[test]
7085 fn is_scrutinee_offset_locates_the_scrutinee() {
7086 let text = " order.status is Pen";
7087 let off = is_scrutinee_offset(text, text.len()).expect("at an is-position");
7088 assert_eq!(&text[off..off + 1], "s");
7090 assert!(off < text.find(" is ").unwrap());
7091 let text2 = " x is ";
7093 let off2 = is_scrutinee_offset(text2, text2.len()).expect("at an is-position");
7094 assert_eq!(&text2[off2..off2 + 1], "x");
7095 assert!(is_scrutinee_offset(" basis ", " basis ".len()).is_none());
7097 assert!(is_scrutinee_offset(" let x = ", " let x = ".len()).is_none());
7099 }
7100
7101 #[test]
7104 fn let_else_match_head_reads_the_value_and_the_covered_variant() {
7105 let t = " let Ok(n) = load(r) else match {\n ";
7107 let h = let_else_match_head(t, t.len()).expect("at an arm-start");
7108 assert_eq!(&t[h.value_end..h.value_end + 1], ")"); assert!(!h.effectful);
7110 assert_eq!(h.covered.as_deref(), Some("Ok"));
7111
7112 let t = "let Some(x) <- find(k) else match {\n None => 0,\n Er";
7114 let h = let_else_match_head(t, t.len()).expect("at a later arm-start");
7115 assert!(h.effectful);
7116 assert_eq!(&t[h.value_end..h.value_end + 1], ")");
7117 assert_eq!(h.covered.as_deref(), Some("Some"));
7118
7119 let t = "let Err(Missing) = r else match {\n ";
7121 assert_eq!(let_else_match_head(t, t.len()).unwrap().covered, None);
7122
7123 let t = "let Status.Done = s else match {\n ";
7125 assert_eq!(
7126 let_else_match_head(t, t.len()).unwrap().covered.as_deref(),
7127 Some("Done")
7128 );
7129
7130 let t = "let Ok(b) = check(a == 1) else match {\n ";
7132 let h = let_else_match_head(t, t.len()).unwrap();
7133 assert_eq!(&t[h.value_end..h.value_end + 1], ")");
7134
7135 assert!(let_else_match_head("match c {\n ", 11).is_none());
7137 let t = "let Ok(n) = r else match {\n Err(e) => ";
7138 assert!(let_else_match_head(t, t.len()).is_none());
7139 }
7140
7141 #[test]
7142 fn match_scrutinee_offset_locates_the_scrutinee() {
7143 let t = "match order.status {\n ";
7145 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
7146 assert_eq!(&t[off..off + 1], "s"); assert!(off < t.find(" {").unwrap());
7148
7149 let t = "match color { Re";
7151 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
7152 assert_eq!(&t[off..off + 1], "r"); let t = "match c {\n Red => 1,\n Gr";
7156 let off = match_scrutinee_offset(t, t.len()).expect("at a later arm-start");
7157 assert_eq!(&t[off..off + 1], "c");
7158
7159 let t = "match c {\n Red => f(a, b),\n ";
7162 assert!(match_scrutinee_offset(t, t.len()).is_some());
7163
7164 assert!(
7166 match_scrutinee_offset("match c {\n Red => ", "match c {\n Red => ".len()).is_none()
7167 );
7168
7169 assert!(match_scrutinee_offset("fn f() {\n ", "fn f() {\n ".len()).is_none());
7171
7172 assert!(match_scrutinee_offset("match c {\n Ok(", "match c {\n Ok(".len()).is_none());
7174
7175 assert!(match_scrutinee_offset("match c ", "match c ".len()).is_none());
7177 assert!(match_scrutinee_offset("match {\n ", "match {\n ".len()).is_none());
7178 }
7179
7180 #[test]
7184 fn nested_pattern_offset_locates_the_scrutinee_and_variant() {
7185 let t = "match res {\n Some(";
7187 let (off, variant) = nested_pattern_offset(t, t.len()).expect("inside a nested pattern");
7188 assert_eq!(&t[off..off + 1], "s"); assert_eq!(variant, "Some");
7190
7191 let t = "match res {\n Ok(Po";
7193 let (off, variant) = nested_pattern_offset(t, t.len()).expect("mid partial");
7194 assert_eq!(&t[off..off + 1], "s");
7195 assert_eq!(variant, "Ok");
7196
7197 let t = "match r {\n Ok(n) => n,\n Err(";
7199 let (off, variant) = nested_pattern_offset(t, t.len()).expect("later arm");
7200 assert_eq!(&t[off..off + 1], "r");
7201 assert_eq!(variant, "Err");
7202
7203 assert!(nested_pattern_offset("match r {\n ok(", "match r {\n ok(".len()).is_none());
7206
7207 assert!(nested_pattern_offset("match c {\n ", "match c {\n ".len()).is_none());
7209 assert!(nested_pattern_offset("match c {\n Ok", "match c {\n Ok".len()).is_none());
7210
7211 let t = "match c {\n Ok(n) => g(";
7213 assert!(nested_pattern_offset(t, t.len()).is_none());
7214
7215 assert!(nested_pattern_offset("fn f() {\n h(", "fn f() {\n h(".len()).is_none());
7217 }
7218
7219 #[test]
7223 fn is_bynk_toml_matches_only_the_manifest() {
7224 let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
7229 let toml = Url::from_file_path(base.join("bynk.toml")).expect("abs path");
7230 assert!(is_bynk_toml(&toml));
7231 let nested = Url::from_file_path(base.join("sub").join("bynk.toml")).expect("abs path");
7232 assert!(is_bynk_toml(&nested));
7233
7234 let src = Url::from_file_path(base.join("src").join("main.bynk")).expect("abs path");
7236 assert!(!is_bynk_toml(&src));
7237 let decoy = Url::from_file_path(base.join("notbynk.toml")).expect("abs path");
7239 assert!(!is_bynk_toml(&decoy));
7240 let remote = Url::parse("https://example.com/bynk.toml").expect("url");
7242 assert!(!is_bynk_toml(&remote));
7243 }
7244
7245 #[tokio::test]
7250 async fn organise_imports_is_offered_only_when_asked_for() {
7251 let other = "commons shared.other\n\ntype Other = { n: Int }\n";
7252 let widget = "commons shared.widget\n\ntype Widget = { size: Int }\n";
7253 let app = "commons app.a\n\nuses shared.widget\nuses shared.other\n\nfn area(w: Widget) -> Int {\n w.size\n}\n";
7254 let s = scratch_project(
7255 "organise_only",
7256 &[
7257 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7258 ("src/shared/other.bynk", other),
7259 ("src/shared/widget.bynk", widget),
7260 ("src/app/a.bynk", app),
7261 ],
7262 );
7263 let backend = backend_at(&s.0).await;
7264 let uri = file_uri(&s.0, "src/app/a.bynk");
7265 open(&backend, &uri, app).await;
7266 backend.run_round().await;
7267
7268 let request = |only: Option<Vec<CodeActionKind>>| CodeActionParams {
7269 text_document: TextDocumentIdentifier { uri: uri.clone() },
7270 range: Range::default(),
7271 context: CodeActionContext {
7272 diagnostics: Vec::new(),
7273 only,
7274 trigger_kind: None,
7275 },
7276 work_done_progress_params: Default::default(),
7277 partial_result_params: Default::default(),
7278 };
7279 let organise_titles = |actions: Vec<CodeActionOrCommand>| -> Vec<String> {
7280 actions
7281 .into_iter()
7282 .filter_map(|a| match a {
7283 CodeActionOrCommand::CodeAction(a)
7284 if a.kind == Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS) =>
7285 {
7286 Some(a.title)
7287 }
7288 _ => None,
7289 })
7290 .collect()
7291 };
7292
7293 for only in [None, Some(vec![CodeActionKind::QUICKFIX])] {
7294 let actions = backend
7295 .code_action(request(only.clone()))
7296 .await
7297 .unwrap()
7298 .unwrap();
7299 assert!(organise_titles(actions).is_empty(), "offered for {only:?}");
7300 }
7301 for only in [
7302 CodeActionKind::SOURCE,
7303 CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
7304 ] {
7305 let actions = backend
7306 .code_action(request(Some(vec![only.clone()])))
7307 .await
7308 .unwrap()
7309 .unwrap();
7310 assert_eq!(actions.len(), 1, "one action for {only:?}");
7311 let CodeActionOrCommand::CodeAction(action) = &actions[0] else {
7312 panic!("expected a CodeAction");
7313 };
7314 assert_eq!(action.kind, Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS));
7315 let Some(DocumentChanges::Edits(doc_edits)) =
7316 &action.edit.as_ref().unwrap().document_changes
7317 else {
7318 panic!("expected versioned document edits");
7319 };
7320 assert_eq!(doc_edits[0].text_document.version, Some(1));
7321 let OneOf::Left(edit) = &doc_edits[0].edits[0] else {
7322 panic!("expected a plain TextEdit");
7323 };
7324 assert_eq!(edit.new_text, "uses shared.widget");
7326 }
7327 }
7328
7329 #[tokio::test]
7333 async fn fix_all_is_offered_only_when_asked_for() {
7334 let src = "context app.c\n\ncapability Clock {\n fn now() -> Effect[Int]\n}\n\nprovides Clock = Sys {\n fn now() -> Effect[Int] {\n 1\n }\n}\n\nservice one {\n on call(x: Int) -> Effect[Int] given Clock {\n x\n }\n}\n\nservice two {\n on call(x: Int) -> Effect[Int] given Clock {\n x\n }\n}\n";
7335 let s = scratch_project(
7336 "fix_all_only",
7337 &[
7338 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7339 ("src/app/c.bynk", src),
7340 ],
7341 );
7342 let backend = backend_at(&s.0).await;
7343 let uri = file_uri(&s.0, "src/app/c.bynk");
7344 open(&backend, &uri, src).await;
7345 backend.run_round().await;
7346
7347 let request = |only: Option<Vec<CodeActionKind>>| CodeActionParams {
7348 text_document: TextDocumentIdentifier { uri: uri.clone() },
7349 range: Range::default(),
7350 context: CodeActionContext {
7351 diagnostics: Vec::new(),
7352 only,
7353 trigger_kind: None,
7354 },
7355 work_done_progress_params: Default::default(),
7356 partial_result_params: Default::default(),
7357 };
7358 let fix_all = |actions: Vec<CodeActionOrCommand>| -> Vec<CodeAction> {
7359 actions
7360 .into_iter()
7361 .filter_map(|a| match a {
7362 CodeActionOrCommand::CodeAction(a)
7363 if a.kind == Some(crate::fix_all::kind()) =>
7364 {
7365 Some(a)
7366 }
7367 _ => None,
7368 })
7369 .collect()
7370 };
7371 for only in [None, Some(vec![CodeActionKind::QUICKFIX])] {
7372 let actions = backend
7373 .code_action(request(only.clone()))
7374 .await
7375 .unwrap()
7376 .unwrap();
7377 assert!(fix_all(actions).is_empty(), "offered for {only:?}");
7378 }
7379 for only in [
7380 CodeActionKind::SOURCE,
7381 CodeActionKind::SOURCE_FIX_ALL,
7382 crate::fix_all::kind(),
7383 ] {
7384 let actions = backend
7385 .code_action(request(Some(vec![only.clone()])))
7386 .await
7387 .unwrap()
7388 .unwrap();
7389 let found = fix_all(actions);
7390 assert_eq!(found.len(), 1, "one fix-all for {only:?}");
7391 let Some(DocumentChanges::Edits(doc_edits)) =
7392 &found[0].edit.as_ref().unwrap().document_changes
7393 else {
7394 panic!("expected versioned document edits");
7395 };
7396 assert_eq!(doc_edits[0].edits.len(), 2);
7398 }
7399 }
7400
7401 #[tokio::test]
7405 async fn fill_match_arms_lists_then_resolves() {
7406 let src = "commons app.m\n\ntype Shape =\n | Circle(r: Int)\n | Dot\n\nfn area(s: Shape) -> Int {\n match s {\n Dot => 1,\n }\n}\n";
7407 let s = scratch_project(
7408 "fill_arms",
7409 &[
7410 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7411 ("src/app/m.bynk", src),
7412 ],
7413 );
7414 let backend = backend_at(&s.0).await;
7415 let uri = file_uri(&s.0, "src/app/m.bynk");
7416 open(&backend, &uri, src).await;
7417 backend.run_round().await;
7418
7419 let at = crate::position::offset_to_position(src, src.find("Dot =>").unwrap());
7420 let actions = backend
7421 .code_action(CodeActionParams {
7422 text_document: TextDocumentIdentifier { uri: uri.clone() },
7423 range: Range { start: at, end: at },
7424 context: CodeActionContext::default(),
7425 work_done_progress_params: Default::default(),
7426 partial_result_params: Default::default(),
7427 })
7428 .await
7429 .unwrap()
7430 .unwrap();
7431 let listed: Vec<CodeAction> = actions
7432 .into_iter()
7433 .filter_map(|a| match a {
7434 CodeActionOrCommand::CodeAction(a) if a.title.starts_with("Fill") => Some(a),
7435 _ => None,
7436 })
7437 .collect();
7438 assert_eq!(listed.len(), 1, "one fill action");
7439 assert!(listed[0].edit.is_none(), "listed without its edit");
7440
7441 let resolved = backend
7442 .code_action_resolve(listed[0].clone())
7443 .await
7444 .unwrap();
7445 let Some(DocumentChanges::Edits(doc_edits)) =
7446 &resolved.edit.as_ref().unwrap().document_changes
7447 else {
7448 panic!("expected versioned document edits");
7449 };
7450 assert_eq!(doc_edits[0].text_document.version, Some(1));
7451 let inserted: String = doc_edits[0]
7452 .edits
7453 .iter()
7454 .map(|e| match e {
7455 OneOf::Left(e) => e.new_text.clone(),
7456 OneOf::Right(e) => e.text_edit.new_text.clone(),
7457 })
7458 .collect();
7459 assert_eq!(inserted, "\n Circle(_) => 0,");
7460
7461 let mut stale = listed[0].clone();
7462 stale.data.as_mut().unwrap()["version"] = serde_json::json!(0);
7463 assert!(backend.code_action_resolve(stale).await.is_err());
7464
7465 let other = CodeAction {
7467 title: "x".into(),
7468 ..Default::default()
7469 };
7470 assert_eq!(
7471 backend.code_action_resolve(other.clone()).await.unwrap(),
7472 other
7473 );
7474 }
7475
7476 #[tokio::test]
7480 async fn inline_variable_lists_then_resolves() {
7481 let src = "commons app.m\n\nfn f(a: Int) -> Int {\n let y = a + 1\n y * 3\n}\n";
7482 let s = scratch_project(
7483 "inline_var",
7484 &[
7485 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7486 ("src/app/m.bynk", src),
7487 ],
7488 );
7489 let backend = backend_at(&s.0).await;
7490 let uri = file_uri(&s.0, "src/app/m.bynk");
7491 open(&backend, &uri, src).await;
7492 backend.run_round().await;
7493
7494 let at = crate::position::offset_to_position(src, src.find("y =").unwrap());
7495 let actions = backend
7496 .code_action(CodeActionParams {
7497 text_document: TextDocumentIdentifier { uri: uri.clone() },
7498 range: Range { start: at, end: at },
7499 context: CodeActionContext::default(),
7500 work_done_progress_params: Default::default(),
7501 partial_result_params: Default::default(),
7502 })
7503 .await
7504 .unwrap()
7505 .unwrap();
7506 let listed: Vec<CodeAction> = actions
7507 .into_iter()
7508 .filter_map(|a| match a {
7509 CodeActionOrCommand::CodeAction(a)
7510 if a.kind == Some(CodeActionKind::REFACTOR_INLINE) =>
7511 {
7512 Some(a)
7513 }
7514 _ => None,
7515 })
7516 .collect();
7517 assert_eq!(listed.len(), 1, "one inline action");
7518 assert_eq!(listed[0].title, "Inline `y`");
7519 assert!(listed[0].edit.is_none());
7520
7521 let resolved = backend
7522 .code_action_resolve(listed[0].clone())
7523 .await
7524 .unwrap();
7525 let Some(DocumentChanges::Edits(doc_edits)) =
7526 &resolved.edit.as_ref().unwrap().document_changes
7527 else {
7528 panic!("expected versioned document edits");
7529 };
7530 assert_eq!(doc_edits[0].text_document.version, Some(1));
7531 let texts: Vec<String> = doc_edits[0]
7532 .edits
7533 .iter()
7534 .map(|e| match e {
7535 OneOf::Left(e) => e.new_text.clone(),
7536 OneOf::Right(e) => e.text_edit.new_text.clone(),
7537 })
7538 .collect();
7539 assert_eq!(texts, vec![String::new(), "(a + 1)".to_string()]);
7542
7543 let mut stale = listed[0].clone();
7544 stale.data.as_mut().unwrap()["version"] = serde_json::json!(0);
7545 assert!(backend.code_action_resolve(stale).await.is_err());
7546 }
7547
7548 #[tokio::test]
7552 async fn provider_stub_lists_then_resolves() {
7553 let src = "context app.c\n\ncapability Clock {\n fn now() -> Effect[Int]\n}\n";
7554 let s = scratch_project(
7555 "provider_stub",
7556 &[
7557 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7558 ("src/app/c.bynk", src),
7559 ],
7560 );
7561 let backend = backend_at(&s.0).await;
7562 let uri = file_uri(&s.0, "src/app/c.bynk");
7563 open(&backend, &uri, src).await;
7564 backend.run_round().await;
7565
7566 let at = crate::position::offset_to_position(src, src.find("Clock").unwrap());
7567 let actions = backend
7568 .code_action(CodeActionParams {
7569 text_document: TextDocumentIdentifier { uri: uri.clone() },
7570 range: Range { start: at, end: at },
7571 context: CodeActionContext::default(),
7572 work_done_progress_params: Default::default(),
7573 partial_result_params: Default::default(),
7574 })
7575 .await
7576 .unwrap()
7577 .unwrap();
7578 let listed: Vec<CodeAction> = actions
7579 .into_iter()
7580 .filter_map(|a| match a {
7581 CodeActionOrCommand::CodeAction(a) if a.title.starts_with("Generate provider") => {
7582 Some(a)
7583 }
7584 _ => None,
7585 })
7586 .collect();
7587 assert_eq!(listed.len(), 1, "one stub action");
7588 assert_eq!(listed[0].kind, Some(CodeActionKind::REFACTOR));
7589 assert!(listed[0].edit.is_none());
7590
7591 let resolved = backend
7592 .code_action_resolve(listed[0].clone())
7593 .await
7594 .unwrap();
7595 let Some(DocumentChanges::Edits(doc_edits)) =
7596 &resolved.edit.as_ref().unwrap().document_changes
7597 else {
7598 panic!("expected versioned document edits");
7599 };
7600 assert_eq!(doc_edits[0].text_document.version, Some(1));
7601 let OneOf::Left(edit) = &doc_edits[0].edits[0] else {
7602 panic!("expected a plain TextEdit");
7603 };
7604 assert_eq!(
7605 edit.new_text,
7606 "\n\nprovides Clock = ClockImpl {\n fn now() -> Effect[Int] {\n 0\n }\n}"
7607 );
7608
7609 let mut stale = listed[0].clone();
7610 stale.data.as_mut().unwrap()["version"] = serde_json::json!(0);
7611 assert!(backend.code_action_resolve(stale).await.is_err());
7612 }
7613
7614 #[tokio::test]
7618 async fn extract_function_lists_then_resolves_or_says_why_not() {
7619 let src = "context app.m\n\ncapability Clock {\n fn now() -> Effect[Int]\n}\n\nprovides Clock = Sys {\n fn now() -> Effect[Int] {\n 1\n }\n}\n\nservice s {\n on call(x: Int) -> Effect[Int] given Clock {\n let n <- Clock.now()\n let y = x * 2 + 1\n n + y\n }\n}\n";
7620 let s = scratch_project(
7621 "extract_fn",
7622 &[
7623 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7624 ("src/app/m.bynk", src),
7625 ],
7626 );
7627 let backend = backend_at(&s.0).await;
7628 let uri = file_uri(&s.0, "src/app/m.bynk");
7629 open(&backend, &uri, src).await;
7630 backend.run_round().await;
7631
7632 let request = |sel: &str| {
7633 let start = src.find(sel).unwrap();
7634 CodeActionParams {
7635 text_document: TextDocumentIdentifier { uri: uri.clone() },
7636 range: Range {
7637 start: crate::position::offset_to_position(src, start),
7638 end: crate::position::offset_to_position(src, start + sel.len()),
7639 },
7640 context: CodeActionContext::default(),
7641 work_done_progress_params: Default::default(),
7642 partial_result_params: Default::default(),
7643 }
7644 };
7645 let extract_fn = |actions: Vec<CodeActionOrCommand>| -> Vec<CodeAction> {
7646 actions
7647 .into_iter()
7648 .filter_map(|a| match a {
7649 CodeActionOrCommand::CodeAction(a)
7650 if a.title.starts_with("Extract function") =>
7651 {
7652 Some(a)
7653 }
7654 _ => None,
7655 })
7656 .collect()
7657 };
7658
7659 let listed = extract_fn(
7660 backend
7661 .code_action(request("x * 2 + 1"))
7662 .await
7663 .unwrap()
7664 .unwrap(),
7665 );
7666 assert_eq!(listed.len(), 1);
7667 assert!(listed[0].edit.is_none());
7668 let resolved = backend
7669 .code_action_resolve(listed[0].clone())
7670 .await
7671 .unwrap();
7672 let Some(DocumentChanges::Edits(doc_edits)) =
7673 &resolved.edit.as_ref().unwrap().document_changes
7674 else {
7675 panic!("expected versioned document edits");
7676 };
7677 assert_eq!(doc_edits[0].text_document.version, Some(1));
7678 assert_eq!(doc_edits[0].edits.len(), 2);
7679
7680 let refused = extract_fn(
7682 backend
7683 .code_action(request("Clock.now()"))
7684 .await
7685 .unwrap()
7686 .unwrap(),
7687 );
7688 assert!(refused.is_empty());
7689 backend.state.write().await.supports_code_action_disabled = true;
7691 let refused = extract_fn(
7692 backend
7693 .code_action(request("Clock.now()"))
7694 .await
7695 .unwrap()
7696 .unwrap(),
7697 );
7698 assert_eq!(refused.len(), 1);
7699 let reason = &refused[0].disabled.as_ref().expect("disabled").reason;
7700 assert!(reason.contains("capability-free"), "{reason}");
7701 }
7702
7703 #[tokio::test]
7708 async fn fix_all_is_withheld_when_the_recheck_fails() {
7709 use bynk_syntax::error::{Applicability, CompileError};
7710 let src = "commons app.m\n\nfn f() -> Int {\n 1\n}\n";
7711 let s = scratch_project(
7712 "fix_all_gate",
7713 &[
7714 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7715 ("src/app/m.bynk", src),
7716 ],
7717 );
7718 let backend = backend_at(&s.0).await;
7719 let uri = file_uri(&s.0, "src/app/m.bynk");
7720 open(&backend, &uri, src).await;
7721 backend.run_round().await;
7722
7723 let one = src.find(" 1\n").unwrap() + 2;
7724 let one = bynk_syntax::span::Span::new(one, one + 1);
7725 let inject = |replacement: &'static str| {
7730 let backend = backend.clone();
7731 async move {
7732 let root = backend.test_root().await;
7733 let mut state = backend.state.write().await;
7734 let project = state.projects.get_mut(&root).unwrap();
7735 let analysis = Arc::get_mut(project.analysis.as_mut().unwrap())
7736 .expect("the round is not shared");
7737 let rel = analysis.snapshots.keys().next().unwrap().clone();
7738 let error = CompileError::new("bynk.given.unused_capability", one, "injected")
7739 .with_suggestion(
7740 "replace",
7741 vec![(one, replacement.to_string())],
7742 Applicability::MachineApplicable,
7743 );
7744 analysis.diagnostics.insert(
7745 rel,
7746 vec![bynk_ide::Diagnostic {
7747 severity: bynk_syntax::Severity::Warning,
7748 label_files: Vec::new(),
7749 error,
7750 }],
7751 );
7752 }
7753 };
7754 let fix_all_count = || {
7755 let backend = backend.clone();
7756 let uri = uri.clone();
7757 async move {
7758 backend
7759 .code_action(CodeActionParams {
7760 text_document: TextDocumentIdentifier { uri },
7761 range: Range::default(),
7762 context: CodeActionContext {
7763 diagnostics: Vec::new(),
7764 only: Some(vec![crate::fix_all::kind()]),
7765 trigger_kind: None,
7766 },
7767 work_done_progress_params: Default::default(),
7768 partial_result_params: Default::default(),
7769 })
7770 .await
7771 .unwrap()
7772 .unwrap()
7773 .len()
7774 }
7775 };
7776
7777 inject("2").await;
7778 assert_eq!(fix_all_count().await, 1, "a clean fix is offered");
7779 inject("\"one\"").await;
7780 assert_eq!(fix_all_count().await, 0, "a breaking fix is withheld");
7781 }
7782
7783 #[test]
7786 fn advertises_code_actions_and_the_index_riders() {
7787 let caps = server_capabilities();
7788 let Some(CodeActionProviderCapability::Options(opts)) = caps.code_action_provider else {
7789 panic!("codeActionProvider not advertised with options");
7790 };
7791 assert_eq!(
7792 opts.code_action_kinds,
7793 Some(vec![
7794 CodeActionKind::QUICKFIX,
7795 CodeActionKind::REFACTOR,
7796 CodeActionKind::REFACTOR_EXTRACT,
7797 CodeActionKind::REFACTOR_INLINE,
7798 CodeActionKind::SOURCE_ORGANIZE_IMPORTS,
7799 CodeActionKind::new("source.fixAll.bynk"),
7800 ])
7801 );
7802 assert_eq!(
7803 opts.resolve_provider,
7804 Some(true),
7805 "fill-arms resolves lazily"
7806 );
7807 assert!(matches!(
7808 caps.workspace_symbol_provider,
7809 Some(OneOf::Left(true))
7810 ));
7811 assert!(matches!(
7812 caps.document_highlight_provider,
7813 Some(OneOf::Left(true))
7814 ));
7815 }
7816
7817 #[test]
7820 fn advertises_save_notifications() {
7821 let caps = server_capabilities();
7824 let Some(TextDocumentSyncCapability::Options(opts)) = caps.text_document_sync else {
7825 panic!("textDocumentSync not advertised with options");
7826 };
7827 assert_eq!(opts.change, Some(TextDocumentSyncKind::FULL));
7828 assert!(matches!(
7829 opts.save,
7830 Some(TextDocumentSyncSaveOptions::Supported(true))
7831 ));
7832 }
7833
7834 #[test]
7835 fn advertises_inlay_hints() {
7836 let caps = server_capabilities();
7837 assert!(matches!(caps.inlay_hint_provider, Some(OneOf::Left(true))));
7838 }
7839
7840 #[test]
7842 fn advertises_type_definition() {
7843 let caps = server_capabilities();
7844 assert!(matches!(
7845 caps.type_definition_provider,
7846 Some(TypeDefinitionProviderCapability::Simple(true))
7847 ));
7848 }
7849
7850 #[test]
7852 fn advertises_document_links() {
7853 let caps = server_capabilities();
7854 assert!(caps.document_link_provider.is_some());
7855 }
7856
7857 #[test]
7859 fn advertises_will_rename_files() {
7860 let caps = server_capabilities();
7861 let file_ops = caps
7862 .workspace
7863 .as_ref()
7864 .and_then(|w| w.file_operations.as_ref())
7865 .expect("workspace.fileOperations advertised");
7866 let will_rename = file_ops
7867 .will_rename
7868 .as_ref()
7869 .expect("willRename registered");
7870 let filter = &will_rename.filters[0];
7871 assert_eq!(filter.pattern.glob, "**/*.bynk");
7872 assert_eq!(filter.pattern.matches, Some(FileOperationPatternKind::File));
7873 let folders = &will_rename.filters[1];
7874 assert_eq!(folders.scheme.as_deref(), Some("file"));
7875 assert_eq!(folders.pattern.glob, "**");
7876 assert_eq!(
7877 folders.pattern.matches,
7878 Some(FileOperationPatternKind::Folder)
7879 );
7880 }
7881
7882 #[test]
7884 fn advertises_the_explain_request() {
7885 let caps = server_capabilities();
7886 let experimental = caps.experimental.expect("experimental advertised");
7887 assert_eq!(experimental.get("explain"), Some(&serde_json::json!(true)));
7888 }
7889
7890 #[tokio::test]
7892 async fn initialize_records_the_explain_command_capability() {
7893 for (experimental, expected) in [
7894 (None, false),
7895 (
7896 Some(serde_json::json!({ "explainDiagnosticCommand": true })),
7897 true,
7898 ),
7899 ] {
7900 let backend = bare_backend().await;
7901 backend
7902 .initialize(InitializeParams {
7903 capabilities: ClientCapabilities {
7904 experimental,
7905 ..Default::default()
7906 },
7907 ..Default::default()
7908 })
7909 .await
7910 .expect("initialize");
7911 assert_eq!(
7912 backend.state.read().await.supports_explain_command,
7913 expected
7914 );
7915 }
7916 }
7917
7918 #[tokio::test]
7922 async fn code_action_offers_explain_for_a_curated_code_when_the_client_supports_it() {
7923 let src = "commons demo.a\n\nfn f(u: Usr) -> Int {\n 1\n}\n";
7924 let s = scratch_project(
7925 "explain_action",
7926 &[
7927 ("bynk.toml", "[project]\nname=\"demo\"\n"),
7928 ("src/demo/a.bynk", src),
7929 ],
7930 );
7931 let backend = backend_at(&s.0).await;
7932 let uri = file_uri(&s.0, "src/demo/a.bynk");
7933 backend.run_round().await;
7934 let at = crate::position::offset_to_position(src, src.find("Usr").unwrap());
7935 let request = || CodeActionParams {
7936 text_document: TextDocumentIdentifier { uri: uri.clone() },
7937 range: Range::new(at, at),
7938 context: CodeActionContext::default(),
7939 work_done_progress_params: Default::default(),
7940 partial_result_params: Default::default(),
7941 };
7942 let titles = |actions: &CodeActionResponse| -> Vec<String> {
7943 actions
7944 .iter()
7945 .map(|a| match a {
7946 CodeActionOrCommand::CodeAction(c) => c.title.clone(),
7947 CodeActionOrCommand::Command(c) => c.title.clone(),
7948 })
7949 .collect()
7950 };
7951 let explain_title = "Explain `bynk.resolve.unknown_type`";
7952
7953 let without = backend.code_action(request()).await.unwrap().unwrap();
7955 assert!(
7956 !titles(&without).iter().any(|t| t == explain_title),
7957 "{:?}",
7958 titles(&without)
7959 );
7960
7961 backend.state.write().await.supports_explain_command = true;
7962 let with = backend.code_action(request()).await.unwrap().unwrap();
7963 let names = titles(&with);
7964 let pos = names
7965 .iter()
7966 .position(|t| t == explain_title)
7967 .unwrap_or_else(|| panic!("explain action offered: {names:?}"));
7968 assert_eq!(pos, names.len() - 1, "explain comes last: {names:?}");
7969 let CodeActionOrCommand::CodeAction(action) = &with[pos] else {
7970 panic!("a CodeAction");
7971 };
7972 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
7973 assert_eq!(action.is_preferred, Some(false));
7974 assert!(action.edit.is_none());
7975 let cmd = action.command.as_ref().expect("carries a command");
7976 assert_eq!(cmd.command, "bynk.explainDiagnostic");
7977 assert_eq!(
7978 cmd.arguments,
7979 Some(vec![serde_json::json!("bynk.resolve.unknown_type")])
7980 );
7981 }
7982
7983 #[tokio::test]
7985 async fn explain_request_serves_curated_and_declines_the_rest() {
7986 let backend = bare_backend().await;
7987 let ask = |code: &str| explain_request::ExplainParams { code: code.into() };
7988 let curated = backend
7989 .explain(ask("bynk.resolve.unknown_type"))
7990 .await
7991 .unwrap()
7992 .expect("curated");
7993 assert_eq!(curated.title, "bynk.resolve.unknown_type");
7994 assert!(
7995 backend
7996 .explain(ask("bynk.resolve.duplicate_type"))
7997 .await
7998 .unwrap()
7999 .is_none()
8000 );
8001 assert!(backend.explain(ask("nonsense")).await.unwrap().is_none());
8002 }
8003
8004 #[test]
8006 fn advertises_completion_with_dot_trigger_and_resolve() {
8007 let caps = server_capabilities();
8008 let opts = caps.completion_provider.expect("completion advertised");
8009 assert_eq!(opts.resolve_provider, Some(true), "resolve_provider");
8010 assert!(
8011 opts.trigger_characters
8012 .as_deref()
8013 .is_some_and(|t| t.iter().any(|c| c == ".")),
8014 "`.` trigger char"
8015 );
8016 }
8017
8018 #[test]
8021 fn advertises_semantic_tokens() {
8022 let caps = server_capabilities();
8023 let Some(SemanticTokensServerCapabilities::SemanticTokensOptions(opts)) =
8024 caps.semantic_tokens_provider
8025 else {
8026 panic!("semanticTokensProvider not advertised with options");
8027 };
8028 assert_eq!(opts.full, Some(SemanticTokensFullOptions::Bool(true)));
8029 assert_eq!(opts.range, Some(true));
8030 assert_eq!(opts.legend, crate::index_queries::semantic_tokens_legend());
8031 }
8032
8033 async fn bare_backend() -> Backend {
8038 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
8039 service.inner().clone()
8040 }
8041
8042 fn file_uri(root: &std::path::Path, rel: &str) -> Url {
8043 let abs = root.join(rel);
8044 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
8045 }
8046
8047 async fn set_folders(backend: &Backend, roots: &[&std::path::Path]) {
8048 backend.state.write().await.folders = roots
8049 .iter()
8050 .map(|r| r.canonicalize().unwrap_or_else(|_| r.to_path_buf()))
8051 .collect();
8052 }
8053
8054 async fn open(backend: &Backend, uri: &Url, text: &str) {
8055 backend
8056 .did_open(DidOpenTextDocumentParams {
8057 text_document: TextDocumentItem {
8058 uri: uri.clone(),
8059 language_id: "bynk".into(),
8060 version: 1,
8061 text: text.to_string(),
8062 },
8063 })
8064 .await;
8065 }
8066
8067 fn snapshot_keys(a: &Analysis) -> Vec<String> {
8068 let mut keys: Vec<String> = a
8069 .snapshots
8070 .keys()
8071 .map(|p| p.to_string_lossy().replace('\\', "/"))
8072 .collect();
8073 keys.sort();
8074 keys
8075 }
8076
8077 #[tokio::test]
8082 async fn two_projects_under_one_folder_are_two_projects() {
8083 let ax_src = "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n";
8084 let by_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
8085 let s = scratch_project(
8086 "d_two",
8087 &[
8088 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
8089 ("a/src/x.bynk", ax_src),
8090 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
8091 ("b/src/y.bynk", by_src),
8092 ],
8093 );
8094 let backend = bare_backend().await;
8095 set_folders(&backend, &[&s.0]).await;
8096 let ax = file_uri(&s.0, "a/src/x.bynk");
8097 let by = file_uri(&s.0, "b/src/y.bynk");
8098 open(&backend, &ax, ax_src).await;
8099 open(&backend, &by, by_src).await;
8100
8101 assert_ne!(
8102 Backend::root_for_uri_uncached(&ax).unwrap(),
8103 Backend::root_for_uri_uncached(&by).unwrap(),
8104 "the two files resolve to different project roots",
8105 );
8106 assert_eq!(
8107 backend.state.read().await.projects.len(),
8108 2,
8109 "one entry per project, not one for the shared folder",
8110 );
8111
8112 let a = backend.analysis_for(&ax).await.expect("A analysed");
8113 let b = backend.analysis_for(&by).await.expect("B analysed");
8114 assert_eq!(
8115 snapshot_keys(&a),
8116 vec!["src/x.bynk"],
8117 "A sees only A's file"
8118 );
8119 assert_eq!(
8120 snapshot_keys(&b),
8121 vec!["src/y.bynk"],
8122 "B sees only B's file"
8123 );
8124 }
8125
8126 #[tokio::test]
8130 async fn removing_a_folder_prunes_an_idle_project() {
8131 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
8132 let s = scratch_project(
8133 "d_prune",
8134 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
8135 );
8136 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8137 let backend = bare_backend().await;
8138 set_folders(&backend, &[&s.0]).await;
8139 let uri = file_uri(&s.0, "src/a.bynk");
8140 open(&backend, &uri, a).await;
8141 backend.analysis_for(&uri).await.expect("analysed");
8142 backend
8144 .did_close(DidCloseTextDocumentParams {
8145 text_document: TextDocumentIdentifier { uri: uri.clone() },
8146 })
8147 .await;
8148 assert_eq!(backend.state.read().await.projects.len(), 1);
8149
8150 backend
8151 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8152 event: WorkspaceFoldersChangeEvent {
8153 added: vec![],
8154 removed: vec![WorkspaceFolder {
8155 uri: Url::from_file_path(&folder).unwrap(),
8156 name: "p".into(),
8157 }],
8158 },
8159 })
8160 .await;
8161 assert!(
8162 backend.state.read().await.projects.is_empty(),
8163 "an idle project is pruned when its last covering folder is removed",
8164 );
8165 }
8166
8167 #[tokio::test]
8170 async fn removing_a_folder_retains_a_project_with_an_open_buffer() {
8171 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
8172 let s = scratch_project(
8173 "d_retain",
8174 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
8175 );
8176 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8177 let backend = bare_backend().await;
8178 set_folders(&backend, &[&s.0]).await;
8179 let uri = file_uri(&s.0, "src/a.bynk");
8180 open(&backend, &uri, a).await; backend
8183 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8184 event: WorkspaceFoldersChangeEvent {
8185 added: vec![],
8186 removed: vec![WorkspaceFolder {
8187 uri: Url::from_file_path(&folder).unwrap(),
8188 name: "p".into(),
8189 }],
8190 },
8191 })
8192 .await;
8193 assert_eq!(
8194 backend.state.read().await.projects.len(),
8195 1,
8196 "a project with an open buffer must survive folder removal",
8197 );
8198 assert!(
8199 backend.analysis_for(&uri).await.is_some(),
8200 "and it must still answer requests",
8201 );
8202 }
8203
8204 #[tokio::test]
8208 async fn closing_the_last_buffer_of_a_folder_removed_project_prunes_it() {
8209 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
8210 let s = scratch_project(
8211 "d_close_prune",
8212 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
8213 );
8214 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8215 let backend = bare_backend().await;
8216 set_folders(&backend, &[&s.0]).await;
8217 let uri = file_uri(&s.0, "src/a.bynk");
8218 open(&backend, &uri, a).await;
8219 backend.analysis_for(&uri).await.expect("analysed");
8220
8221 backend
8223 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8224 event: WorkspaceFoldersChangeEvent {
8225 added: vec![],
8226 removed: vec![WorkspaceFolder {
8227 uri: Url::from_file_path(&folder).unwrap(),
8228 name: "p".into(),
8229 }],
8230 },
8231 })
8232 .await;
8233 assert_eq!(
8234 backend.state.read().await.projects.len(),
8235 1,
8236 "retained while its buffer is open",
8237 );
8238
8239 backend
8241 .did_close(DidCloseTextDocumentParams {
8242 text_document: TextDocumentIdentifier { uri: uri.clone() },
8243 })
8244 .await;
8245 assert!(
8246 backend.state.read().await.projects.is_empty(),
8247 "closing the last buffer of a folder-removed project must prune it",
8248 );
8249 }
8250
8251 #[tokio::test]
8255 async fn a_rename_in_one_project_ignores_a_dirty_buffer_in_another() {
8256 let a_src = "commons a.x\n\ntype Money = Int where Positive\n\nfn charge(m: Money) -> Money {\n m\n}\n";
8257 let b_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
8258 let s = scratch_project(
8259 "d_rename_iso",
8260 &[
8261 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
8262 ("a/src/x.bynk", a_src),
8263 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
8264 ("b/src/y.bynk", b_src),
8265 ],
8266 );
8267 let backend = bare_backend().await;
8268 set_folders(&backend, &[&s.0]).await;
8269 let ax = file_uri(&s.0, "a/src/x.bynk");
8270 let by = file_uri(&s.0, "b/src/y.bynk");
8271 open(&backend, &ax, a_src).await;
8272 open(&backend, &by, b_src).await;
8273 backend.analysis_for(&ax).await.expect("A analysed");
8274 backend.analysis_for(&by).await.expect("B analysed");
8275
8276 backend
8278 .did_change(DidChangeTextDocumentParams {
8279 text_document: VersionedTextDocumentIdentifier {
8280 uri: by.clone(),
8281 version: 2,
8282 },
8283 content_changes: vec![TextDocumentContentChangeEvent {
8284 range: None,
8285 range_length: None,
8286 text: format!("\n{b_src}"),
8287 }],
8288 })
8289 .await;
8290
8291 let off = a_src.find("Money").unwrap();
8293 let pos = crate::position::offset_to_position(a_src, off);
8294 let edit = backend
8295 .rename(RenameParams {
8296 text_document_position: TextDocumentPositionParams {
8297 text_document: TextDocumentIdentifier { uri: ax.clone() },
8298 position: pos,
8299 },
8300 new_name: "Amount".into(),
8301 work_done_progress_params: Default::default(),
8302 })
8303 .await
8304 .expect("rename must not error");
8305 assert!(
8306 edit.is_some(),
8307 "a rename in project A must not be blocked by a dirty buffer in project B",
8308 );
8309 }
8310
8311 #[tokio::test]
8316 async fn initialize_captures_the_dynamic_watcher_capability() {
8317 let backend = bare_backend().await;
8318 let params = InitializeParams {
8319 capabilities: ClientCapabilities {
8320 workspace: Some(WorkspaceClientCapabilities {
8321 did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
8322 dynamic_registration: Some(true),
8323 relative_pattern_support: None,
8324 }),
8325 ..Default::default()
8326 }),
8327 ..Default::default()
8328 },
8329 ..Default::default()
8330 };
8331 backend.initialize(params).await.expect("initialize");
8332 assert!(
8333 backend.state.read().await.supports_dynamic_watchers,
8334 "the client's dynamic-registration support must be captured for `initialized`",
8335 );
8336 }
8337
8338 #[tokio::test]
8344 async fn initialize_captures_each_decoration_refresh_capability() {
8345 let backend = bare_backend().await;
8346 let params = InitializeParams {
8347 capabilities: ClientCapabilities {
8348 workspace: Some(WorkspaceClientCapabilities {
8349 semantic_tokens: Some(SemanticTokensWorkspaceClientCapabilities {
8351 refresh_support: Some(true),
8352 }),
8353 inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
8355 refresh_support: Some(false),
8356 }),
8357 ..Default::default()
8359 }),
8360 ..Default::default()
8361 },
8362 ..Default::default()
8363 };
8364 backend.initialize(params).await.expect("initialize");
8365 let refresh = backend.state.read().await.supports_refresh;
8366 assert!(
8367 refresh.semantic_tokens,
8368 "semantic tokens: advertised → true"
8369 );
8370 assert!(!refresh.inlay_hints, "inlay hints: withheld → false");
8371 assert!(!refresh.code_lens, "code lens: absent → false");
8372 }
8373
8374 #[tokio::test]
8376 async fn discover_projects_under_skips_a_workspace_root() {
8377 let s = scratch_project(
8378 "e_discover_workspace",
8379 &[
8380 ("bynk.toml", "[workspace]\nmembers = [\"packages/*\"]\n"),
8381 ("packages/a/bynk.toml", "[package]\nname=\"acme/a\"\n"),
8382 ("packages/a/src/x.bynk", "commons a.x\n"),
8383 ],
8384 );
8385 let roots = Backend::discover_projects_under(&s.0);
8386 let names: Vec<String> = roots
8387 .iter()
8388 .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
8389 .collect();
8390 assert_eq!(names, vec!["a"], "the member, not the root; got {roots:?}");
8391 }
8392
8393 #[tokio::test]
8396 async fn discover_projects_under_finds_nested_projects_and_skips_caches() {
8397 let s = scratch_project(
8398 "e_discover",
8399 &[
8400 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
8401 ("packages/a/src/x.bynk", "commons a.x\n"),
8402 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
8403 ("packages/b/src/y.bynk", "commons b.y\n"),
8404 ("node_modules/dep/bynk.toml", "[project]\nname=\"dep\"\n"),
8406 ],
8407 );
8408 let mut roots = Backend::discover_projects_under(&s.0);
8409 roots.sort();
8410 let names: Vec<String> = roots
8411 .iter()
8412 .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
8413 .collect();
8414 assert_eq!(
8415 names,
8416 vec!["a", "b"],
8417 "both monorepo projects found, node_modules skipped; got {roots:?}",
8418 );
8419 }
8420
8421 #[tokio::test]
8425 async fn initialized_warms_every_project_under_the_folders() {
8426 let s = scratch_project(
8427 "e_warm",
8428 &[
8429 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
8430 (
8431 "packages/a/src/x.bynk",
8432 "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n",
8433 ),
8434 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
8435 (
8436 "packages/b/src/y.bynk",
8437 "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n",
8438 ),
8439 ],
8440 );
8441 let backend = bare_backend().await;
8442 set_folders(&backend, &[&s.0]).await;
8443
8444 backend.initialized(InitializedParams {}).await;
8446
8447 assert_eq!(
8448 backend.state.read().await.projects.len(),
8449 2,
8450 "both monorepo projects are warmed at `initialized`, before any open",
8451 );
8452 let ax = file_uri(&s.0, "packages/a/src/x.bynk");
8454 assert!(
8455 backend.analysis_for(&ax).await.is_some(),
8456 "a warmed project answers index requests with no `did_open`",
8457 );
8458 }
8459
8460 #[tokio::test]
8465 async fn initialized_warms_an_implicit_src_project() {
8466 let s = scratch_project(
8467 "e_implicit",
8468 &[(
8469 "src/a.bynk",
8470 "commons demo.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
8471 )],
8472 );
8473 let backend = bare_backend().await;
8474 set_folders(&backend, &[&s.0]).await;
8475 backend.initialized(InitializedParams {}).await;
8476 assert_eq!(
8477 backend.state.read().await.projects.len(),
8478 1,
8479 "a rootless `src/` project is warmed at startup, not only on open",
8480 );
8481 }
8482
8483 #[cfg(unix)]
8487 #[tokio::test]
8488 async fn discover_projects_under_survives_a_symlink_cycle() {
8489 let s = scratch_project(
8490 "e_cycle",
8491 &[
8492 ("bynk.toml", "[project]\nname=\"p\"\n"),
8493 ("src/a.bynk", "commons p.a\n"),
8494 ],
8495 );
8496 std::os::unix::fs::symlink(&s.0, s.0.join("loop")).ok();
8498 let roots = Backend::discover_projects_under(&s.0); let canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8500 assert!(
8501 roots.contains(&canon),
8502 "the project is found and the walk terminates despite the cycle",
8503 );
8504 }
8505
8506 #[tokio::test]
8510 async fn a_created_manifest_warms_a_new_project() {
8511 let s = scratch_project("e_created", &[("src/a.bynk", "commons p.a\n")]);
8512 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
8513 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8514 let backend = bare_backend().await;
8515 set_folders(&backend, &[&s.0]).await;
8516 assert!(
8517 backend.state.read().await.projects.is_empty(),
8518 "no entry before the watcher fires",
8519 );
8520
8521 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
8522 backend
8523 .did_change_watched_files(DidChangeWatchedFilesParams {
8524 changes: vec![FileEvent {
8525 uri: toml_uri,
8526 typ: FileChangeType::CREATED,
8527 }],
8528 })
8529 .await;
8530 assert_eq!(
8531 backend.state.read().await.projects.len(),
8532 1,
8533 "a created bynk.toml warms its project via the watcher event",
8534 );
8535 }
8536
8537 #[tokio::test]
8541 async fn a_schema_lock_watcher_event_invalidates_the_cached_lock() {
8542 const OLD: &str = "version = 1\n# aaaa\n";
8543 const NEW: &str = "version = 1\n# bbbb\n";
8544 let s = scratch_project(
8545 "lock_watch",
8546 &[
8547 ("bynk.toml", "[project]\nname = \"p\"\n"),
8548 ("bynk.schema.lock", OLD),
8549 ],
8550 );
8551 let backend = backend_at(&s.0).await;
8552 let root = backend.test_root().await;
8553 let lock = root.join("bynk.schema.lock");
8554 let on = |text: &str| bynk_ide::SchemaLock::On {
8555 existing: Some(text.to_string()),
8556 };
8557 assert_eq!(backend.schema_lock_for(&root).await, on(OLD));
8558
8559 let mtime = std::fs::metadata(&lock).unwrap().modified().unwrap();
8562 std::fs::write(&lock, NEW).unwrap();
8563 std::fs::File::options()
8564 .write(true)
8565 .open(&lock)
8566 .unwrap()
8567 .set_modified(mtime)
8568 .unwrap();
8569 assert_eq!(
8570 backend.schema_lock_for(&root).await,
8571 on(OLD),
8572 "an unchanged stamp serves the cache"
8573 );
8574
8575 backend
8576 .did_change_watched_files(DidChangeWatchedFilesParams {
8577 changes: vec![FileEvent {
8578 uri: Url::from_file_path(&lock).unwrap(),
8579 typ: FileChangeType::CHANGED,
8580 }],
8581 })
8582 .await;
8583 assert_eq!(
8584 backend.schema_lock_for(&root).await,
8585 on(NEW),
8586 "the watcher event must clear the cached lock"
8587 );
8588 }
8589
8590 #[tokio::test]
8594 async fn root_for_uri_populates_the_cache() {
8595 let s = scratch_project("g_cache_hit", &[("a.bynk", "commons demo.a\n")]);
8596 let uri = file_uri(&s.0, "a.bynk");
8597 let backend = bare_backend().await;
8598
8599 assert!(
8600 backend.root_for_uri(&uri).await.is_none(),
8601 "no bynk.toml and no src/ ancestor — routes to no project",
8602 );
8603 assert_eq!(
8604 backend.state.read().await.root_cache.get(&uri),
8605 Some(&None),
8606 "the miss is cached too",
8607 );
8608 }
8609
8610 #[tokio::test]
8614 async fn a_created_manifest_invalidates_the_cached_route() {
8615 let s = scratch_project("g_cache_invalidate", &[("a.bynk", "commons p.a\n")]);
8616 let uri = file_uri(&s.0, "a.bynk");
8617 let backend = bare_backend().await;
8618
8619 assert!(
8620 backend.root_for_uri(&uri).await.is_none(),
8621 "precondition: cached as routing to no project",
8622 );
8623
8624 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
8625 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8626 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
8627 backend
8628 .did_change_watched_files(DidChangeWatchedFilesParams {
8629 changes: vec![FileEvent {
8630 uri: toml_uri,
8631 typ: FileChangeType::CREATED,
8632 }],
8633 })
8634 .await;
8635
8636 assert_eq!(
8637 backend.root_for_uri(&uri).await,
8638 Some(root),
8639 "re-routes to the new project now the stale cache entry is gone",
8640 );
8641 }
8642
8643 #[test]
8649 fn root_cache_write_is_current_rejects_a_generation_that_moved() {
8650 assert!(
8651 Backend::root_cache_write_is_current(3, 3),
8652 "no clear happened since the read — the write-back applies",
8653 );
8654 assert!(
8655 !Backend::root_cache_write_is_current(3, 4),
8656 "a clear bumped the generation since the read — the write-back must be dropped",
8657 );
8658 }
8659
8660 #[tokio::test]
8667 async fn a_workspace_folder_change_bumps_the_root_cache_generation() {
8668 let s = scratch_project("g_race_folder", &[("bynk.toml", "[project]\nname=\"p\"\n")]);
8669 let backend = bare_backend().await;
8670 let before = backend.state.read().await.root_cache_generation;
8671
8672 backend
8673 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8674 event: WorkspaceFoldersChangeEvent {
8675 added: vec![WorkspaceFolder {
8676 uri: Url::from_file_path(&s.0).unwrap(),
8677 name: "p".into(),
8678 }],
8679 removed: vec![],
8680 },
8681 })
8682 .await;
8683
8684 assert!(
8685 backend.state.read().await.root_cache_generation > before,
8686 "a workspace-folder change must bump the generation, not just clear the cache",
8687 );
8688 }
8689
8690 #[tokio::test]
8693 async fn an_added_folder_is_warmed() {
8694 let s = scratch_project(
8695 "e_added",
8696 &[
8697 ("bynk.toml", "[project]\nname=\"p\"\n"),
8698 (
8699 "src/a.bynk",
8700 "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
8701 ),
8702 ],
8703 );
8704 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
8705 let backend = bare_backend().await; assert!(backend.state.read().await.projects.is_empty());
8707
8708 backend
8709 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
8710 event: WorkspaceFoldersChangeEvent {
8711 added: vec![WorkspaceFolder {
8712 uri: Url::from_file_path(&folder).unwrap(),
8713 name: "p".into(),
8714 }],
8715 removed: vec![],
8716 },
8717 })
8718 .await;
8719
8720 assert_eq!(
8721 backend.state.read().await.projects.len(),
8722 1,
8723 "an added workspace folder's project is warmed proactively",
8724 );
8725 }
8726
8727 fn change_params(uri: &Url, version: i32, text: &str) -> DidChangeTextDocumentParams {
8730 DidChangeTextDocumentParams {
8731 text_document: VersionedTextDocumentIdentifier {
8732 uri: uri.clone(),
8733 version,
8734 },
8735 content_changes: vec![TextDocumentContentChangeEvent {
8736 range: None,
8737 range_length: None,
8738 text: text.to_string(),
8739 }],
8740 }
8741 }
8742
8743 #[tokio::test]
8748 async fn a_single_file_burst_coalesces_by_generation() {
8749 let s = scratch_project("f_single", &[("a.bynk", "commons demo.a\n")]);
8751 let uri = file_uri(&s.0, "a.bynk");
8752 assert!(
8753 Backend::root_for_uri_uncached(&uri).is_none(),
8754 "precondition: the file routes to no project",
8755 );
8756 let backend = bare_backend().await;
8757 for _ in 0..3 {
8758 backend.schedule_single_file(uri.clone()).await;
8759 }
8760 assert_eq!(
8761 backend
8762 .state
8763 .read()
8764 .await
8765 .single_file_generations
8766 .get(&uri)
8767 .copied(),
8768 Some(3),
8769 "each change bumps the generation; only the third task passes its check",
8770 );
8771 }
8772
8773 #[tokio::test]
8776 async fn did_close_clears_the_single_file_generation() {
8777 let s = scratch_project("f_close", &[("a.bynk", "commons demo.a\n")]);
8778 let uri = file_uri(&s.0, "a.bynk");
8779 let backend = bare_backend().await;
8780 backend.schedule_single_file(uri.clone()).await;
8781 assert!(
8782 backend
8783 .state
8784 .read()
8785 .await
8786 .single_file_generations
8787 .contains_key(&uri),
8788 "the generation exists after scheduling",
8789 );
8790 backend
8791 .did_close(DidCloseTextDocumentParams {
8792 text_document: TextDocumentIdentifier { uri: uri.clone() },
8793 })
8794 .await;
8795 assert!(
8796 !backend
8797 .state
8798 .read()
8799 .await
8800 .single_file_generations
8801 .contains_key(&uri),
8802 "did_close clears the single-file generation",
8803 );
8804 }
8805
8806 #[tokio::test]
8811 async fn a_project_change_burst_coalesces_through_did_change() {
8812 let src = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
8813 let s = scratch_project(
8814 "f_burst",
8815 &[
8816 ("bynk.toml", "[project]\nname=\"q\"\n"),
8817 ("src/a.bynk", src),
8818 ],
8819 );
8820 let backend = backend_at(&s.0).await;
8821 let root = backend.test_root().await;
8822 let uri = file_uri(&s.0, "src/a.bynk");
8823 open(&backend, &uri, src).await;
8824
8825 let gen_before = {
8826 let state = backend.state.read().await;
8827 state.projects.get(&root).unwrap().analysis_generation
8828 };
8829 for v in 2..=5 {
8830 backend
8831 .did_change(change_params(
8832 &uri,
8833 v,
8834 &format!("{}{src}", "\n".repeat(v as usize)),
8835 ))
8836 .await;
8837 }
8838 let gen_after = {
8839 let state = backend.state.read().await;
8840 state.projects.get(&root).unwrap().analysis_generation
8841 };
8842 assert_eq!(
8843 gen_after - gen_before,
8844 4,
8845 "each of the four changes bumps the generation once — only the last \
8846 scheduled round runs (no per-change round, no stacked debounce)",
8847 );
8848 }
8849
8850 fn completion_labels(response: Option<CompletionResponse>) -> Vec<String> {
8860 match response {
8861 Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(),
8862 Some(CompletionResponse::List(list)) => {
8863 list.items.into_iter().map(|i| i.label).collect()
8864 }
8865 None => Vec::new(),
8866 }
8867 }
8868
8869 async fn complete_at(backend: &Backend, uri: &Url, text: &str, needle: &str) -> Vec<String> {
8870 let offset = text.find(needle).expect("needle present") + needle.len();
8871 let pos = crate::position::offset_to_position(text, offset);
8872 let response = backend
8873 .completion(CompletionParams {
8874 text_document_position: TextDocumentPositionParams {
8875 text_document: TextDocumentIdentifier { uri: uri.clone() },
8876 position: pos,
8877 },
8878 work_done_progress_params: Default::default(),
8879 partial_result_params: Default::default(),
8880 context: None,
8881 })
8882 .await
8883 .expect("completion must not error");
8884 completion_labels(response)
8885 }
8886
8887 #[tokio::test]
8893 async fn store_map_receiver_completion_merges_both_vocabularies() {
8894 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";
8895 let s = scratch_project(
8896 "store_map_merge",
8897 &[
8898 ("bynk.toml", "[project]\nname=\"shop\"\n"),
8899 ("src/a.bynk", src),
8900 ],
8901 );
8902 let backend = backend_at(&s.0).await;
8903 let uri = file_uri(&s.0, "src/a.bynk");
8904 open(&backend, &uri, src).await;
8905 backend.run_round().await;
8906
8907 let labels = complete_at(&backend, &uri, src, " items.").await;
8908 assert!(
8909 labels.contains(&"filter".to_string()),
8910 "the Query kernel vocabulary: {labels:?}"
8911 );
8912 assert!(
8913 labels.contains(&"collect".to_string()),
8914 "the Query kernel vocabulary: {labels:?}"
8915 );
8916 assert!(
8917 labels.contains(&"put".to_string()),
8918 "the store entry ops: {labels:?}"
8919 );
8920 assert!(
8921 labels.contains(&"entries".to_string()),
8922 "the Map query accessors: {labels:?}"
8923 );
8924 }
8925
8926 #[tokio::test]
8931 async fn let_else_match_offers_the_residual_variants() {
8932 let src = "context shop\n\nfn f(r: Result[Int, String]) -> Int {\n let Ok(n) = r else match {\n Err(e) => 0,\n }\n n\n}\n\nfn g(o: Option[Int]) -> Effect[Int] {\n let Some(m) <- Effect.pure(o) else match {\n None => 0,\n }\n m\n}\n";
8933 let s = scratch_project(
8934 "let_else_match_residual",
8935 &[
8936 ("bynk.toml", "[project]\nname=\"shop\"\n"),
8937 ("src/shop.bynk", src),
8938 ],
8939 );
8940 let backend = backend_at(&s.0).await;
8941 let uri = file_uri(&s.0, "src/shop.bynk");
8942 open(&backend, &uri, src).await;
8943 backend.run_round().await;
8944
8945 let labels = complete_at(&backend, &uri, src, "r else match {\n ").await;
8946 assert!(labels.contains(&"Err".to_string()), "{labels:?}");
8947 assert!(
8948 !labels.contains(&"Ok".to_string()),
8949 "`Ok(n)` covers `Ok`: {labels:?}"
8950 );
8951
8952 let labels = complete_at(&backend, &uri, src, "pure(o) else match {\n ").await;
8953 assert!(labels.contains(&"None".to_string()), "{labels:?}");
8954 assert!(
8955 !labels.contains(&"Some".to_string()),
8956 "`Some(m)` covers `Some`: {labels:?}"
8957 );
8958 }
8959
8960 #[tokio::test]
8969 async fn store_field_vocabulary_survives_an_unrelated_resolve_failure() {
8970 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";
8971 let s = scratch_project(
8972 "store_map_resolve_gap",
8973 &[
8974 ("bynk.toml", "[project]\nname=\"shop\"\n"),
8975 ("src/a.bynk", src),
8976 ],
8977 );
8978 let backend = backend_at(&s.0).await;
8979 let uri = file_uri(&s.0, "src/a.bynk");
8980 open(&backend, &uri, src).await;
8981 backend.run_round().await;
8982
8983 let analysis = backend.test_analysis().await.expect("a round committed");
8987 let rel = Backend::uri_to_rel(&analysis, &uri).expect("uri resolves");
8988 assert!(
8989 analysis
8990 .diagnostics
8991 .get(&rel)
8992 .is_some_and(|ds| !ds.is_empty()),
8993 "the fixture must actually fail to check — an undeclared return \
8994 type is the trigger this test exercises",
8995 );
8996
8997 let labels = complete_at(&backend, &uri, src, " items.").await;
8998 assert!(
9001 labels.contains(&"filter".to_string()),
9002 "an unrelated resolve error must not blank the typed half: {labels:?}"
9003 );
9004 assert!(
9005 labels.contains(&"put".to_string()),
9006 "store entry ops must survive an unrelated resolve failure: {labels:?}"
9007 );
9008 assert!(
9009 labels.contains(&"entries".to_string()),
9010 "Map query accessors must survive an unrelated resolve failure: {labels:?}"
9011 );
9012 }
9013
9014 #[tokio::test]
9028 async fn an_unsaved_edit_in_one_file_is_visible_to_completion_in_another() {
9029 let widget_v1 = "commons shared.widget\n\ntype Widget = { size: Int }\n";
9030 let widget_v2 = "commons shared.widget\n\ntype Widget = { size: Int, weight: Int }\n";
9031 let use_src =
9032 "commons app.use\n\nuses shared.widget\n\nfn make() -> Widget {\n Widget { \n}\n";
9033 let s = scratch_project(
9034 "cross_file_unsaved",
9035 &[
9036 (
9037 "bynk.toml",
9038 "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
9039 ),
9040 ("shared/widget.bynk", widget_v1),
9041 ("app/use.bynk", use_src),
9042 ],
9043 );
9044 let backend = backend_at(&s.0).await;
9045 let widget_uri = file_uri(&s.0, "shared/widget.bynk");
9046 let use_uri = file_uri(&s.0, "app/use.bynk");
9047 open(&backend, &widget_uri, widget_v1).await;
9048 open(&backend, &use_uri, use_src).await;
9049 backend.run_round().await;
9050
9051 let before = complete_at(&backend, &use_uri, use_src, " Widget { ").await;
9053 assert!(before.contains(&"size".to_string()), "{before:?}");
9054 assert!(
9055 !before.contains(&"weight".to_string()),
9056 "the fixture must not already have `weight` on disk: {before:?}"
9057 );
9058
9059 backend
9061 .did_change(DidChangeTextDocumentParams {
9062 text_document: VersionedTextDocumentIdentifier {
9063 uri: widget_uri.clone(),
9064 version: 2,
9065 },
9066 content_changes: vec![TextDocumentContentChangeEvent {
9067 range: None,
9068 range_length: None,
9069 text: widget_v2.to_string(),
9070 }],
9071 })
9072 .await;
9073
9074 let after = complete_at(&backend, &use_uri, use_src, " Widget { ").await;
9075 assert!(
9076 after.contains(&"weight".to_string()),
9077 "an unsaved edit to `widget.bynk` must be visible to `use.bynk`'s \
9078 cross-file completion in the same round: {after:?}"
9079 );
9080 assert!(after.contains(&"size".to_string()), "{after:?}");
9081
9082 assert_eq!(
9084 std::fs::read_to_string(s.0.join("shared/widget.bynk")).unwrap(),
9085 widget_v1,
9086 "the edit must never have been saved to disk"
9087 );
9088 }
9089
9090 #[tokio::test]
9102 async fn type_receivers_slow_path_sees_a_closed_files_disk_content() {
9103 let widget_src = "commons shared.widget\n\ntype Widget = { size: Int }\n";
9104 let use_src =
9105 "commons app.use\n\nuses shared.widget\n\nfn area(w: Widget) -> Int {\n w.\n}\n";
9106 let s = scratch_project(
9107 "type_receiver_slow_path",
9108 &[
9109 (
9110 "bynk.toml",
9111 "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
9112 ),
9113 ("shared/widget.bynk", widget_src),
9114 ("app/use.bynk", use_src),
9115 ],
9116 );
9117 let backend = backend_at(&s.0).await;
9118 let use_uri = file_uri(&s.0, "app/use.bynk");
9119 open(&backend, &use_uri, use_src).await;
9121
9122 let labels = complete_at(&backend, &use_uri, use_src, " w.").await;
9123 assert!(
9124 labels.contains(&"size".to_string()),
9125 "type_receiver's slow path must resolve `w: Widget` off the \
9126 closed widget.bynk's real disk content: {labels:?}"
9127 );
9128 }
9129}