1pub mod architecture_request;
32pub mod capability_fixes;
33pub mod code_actions;
34pub mod completion;
35mod content;
36mod document_symbols;
37pub mod documentation_request;
38mod extract;
39pub mod hover;
40pub mod index_queries;
41mod inlay_hints;
42mod locals_nav;
43pub mod position;
44mod project;
45mod publish;
46pub mod sequence_request;
47mod signature_help;
48mod structure;
49pub mod symbols;
50pub mod wire_contract_request;
51
52use std::path::PathBuf;
53use std::sync::Arc;
54
55use tokio::sync::RwLock;
56use tower_lsp::jsonrpc::Result as JsonRpcResult;
57use tower_lsp::lsp_types::request::{
58 GotoImplementationParams, GotoImplementationResponse, GotoTypeDefinitionParams,
59 GotoTypeDefinitionResponse,
60};
61use tower_lsp::lsp_types::*;
62use tower_lsp::{Client, LanguageServer, LspService, Server};
63
64use crate::project::ProjectConfig;
65
66const SERVER_NAME: &str = "bynkc-lsp";
67const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
68
69#[derive(Debug, Clone)]
71struct DocumentState {
72 text: String,
73 version: i32,
74}
75
76#[derive(Debug)]
81struct Analysis {
82 project_root: PathBuf,
87 index: bynk_check::index::ProjectIndex,
88 snapshots: std::collections::HashMap<PathBuf, String>,
90 versions: std::collections::HashMap<PathBuf, i32>,
93 diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>>,
99 hints: bynk_check::hints::FileHints,
102 requirements: bynk_check::requirements::FileRequirements,
106 locals: bynk_check::locals::FileLocals,
110 expr_types: bynk_check::expr_types::FileExprTypes,
113 ty_intern: std::sync::Arc<bynk_check::checker::Types>,
117 unit_sources: std::collections::HashMap<String, Vec<PathBuf>>,
120 sequence_info: std::collections::HashMap<String, bynk_ide::ContextSequenceInfo>,
124 boundary_info: std::collections::HashMap<String, bynk_ide::ContextBoundaryInfo>,
128 doc_scope: std::collections::HashMap<String, Vec<String>>,
133}
134
135impl Analysis {
136 fn diag_categories(&self) -> Vec<(PathBuf, String)> {
139 self.diagnostics
140 .iter()
141 .flat_map(|(path, diags)| {
142 diags
143 .iter()
144 .map(|d| (path.clone(), d.error.category.to_string()))
145 })
146 .collect()
147 }
148}
149
150#[derive(Debug, Default)]
155struct ProjectState {
156 config: ProjectConfig,
160 analysis: Option<Arc<Analysis>>,
164 published: std::collections::HashSet<Url>,
169 analysis_generation: u64,
173 analysis_round_started: u64,
179 analysis_round_committed: u64,
181}
182
183#[derive(Debug, Clone, Copy, Default)]
187struct RefreshSupport {
188 semantic_tokens: bool,
189 inlay_hints: bool,
190 code_lens: bool,
191}
192
193#[derive(Debug, Default)]
196struct State {
197 projects: std::collections::HashMap<PathBuf, ProjectState>,
203 folders: Vec<PathBuf>,
208 docs: std::collections::HashMap<Url, DocumentState>,
211 supports_dynamic_watchers: bool,
217 supports_refresh: RefreshSupport,
223 single_file_generations: std::collections::HashMap<Url, u64>,
228 root_cache: std::collections::HashMap<Url, Option<PathBuf>>,
245 root_cache_generation: u64,
252}
253
254#[derive(Clone)]
255pub struct Backend {
256 client: Client,
257 state: Arc<RwLock<State>>,
258 refresh_lock: Arc<tokio::sync::Mutex<()>>,
263}
264
265impl Backend {
266 fn new(client: Client) -> Self {
267 Self {
268 client,
269 state: Arc::new(RwLock::new(State::default())),
270 refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
271 }
272 }
273
274 fn find_project_root(start: &std::path::Path) -> Option<PathBuf> {
277 let mut current = if start.is_file() {
278 start.parent()?.to_path_buf()
279 } else {
280 start.to_path_buf()
281 };
282 loop {
283 let candidate = current.join("bynk.toml");
284 if candidate.is_file() {
285 return Some(current);
286 }
287 current = current.parent()?.to_path_buf();
288 }
289 }
290
291 fn find_source_root(start: &std::path::Path) -> Option<PathBuf> {
297 let mut current = if start.is_file() {
298 start.parent()?.to_path_buf()
299 } else {
300 start.to_path_buf()
301 };
302 loop {
303 if current.file_name().and_then(|n| n.to_str()) == Some("src") {
304 return Some(current);
305 }
306 current = current.parent()?.to_path_buf();
307 }
308 }
309
310 fn resolve_root(start: &std::path::Path) -> Option<(PathBuf, project::ProjectConfig)> {
317 if let Some(root) = Self::find_project_root(start) {
318 let config = project::load_config(&root).unwrap_or_default();
319 return Some((root, config));
320 }
321 let src = Self::find_source_root(start)?;
326 let root = src.parent()?.to_path_buf();
327 Some((root, project::ProjectConfig::default()))
328 }
329
330 fn resolve_canonical(uri: &Url) -> Option<(PathBuf, project::ProjectConfig)> {
337 let path = uri.to_file_path().ok()?;
338 let (root, config) = Self::resolve_root(&path)?;
339 Some((root.canonicalize().unwrap_or(root), config))
340 }
341
342 fn root_for_uri_uncached(uri: &Url) -> Option<PathBuf> {
349 Self::resolve_canonical(uri).map(|(root, _)| root)
350 }
351
352 async fn root_for_uri(&self, uri: &Url) -> Option<PathBuf> {
369 let generation = {
370 let state = self.state.read().await;
371 if let Some(cached) = state.root_cache.get(uri) {
372 return cached.clone();
373 }
374 state.root_cache_generation
375 };
376 let root = Self::root_for_uri_uncached(uri);
377 let mut state = self.state.write().await;
378 if Self::root_cache_write_is_current(generation, state.root_cache_generation) {
379 state.root_cache.insert(uri.clone(), root.clone());
380 }
381 root
382 }
383
384 fn root_cache_write_is_current(read_generation: u64, current_generation: u64) -> bool {
392 read_generation == current_generation
393 }
394
395 fn discover_projects_under(folder: &std::path::Path) -> Vec<PathBuf> {
407 fn should_skip(name: &std::ffi::OsStr) -> bool {
408 let name = name.to_string_lossy();
409 matches!(name.as_ref(), "out" | "node_modules" | "target" | ".git")
410 || name.starts_with('.')
411 }
412 fn walk(
413 dir: &std::path::Path,
414 out: &mut Vec<PathBuf>,
415 visited: &mut std::collections::HashSet<PathBuf>,
416 ) {
417 let canon_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
420 if !visited.insert(canon_dir.clone()) {
421 return;
422 }
423 if dir.join("bynk.toml").is_file() && !out.contains(&canon_dir) {
424 out.push(canon_dir);
425 }
426 let Ok(entries) = std::fs::read_dir(dir) else {
427 return;
428 };
429 for entry in entries.flatten() {
430 let path = entry.path();
431 if path.is_dir() && !should_skip(&entry.file_name()) {
432 walk(&path, out, visited);
433 }
434 }
435 }
436 let mut roots = Vec::new();
437 if let Some((root, _)) = Self::resolve_root(folder) {
439 let canon = root.canonicalize().unwrap_or(root);
440 roots.push(canon);
441 }
442 if folder.join("src").is_dir() && !folder.join("bynk.toml").is_file() {
447 let canon = folder
448 .canonicalize()
449 .unwrap_or_else(|_| folder.to_path_buf());
450 if !roots.contains(&canon) {
451 roots.push(canon);
452 }
453 }
454 let mut visited = std::collections::HashSet::new();
455 walk(folder, &mut roots, &mut visited);
456 roots
457 }
458
459 async fn schedule_diagnostics(&self, uri: &Url) {
466 if let Some(root) = self.root_for_uri(uri).await {
472 {
473 let mut state = self.state.write().await;
474 if !state.projects.contains_key(&root) {
475 let config = project::load_config(&root).unwrap_or_default();
476 state.projects.insert(
477 root.clone(),
478 ProjectState {
479 config,
480 ..Default::default()
481 },
482 );
483 }
484 }
485 self.schedule_project_diagnostics(root).await;
486 } else {
487 self.schedule_single_file(uri.clone()).await;
488 }
489 }
490
491 async fn schedule_project_diagnostics(&self, root: PathBuf) {
501 let (generation, debounce) = {
502 let mut state = self.state.write().await;
503 let Some(ps) = state.projects.get_mut(&root) else {
504 return;
505 };
506 ps.analysis_generation += 1;
507 (ps.analysis_generation, ps.config.diagnostics_debounce_ms)
508 };
509 let this = self.clone();
510 tokio::spawn(async move {
511 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
512 let superseded = match this.state.read().await.projects.get(&root) {
513 Some(ps) => ps.analysis_generation != generation,
514 None => true, };
516 if superseded {
517 return;
518 }
519 this.run_project_diagnostics(root).await;
520 });
521 }
522
523 async fn schedule_single_file(&self, uri: Url) {
529 let debounce = ProjectConfig::default().diagnostics_debounce_ms;
530 let generation = {
531 let mut state = self.state.write().await;
532 let g = state
533 .single_file_generations
534 .entry(uri.clone())
535 .or_insert(0);
536 *g += 1;
537 *g
538 };
539 let this = self.clone();
540 tokio::spawn(async move {
541 tokio::time::sleep(std::time::Duration::from_millis(debounce)).await;
542 let current = this
543 .state
544 .read()
545 .await
546 .single_file_generations
547 .get(&uri)
548 .copied();
549 if current != Some(generation) {
550 return;
551 }
552 this.diagnose_single_file(&uri).await;
553 });
554 }
555
556 async fn diagnose_single_file(&self, uri: &Url) {
560 let (text, version) = {
561 let state = self.state.read().await;
562 match state.docs.get(uri) {
563 Some(d) => (d.text.clone(), d.version),
564 None => return,
565 }
566 };
567 let positions = crate::position::PositionMap::new(&text);
568 let lsp_diags: Vec<Diagnostic> = bynk_ide::diagnose(&text)
569 .into_iter()
570 .map(|d| make_diagnostic(&d, &positions, uri))
571 .collect();
572 self.client
573 .publish_diagnostics(uri.clone(), lsp_diags, Some(version))
574 .await;
575 }
576
577 async fn run_project_diagnostics(&self, root: PathBuf) {
582 let (round, root, canonical_root, overlay, versions, previously_dirty) = {
583 let mut state = self.state.write().await;
584 let Some(ps) = state.projects.get_mut(&root) else {
588 return;
589 };
590 ps.analysis_round_started += 1;
591 let round = ps.analysis_round_started;
592 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone());
596 let previously_dirty = ps.published.clone();
597 let mut overlay = std::collections::HashMap::new();
598 let mut versions = std::collections::HashMap::new();
599 for (uri, doc) in &state.docs {
605 if let Ok(p) = uri.to_file_path() {
606 let canonical = p.canonicalize().unwrap_or(p);
607 if let Ok(rel) = canonical.strip_prefix(&canonical_root) {
610 versions.insert(rel.to_path_buf(), doc.version);
611 }
612 overlay.insert(canonical, doc.text.clone());
613 }
614 }
615 (
616 round,
617 root,
618 canonical_root,
619 overlay,
620 versions,
621 previously_dirty,
622 )
623 };
624
625 let roots = bynk_ide::AnalysisRoots::Project(root.clone());
627 let Ok(result) = tokio::task::spawn_blocking(move || {
632 let content = crate::content::sweep_project_content(&roots, &overlay);
633 bynk_ide::diagnose_project_with(&roots, &content)
634 })
635 .await
636 else {
637 return;
638 };
639
640 let mut new_by_uri: std::collections::HashMap<Url, Vec<Diagnostic>> =
641 std::collections::HashMap::new();
642 let mut version_by_uri: std::collections::HashMap<Url, Option<i32>> =
647 std::collections::HashMap::new();
648 let mut snapshots = std::collections::HashMap::new();
649 let mut diagnostics: std::collections::HashMap<PathBuf, Vec<bynk_ide::Diagnostic>> =
650 std::collections::HashMap::new();
651 for file in &result.files {
652 let abs = canonical_root.join(&file.source_path);
653 let abs = abs.canonicalize().unwrap_or(abs);
654 let Ok(uri) = Url::from_file_path(&abs) else {
655 continue;
656 };
657 let positions = crate::position::PositionMap::new(&file.text);
660 let diags: Vec<Diagnostic> = file
661 .diagnostics
662 .iter()
663 .map(|d| make_diagnostic(d, &positions, &uri))
664 .collect();
665 version_by_uri.insert(uri.clone(), versions.get(&file.source_path).copied());
666 new_by_uri.insert(uri, diags);
667 diagnostics.insert(file.source_path.clone(), file.diagnostics.clone());
668 snapshots.insert(file.source_path.clone(), file.text.clone());
669 }
670 {
674 let analysis = Arc::new(Analysis {
675 project_root: canonical_root.clone(),
676 index: result.index.clone(),
677 snapshots,
678 versions,
679 diagnostics,
680 hints: result.hints,
681 requirements: result.requirements,
682 locals: result.locals,
683 expr_types: result.expr_types,
684 ty_intern: result.ty_intern,
685 unit_sources: result.unit_sources,
686 sequence_info: result.sequence_info,
687 boundary_info: result.boundary_info,
688 doc_scope: result.doc_scope,
689 });
690 let mut state = self.state.write().await;
691 let Some(ps) = state.projects.get_mut(&root) else {
692 return; };
694 if ps.analysis_round_committed >= round {
697 return;
698 }
699 ps.analysis_round_committed = round;
700 ps.analysis = Some(analysis);
701 }
702 let unattributed_anchor = {
707 let toml = root.join("bynk.toml");
708 if toml.is_file() {
709 Url::from_file_path(toml).ok()
710 } else {
711 result.files.first().and_then(|f| {
712 let abs = canonical_root.join(&f.source_path);
713 let abs = abs.canonicalize().unwrap_or(abs);
714 Url::from_file_path(abs).ok()
715 })
716 }
717 };
718 if !result.unattributed.is_empty()
719 && let Some(anchor_uri) = unattributed_anchor
720 {
721 let entry = new_by_uri.entry(anchor_uri).or_default();
722 for d in &result.unattributed {
723 entry.push(Diagnostic {
724 range: Default::default(),
725 severity: Some(match d.severity {
726 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
727 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
728 }),
729 code: Some(tower_lsp::lsp_types::NumberOrString::String(
730 d.error.category.to_string(),
731 )),
732 message: d.error.message.clone(),
733 ..Default::default()
734 });
735 }
736 }
737
738 let (publishes, dirty) = publish::publish_plan(&previously_dirty, new_by_uri);
739 for (uri, diags) in publishes {
740 let version = version_by_uri.get(&uri).copied().flatten();
745 self.client.publish_diagnostics(uri, diags, version).await;
746 }
747 let still_current = {
748 let mut state = self.state.write().await;
749 if let Some(ps) = state.projects.get_mut(&root)
750 && ps.analysis_round_committed == round
751 {
752 ps.published = dirty;
753 true
754 } else {
755 false
756 }
757 };
758 if still_current {
769 let refresh = self.state.read().await.supports_refresh;
770 if refresh.semantic_tokens || refresh.inlay_hints || refresh.code_lens {
771 let client = self.client.clone();
772 tokio::spawn(async move {
773 if refresh.semantic_tokens {
774 let _ = client.semantic_tokens_refresh().await;
775 }
776 if refresh.inlay_hints {
777 let _ = client.inlay_hint_refresh().await;
778 }
779 if refresh.code_lens {
780 let _ = client.code_lens_refresh().await;
781 }
782 });
783 }
784 }
785 }
786
787 async fn analysis_roots_for(&self, uri: &Url) -> Option<bynk_ide::AnalysisRoots> {
797 Some(bynk_ide::AnalysisRoots::Project(
798 self.root_for_uri(uri).await?,
799 ))
800 }
801
802 async fn project_content(
817 &self,
818 uri: &Url,
819 ) -> Option<Arc<std::collections::HashMap<PathBuf, String>>> {
820 let roots = self.analysis_roots_for(uri).await?;
821 let current = uri
822 .to_file_path()
823 .ok()
824 .and_then(|p| std::fs::canonicalize(&p).ok());
825 let overlay = {
826 let state = self.state.read().await;
827 let mut ov = std::collections::HashMap::new();
828 for (u, doc) in &state.docs {
829 if let Ok(p) = u.to_file_path() {
830 let canonical = p.canonicalize().unwrap_or(p);
831 ov.insert(canonical, doc.text.clone());
832 }
833 }
834 ov
835 };
836 tokio::task::spawn_blocking(move || {
837 let mut content = crate::content::sweep_project_content(&roots, &overlay);
838 if let Some(current) = current {
839 content.remove(¤t);
840 }
841 Arc::new(content)
845 })
846 .await
847 .ok()
848 }
849
850 fn local_sites(
853 &self,
854 analysis: &Analysis,
855 rel: &std::path::Path,
856 offset: usize,
857 ) -> Option<Vec<bynk_syntax::span::Span>> {
858 let text = analysis.snapshots.get(rel)?;
859 let locals = analysis.locals.get(rel)?;
860 crate::locals_nav::local_sites_at(locals, text, offset)
861 }
862
863 async fn locals_completions(&self, uri: &Url, pos: Position) -> Vec<CompletionItem> {
869 let analysis = self.analysis_for(uri).await;
873 let Some(analysis) = analysis else {
874 return Vec::new();
875 };
876 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
877 return Vec::new();
878 };
879 let (Some(text), Some(locals)) = (analysis.snapshots.get(&rel), analysis.locals.get(&rel))
880 else {
881 return Vec::new();
882 };
883 let Some(offset) = crate::position::position_to_offset(text, pos) else {
884 return Vec::new();
885 };
886 bynk_check::locals::locals_at(locals, offset)
887 .into_iter()
888 .map(|b| CompletionItem {
889 label: b.name.clone(),
890 kind: Some(CompletionItemKind::VARIABLE),
891 detail: Some(b.ty.clone()),
892 ..Default::default()
893 })
894 .collect()
895 }
896
897 fn local_locations(
899 &self,
900 analysis: &Analysis,
901 rel: &std::path::Path,
902 spans: &[bynk_syntax::span::Span],
903 ) -> Vec<Location> {
904 let Some(text) = analysis.snapshots.get(rel) else {
905 return Vec::new();
906 };
907 let Ok(uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
908 return Vec::new();
909 };
910 spans
911 .iter()
912 .map(|s| Location {
913 uri: uri.clone(),
914 range: crate::position::span_to_range(text, *s),
915 })
916 .collect()
917 }
918
919 async fn value_member_completions(
938 &self,
939 uri: &Url,
940 text: &str,
941 offset: usize,
942 ) -> Vec<CompletionItem> {
943 let Some((rewritten, recv_offset)) = completion::value_receiver_rewrite(text, offset)
944 else {
945 return Vec::new();
946 };
947 let mut items: Vec<CompletionItem> = Vec::new();
948 if let Some((ty, tys)) = self
949 .type_receiver(uri, rewritten.clone(), recv_offset)
950 .await
951 {
952 let files = self.project_content(uri).await;
953 items.extend(
954 completion::value_member_candidates(ty, &tys, text, files.as_deref())
955 .into_iter()
956 .map(to_completion_item),
957 );
958 }
959 let locals = self.fast_path_locals(uri, &rewritten).await;
960 items.extend(
961 completion::store_field_member_candidates(&rewritten, recv_offset, &locals)
962 .into_iter()
963 .map(to_completion_item),
964 );
965 items
966 }
967
968 async fn fast_path_locals(
974 &self,
975 uri: &Url,
976 rewritten: &str,
977 ) -> Vec<bynk_check::locals::LocalBinding> {
978 let Some(analysis) = self.project_analysis_for(uri).await else {
979 return Vec::new();
980 };
981 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
982 return Vec::new();
983 };
984 if analysis.snapshots.get(&rel).map(String::as_str) != Some(rewritten) {
985 return Vec::new();
986 }
987 analysis.locals.get(&rel).cloned().unwrap_or_default()
988 }
989
990 async fn is_pattern_completions(
995 &self,
996 uri: &Url,
997 text: &str,
998 offset: usize,
999 ) -> Vec<CompletionItem> {
1000 let Some(scrut_off) = is_scrutinee_offset(text, offset) else {
1001 return Vec::new();
1002 };
1003 self.scrutinee_variant_completions(uri, text, scrut_off)
1004 .await
1005 }
1006
1007 async fn match_arm_completions(
1011 &self,
1012 uri: &Url,
1013 text: &str,
1014 offset: usize,
1015 ) -> Vec<CompletionItem> {
1016 let Some(scrut_off) = match_scrutinee_offset(text, offset) else {
1017 return Vec::new();
1018 };
1019 self.scrutinee_variant_completions(uri, text, scrut_off)
1020 .await
1021 }
1022
1023 async fn scrutinee_variant_completions(
1030 &self,
1031 uri: &Url,
1032 text: &str,
1033 scrut_off: usize,
1034 ) -> Vec<CompletionItem> {
1035 let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1036 return Vec::new();
1037 };
1038 let files = self.project_content(uri).await;
1039 completion::variants_for_ty(ty, &tys, text, files.as_deref())
1040 .into_iter()
1041 .map(to_completion_item)
1042 .collect()
1043 }
1044
1045 async fn nested_pattern_completions(
1052 &self,
1053 uri: &Url,
1054 text: &str,
1055 offset: usize,
1056 ) -> Vec<CompletionItem> {
1057 let Some((scrut_off, variant)) = nested_pattern_offset(text, offset) else {
1058 return Vec::new();
1059 };
1060 let Some((ty, tys)) = self.type_receiver(uri, text.to_string(), scrut_off).await else {
1061 return Vec::new();
1062 };
1063 let files = self.project_content(uri).await;
1064 completion::nested_variant_completions(ty, &tys, &variant, text, files.as_deref())
1065 .into_iter()
1066 .map(to_completion_item)
1067 .collect()
1068 }
1069
1070 async fn type_receiver(
1075 &self,
1076 uri: &Url,
1077 rewritten: String,
1078 recv_offset: usize,
1079 ) -> Option<(
1080 bynk_check::checker::TyId,
1081 std::sync::Arc<bynk_check::checker::Types>,
1082 )> {
1083 let roots = self.analysis_roots_for(uri).await?;
1087 let project_root = roots.project_root().to_path_buf();
1088 let canonical_root = project_root
1089 .canonicalize()
1090 .unwrap_or_else(|_| project_root.clone());
1091 let cur = uri.to_file_path().ok()?;
1092 let cur = cur.canonicalize().unwrap_or(cur);
1093 let rel = cur.strip_prefix(&canonical_root).ok()?.to_path_buf();
1095 let overlay = {
1097 let state = self.state.read().await;
1098 let mut ov = std::collections::HashMap::new();
1099 for (u, doc) in &state.docs {
1100 if let Ok(p) = u.to_file_path() {
1101 let canonical = p.canonicalize().unwrap_or(p);
1102 let t = if u == uri {
1103 rewritten.clone()
1104 } else {
1105 doc.text.clone()
1106 };
1107 ov.insert(canonical, t);
1108 }
1109 }
1110 ov
1111 };
1112 if let Some(analysis) = self.project_analysis_for(uri).await
1118 && analysis.snapshots.get(&rel).map(String::as_str) == Some(rewritten.as_str())
1119 && let Some((_, entries)) = analysis.expr_types.iter().find(|(p, _)| **p == rel)
1120 {
1121 return bynk_check::expr_types::type_at_offset(entries, recv_offset)
1122 .map(|t| (t, std::sync::Arc::clone(&analysis.ty_intern)));
1123 }
1124 let result = tokio::task::spawn_blocking(move || {
1128 let content = crate::content::sweep_project_content(&roots, &overlay);
1129 bynk_ide::diagnose_project_with(&roots, &content)
1130 })
1131 .await
1132 .ok()?;
1133 let (_, entries) = result.expr_types.iter().find(|(p, _)| **p == rel)?;
1134 bynk_check::expr_types::type_at_offset(entries, recv_offset)
1135 .map(|t| (t, std::sync::Arc::clone(&result.ty_intern)))
1136 }
1137
1138 async fn project_analysis(&self, root: &std::path::Path) -> Option<Arc<Analysis>> {
1141 self.state.read().await.projects.get(root)?.analysis.clone()
1142 }
1143
1144 async fn project_analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1148 let root = self.root_for_uri(uri).await?;
1149 self.project_analysis(&root).await
1150 }
1151
1152 async fn ensure_project_analysed(
1157 &self,
1158 root: PathBuf,
1159 config: ProjectConfig,
1160 ) -> Option<Arc<Analysis>> {
1161 {
1162 let mut state = self.state.write().await;
1163 state
1164 .projects
1165 .entry(root.clone())
1166 .or_insert_with(|| ProjectState {
1167 config,
1168 ..Default::default()
1169 });
1170 }
1171 if let Some(a) = self.project_analysis(&root).await {
1172 return Some(a);
1173 }
1174 self.refresh_now(root.clone()).await;
1175 self.project_analysis(&root).await
1176 }
1177
1178 async fn prune_orphaned_projects(&self) -> Vec<Url> {
1189 let (folders, open_uris) = {
1212 let state = self.state.read().await;
1213 (
1214 state.folders.clone(),
1215 state.docs.keys().cloned().collect::<Vec<_>>(),
1216 )
1217 };
1218 let open_roots: std::collections::HashSet<PathBuf> = open_uris
1219 .iter()
1220 .filter_map(Self::root_for_uri_uncached)
1221 .collect();
1222 let covered = |root: &std::path::Path| {
1223 folders
1224 .iter()
1225 .any(|f| f.starts_with(root) || root.starts_with(f))
1226 || open_roots.contains(root)
1227 };
1228 let mut state = self.state.write().await;
1229 let orphaned: Vec<PathBuf> = state
1230 .projects
1231 .keys()
1232 .filter(|r| !covered(r))
1233 .cloned()
1234 .collect();
1235 let mut to_clear = Vec::new();
1236 for root in orphaned {
1237 if let Some(ps) = state.projects.remove(&root) {
1238 to_clear.extend(ps.published);
1239 }
1240 }
1241 to_clear
1242 }
1243
1244 async fn warm_projects(&self, folders: &[PathBuf]) {
1251 if folders.is_empty() {
1252 return;
1253 }
1254 let folders = folders.to_vec();
1258 let roots = tokio::task::spawn_blocking(move || {
1259 let mut roots: Vec<PathBuf> = Vec::new();
1260 for folder in &folders {
1261 for root in Self::discover_projects_under(folder) {
1262 if !roots.contains(&root) {
1263 roots.push(root);
1264 }
1265 }
1266 }
1267 roots
1268 })
1269 .await
1270 .unwrap_or_default();
1271 for root in roots {
1272 let config = project::load_config(&root).unwrap_or_default();
1273 {
1274 let mut state = self.state.write().await;
1275 state
1276 .projects
1277 .entry(root.clone())
1278 .or_insert_with(|| ProjectState {
1279 config,
1280 ..Default::default()
1281 });
1282 }
1283 self.schedule_project_diagnostics(root).await;
1284 }
1285 }
1286
1287 async fn register_file_watchers(&self) {
1293 use tower_lsp::lsp_types::{
1294 DidChangeWatchedFilesRegistrationOptions, FileSystemWatcher, GlobPattern, Registration,
1295 };
1296 let watchers = ["**/*.bynk", "**/bynk.toml"]
1297 .into_iter()
1298 .map(|g| FileSystemWatcher {
1299 glob_pattern: GlobPattern::String(g.to_string()),
1300 kind: None, })
1302 .collect();
1303 let registration = Registration {
1304 id: "bynk-watched-files".to_string(),
1305 method: "workspace/didChangeWatchedFiles".to_string(),
1306 register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions {
1307 watchers,
1308 })
1309 .ok(),
1310 };
1311 if let Err(e) = self.client.register_capability(vec![registration]).await {
1312 self.client
1313 .log_message(
1314 MessageType::WARNING,
1315 format!("bynkc-lsp: file-watcher registration failed: {e}"),
1316 )
1317 .await;
1318 }
1319 }
1320
1321 async fn config_for(&self, uri: &Url) -> ProjectConfig {
1325 let Some(root) = self.root_for_uri(uri).await else {
1326 return ProjectConfig::default();
1327 };
1328 self.state
1329 .read()
1330 .await
1331 .projects
1332 .get(&root)
1333 .map(|p| p.config.clone())
1334 .unwrap_or_default()
1335 }
1336
1337 async fn analysis_for(&self, uri: &Url) -> Option<Arc<Analysis>> {
1357 let root = self.root_for_uri(uri).await?;
1358 let want = self.state.read().await.docs.get(uri).map(|d| d.version);
1363 let current = |a: &Arc<Analysis>| {
1364 let Some(rel) = Self::uri_to_rel(a, uri) else {
1365 return false; };
1367 if !a.snapshots.contains_key(&rel) {
1372 return false;
1373 }
1374 match want {
1375 Some(v) => a.versions.get(&rel) == Some(&v),
1378 None => true,
1381 }
1382 };
1383
1384 if let Some(a) = self.project_analysis(&root).await
1385 && current(&a)
1386 {
1387 return Some(a);
1388 }
1389
1390 let _guard = self.refresh_lock.lock().await;
1395 if let Some(a) = self.project_analysis(&root).await
1396 && current(&a)
1397 {
1398 return Some(a);
1399 }
1400 self.refresh_now(root.clone()).await;
1401 let a = self.project_analysis(&root).await?;
1402 current(&a).then_some(a)
1406 }
1407
1408 async fn committed_analysis(&self, uri: &Url) -> Option<Arc<Analysis>> {
1433 let root = self.root_for_uri(uri).await?;
1434 let a = self.project_analysis(&root).await?;
1435 let rel = Self::uri_to_rel(&a, uri)?;
1438 a.snapshots.contains_key(&rel).then_some(a)
1439 }
1440
1441 async fn analysis_covering_open_buffers(
1462 &self,
1463 root: &std::path::Path,
1464 ) -> Option<Arc<Analysis>> {
1465 let all_current =
1469 |a: &Arc<Analysis>, docs: &std::collections::HashMap<Url, DocumentState>| {
1470 docs.iter()
1471 .all(|(uri, doc)| match Self::uri_to_rel(a, uri) {
1472 Some(rel) if a.snapshots.contains_key(&rel) => {
1473 a.versions.get(&rel) == Some(&doc.version)
1474 }
1475 _ => true,
1476 })
1477 };
1478
1479 {
1480 let state = self.state.read().await;
1481 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1482 && all_current(&a, &state.docs)
1483 {
1484 return Some(a);
1485 }
1486 }
1487 let _guard = self.refresh_lock.lock().await;
1488 {
1489 let state = self.state.read().await;
1490 if let Some(a) = state.projects.get(root).and_then(|p| p.analysis.clone())
1491 && all_current(&a, &state.docs)
1492 {
1493 return Some(a);
1494 }
1495 }
1496 self.refresh_now(root.to_path_buf()).await;
1497 let state = self.state.read().await;
1498 let a = state.projects.get(root).and_then(|p| p.analysis.clone())?;
1499 all_current(&a, &state.docs).then_some(a)
1500 }
1501
1502 async fn refresh_now(&self, root: PathBuf) {
1507 if let Some(ps) = self.state.write().await.projects.get_mut(&root) {
1508 ps.analysis_generation += 1;
1509 }
1510 self.run_project_diagnostics(root).await;
1511 }
1512
1513 fn uri_to_rel(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1515 let p = uri.to_file_path().ok()?;
1516 let canonical = p.canonicalize().unwrap_or(p);
1517 canonical
1522 .strip_prefix(&analysis.project_root)
1523 .ok()
1524 .map(|r| r.to_path_buf())
1525 }
1526
1527 fn uri_to_rel_for_new_path(analysis: &Analysis, uri: &Url) -> Option<PathBuf> {
1538 let p = uri.to_file_path().ok()?;
1539 let file_name = p.file_name()?;
1540 let parent = p.parent()?;
1541 let canonical_parent = parent
1542 .canonicalize()
1543 .unwrap_or_else(|_| parent.to_path_buf());
1544 canonical_parent
1545 .join(file_name)
1546 .strip_prefix(&analysis.project_root)
1547 .ok()
1548 .map(|r| r.to_path_buf())
1549 }
1550
1551 async fn unit_reference_definition(&self, uri: &Url, pos: Position) -> Option<Location> {
1557 let analysis = self.analysis_for(uri).await;
1563 let text = self
1564 .state
1565 .read()
1566 .await
1567 .docs
1568 .get(uri)
1569 .map(|d| d.text.clone());
1570 let (text, analysis) = (text?, analysis?);
1571 let offset = cursor_offset(&text, pos);
1572 for (unit, span) in crate::symbols::unit_reference_spans(&text) {
1573 if span.start <= offset && offset <= span.end {
1574 let rel = analysis.unit_sources.get(&unit)?.first()?;
1575 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
1576 return Some(Location {
1577 uri: target,
1578 range: Range::default(),
1579 });
1580 }
1581 }
1582 None
1583 }
1584
1585 fn site_to_location(
1588 analysis: &Analysis,
1589 site: &bynk_check::index::SiteRef,
1590 ) -> Option<Location> {
1591 let text = analysis.snapshots.get(&site.path)?;
1592 let abs = analysis.project_root.join(&site.path);
1593 let uri = Url::from_file_path(abs).ok()?;
1594 Some(Location {
1595 uri,
1596 range: crate::position::span_to_range(text, site.span),
1597 })
1598 }
1599
1600 fn call_hierarchy_item(
1605 analysis: &Analysis,
1606 key: &bynk_check::index::SymbolKey,
1607 def: &bynk_check::index::SiteRef,
1608 ) -> Option<CallHierarchyItem> {
1609 let location = Self::site_to_location(analysis, def)?;
1610 Some(CallHierarchyItem {
1611 name: key.name.clone(),
1612 kind: lsp_symbol_kind(key.kind),
1613 tags: None,
1614 detail: Some(key.unit.clone()),
1615 uri: location.uri,
1616 range: location.range,
1617 selection_range: location.range,
1618 data: serde_json::to_value(SerKey::from(key)).ok(),
1619 })
1620 }
1621
1622 fn call_ranges(analysis: &Analysis, sites: &[&bynk_check::index::SiteRef]) -> Vec<Range> {
1625 sites
1626 .iter()
1627 .filter_map(|s| {
1628 let text = analysis.snapshots.get(&s.path)?;
1629 Some(crate::position::span_to_range(text, s.span))
1630 })
1631 .collect()
1632 }
1633
1634 async fn semantic_tokens_for(&self, uri: &Url, range: Option<Range>) -> Vec<SemanticToken> {
1639 let analysis = self.committed_analysis(uri).await;
1643 let Some(analysis) = analysis else {
1644 return Vec::new();
1645 };
1646 let Some(rel) = Self::uri_to_rel(&analysis, uri) else {
1647 return Vec::new();
1648 };
1649 let Some(text) = analysis.snapshots.get(&rel) else {
1650 return Vec::new();
1651 };
1652 let span = match range {
1653 None => None,
1654 Some(r) => {
1657 let (Some(start), Some(end)) = (
1658 crate::position::position_to_offset(text, r.start),
1659 crate::position::position_to_offset(text, r.end),
1660 ) else {
1661 return Vec::new();
1662 };
1663 Some(bynk_syntax::span::Span::new(start, end))
1664 }
1665 };
1666 let lt = analysis
1667 .locals
1668 .get(&rel)
1669 .map(|l| crate::locals_nav::local_token_sites(l, text))
1670 .unwrap_or_default();
1671 let dt = crate::symbols::handler_annotation_token_spans(text);
1675 crate::index_queries::semantic_tokens(&analysis.index, <, &dt, &rel, text, span)
1676 }
1677
1678 async fn index_position(
1681 &self,
1682 uri: &Url,
1683 position: Position,
1684 ) -> Option<(Arc<Analysis>, PathBuf, usize)> {
1685 let analysis = self.analysis_for(uri).await?;
1690 let rel = Self::uri_to_rel(&analysis, uri)?;
1691 let text = analysis.snapshots.get(&rel)?;
1692 let offset = crate::position::position_to_offset(text, position)?;
1693 Some((analysis, rel, offset))
1694 }
1695
1696 async fn identifier_at(
1700 &self,
1701 uri: &Url,
1702 position: Position,
1703 ) -> Option<(String, bynk_syntax::span::Span, String)> {
1704 let text = {
1705 let state = self.state.read().await;
1706 state.docs.get(uri)?.text.clone()
1707 };
1708 let offset = crate::position::position_to_offset(&text, position)?;
1709 let tokens = bynk_syntax::lexer::tokenize_expanding_holes(&text).ok()?;
1713 for t in &tokens {
1715 if t.span.start <= offset
1716 && offset < t.span.end
1717 && matches!(
1718 t.kind,
1719 bynk_syntax::lexer::TokenKind::Ident
1720 | bynk_syntax::lexer::TokenKind::Int
1721 | bynk_syntax::lexer::TokenKind::String
1722 | bynk_syntax::lexer::TokenKind::Bool
1723 | bynk_syntax::lexer::TokenKind::Float
1724 | bynk_syntax::lexer::TokenKind::Result
1725 | bynk_syntax::lexer::TokenKind::Option
1726 | bynk_syntax::lexer::TokenKind::Effect
1727 )
1728 {
1729 let name = text[t.span.start..t.span.end].to_string();
1730 return Some((name, t.span, text));
1731 }
1732 }
1733 None
1734 }
1735
1736 async fn sequence_model(
1743 &self,
1744 params: sequence_request::SequenceModelParams,
1745 ) -> JsonRpcResult<Option<sequence_request::WireSequenceModel>> {
1746 let uri = params.text_document.uri;
1747 let Some(analysis) = self.committed_analysis(&uri).await else {
1748 return Ok(None);
1749 };
1750 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1751 return Ok(None);
1752 };
1753 let Some(text) = analysis.snapshots.get(&rel) else {
1754 return Ok(None);
1755 };
1756 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
1757 return Ok(None);
1758 };
1759 let info = bynk_ide::symbols::own_declaration_name(text)
1760 .and_then(|(name, _)| analysis.sequence_info.get(&name));
1761 let model = sequence_request::sequence_model_at(text, offset, info);
1762 Ok(model.map(|m| sequence_request::to_wire(&m, text)))
1763 }
1764
1765 async fn documentation_model(
1774 &self,
1775 params: documentation_request::DocumentationModelParams,
1776 ) -> JsonRpcResult<Option<documentation_request::WireDocModel>> {
1777 let uri = params.text_document.uri;
1778 let Some(analysis) = self.committed_analysis(&uri).await else {
1779 return Ok(None);
1780 };
1781 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1782 return Ok(None);
1783 };
1784 let Some(text) = analysis.snapshots.get(&rel) else {
1785 return Ok(None);
1786 };
1787 let model = documentation_request::documentation_model_at(text);
1788 Ok(model.map(|m| documentation_request::to_wire(&m, text)))
1789 }
1790
1791 async fn architecture_model(
1801 &self,
1802 params: architecture_request::ArchitectureModelParams,
1803 ) -> JsonRpcResult<Option<architecture_request::WireArchModel>> {
1804 let uri = params.text_document.uri;
1805 let Some(analysis) = self.committed_analysis(&uri).await else {
1806 return Ok(None);
1807 };
1808 let model = architecture_request::architecture_model_for(
1809 &analysis.unit_sources,
1810 &analysis.snapshots,
1811 &analysis.sequence_info,
1812 );
1813 Ok(Some(architecture_request::to_wire(
1814 &model,
1815 &analysis.project_root,
1816 &analysis.snapshots,
1817 )))
1818 }
1819
1820 async fn wire_contract(
1830 &self,
1831 params: wire_contract_request::WireContractParams,
1832 ) -> JsonRpcResult<Option<wire_contract_request::WcModel>> {
1833 let uri = params.text_document.uri;
1834 let Some(analysis) = self.committed_analysis(&uri).await else {
1835 return Ok(None);
1836 };
1837 let tys = &analysis.ty_intern;
1838 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
1839 return Ok(None);
1840 };
1841 let Some(text) = analysis.snapshots.get(&rel) else {
1842 return Ok(None);
1843 };
1844 let Some(offset) = crate::position::position_to_offset(text, params.position) else {
1845 return Ok(None);
1846 };
1847 let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text) else {
1850 return Ok(None);
1851 };
1852 let Some(info) = analysis.boundary_info.get(&unit) else {
1853 return Ok(None);
1854 };
1855 let expr_types = analysis
1856 .expr_types
1857 .get(&rel)
1858 .map(|v| v.as_slice())
1859 .unwrap_or(&[]);
1860 let context_count = bynk_ide::wire_contract::real_context_count(
1861 &analysis.boundary_info,
1862 &analysis.unit_sources,
1863 );
1864 let Some(model) = bynk_ide::wire_contract::wire_contract_at(
1865 &unit,
1866 text,
1867 offset,
1868 info,
1869 expr_types,
1870 tys,
1871 context_count,
1872 ) else {
1873 return Ok(None);
1874 };
1875 let search_order = analysis
1879 .doc_scope
1880 .get(&unit)
1881 .cloned()
1882 .unwrap_or_else(|| vec![unit.clone()]);
1883 Ok(Some(wire_contract_request::to_wire(
1884 &model,
1885 &analysis.project_root,
1886 text,
1887 &info.types,
1888 &analysis.index,
1889 &analysis.snapshots,
1890 &search_order,
1891 )))
1892 }
1893}
1894
1895#[tower_lsp::async_trait]
1896impl LanguageServer for Backend {
1897 async fn initialize(&self, params: InitializeParams) -> JsonRpcResult<InitializeResult> {
1898 let dynamic_watchers = params
1905 .capabilities
1906 .workspace
1907 .as_ref()
1908 .and_then(|w| w.did_change_watched_files.as_ref())
1909 .and_then(|d| d.dynamic_registration)
1910 .unwrap_or(false);
1911 let ws = params.capabilities.workspace.as_ref();
1915 let supports_refresh = RefreshSupport {
1916 semantic_tokens: ws
1917 .and_then(|w| w.semantic_tokens.as_ref())
1918 .and_then(|s| s.refresh_support)
1919 .unwrap_or(false),
1920 inlay_hints: ws
1921 .and_then(|w| w.inlay_hint.as_ref())
1922 .and_then(|i| i.refresh_support)
1923 .unwrap_or(false),
1924 code_lens: ws
1925 .and_then(|w| w.code_lens.as_ref())
1926 .and_then(|c| c.refresh_support)
1927 .unwrap_or(false),
1928 };
1929 {
1930 let mut state = self.state.write().await;
1931 state.supports_dynamic_watchers = dynamic_watchers;
1932 state.supports_refresh = supports_refresh;
1933 if let Some(folders) = ¶ms.workspace_folders {
1934 state.folders = folders
1935 .iter()
1936 .filter_map(|f| f.uri.to_file_path().ok())
1937 .map(|p| p.canonicalize().unwrap_or(p))
1938 .collect();
1939 }
1940 }
1941 Ok(InitializeResult {
1942 capabilities: server_capabilities(),
1943 server_info: Some(ServerInfo {
1944 name: SERVER_NAME.into(),
1945 version: Some(SERVER_VERSION.into()),
1946 }),
1947 })
1948 }
1949
1950 async fn initialized(&self, _: InitializedParams) {
1951 let (folders, dynamic) = {
1952 let s = self.state.read().await;
1953 (s.folders.clone(), s.supports_dynamic_watchers)
1954 };
1955 if dynamic {
1961 self.register_file_watchers().await;
1962 }
1963 self.warm_projects(&folders).await;
1966 let msg = if folders.is_empty() {
1967 "bynkc-lsp: no workspace folders; single-file mode".to_string()
1968 } else {
1969 format!(
1970 "bynkc-lsp: {} workspace folder(s); projects resolved per file",
1971 folders.len()
1972 )
1973 };
1974 self.client.log_message(MessageType::INFO, msg).await;
1975 }
1976
1977 async fn shutdown(&self) -> JsonRpcResult<()> {
1978 Ok(())
1979 }
1980
1981 async fn did_open(&self, params: DidOpenTextDocumentParams) {
1982 let uri = params.text_document.uri.clone();
1983 {
1984 let mut state = self.state.write().await;
1985 state.docs.insert(
1986 uri.clone(),
1987 DocumentState {
1988 text: params.text_document.text,
1989 version: params.text_document.version,
1990 },
1991 );
1992 }
1993 self.schedule_diagnostics(&uri).await;
1996 }
1997
1998 async fn did_change(&self, params: DidChangeTextDocumentParams) {
1999 let uri = params.text_document.uri.clone();
2000 {
2001 let mut state = self.state.write().await;
2002 if let Some(doc) = state.docs.get_mut(&uri)
2003 && let Some(change) = params.content_changes.into_iter().next_back()
2004 {
2005 doc.text = change.text;
2006 doc.version = params.text_document.version;
2007 }
2008 }
2009 if self.config_for(&uri).await.diagnostics_mode == crate::project::DiagnosticsMode::OnSave {
2014 return;
2015 }
2016 self.schedule_diagnostics(&uri).await;
2020 }
2021
2022 async fn did_save(&self, params: DidSaveTextDocumentParams) {
2023 self.schedule_diagnostics(¶ms.text_document.uri).await;
2026 }
2027
2028 async fn did_close(&self, params: DidCloseTextDocumentParams) {
2029 let uri = params.text_document.uri;
2030 {
2031 let mut state = self.state.write().await;
2032 state.docs.remove(&uri);
2033 state.single_file_generations.remove(&uri);
2036 }
2037 for cleared in self.prune_orphaned_projects().await {
2042 self.client
2043 .publish_diagnostics(cleared, Vec::new(), None)
2044 .await;
2045 }
2046 }
2047
2048 async fn hover(&self, params: HoverParams) -> JsonRpcResult<Option<Hover>> {
2053 let uri = params.text_document_position_params.text_document.uri;
2054 let pos = params.text_document_position_params.position;
2055 let positioned = self.index_position(&uri, pos).await;
2057 let doc_text = {
2060 let state = self.state.read().await;
2061 state.docs.get(&uri).map(|d| d.text.clone())
2062 };
2063 let doc = doc_text
2064 .as_deref()
2065 .and_then(|t| Some((t, crate::position::position_to_offset(t, pos)?)));
2066 let files = self.project_content(&uri).await;
2067 let analysis = positioned
2068 .as_ref()
2069 .map(|(a, rel, offset)| crate::hover::HoverAnalysis {
2070 index: &a.index,
2071 snapshots: &a.snapshots,
2072 locals: &a.locals,
2073 expr_types: &a.expr_types,
2074 tys: &a.ty_intern,
2075 rel,
2076 offset: *offset,
2077 project_root: &a.project_root,
2078 doc_scope: &a.doc_scope,
2079 boundary_info: &a.boundary_info,
2080 context_count: bynk_ide::wire_contract::real_context_count(
2083 &a.boundary_info,
2084 &a.unit_sources,
2085 ),
2086 });
2087 let content = crate::hover::hover_content(&crate::hover::HoverInput {
2088 analysis,
2089 doc,
2090 uri: &uri,
2091 files: files.as_deref(),
2092 });
2093 Ok(content.map(|value| Hover {
2094 contents: HoverContents::Markup(MarkupContent {
2095 kind: MarkupKind::Markdown,
2096 value,
2097 }),
2098 range: None,
2099 }))
2100 }
2101
2102 async fn signature_help(
2104 &self,
2105 params: SignatureHelpParams,
2106 ) -> JsonRpcResult<Option<SignatureHelp>> {
2107 let uri = params.text_document_position_params.text_document.uri;
2108 let pos = params.text_document_position_params.position;
2109 let text = {
2110 let s = self.state.read().await;
2111 s.docs.get(&uri).map(|d| d.text.clone())
2112 };
2113 let Some(text) = text else { return Ok(None) };
2114 let offset = cursor_offset(&text, pos);
2115 let Some(ctx) = crate::signature_help::call_context(&text, offset) else {
2116 return Ok(None);
2117 };
2118 let files = self.project_content(&uri).await;
2119 let resolved_label = {
2124 let callee = ctx.callee.clone();
2125 let text = text.clone();
2126 let files = files.clone();
2127 match tokio::task::spawn_blocking(move || {
2128 crate::signature_help::resolve_label(&callee, &text, files.as_deref())
2129 })
2130 .await
2131 {
2132 Ok(l) => l,
2133 Err(e) => {
2134 tracing::error!("signature-help label task failed: {e}");
2135 None
2136 }
2137 }
2138 };
2139 let label = match resolved_label {
2140 Some(l) => Some(l),
2141 None => match crate::signature_help::value_receiver_method(&ctx.callee) {
2144 Some((_, method)) => {
2145 if let Some((rewritten, recv_offset)) =
2146 crate::signature_help::value_receiver_rewrite(
2147 &text,
2148 &ctx.callee,
2149 ctx.open_paren,
2150 offset,
2151 )
2152 && let Some((ty, tys)) =
2153 self.type_receiver(&uri, rewritten, recv_offset).await
2154 {
2155 crate::signature_help::kernel_method_signature(ty, &tys, method)
2156 } else {
2157 None
2158 }
2159 }
2160 None => None,
2161 },
2162 };
2163 let Some(label) = label else { return Ok(None) };
2164 let active = ctx.active_param as u32;
2165 let parameters: Vec<ParameterInformation> = crate::signature_help::param_ranges(&label)
2166 .into_iter()
2167 .map(|(s, e)| ParameterInformation {
2168 label: ParameterLabel::LabelOffsets([s as u32, e as u32]),
2169 documentation: None,
2170 })
2171 .collect();
2172 Ok(Some(SignatureHelp {
2173 signatures: vec![SignatureInformation {
2174 label,
2175 documentation: None,
2176 parameters: Some(parameters),
2177 active_parameter: Some(active),
2178 }],
2179 active_signature: Some(0),
2180 active_parameter: Some(active),
2181 }))
2182 }
2183
2184 async fn code_lens(&self, params: CodeLensParams) -> JsonRpcResult<Option<Vec<CodeLens>>> {
2187 let uri = params.text_document.uri;
2188 let analysis = self.committed_analysis(&uri).await;
2190 let Some(analysis) = analysis else {
2191 return Ok(Some(Vec::new()));
2192 };
2193 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2194 return Ok(Some(Vec::new()));
2195 };
2196 let Some(text) = analysis.snapshots.get(&rel) else {
2197 return Ok(Some(Vec::new()));
2198 };
2199 let show_references = |range: Range, locations: Vec<Location>, title: String| CodeLens {
2203 range,
2204 command: Some(Command {
2205 title,
2206 command: "editor.action.showReferences".to_string(),
2207 arguments: Some(vec![
2208 serde_json::to_value(&uri).unwrap_or_default(),
2209 serde_json::to_value(range.start).unwrap_or_default(),
2210 serde_json::to_value(&locations).unwrap_or_default(),
2211 ]),
2212 }),
2213 data: None,
2214 };
2215 let mut lenses: Vec<CodeLens> = crate::index_queries::code_lenses(&analysis.index, &rel)
2216 .into_iter()
2217 .map(|(def, refs)| {
2218 let range = crate::position::span_to_range(text, def.span);
2219 let locations: Vec<Location> = refs
2220 .iter()
2221 .filter_map(|r| Self::site_to_location(&analysis, r))
2222 .collect();
2223 let n = refs.len();
2224 show_references(
2225 range,
2226 locations,
2227 format!("{n} reference{}", if n == 1 { "" } else { "s" }),
2228 )
2229 })
2230 .collect();
2231 lenses.extend(
2235 crate::index_queries::capability_provider_lenses(&analysis.index, &rel)
2236 .into_iter()
2237 .map(|(def, providers)| {
2238 let range = crate::position::span_to_range(text, def.span);
2239 let locations: Vec<Location> = providers
2240 .iter()
2241 .filter_map(|r| Self::site_to_location(&analysis, r))
2242 .collect();
2243 let n = providers.len();
2244 show_references(
2245 range,
2246 locations,
2247 format!("{n} provider{}", if n == 1 { "" } else { "s" }),
2248 )
2249 }),
2250 );
2251 lenses.extend(
2255 crate::index_queries::refinement_family_lenses(&analysis.index, &rel)
2256 .into_iter()
2257 .map(|(def, base, family)| {
2258 let range = crate::position::span_to_range(text, def.span);
2259 let locations: Vec<Location> = family
2260 .iter()
2261 .filter_map(|r| Self::site_to_location(&analysis, r))
2262 .collect();
2263 let n = family.len();
2264 show_references(
2265 range,
2266 locations,
2267 format!("{n} refinements of {}", base.name()),
2268 )
2269 }),
2270 );
2271 lenses.extend(
2280 crate::sequence_request::handler_lens_sites(text)
2281 .into_iter()
2282 .map(|span| {
2283 let range = crate::position::span_to_range(text, span);
2284 CodeLens {
2285 range,
2286 command: Some(Command {
2287 title: "Show Sequence".to_string(),
2288 command: "bynk.showSequenceDiagram".to_string(),
2289 arguments: Some(vec![
2290 serde_json::to_value(&uri).unwrap_or_default(),
2291 serde_json::to_value(range.start).unwrap_or_default(),
2292 ]),
2293 }),
2294 data: None,
2295 }
2296 }),
2297 );
2298 Ok(Some(lenses))
2299 }
2300
2301 async fn prepare_call_hierarchy(
2302 &self,
2303 params: CallHierarchyPrepareParams,
2304 ) -> JsonRpcResult<Option<Vec<CallHierarchyItem>>> {
2305 let uri = params.text_document_position_params.text_document.uri;
2306 let pos = params.text_document_position_params.position;
2307 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2308 return Ok(None);
2309 };
2310 let Some((key, def)) =
2311 crate::index_queries::prepare_call_hierarchy(&analysis.index, &rel, offset)
2312 else {
2313 return Ok(None);
2314 };
2315 Ok(Self::call_hierarchy_item(&analysis, key, def).map(|item| vec![item]))
2316 }
2317
2318 async fn incoming_calls(
2319 &self,
2320 params: CallHierarchyIncomingCallsParams,
2321 ) -> JsonRpcResult<Option<Vec<CallHierarchyIncomingCall>>> {
2322 let analysis = self.analysis_for(¶ms.item.uri).await;
2323 let Some(analysis) = analysis else {
2324 return Ok(Some(Vec::new()));
2325 };
2326 let Some(key) = SerKey::read(¶ms.item.data) else {
2327 return Ok(Some(Vec::new()));
2328 };
2329 let calls = crate::index_queries::incoming_calls(&analysis.index, &key)
2330 .into_iter()
2331 .filter_map(|rel| {
2332 let from = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2333 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2334 Some(CallHierarchyIncomingCall { from, from_ranges })
2335 })
2336 .collect();
2337 Ok(Some(calls))
2338 }
2339
2340 async fn outgoing_calls(
2341 &self,
2342 params: CallHierarchyOutgoingCallsParams,
2343 ) -> JsonRpcResult<Option<Vec<CallHierarchyOutgoingCall>>> {
2344 let analysis = self.analysis_for(¶ms.item.uri).await;
2345 let Some(analysis) = analysis else {
2346 return Ok(Some(Vec::new()));
2347 };
2348 let Some(key) = SerKey::read(¶ms.item.data) else {
2349 return Ok(Some(Vec::new()));
2350 };
2351 let calls = crate::index_queries::outgoing_calls(&analysis.index, &key)
2352 .into_iter()
2353 .filter_map(|rel| {
2354 let to = Self::call_hierarchy_item(&analysis, rel.key, rel.def)?;
2355 let from_ranges = Self::call_ranges(&analysis, &rel.sites);
2356 Some(CallHierarchyOutgoingCall { to, from_ranges })
2357 })
2358 .collect();
2359 Ok(Some(calls))
2360 }
2361
2362 async fn goto_implementation(
2367 &self,
2368 params: GotoImplementationParams,
2369 ) -> JsonRpcResult<Option<GotoImplementationResponse>> {
2370 let uri = params.text_document_position_params.text_document.uri;
2371 let pos = params.text_document_position_params.position;
2372 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2373 return Ok(None);
2374 };
2375 let Some((key, _)) = analysis.index.symbol_at(&rel, offset) else {
2376 return Ok(None);
2377 };
2378 if key.kind != bynk_check::index::SymbolKind::Capability {
2379 return Ok(None);
2380 }
2381 let locations: Vec<Location> = crate::index_queries::implementations(&analysis.index, key)
2382 .into_iter()
2383 .filter_map(|d| Self::site_to_location(&analysis, d))
2384 .collect();
2385 if locations.is_empty() {
2386 return Ok(None);
2387 }
2388 Ok(Some(GotoDefinitionResponse::Array(locations)))
2389 }
2390
2391 async fn goto_type_definition(
2397 &self,
2398 params: GotoTypeDefinitionParams,
2399 ) -> JsonRpcResult<Option<GotoTypeDefinitionResponse>> {
2400 let uri = params.text_document_position_params.text_document.uri;
2401 let pos = params.text_document_position_params.position;
2402 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2403 return Ok(None);
2404 };
2405 let tys = &analysis.ty_intern;
2406 let Some(entries) = analysis.expr_types.get(&rel) else {
2407 return Ok(None);
2408 };
2409 let Some(ty) = bynk_check::expr_types::type_at_offset(entries, offset) else {
2410 return Ok(None);
2411 };
2412 let Some(name) = crate::index_queries::named_type_target(ty, tys) else {
2413 return Ok(None);
2414 };
2415 let locations: Vec<Location> =
2416 crate::index_queries::type_definitions_named(&analysis.index, &name)
2417 .into_iter()
2418 .filter_map(|d| Self::site_to_location(&analysis, d))
2419 .collect();
2420 if locations.is_empty() {
2421 return Ok(None);
2422 }
2423 Ok(Some(GotoDefinitionResponse::Array(locations)))
2424 }
2425
2426 async fn document_link(
2439 &self,
2440 params: DocumentLinkParams,
2441 ) -> JsonRpcResult<Option<Vec<DocumentLink>>> {
2442 let uri = params.text_document.uri;
2443 let analysis = self.committed_analysis(&uri).await;
2447 let text = self
2448 .state
2449 .read()
2450 .await
2451 .docs
2452 .get(&uri)
2453 .map(|d| d.text.clone());
2454 let (Some(text), Some(analysis)) = (text, analysis) else {
2455 return Ok(None);
2456 };
2457 let mut links: Vec<DocumentLink> = crate::symbols::unit_reference_spans(&text)
2458 .into_iter()
2459 .filter_map(|(unit, span)| {
2460 let rel = analysis.unit_sources.get(&unit)?.first()?;
2461 let target = Url::from_file_path(analysis.project_root.join(rel)).ok()?;
2462 Some(DocumentLink {
2463 range: crate::position::span_to_range(&text, span),
2464 target: Some(target),
2465 tooltip: Some(format!("Open unit `{unit}`")),
2466 data: None,
2467 })
2468 })
2469 .collect();
2470 if let Some((owner_unit, _)) = crate::symbols::own_declaration_name(&text) {
2474 for (name, span) in crate::symbols::doc_link_spans(&text) {
2475 let Some(def) = crate::index_queries::resolve_doc_link(
2476 &analysis.index,
2477 &analysis.doc_scope,
2478 &owner_unit,
2479 &name,
2480 ) else {
2481 continue;
2482 };
2483 let Ok(target) = Url::from_file_path(analysis.project_root.join(&def.path)) else {
2484 continue;
2485 };
2486 links.push(DocumentLink {
2487 range: crate::position::span_to_range(&text, span),
2488 target: Some(target),
2489 tooltip: Some(format!("Go to `{name}`")),
2490 data: None,
2491 });
2492 }
2493 }
2494 Ok((!links.is_empty()).then_some(links))
2495 }
2496
2497 async fn completion(
2498 &self,
2499 params: CompletionParams,
2500 ) -> JsonRpcResult<Option<CompletionResponse>> {
2501 let uri = params.text_document_position.text_document.uri;
2502 let pos = params.text_document_position.position;
2503 let text = {
2504 let s = self.state.read().await;
2505 s.docs.get(&uri).map(|d| d.text.clone())
2506 };
2507 let Some(text) = text else { return Ok(None) };
2508 let offset = cursor_offset(&text, pos);
2509 let line_prefix = text[..offset].rsplit('\n').next().unwrap_or("").to_string();
2513 let files = self.project_content(&uri).await;
2514 let candidates = {
2519 let line_prefix = line_prefix.clone();
2520 let text = text.clone();
2521 match tokio::task::spawn_blocking(move || {
2522 completion::complete(&line_prefix, &text, files.as_deref())
2523 })
2524 .await
2525 {
2526 Ok(c) => c,
2527 Err(e) => {
2532 tracing::error!("completion enumeration task failed: {e}");
2533 Vec::new()
2534 }
2535 }
2536 };
2537 let mut items: Vec<CompletionItem> =
2538 candidates.into_iter().map(to_completion_item).collect();
2539 if completion::is_keyword_position(&line_prefix)
2544 || completion::is_expression_position(&line_prefix)
2545 {
2546 items.extend(self.locals_completions(&uri, pos).await);
2547 }
2548 items.extend(contract_param_completions(&text, offset, &line_prefix));
2553 items.extend(cors_completions(&text, offset, &line_prefix));
2557 items.extend(security_completions(&text, offset, &line_prefix));
2560 items.extend(cache_completions(&text, offset, &line_prefix));
2564 items.extend(limits_completions(&text, offset, &line_prefix));
2567 items.extend(limit_completions(&text, offset, &line_prefix));
2570 let mut pattern_items = self.nested_pattern_completions(&uri, &text, offset).await;
2582 pattern_items.extend(self.match_arm_completions(&uri, &text, offset).await);
2583 if !pattern_items.is_empty() {
2584 let mut merged = pattern_items;
2585 merged.extend(items);
2586 stamp_resolve_data(&mut merged, &uri);
2587 return Ok(Some(CompletionResponse::Array(merged)));
2588 }
2589 if items.is_empty() {
2590 let is_items = self.is_pattern_completions(&uri, &text, offset).await;
2593 if !is_items.is_empty() {
2594 return Ok(Some(CompletionResponse::Array(is_items)));
2595 }
2596 let value_items = self.value_member_completions(&uri, &text, offset).await;
2600 return Ok((!value_items.is_empty()).then_some(CompletionResponse::Array(value_items)));
2601 }
2602 stamp_resolve_data(&mut items, &uri);
2604 Ok(Some(CompletionResponse::Array(items)))
2605 }
2606
2607 async fn completion_resolve(&self, mut item: CompletionItem) -> JsonRpcResult<CompletionItem> {
2614 if item.documentation.is_some() {
2615 return Ok(item);
2616 }
2617 let Some(uri) = item
2618 .data
2619 .as_ref()
2620 .and_then(|d| d.get("uri"))
2621 .and_then(serde_json::Value::as_str)
2622 .and_then(|s| Url::parse(s).ok())
2623 else {
2624 return Ok(item);
2625 };
2626 let local = {
2627 let s = self.state.read().await;
2628 s.docs.get(&uri).map(|d| d.text.clone())
2629 };
2630 let doc = match local
2631 .as_deref()
2632 .and_then(|t| crate::symbols::describe_symbol(t, &item.label))
2633 {
2634 Some(md) => Some(md),
2635 None => {
2641 let files = self.project_content(&uri).await;
2642 let uri = uri.clone();
2643 let label = item.label.clone();
2644 match tokio::task::spawn_blocking(move || {
2645 files
2646 .and_then(|files| {
2647 crate::symbols::describe_symbol_cross_file(&files, &uri, &label)
2648 })
2649 .map(|(_uri, md)| md)
2650 .or_else(|| crate::symbols::describe_firstparty_symbol(&label))
2654 })
2655 .await
2656 {
2657 Ok(md) => md,
2658 Err(e) => {
2659 tracing::error!("completion-resolve describe task failed: {e}");
2660 None
2661 }
2662 }
2663 }
2664 };
2665 if let Some(md) = doc {
2666 item.documentation = Some(Documentation::MarkupContent(MarkupContent {
2667 kind: MarkupKind::Markdown,
2668 value: md,
2669 }));
2670 }
2671 Ok(item)
2672 }
2673
2674 async fn goto_definition(
2675 &self,
2676 params: GotoDefinitionParams,
2677 ) -> JsonRpcResult<Option<GotoDefinitionResponse>> {
2678 let uri = params
2679 .text_document_position_params
2680 .text_document
2681 .uri
2682 .clone();
2683 let pos = params.text_document_position_params.position;
2684 if let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await {
2689 if let Some((_, def)) =
2690 crate::index_queries::definition_at(&analysis.index, &rel, offset)
2691 && let Some(location) = Self::site_to_location(&analysis, def)
2692 {
2693 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2694 }
2695 if let Some(text) = analysis.snapshots.get(&rel)
2698 && let Some(locals) = analysis.locals.get(&rel)
2699 && let Some(def) = crate::locals_nav::local_definition_at(locals, text, offset)
2700 && let Some(location) = self
2701 .local_locations(&analysis, &rel, &[def])
2702 .into_iter()
2703 .next()
2704 {
2705 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2706 }
2707 }
2708 if let Some(location) = self.unit_reference_definition(&uri, pos).await {
2713 return Ok(Some(GotoDefinitionResponse::Scalar(location)));
2714 }
2715 let Some((name, _span, text)) = self.identifier_at(&uri, pos).await else {
2716 return Ok(None);
2717 };
2718 if let Some(decl_span) = crate::symbols::find_declaration_span(&text, &name) {
2719 let range = crate::position::span_to_range(&text, decl_span);
2720 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2721 uri,
2722 range,
2723 })));
2724 }
2725 if let Some(files) = self.project_content(&uri).await
2727 && let Some(found) = crate::symbols::find_declaration_cross_file(&files, &uri, &name)
2728 {
2729 let range = crate::position::span_to_range(&found.source, found.span);
2730 return Ok(Some(GotoDefinitionResponse::Scalar(Location {
2731 uri: found.uri,
2732 range,
2733 })));
2734 }
2735 Ok(None)
2736 }
2737
2738 async fn formatting(
2739 &self,
2740 params: DocumentFormattingParams,
2741 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2742 let uri = params.text_document.uri;
2743 let text = {
2744 let s = self.state.read().await;
2745 s.docs.get(&uri).map(|d| d.text.clone())
2746 };
2747 let Some(text) = text else { return Ok(None) };
2748 let opts = self.config_for(&uri).await.format_options();
2751 match bynk_fmt::format_source(&text, &opts) {
2752 Ok(formatted) => {
2753 if formatted == text {
2754 Ok(Some(Vec::new()))
2755 } else {
2756 let end_pos = crate::position::end_position(&text);
2758 Ok(Some(vec![TextEdit {
2759 range: Range {
2760 start: Position::new(0, 0),
2761 end: end_pos,
2762 },
2763 new_text: formatted,
2764 }]))
2765 }
2766 }
2767 Err(_) => {
2768 Ok(Some(Vec::new()))
2771 }
2772 }
2773 }
2774
2775 async fn range_formatting(
2776 &self,
2777 params: DocumentRangeFormattingParams,
2778 ) -> JsonRpcResult<Option<Vec<TextEdit>>> {
2779 self.formatting(DocumentFormattingParams {
2782 text_document: params.text_document,
2783 options: params.options,
2784 work_done_progress_params: params.work_done_progress_params,
2785 })
2786 .await
2787 }
2788
2789 async fn document_symbol(
2790 &self,
2791 params: DocumentSymbolParams,
2792 ) -> JsonRpcResult<Option<DocumentSymbolResponse>> {
2793 let uri = params.text_document.uri;
2795 let text = {
2796 let s = self.state.read().await;
2797 s.docs.get(&uri).map(|d| d.text.clone())
2798 };
2799 let Some(text) = text else { return Ok(None) };
2800 let syms = crate::document_symbols::outline(&text);
2801 if syms.is_empty() {
2802 return Ok(None);
2803 }
2804 Ok(Some(DocumentSymbolResponse::Nested(syms)))
2805 }
2806
2807 async fn folding_range(
2810 &self,
2811 params: FoldingRangeParams,
2812 ) -> JsonRpcResult<Option<Vec<FoldingRange>>> {
2813 let uri = params.text_document.uri;
2814 let text = {
2815 let s = self.state.read().await;
2816 s.docs.get(&uri).map(|d| d.text.clone())
2817 };
2818 let Some(text) = text else { return Ok(None) };
2819 Ok(Some(crate::structure::folding_ranges(&text)))
2820 }
2821
2822 async fn selection_range(
2825 &self,
2826 params: SelectionRangeParams,
2827 ) -> JsonRpcResult<Option<Vec<SelectionRange>>> {
2828 let uri = params.text_document.uri;
2829 let text = {
2830 let s = self.state.read().await;
2831 s.docs.get(&uri).map(|d| d.text.clone())
2832 };
2833 let Some(text) = text else { return Ok(None) };
2834 Ok(Some(crate::structure::selection_ranges(
2835 &text,
2836 ¶ms.positions,
2837 )))
2838 }
2839
2840 async fn references(&self, params: ReferenceParams) -> JsonRpcResult<Option<Vec<Location>>> {
2841 let uri = params.text_document_position.text_document.uri;
2842 let pos = params.text_document_position.position;
2843 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
2844 return Ok(None);
2845 };
2846 let include_decl = params.context.include_declaration;
2847 if let Some(sites) =
2848 crate::index_queries::sites_for(&analysis.index, &rel, offset, include_decl)
2849 {
2850 let locations: Vec<Location> = sites
2851 .into_iter()
2852 .filter_map(|site| Self::site_to_location(&analysis, site))
2853 .collect();
2854 return Ok(Some(locations));
2855 }
2856 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
2858 let spans = if include_decl {
2859 &spans[..]
2860 } else {
2861 &spans[1..]
2862 }; let locations = self.local_locations(&analysis, &rel, spans);
2864 return Ok(Some(locations));
2865 }
2866 Ok(None)
2867 }
2868
2869 async fn code_action(
2881 &self,
2882 params: CodeActionParams,
2883 ) -> JsonRpcResult<Option<CodeActionResponse>> {
2884 let uri = params.text_document.uri;
2885 let analysis = self.committed_analysis(&uri).await;
2889 let Some(analysis) = analysis else {
2890 return Ok(Some(Vec::new()));
2891 };
2892 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2893 return Ok(Some(Vec::new()));
2894 };
2895 let (Some(text), Some(diags)) =
2896 (analysis.snapshots.get(&rel), analysis.diagnostics.get(&rel))
2897 else {
2898 return Ok(Some(Vec::new()));
2899 };
2900 let (Some(start), Some(end)) = (
2903 crate::position::position_to_offset(text, params.range.start),
2904 crate::position::position_to_offset(text, params.range.end),
2905 ) else {
2906 return Ok(Some(Vec::new()));
2907 };
2908 let version = analysis.versions.get(&rel).copied();
2909 let span = bynk_syntax::span::Span::new(start, end);
2910 let mut actions = crate::code_actions::quick_fixes(text, diags, span, &uri, version);
2911 actions.extend(crate::capability_fixes::header_quick_fixes(
2914 text,
2915 diags,
2916 span,
2917 &uri,
2918 version,
2919 &analysis.index,
2920 ));
2921 actions.extend(crate::extract::extract_variable(text, span, &uri, version));
2922 let empty_reqs = Vec::new();
2923 let empty_locals = Vec::new();
2924 let empty_types = Vec::new();
2925 actions.extend(crate::extract::extract_function(
2926 text,
2927 span,
2928 &uri,
2929 version,
2930 analysis.requirements.get(&rel).unwrap_or(&empty_reqs),
2931 analysis.locals.get(&rel).unwrap_or(&empty_locals),
2932 analysis.expr_types.get(&rel).unwrap_or(&empty_types),
2933 &analysis.ty_intern,
2934 ));
2935 let actions = crate::code_actions::filter_by_only(actions, params.context.only.as_deref());
2937 Ok(Some(actions))
2938 }
2939
2940 async fn inlay_hint(&self, params: InlayHintParams) -> JsonRpcResult<Option<Vec<InlayHint>>> {
2945 let uri = params.text_document.uri;
2946 let analysis = self.committed_analysis(&uri).await;
2948 let Some(analysis) = analysis else {
2949 return Ok(Some(Vec::new()));
2950 };
2951 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
2952 return Ok(Some(Vec::new()));
2953 };
2954 let Some(text) = analysis.snapshots.get(&rel) else {
2955 return Ok(Some(Vec::new()));
2956 };
2957 let (Some(start), Some(end)) = (
2960 crate::position::position_to_offset(text, params.range.start),
2961 crate::position::position_to_offset(text, params.range.end),
2962 ) else {
2963 return Ok(Some(Vec::new()));
2964 };
2965 let visible = bynk_syntax::span::Span::new(start, end);
2966 let mut hints = analysis
2970 .hints
2971 .get(&rel)
2972 .map(|h| crate::inlay_hints::inlay_hints(text, h, visible))
2973 .unwrap_or_default();
2974 if let Some(reqs) = analysis.requirements.get(&rel) {
2975 hints.extend(crate::inlay_hints::given_hints(text, reqs, visible));
2976 }
2977 Ok(Some(hints))
2978 }
2979
2980 async fn semantic_tokens_full(
2984 &self,
2985 params: SemanticTokensParams,
2986 ) -> JsonRpcResult<Option<SemanticTokensResult>> {
2987 let data = self
2988 .semantic_tokens_for(¶ms.text_document.uri, None)
2989 .await;
2990 Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
2991 result_id: None,
2992 data,
2993 })))
2994 }
2995
2996 async fn semantic_tokens_range(
2999 &self,
3000 params: SemanticTokensRangeParams,
3001 ) -> JsonRpcResult<Option<SemanticTokensRangeResult>> {
3002 let data = self
3003 .semantic_tokens_for(¶ms.text_document.uri, Some(params.range))
3004 .await;
3005 Ok(Some(SemanticTokensRangeResult::Tokens(SemanticTokens {
3006 result_id: None,
3007 data,
3008 })))
3009 }
3010
3011 async fn symbol(
3021 &self,
3022 params: WorkspaceSymbolParams,
3023 ) -> JsonRpcResult<Option<Vec<SymbolInformation>>> {
3024 let candidates: Vec<(PathBuf, ProjectConfig)> = {
3025 let (mut set, folders) = {
3028 let state = self.state.read().await;
3029 let known: std::collections::HashMap<PathBuf, ProjectConfig> = state
3030 .projects
3031 .iter()
3032 .map(|(r, p)| (r.clone(), p.config.clone()))
3033 .collect();
3034 (known, state.folders.clone())
3035 };
3036 for folder in &folders {
3037 if let Some((root, config)) = Self::resolve_root(folder) {
3038 let root = root.canonicalize().unwrap_or(root);
3039 set.entry(root).or_insert(config);
3040 }
3041 }
3042 set.into_iter().collect()
3043 };
3044 let mut symbols: Vec<SymbolInformation> = Vec::new();
3045 for (root, config) in candidates {
3046 let Some(analysis) = self.ensure_project_analysed(root, config).await else {
3047 continue;
3048 };
3049 for (key, def) in
3050 crate::index_queries::workspace_symbols(&analysis.index, ¶ms.query)
3051 {
3052 let Some(location) = Self::site_to_location(&analysis, def) else {
3053 continue;
3054 };
3055 #[allow(deprecated)]
3056 symbols.push(SymbolInformation {
3057 name: key.name.clone(),
3058 kind: lsp_symbol_kind(key.kind),
3059 tags: None,
3060 deprecated: None,
3061 location,
3062 container_name: Some(key.unit.clone()),
3063 });
3064 }
3065 }
3066 symbols.sort_by(|a, b| {
3071 a.name
3072 .cmp(&b.name)
3073 .then_with(|| a.container_name.cmp(&b.container_name))
3074 });
3075 Ok(Some(symbols))
3076 }
3077
3078 async fn document_highlight(
3082 &self,
3083 params: DocumentHighlightParams,
3084 ) -> JsonRpcResult<Option<Vec<DocumentHighlight>>> {
3085 let uri = params.text_document_position_params.text_document.uri;
3086 let pos = params.text_document_position_params.position;
3087 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3088 return Ok(None);
3089 };
3090 let Some(text) = analysis.snapshots.get(&rel) else {
3091 return Ok(None);
3092 };
3093 if let Some(sites) =
3094 crate::index_queries::document_highlights(&analysis.index, &rel, offset)
3095 {
3096 let highlights: Vec<DocumentHighlight> = sites
3097 .into_iter()
3098 .map(|s| DocumentHighlight {
3099 range: crate::position::span_to_range(text, s.span),
3100 kind: None,
3101 })
3102 .collect();
3103 return Ok(Some(highlights));
3104 }
3105 if let Some(spans) = self.local_sites(&analysis, &rel, offset) {
3107 let highlights = spans
3108 .iter()
3109 .map(|s| DocumentHighlight {
3110 range: crate::position::span_to_range(text, *s),
3111 kind: None,
3112 })
3113 .collect();
3114 return Ok(Some(highlights));
3115 }
3116 Ok(None)
3117 }
3118
3119 async fn prepare_rename(
3120 &self,
3121 params: TextDocumentPositionParams,
3122 ) -> JsonRpcResult<Option<PrepareRenameResponse>> {
3123 let uri = params.text_document.uri;
3124 let pos = params.position;
3125 let Some((analysis, rel, offset)) = self.index_position(&uri, pos).await else {
3129 return Ok(None);
3130 };
3131 let Some((key, site)) = crate::index_queries::prepare_rename(&analysis.index, &rel, offset)
3132 else {
3133 return Ok(None);
3134 };
3135 let Some(text) = analysis.snapshots.get(&rel) else {
3136 return Ok(None);
3137 };
3138 Ok(Some(PrepareRenameResponse::RangeWithPlaceholder {
3139 range: crate::position::span_to_range(text, site.span),
3140 placeholder: key.name.clone(),
3141 }))
3142 }
3143
3144 async fn rename(&self, params: RenameParams) -> JsonRpcResult<Option<WorkspaceEdit>> {
3145 let uri = params.text_document_position.text_document.uri;
3146 let pos = params.text_document_position.position;
3147 let new_name = params.new_name;
3148 let refused = |msg: String| tower_lsp::jsonrpc::Error {
3149 code: tower_lsp::jsonrpc::ErrorCode::InvalidParams,
3150 message: msg.into(),
3151 data: None,
3152 };
3153 let Some(root) = self.root_for_uri(&uri).await else {
3164 return Err(refused("rename requires a project (bynk.toml)".into()));
3165 };
3166 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3167 return Err(refused("rename requires a project (bynk.toml)".into()));
3168 };
3169 let Some(rel) = Self::uri_to_rel(&analysis, &uri) else {
3170 return Ok(None);
3171 };
3172 let Some(text) = analysis.snapshots.get(&rel) else {
3173 return Ok(None);
3174 };
3175 let Some(offset) = crate::position::position_to_offset(text, pos) else {
3176 return Ok(None);
3177 };
3178 let plan = crate::index_queries::plan_rename(&analysis.index, &rel, offset, &new_name)
3179 .map_err(refused)?;
3180
3181 let mut overlay = std::collections::HashMap::new();
3195 for (rel_path, text) in &analysis.snapshots {
3196 let edited = match plan.edits.get(rel_path) {
3197 Some(spans) => crate::index_queries::apply_edits(text, spans, &plan.new_name),
3198 None => text.clone(),
3199 };
3200 let abs = analysis.project_root.join(rel_path);
3201 let abs = abs.canonicalize().unwrap_or(abs);
3202 overlay.insert(abs, edited);
3203 }
3204 let roots = bynk_ide::AnalysisRoots::Project(analysis.project_root.clone());
3205 let Ok(post) =
3206 tokio::task::spawn_blocking(move || bynk_ide::diagnose_project_with(&roots, &overlay))
3207 .await
3208 else {
3209 return Err(refused("rename validation failed to run".into()));
3210 };
3211
3212 let post_diags: Vec<(PathBuf, String)> = post
3214 .files
3215 .iter()
3216 .flat_map(|f| {
3217 f.diagnostics
3218 .iter()
3219 .map(|d| (f.source_path.clone(), d.error.category.to_string()))
3220 })
3221 .collect();
3222 crate::index_queries::no_new_diagnostics(&analysis.diag_categories(), &post_diags)
3223 .map_err(refused)?;
3224
3225 if !crate::index_queries::index_unchanged_modulo_rename(&analysis.index, &post.index, &plan)
3228 {
3229 return Err(refused(format!(
3230 "renaming `{}` to `{new_name}` would silently re-bind another name — refused",
3231 plan.key.name
3232 )));
3233 }
3234
3235 let mut document_edits: Vec<TextDocumentEdit> = Vec::new();
3238 for (rel_path, spans) in &plan.edits {
3239 let Some(text) = analysis.snapshots.get(rel_path) else {
3240 continue;
3241 };
3242 let abs = analysis.project_root.join(rel_path);
3243 let Ok(file_uri) = Url::from_file_path(&abs) else {
3244 continue;
3245 };
3246 let edits: Vec<OneOf<TextEdit, AnnotatedTextEdit>> = spans
3247 .iter()
3248 .map(|span| {
3249 OneOf::Left(TextEdit {
3250 range: crate::position::span_to_range(text, *span),
3251 new_text: plan.new_name.clone(),
3252 })
3253 })
3254 .collect();
3255 document_edits.push(TextDocumentEdit {
3256 text_document: OptionalVersionedTextDocumentIdentifier {
3257 uri: file_uri,
3258 version: analysis.versions.get(rel_path).copied(),
3259 },
3260 edits,
3261 });
3262 }
3263 Ok(Some(WorkspaceEdit {
3264 changes: None,
3265 document_changes: Some(DocumentChanges::Edits(document_edits)),
3266 change_annotations: None,
3267 }))
3268 }
3269
3270 async fn will_rename_files(
3296 &self,
3297 params: RenameFilesParams,
3298 ) -> JsonRpcResult<Option<WorkspaceEdit>> {
3299 let mut combined: std::collections::HashMap<Url, (Option<i32>, Vec<TextEdit>)> =
3300 std::collections::HashMap::new();
3301 for fr in ¶ms.files {
3302 let (Ok(old_uri), Ok(new_uri)) = (Url::parse(&fr.old_uri), Url::parse(&fr.new_uri))
3303 else {
3304 continue;
3305 };
3306 let Some(root) = self.root_for_uri(&old_uri).await else {
3307 continue;
3308 };
3309 let Some(analysis) = self.analysis_covering_open_buffers(&root).await else {
3310 continue;
3311 };
3312 let Some(old_rel) = Self::uri_to_rel(&analysis, &old_uri) else {
3313 continue;
3314 };
3315 let Some(new_rel) = Self::uri_to_rel_for_new_path(&analysis, &new_uri) else {
3324 continue;
3325 };
3326 let Some(text) = analysis.snapshots.get(&old_rel) else {
3327 continue;
3328 };
3329 let Some((old_name, name_span)) = crate::symbols::own_declaration_name(text) else {
3330 continue;
3331 };
3332 let Some(new_name) = bynk_ide::renamed_unit_name(&old_rel, &old_name, &new_rel) else {
3333 continue;
3334 };
3335 if new_name == old_name {
3336 continue;
3337 }
3338 if analysis.unit_sources.contains_key(&new_name) {
3341 continue;
3342 }
3343 let Ok(old_file_uri) = Url::from_file_path(analysis.project_root.join(&old_rel)) else {
3350 continue;
3351 };
3352 combined
3355 .entry(old_file_uri)
3356 .or_insert_with(|| (analysis.versions.get(&old_rel).copied(), Vec::new()))
3357 .1
3358 .push(TextEdit {
3359 range: crate::position::span_to_range(text, name_span),
3360 new_text: new_name.clone(),
3361 });
3362 for (rel, snap_text) in &analysis.snapshots {
3364 if *rel == old_rel {
3365 continue;
3366 }
3367 let edits: Vec<TextEdit> = crate::symbols::unit_reference_spans(snap_text)
3368 .into_iter()
3369 .filter(|(unit, _)| *unit == old_name)
3370 .map(|(_, span)| TextEdit {
3371 range: crate::position::span_to_range(snap_text, span),
3372 new_text: new_name.clone(),
3373 })
3374 .collect();
3375 if edits.is_empty() {
3376 continue;
3377 }
3378 let Ok(file_uri) = Url::from_file_path(analysis.project_root.join(rel)) else {
3379 continue;
3380 };
3381 combined
3382 .entry(file_uri)
3383 .or_insert_with(|| (analysis.versions.get(rel).copied(), Vec::new()))
3384 .1
3385 .extend(edits);
3386 }
3387 }
3388 if combined.is_empty() {
3389 return Ok(None);
3390 }
3391 let document_edits: Vec<TextDocumentEdit> = combined
3392 .into_iter()
3393 .map(|(uri, (version, edits))| TextDocumentEdit {
3394 text_document: OptionalVersionedTextDocumentIdentifier { uri, version },
3395 edits: edits.into_iter().map(OneOf::Left).collect(),
3396 })
3397 .collect();
3398 Ok(Some(WorkspaceEdit {
3399 changes: None,
3400 document_changes: Some(DocumentChanges::Edits(document_edits)),
3401 change_annotations: None,
3402 }))
3403 }
3404
3405 async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) {
3406 if params.changes.iter().any(|ev| is_bynk_toml(&ev.uri)) {
3413 let mut state = self.state.write().await;
3414 state.root_cache.clear();
3415 state.root_cache_generation += 1;
3416 }
3417 let mut uris_to_refresh = Vec::new();
3422 let mut roots_to_reanalyse: std::collections::HashSet<PathBuf> =
3425 std::collections::HashSet::new();
3426 let mut config_changed_roots: std::collections::HashSet<PathBuf> =
3432 std::collections::HashSet::new();
3433 let open_docs: std::collections::HashSet<Url> =
3437 self.state.read().await.docs.keys().cloned().collect();
3438 for ev in ¶ms.changes {
3439 if is_bynk_toml(&ev.uri) {
3440 if let Ok(p) = ev.uri.to_file_path()
3442 && let Some(dir) = p.parent()
3443 {
3444 let root = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
3445 config_changed_roots.insert(root.clone());
3446 roots_to_reanalyse.insert(root);
3447 }
3448 } else if open_docs.contains(&ev.uri) {
3449 uris_to_refresh.push(ev.uri.clone());
3450 } else if ev.uri.path().ends_with(".bynk")
3451 && let Some(root) = self.root_for_uri(&ev.uri).await
3452 {
3453 roots_to_reanalyse.insert(root);
3454 }
3455 }
3456 for root in &config_changed_roots {
3461 let config = project::load_config(root).unwrap_or_default();
3462 let mut state = self.state.write().await;
3463 state
3464 .projects
3465 .entry(root.clone())
3466 .and_modify(|ps| ps.config = config.clone())
3467 .or_insert_with(|| ProjectState {
3468 config,
3469 ..Default::default()
3470 });
3471 }
3472 for uri in uris_to_refresh {
3473 self.schedule_diagnostics(&uri).await;
3474 }
3475 for root in roots_to_reanalyse {
3480 self.schedule_project_diagnostics(root).await;
3481 }
3482 }
3483
3484 async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
3485 let added_dirs: Vec<PathBuf> = {
3490 let mut state = self.state.write().await;
3491 let mut added = Vec::new();
3492 for a in ¶ms.event.added {
3493 if let Ok(p) = a.uri.to_file_path() {
3494 let dir = p.canonicalize().unwrap_or(p);
3495 if !state.folders.contains(&dir) {
3496 state.folders.push(dir.clone());
3497 added.push(dir);
3498 }
3499 }
3500 }
3501 for removed in ¶ms.event.removed {
3502 if let Ok(p) = removed.uri.to_file_path() {
3503 let dir = p.canonicalize().unwrap_or(p);
3504 state.folders.retain(|f| f != &dir);
3505 }
3506 }
3507 state.root_cache.clear();
3514 state.root_cache_generation += 1;
3515 added
3516 };
3517 self.warm_projects(&added_dirs).await;
3520 for uri in self.prune_orphaned_projects().await {
3523 self.client.publish_diagnostics(uri, Vec::new(), None).await;
3524 }
3525 }
3526}
3527
3528fn server_capabilities() -> ServerCapabilities {
3531 ServerCapabilities {
3532 text_document_sync: Some(TextDocumentSyncCapability::Options(
3535 TextDocumentSyncOptions {
3536 open_close: Some(true),
3537 change: Some(TextDocumentSyncKind::FULL),
3538 save: Some(TextDocumentSyncSaveOptions::Supported(true)),
3539 ..Default::default()
3540 },
3541 )),
3542 hover_provider: Some(HoverProviderCapability::Simple(true)),
3543 definition_provider: Some(OneOf::Left(true)),
3544 completion_provider: Some(CompletionOptions {
3549 trigger_characters: Some(vec![
3550 " ".to_string(),
3551 "{".to_string(),
3552 ",".to_string(),
3553 ".".to_string(),
3554 ]),
3555 resolve_provider: Some(true),
3558 ..Default::default()
3559 }),
3560 signature_help_provider: Some(SignatureHelpOptions {
3562 trigger_characters: Some(vec!["(".to_string(), ",".to_string()]),
3563 retrigger_characters: Some(vec![",".to_string()]),
3564 ..Default::default()
3565 }),
3566 code_lens_provider: Some(CodeLensOptions {
3568 resolve_provider: Some(false),
3569 }),
3570 call_hierarchy_provider: Some(CallHierarchyServerCapability::Simple(true)),
3572 implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
3574 type_definition_provider: Some(TypeDefinitionProviderCapability::Simple(true)),
3576 document_link_provider: Some(DocumentLinkOptions {
3578 resolve_provider: Some(false),
3579 work_done_progress_options: Default::default(),
3580 }),
3581 document_formatting_provider: Some(OneOf::Left(true)),
3582 document_range_formatting_provider: Some(OneOf::Left(true)),
3583 document_symbol_provider: Some(OneOf::Left(true)),
3584 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
3586 selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)),
3587 references_provider: Some(OneOf::Left(true)),
3590 rename_provider: Some(OneOf::Right(RenameOptions {
3591 prepare_provider: Some(true),
3592 work_done_progress_options: Default::default(),
3593 })),
3594 code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
3597 code_action_kinds: Some(vec![
3598 CodeActionKind::QUICKFIX,
3599 CodeActionKind::REFACTOR,
3600 CodeActionKind::REFACTOR_EXTRACT,
3601 ]),
3602 ..Default::default()
3603 })),
3604 inlay_hint_provider: Some(OneOf::Left(true)),
3607 semantic_tokens_provider: Some(SemanticTokensServerCapabilities::SemanticTokensOptions(
3611 SemanticTokensOptions {
3612 legend: crate::index_queries::semantic_tokens_legend(),
3613 full: Some(SemanticTokensFullOptions::Bool(true)),
3614 range: Some(true),
3615 ..Default::default()
3616 },
3617 )),
3618 workspace_symbol_provider: Some(OneOf::Left(true)),
3620 document_highlight_provider: Some(OneOf::Left(true)),
3621 workspace: Some(WorkspaceServerCapabilities {
3622 workspace_folders: Some(WorkspaceFoldersServerCapabilities {
3623 supported: Some(true),
3624 change_notifications: Some(OneOf::Left(true)),
3625 }),
3626 file_operations: Some(WorkspaceFileOperationsServerCapabilities {
3630 will_rename: Some(FileOperationRegistrationOptions {
3631 filters: vec![FileOperationFilter {
3632 scheme: Some("file".to_string()),
3633 pattern: FileOperationPattern {
3634 glob: "**/*.bynk".to_string(),
3635 matches: Some(FileOperationPatternKind::File),
3636 options: None,
3637 },
3638 }],
3639 }),
3640 ..Default::default()
3641 }),
3642 }),
3643 experimental: Some(serde_json::json!({
3648 "sequenceModel": true,
3649 "documentationModel": true,
3650 "architectureModel": true,
3651 "wireContract": true,
3652 })),
3653 ..Default::default()
3654 }
3655}
3656
3657fn stamp_resolve_data(items: &mut [CompletionItem], uri: &Url) {
3665 let data = serde_json::json!({ "uri": uri.to_string() });
3666 for item in items.iter_mut() {
3667 item.data = Some(data.clone());
3668 }
3669}
3670
3671fn cors_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3680 if completion::in_cors_field_position(text, offset) {
3681 return completion::CORS_FIELDS
3682 .iter()
3683 .map(|(name, doc)| CompletionItem {
3684 label: name.to_string(),
3685 kind: Some(CompletionItemKind::FIELD),
3686 detail: Some((*doc).to_string()),
3687 insert_text: Some(format!("{name}: ")),
3688 ..Default::default()
3689 })
3690 .collect();
3691 }
3692 if completion::in_service_body_item_position(text, offset, line) {
3693 return vec![CompletionItem {
3694 label: "cors".to_string(),
3695 kind: Some(CompletionItemKind::KEYWORD),
3696 detail: Some("a cross-origin (CORS) policy for this HTTP service".to_string()),
3697 insert_text: Some("cors {\n\torigins: [$0],\n}".to_string()),
3698 insert_text_format: Some(InsertTextFormat::SNIPPET),
3699 ..Default::default()
3700 }];
3701 }
3702 Vec::new()
3703}
3704
3705fn security_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3710 if completion::in_security_field_position(text, offset) {
3711 return completion::SECURITY_FIELDS
3712 .iter()
3713 .map(|(name, doc)| CompletionItem {
3714 label: name.to_string(),
3715 kind: Some(CompletionItemKind::FIELD),
3716 detail: Some((*doc).to_string()),
3717 insert_text: Some(format!("{name}: ")),
3718 ..Default::default()
3719 })
3720 .collect();
3721 }
3722 if completion::in_service_body_item_position(text, offset, line) {
3723 return vec![CompletionItem {
3724 label: "security".to_string(),
3725 kind: Some(CompletionItemKind::KEYWORD),
3726 detail: Some("security response headers for this HTTP service".to_string()),
3727 insert_text: Some("security {\n\tnosniff: $0,\n}".to_string()),
3728 insert_text_format: Some(InsertTextFormat::SNIPPET),
3729 ..Default::default()
3730 }];
3731 }
3732 Vec::new()
3733}
3734
3735fn cache_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3740 if completion::in_cache_arg_position(text, offset) {
3741 return completion::CACHE_ARGS
3742 .iter()
3743 .map(|(name, doc)| CompletionItem {
3744 label: name.to_string(),
3745 kind: Some(CompletionItemKind::FIELD),
3746 detail: Some((*doc).to_string()),
3747 insert_text: Some(format!("{name}: ")),
3748 ..Default::default()
3749 })
3750 .collect();
3751 }
3752 if completion::in_service_body_item_position(text, offset, line) {
3753 return vec![CompletionItem {
3754 label: "@cache".to_string(),
3755 kind: Some(CompletionItemKind::SNIPPET),
3756 detail: Some(
3757 "cache a GET read — a synthesised ETag/304 revalidation with a freshness window"
3758 .to_string(),
3759 ),
3760 insert_text: Some("@cache(maxAge: ${1:5.minutes})".to_string()),
3761 insert_text_format: Some(InsertTextFormat::SNIPPET),
3762 ..Default::default()
3763 }];
3764 }
3765 Vec::new()
3766}
3767
3768fn limits_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3773 if completion::in_limits_field_position(text, offset) {
3774 return completion::LIMITS_FIELDS
3775 .iter()
3776 .map(|(name, doc)| CompletionItem {
3777 label: name.to_string(),
3778 kind: Some(CompletionItemKind::FIELD),
3779 detail: Some((*doc).to_string()),
3780 insert_text: Some(format!("{name}: ")),
3781 ..Default::default()
3782 })
3783 .collect();
3784 }
3785 if completion::in_service_body_item_position(text, offset, line) {
3786 return vec![CompletionItem {
3787 label: "limits".to_string(),
3788 kind: Some(CompletionItemKind::KEYWORD),
3789 detail: Some("request limits for this HTTP service".to_string()),
3790 insert_text: Some("limits {\n\tmaxBody: $0,\n}".to_string()),
3791 insert_text_format: Some(InsertTextFormat::SNIPPET),
3792 ..Default::default()
3793 }];
3794 }
3795 Vec::new()
3796}
3797
3798fn limit_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3803 if completion::in_limit_arg_position(text, offset) {
3804 return completion::LIMIT_ARGS
3805 .iter()
3806 .map(|(name, doc)| CompletionItem {
3807 label: name.to_string(),
3808 kind: Some(CompletionItemKind::FIELD),
3809 detail: Some((*doc).to_string()),
3810 insert_text: Some(format!("{name}: ")),
3811 ..Default::default()
3812 })
3813 .collect();
3814 }
3815 if completion::in_service_body_item_position(text, offset, line) {
3816 return vec![CompletionItem {
3817 label: "@limit".to_string(),
3818 kind: Some(CompletionItemKind::SNIPPET),
3819 detail: Some(
3820 "cap the request body size — a `413` synthesised before the body is read"
3821 .to_string(),
3822 ),
3823 insert_text: Some("@limit(maxBody: ${1:1048576})".to_string()),
3824 insert_text_format: Some(InsertTextFormat::SNIPPET),
3825 ..Default::default()
3826 }];
3827 }
3828 Vec::new()
3829}
3830
3831fn contract_param_completions(text: &str, offset: usize, line: &str) -> Vec<CompletionItem> {
3832 use bynk_syntax::ast::{CommonsItem, SourceUnit};
3833 let Some(is_ensures) = completion::contract_clause_kind(line) else {
3834 return Vec::new();
3835 };
3836 let Ok(tokens) = bynk_syntax::lexer::tokenize(text) else {
3837 return Vec::new();
3838 };
3839 let (Some(unit), _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text) else {
3840 return Vec::new();
3841 };
3842 let items = match &unit {
3843 SourceUnit::Commons(c) => &c.items,
3844 SourceUnit::Context(c) => &c.items,
3845 SourceUnit::Adapter(a) => &a.items,
3846 _ => return Vec::new(),
3847 };
3848 for item in items {
3849 if let CommonsItem::Fn(f) = item
3852 && f.span.start <= offset
3853 && offset <= f.body.span.start
3854 {
3855 let mut out: Vec<CompletionItem> = f
3858 .params
3859 .iter()
3860 .filter(|p| p.name.name != "_")
3861 .map(|p| CompletionItem {
3862 label: p.name.name.clone(),
3863 kind: Some(CompletionItemKind::VARIABLE),
3864 detail: Some(format!(
3865 "parameter: {}",
3866 crate::symbols::type_ref_str(&p.type_ref)
3867 )),
3868 ..Default::default()
3869 })
3870 .collect();
3871 if is_ensures {
3872 out.push(CompletionItem {
3873 label: "result".to_string(),
3874 kind: Some(CompletionItemKind::VARIABLE),
3875 detail: Some("the function's return value".to_string()),
3876 ..Default::default()
3877 });
3878 }
3879 return out;
3880 }
3881 }
3882 Vec::new()
3883}
3884
3885fn is_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
3890 let before = text.get(..cursor)?;
3891 let before = before
3893 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
3894 .trim_end();
3895 let before = before.strip_suffix("is")?;
3896 if !before.ends_with(char::is_whitespace) {
3897 return None;
3898 }
3899 let before = before.trim_end();
3900 (!before.is_empty()).then(|| before.len() - 1)
3901}
3902
3903fn match_scrutinee_offset(text: &str, cursor: usize) -> Option<usize> {
3910 let before = text.get(..cursor)?;
3911 let brace = innermost_open_brace(before)?;
3913 let arm_start = arm_start_offset(before, brace);
3916 let arm = before.get(arm_start..)?;
3917 if arm.contains("=>") {
3918 return None;
3919 }
3920 if !arm
3923 .trim_end_matches(|c: char| c.is_alphanumeric() || c == '_')
3924 .trim()
3925 .is_empty()
3926 {
3927 return None;
3928 }
3929 match_head_scrutinee_offset(before, brace)
3930}
3931
3932fn nested_pattern_offset(text: &str, cursor: usize) -> Option<(usize, String)> {
3939 let before = text.get(..cursor)?;
3940 let brace = innermost_open_brace(before)?;
3941 let arm_start = arm_start_offset(before, brace);
3942 let arm = before.get(arm_start..)?;
3943 if arm.contains("=>") {
3944 return None; }
3946 let paren = innermost_open_paren(arm)?;
3948 let head = arm.get(..paren)?.trim_end();
3951 let variant: String = head
3952 .chars()
3953 .rev()
3954 .take_while(|c| c.is_alphanumeric() || *c == '_')
3955 .collect::<Vec<_>>()
3956 .into_iter()
3957 .rev()
3958 .collect();
3959 if !variant.chars().next().is_some_and(char::is_uppercase) {
3960 return None;
3961 }
3962 let after = arm.get(paren + 1..)?;
3965 if !after
3966 .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '.')
3967 .trim()
3968 .is_empty()
3969 {
3970 return None;
3971 }
3972 let scrut_off = match_head_scrutinee_offset(before, brace)?;
3973 Some((scrut_off, variant))
3974}
3975
3976fn match_head_scrutinee_offset(before: &str, brace: usize) -> Option<usize> {
3982 let head = before.get(..brace)?.trim_end();
3983 let m = head.rfind("match")?;
3984 if head[..m]
3985 .chars()
3986 .next_back()
3987 .is_some_and(|c| c.is_alphanumeric() || c == '_')
3988 {
3989 return None; }
3991 let after = head.get(m + "match".len()..)?;
3992 if !after.starts_with(char::is_whitespace) {
3993 return None;
3994 }
3995 let scrut = after.trim();
3996 if scrut.is_empty() || scrut.contains(['{', '}']) || scrut.contains("=>") {
3997 return None;
3998 }
3999 Some(head.len() - 1)
4001}
4002
4003fn innermost_open_paren(arm: &str) -> Option<usize> {
4007 let mut stack: Vec<usize> = Vec::new();
4008 for (i, c) in arm.char_indices() {
4009 match c {
4010 '(' => stack.push(i),
4011 ')' => {
4012 stack.pop();
4013 }
4014 _ => {}
4015 }
4016 }
4017 stack.pop()
4018}
4019
4020fn innermost_open_brace(before: &str) -> Option<usize> {
4023 let mut stack: Vec<usize> = Vec::new();
4024 for (i, c) in before.char_indices() {
4025 match c {
4026 '{' => stack.push(i),
4027 '}' => {
4028 stack.pop();
4029 }
4030 _ => {}
4031 }
4032 }
4033 stack.pop()
4034}
4035
4036fn arm_start_offset(before: &str, brace: usize) -> usize {
4040 let mut depth = 0i32;
4041 let mut start = brace + 1; for (rel, c) in before[brace + 1..].char_indices() {
4043 match c {
4044 '{' | '(' | '[' => depth += 1,
4045 '}' | ')' | ']' => depth -= 1,
4046 ',' if depth == 0 => start = brace + 1 + rel + c.len_utf8(),
4047 _ => {}
4048 }
4049 }
4050 start
4051}
4052
4053fn to_completion_item(c: completion::Completion) -> CompletionItem {
4054 CompletionItem {
4055 kind: Some(match c.kind {
4056 completion::CompletionKind::Unit => CompletionItemKind::MODULE,
4057 completion::CompletionKind::Capability => CompletionItemKind::INTERFACE,
4058 completion::CompletionKind::Type => CompletionItemKind::STRUCT,
4059 completion::CompletionKind::Keyword => CompletionItemKind::KEYWORD,
4060 completion::CompletionKind::Snippet => CompletionItemKind::SNIPPET,
4061 completion::CompletionKind::Variant => CompletionItemKind::ENUM_MEMBER,
4062 completion::CompletionKind::Member => CompletionItemKind::METHOD,
4063 completion::CompletionKind::Field => CompletionItemKind::FIELD,
4064 completion::CompletionKind::Constructor => CompletionItemKind::CONSTRUCTOR,
4065 completion::CompletionKind::Function => CompletionItemKind::FUNCTION,
4066 }),
4067 insert_text_format: c.insert_text.as_ref().map(|_| InsertTextFormat::SNIPPET),
4070 insert_text: c.insert_text,
4071 label: c.label,
4072 detail: c.detail,
4073 ..Default::default()
4074 }
4075}
4076
4077fn cursor_offset(text: &str, pos: Position) -> usize {
4083 crate::position::position_to_offset(text, pos).unwrap_or(text.len())
4084}
4085
4086#[derive(serde::Serialize, serde::Deserialize)]
4090struct SerKey {
4091 unit: String,
4092 kind: String,
4093 name: String,
4094}
4095
4096impl From<&bynk_check::index::SymbolKey> for SerKey {
4097 fn from(k: &bynk_check::index::SymbolKey) -> Self {
4098 SerKey {
4099 unit: k.unit.clone(),
4100 kind: k.kind.display().to_string(),
4101 name: k.name.clone(),
4102 }
4103 }
4104}
4105
4106impl SerKey {
4107 fn read(data: &Option<serde_json::Value>) -> Option<bynk_check::index::SymbolKey> {
4111 let sk: SerKey = serde_json::from_value(data.as_ref()?.clone()).ok()?;
4112 let kind = match sk.kind.as_str() {
4113 "type" => bynk_check::index::SymbolKind::Type,
4114 "fn" => bynk_check::index::SymbolKind::Fn,
4115 "capability" => bynk_check::index::SymbolKind::Capability,
4116 "service" => bynk_check::index::SymbolKind::Service,
4117 "agent" => bynk_check::index::SymbolKind::Agent,
4118 "provider" => bynk_check::index::SymbolKind::Provider,
4119 _ => return None,
4120 };
4121 Some(bynk_check::index::SymbolKey {
4122 unit: sk.unit,
4123 kind,
4124 name: sk.name,
4125 })
4126 }
4127}
4128
4129fn lsp_symbol_kind(kind: bynk_check::index::SymbolKind) -> SymbolKind {
4130 match kind {
4131 bynk_check::index::SymbolKind::Type => SymbolKind::STRUCT,
4132 bynk_check::index::SymbolKind::Fn => SymbolKind::FUNCTION,
4133 bynk_check::index::SymbolKind::Capability => SymbolKind::INTERFACE,
4134 bynk_check::index::SymbolKind::Service | bynk_check::index::SymbolKind::Agent => {
4135 SymbolKind::CLASS
4136 }
4137 bynk_check::index::SymbolKind::Provider => SymbolKind::OBJECT,
4138 bynk_check::index::SymbolKind::Method => SymbolKind::METHOD,
4139 bynk_check::index::SymbolKind::CapabilityOp => SymbolKind::METHOD,
4140 bynk_check::index::SymbolKind::Field => SymbolKind::FIELD,
4141 bynk_check::index::SymbolKind::Actor => SymbolKind::INTERFACE,
4142 bynk_check::index::SymbolKind::Handler => SymbolKind::METHOD,
4143 bynk_check::index::SymbolKind::Messages => SymbolKind::STRUCT,
4144 }
4145}
4146
4147fn is_bynk_toml(uri: &Url) -> bool {
4151 let Ok(path) = uri.to_file_path() else {
4152 return false;
4153 };
4154 path.file_name().and_then(|n| n.to_str()) == Some("bynk.toml")
4155}
4156
4157fn code_description(code: &str) -> Option<CodeDescription> {
4163 let href = Url::parse(&bynk_syntax::diagnostics::explain(code)?.href()).ok()?;
4164 Some(CodeDescription { href })
4165}
4166
4167#[cfg(test)]
4168mod code_description_tests {
4169 use super::code_description;
4170
4171 #[test]
4172 fn mapped_code_gets_a_valid_book_link() {
4173 let cd = code_description("bynk.resolve.unknown_type")
4174 .expect("a curated code produces a codeDescription");
4175 assert_eq!(cd.href.scheme(), "https");
4176 assert_eq!(cd.href.host_str(), Some("bynk-lang.org"));
4177 assert!(cd.href.path().starts_with("/book/"));
4178 }
4179
4180 #[test]
4181 fn uncurated_code_gets_no_link() {
4182 assert!(code_description("bynk.resolve.duplicate_type").is_none());
4185 assert!(code_description("bynk.not.a_real_code").is_none());
4186 }
4187
4188 #[test]
4189 fn every_curated_explanation_yields_a_parseable_url() {
4190 for e in bynk_syntax::diagnostics::EXPLANATIONS {
4193 assert!(
4194 code_description(e.code).is_some(),
4195 "curated explanation `{}` produced no codeDescription — its href \
4196 `{}` did not parse as a URL",
4197 e.code,
4198 e.href()
4199 );
4200 }
4201 }
4202}
4203
4204fn make_diagnostic(
4205 d: &bynk_ide::Diagnostic,
4206 positions: &crate::position::PositionMap,
4207 uri: &Url,
4208) -> Diagnostic {
4209 let range = positions.range(d.error.span);
4210 let severity = match d.severity {
4211 bynk_syntax::Severity::Error => DiagnosticSeverity::ERROR,
4212 bynk_syntax::Severity::Warning => DiagnosticSeverity::WARNING,
4213 };
4214 let related_information: Vec<DiagnosticRelatedInformation> = d
4215 .error
4216 .labels
4217 .iter()
4218 .map(|(span, msg)| DiagnosticRelatedInformation {
4219 location: Location {
4220 uri: uri.clone(),
4224 range: positions.range(*span),
4225 },
4226 message: msg.clone(),
4227 })
4228 .collect();
4229 let mut message = d.error.message.clone();
4230 for note in &d.error.notes {
4231 message.push_str("\n\n");
4232 message.push_str("note: ");
4233 message.push_str(note);
4234 }
4235 Diagnostic {
4236 range,
4237 severity: Some(severity),
4238 code: Some(NumberOrString::String(d.error.category.to_string())),
4239 code_description: code_description(d.error.category),
4244 source: Some(SERVER_NAME.to_string()),
4245 message,
4246 related_information: if related_information.is_empty() {
4247 None
4248 } else {
4249 Some(related_information)
4250 },
4251 tags: None,
4252 data: None,
4253 }
4254}
4255
4256pub async fn run() {
4259 if std::env::args()
4263 .skip(1)
4264 .any(|a| a == "--version" || a == "-V")
4265 {
4266 println!("{SERVER_NAME} {SERVER_VERSION}");
4267 return;
4268 }
4269 if let Some(home) = std::env::var_os("HOME") {
4272 let path: PathBuf = PathBuf::from(home).join(".bynk-lsp.log");
4273 if let Ok(file) = std::fs::OpenOptions::new()
4274 .create(true)
4275 .append(true)
4276 .open(&path)
4277 {
4278 use tracing_subscriber::prelude::*;
4279 let env_filter = tracing_subscriber::EnvFilter::try_from_env("BYNK_LSP_LOG")
4280 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
4281 let file_layer = tracing_subscriber::fmt::layer()
4282 .with_writer(std::sync::Mutex::new(file))
4283 .with_ansi(false);
4284 tracing_subscriber::registry()
4285 .with(env_filter)
4286 .with(file_layer)
4287 .try_init()
4288 .ok();
4289 }
4290 }
4291 tracing::info!("bynkc-lsp v{} starting", SERVER_VERSION);
4292 let stdin = tokio::io::stdin();
4293 let stdout = tokio::io::stdout();
4294 let (service, socket) = LspService::build(Backend::new)
4299 .custom_method("bynk/sequenceModel", Backend::sequence_model)
4300 .custom_method("bynk/documentationModel", Backend::documentation_model)
4301 .custom_method("bynk/architectureModel", Backend::architecture_model)
4302 .custom_method("bynk/wireContract", Backend::wire_contract)
4303 .finish();
4304 Server::new(stdin, stdout, socket).serve(service).await;
4305}
4306
4307#[cfg(test)]
4308mod tests {
4309 use super::*;
4310
4311 struct Scratch(PathBuf);
4327 impl Drop for Scratch {
4328 fn drop(&mut self) {
4329 let _ = std::fs::remove_dir_all(&self.0);
4330 }
4331 }
4332
4333 fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
4334 let dir = std::env::temp_dir().join(format!(
4335 "bynk_lsp_sliceA_{tag}_{}_{:?}",
4336 std::process::id(),
4337 std::thread::current().id()
4338 ));
4339 let _ = std::fs::remove_dir_all(&dir);
4340 for (rel, body) in files {
4341 let p = dir.join(rel);
4342 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
4343 std::fs::write(&p, body).unwrap();
4344 }
4345 Scratch(dir)
4346 }
4347
4348 async fn backend_at(root: &std::path::Path) -> Backend {
4354 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4355 let backend = service.inner().clone();
4356 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
4359 {
4360 let mut state = backend.state.write().await;
4361 state.folders.push(canonical.clone());
4362 state.projects.insert(
4363 canonical.clone(),
4364 ProjectState {
4365 config: project::load_config(&canonical).unwrap_or_default(),
4366 ..Default::default()
4367 },
4368 );
4369 }
4370 backend
4371 }
4372
4373 impl Backend {
4377 async fn test_root(&self) -> PathBuf {
4378 self.state
4379 .read()
4380 .await
4381 .projects
4382 .keys()
4383 .next()
4384 .cloned()
4385 .expect("a test project entry")
4386 }
4387 async fn run_round(&self) {
4388 let root = self.test_root().await;
4389 self.run_project_diagnostics(root).await;
4390 }
4391 async fn test_analysis(&self) -> Option<Arc<Analysis>> {
4392 let root = self.test_root().await;
4393 self.project_analysis(&root).await
4394 }
4395 async fn test_round_started(&self) -> u64 {
4396 let root = self.test_root().await;
4397 self.state
4398 .read()
4399 .await
4400 .projects
4401 .get(&root)
4402 .map(|p| p.analysis_round_started)
4403 .unwrap_or(0)
4404 }
4405 }
4406
4407 #[tokio::test]
4412 async fn a_round_covers_every_include_tree() {
4413 let s = scratch_project(
4414 "round",
4415 &[
4416 ("bynk.toml", "[project]\nname = \"round\"\n"),
4417 ("src/thing.bynk", "context thing\n"),
4418 ("tests/thing.bynk", "suite thing\n"),
4420 ],
4421 );
4422 let backend = backend_at(&s.0).await;
4423 backend.run_round().await;
4424
4425 let analysis = backend.test_analysis().await.expect("a round committed");
4426 let mut keys: Vec<String> = analysis
4427 .snapshots
4428 .keys()
4429 .map(|p| p.to_string_lossy().replace('\\', "/"))
4430 .collect();
4431 keys.sort();
4432 assert_eq!(
4433 keys,
4434 vec!["src/thing.bynk", "tests/thing.bynk"],
4435 "the round must cover both include trees, with distinct identities",
4436 );
4437 }
4438
4439 #[tokio::test]
4443 async fn a_uri_in_any_include_tree_resolves_to_its_analysed_file() {
4444 let s = scratch_project(
4445 "uri",
4446 &[
4447 ("bynk.toml", "[project]\nname = \"uri\"\n"),
4448 ("src/thing.bynk", "context thing\n"),
4449 ("tests/thing.bynk", "suite thing\n"),
4450 ],
4451 );
4452 let backend = backend_at(&s.0).await;
4453 backend.run_round().await;
4454 let analysis = backend.test_analysis().await.expect("round");
4455
4456 for (rel, label) in [
4457 ("src/thing.bynk", "primary"),
4458 ("tests/thing.bynk", "secondary"),
4459 ] {
4460 let abs = s.0.join(rel);
4461 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4462 let resolved = Backend::uri_to_rel(&analysis, &uri)
4463 .unwrap_or_else(|| panic!("{label} root URI must resolve"));
4464 assert_eq!(
4465 resolved.to_string_lossy().replace('\\', "/"),
4466 rel,
4467 "a {label}-tree URI must name its own analysed file",
4468 );
4469 assert!(
4470 analysis.snapshots.contains_key(&resolved),
4471 "…and that file must be in the round",
4472 );
4473 }
4474 }
4475
4476 #[tokio::test]
4482 async fn project_content_excludes_the_calling_file() {
4483 let s = scratch_project(
4484 "self_excl",
4485 &[
4486 ("bynk.toml", "[project]\nname = \"self_excl\"\n"),
4487 ("a.bynk", "context a\n"),
4488 ("b.bynk", "context b\n"),
4489 ],
4490 );
4491 let backend = backend_at(&s.0).await;
4492 let abs = s.0.join("a.bynk");
4493 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4494 let content = backend
4495 .project_content(&uri)
4496 .await
4497 .expect("a project root resolves to a content map");
4498 assert!(
4499 !content.keys().any(|p| p.file_name().unwrap() == "a.bynk"),
4500 "the calling file's own path must not be in its own project content map: {content:?}"
4501 );
4502 assert!(
4503 content.keys().any(|p| p.file_name().unwrap() == "b.bynk"),
4504 "a sibling project file must still be present: {content:?}"
4505 );
4506 assert_eq!(
4507 content
4508 .iter()
4509 .find(|(p, _)| p.file_name().unwrap() == "b.bynk")
4510 .map(|(_, text)| text.as_str()),
4511 Some("context b\n"),
4512 "the sibling's content must be the real file content, not just its path"
4513 );
4514 }
4515
4516 #[tokio::test]
4520 async fn an_excluded_tree_is_not_analysed() {
4521 let s = scratch_project(
4522 "excl",
4523 &[
4524 (
4525 "bynk.toml",
4526 "[project]\nname = \"excl\"\n\n[paths]\ninclude = [\".\"]\nexclude = [\"generated\"]\n",
4527 ),
4528 ("a.bynk", "context a\n"),
4529 ("generated/gen.bynk", "context gen\n"),
4530 ],
4531 );
4532 let backend = backend_at(&s.0).await;
4533 backend.run_round().await;
4534 let analysis = backend.test_analysis().await.expect("round");
4535 let keys: Vec<String> = analysis
4536 .snapshots
4537 .keys()
4538 .map(|p| p.to_string_lossy().replace('\\', "/"))
4539 .collect();
4540 assert_eq!(keys, vec!["a.bynk"], "excluded trees stay out of the round");
4541 }
4542
4543 #[tokio::test]
4548 async fn references_resolve_in_the_vscode_fixture_layout() {
4549 let s = scratch_project(
4550 "vsc",
4551 &[
4552 (
4553 "bynk.toml",
4554 "[project]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[paths]\nsrc = \"src\"\ntests = \"tests\"\n",
4555 ),
4556 (
4557 "src/text.bynk",
4558 "commons fixture.text\n\nfn shout(s: String) -> String {\n s\n}\n\nfn greet(name: String) -> String {\n \"Hi, \\(shout(name))!\"\n}\n",
4559 ),
4560 ],
4561 );
4562 let (root, config) = Backend::resolve_root(&s.0).expect("bynk.toml is present");
4564 assert_eq!(root, s.0, "the manifest's directory is the project root");
4565
4566 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
4567 let backend = service.inner().clone();
4568 {
4569 let mut st = backend.state.write().await;
4570 let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
4573 st.projects.insert(
4574 canonical,
4575 ProjectState {
4576 config,
4577 ..Default::default()
4578 },
4579 );
4580 }
4581 backend.run_round().await;
4582
4583 let analysis = backend.test_analysis().await.expect("a round committed");
4584 let keys: Vec<String> = analysis
4585 .snapshots
4586 .keys()
4587 .map(|p| p.to_string_lossy().replace('\\', "/"))
4588 .collect();
4589 assert_eq!(keys, vec!["src/text.bynk"], "the fixture file is analysed");
4590
4591 let abs = s.0.join("src/text.bynk");
4593 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4594 let rel = Backend::uri_to_rel(&analysis, &uri).expect("URI resolves into the round");
4595 assert_eq!(rel, PathBuf::from("src/text.bynk"));
4596
4597 let text = analysis.snapshots.get(&rel).expect("snapshot present");
4599 let decl = text.find("shout").expect("`shout` in source");
4600 let pos = crate::position::offset_to_position(text, decl);
4601
4602 let refs = backend
4603 .references(ReferenceParams {
4604 text_document_position: TextDocumentPositionParams {
4605 text_document: TextDocumentIdentifier { uri: uri.clone() },
4606 position: pos,
4607 },
4608 work_done_progress_params: Default::default(),
4609 partial_result_params: Default::default(),
4610 context: ReferenceContext {
4611 include_declaration: true,
4612 },
4613 })
4614 .await
4615 .expect("references must not error");
4616 let found = refs.unwrap_or_default();
4617 assert!(
4618 !found.is_empty(),
4619 "`shout` is referenced by `greet` — references must resolve; got none",
4620 );
4621 }
4622
4623 async fn open_round_edit(
4633 backend: &Backend,
4634 root: &std::path::Path,
4635 v1_text: &str,
4636 v2_text: &str,
4637 ) -> (Url, PathBuf) {
4638 let abs = root.join("src/a.bynk");
4639 let uri = Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap();
4640
4641 backend
4642 .did_open(DidOpenTextDocumentParams {
4643 text_document: TextDocumentItem {
4644 uri: uri.clone(),
4645 language_id: "bynk".into(),
4646 version: 1,
4647 text: v1_text.to_string(),
4648 },
4649 })
4650 .await;
4651 backend.run_round().await;
4652
4653 let a1 = backend.test_analysis().await.expect("round 1");
4655 let rel = Backend::uri_to_rel(&a1, &uri).expect("uri resolves");
4656 assert_eq!(a1.versions.get(&rel), Some(&1), "round 1 is version 1");
4657
4658 backend
4661 .did_change(DidChangeTextDocumentParams {
4662 text_document: VersionedTextDocumentIdentifier {
4663 uri: uri.clone(),
4664 version: 2,
4665 },
4666 content_changes: vec![TextDocumentContentChangeEvent {
4667 range: None,
4668 range_length: None,
4669 text: v2_text.to_string(),
4670 }],
4671 })
4672 .await;
4673 (uri, rel)
4674 }
4675
4676 #[tokio::test]
4682 async fn a_position_after_an_edit_resolves_against_the_new_text() {
4683 let v1 = "commons q.a\n\nfn target(x: Int) -> Int {\n x\n}\n";
4684 let v2 = format!("\n{v1}");
4686 let s = scratch_project(
4687 "fresh_hd",
4688 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4689 );
4690 let backend = backend_at(&s.0).await;
4691 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4692
4693 let a = backend.analysis_for(&uri).await.expect("current analysis");
4695 assert_eq!(a.versions.get(&rel), Some(&2), "gate refreshed to the edit");
4696 assert_eq!(a.snapshots.get(&rel).map(String::as_str), Some(v2.as_str()));
4697
4698 let off_v2 = v2.find("target").unwrap();
4700 let new_pos = crate::position::offset_to_position(&v2, off_v2);
4701 let (a2, rel2, off) = backend
4702 .index_position(&uri, new_pos)
4703 .await
4704 .expect("position resolves");
4705 assert!(
4706 a2.snapshots.get(&rel2).unwrap()[off..].starts_with("target"),
4707 "the new position must land on `target` in the current snapshot — \
4708 the whole point of refreshing",
4709 );
4710 }
4711
4712 #[tokio::test]
4716 async fn a_stale_round_is_never_served() {
4717 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4718 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
4719 let s = scratch_project(
4720 "fresh_stale",
4721 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4722 );
4723 let backend = backend_at(&s.0).await;
4724 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4725
4726 let cached = backend.test_analysis().await.unwrap();
4728 assert_eq!(cached.versions.get(&rel), Some(&1), "cached round is stale");
4729
4730 let a = backend.analysis_for(&uri).await.unwrap();
4732 assert_eq!(
4733 a.versions.get(&rel),
4734 Some(&2),
4735 "analysis_for must refresh past a stale cached round, never serve it",
4736 );
4737 }
4738
4739 #[tokio::test]
4746 async fn committed_analysis_serves_the_stale_round_without_refreshing() {
4747 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4748 let v2 = format!("{v1}\nfn g(y: Int) -> Int {{\n y\n}}\n");
4749 let s = scratch_project(
4750 "fresh_committed",
4751 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4752 );
4753 let backend = backend_at(&s.0).await;
4754 let (uri, rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4755
4756 assert_eq!(
4758 backend.test_analysis().await.unwrap().versions.get(&rel),
4759 Some(&1),
4760 "cached round is stale",
4761 );
4762
4763 let a = backend
4765 .committed_analysis(&uri)
4766 .await
4767 .expect("committed round");
4768 assert_eq!(
4769 a.versions.get(&rel),
4770 Some(&1),
4771 "committed_analysis serves the committed round, stale and all",
4772 );
4773 assert_eq!(
4775 backend.test_analysis().await.unwrap().versions.get(&rel),
4776 Some(&1),
4777 "committed_analysis must not trigger a refresh",
4778 );
4779 }
4780
4781 #[tokio::test]
4785 async fn concurrent_requests_after_one_edit_share_one_round() {
4786 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4787 let v2 = format!("\n{v1}");
4788 let s = scratch_project(
4789 "fresh_coal",
4790 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4791 );
4792 let backend = backend_at(&s.0).await;
4793 let (uri, _rel) = open_round_edit(&backend, &s.0, v1, &v2).await;
4794
4795 let started_before = backend.test_round_started().await;
4796
4797 let calls = (0..5).map(|_| {
4799 let b = backend.clone();
4800 let u = uri.clone();
4801 tokio::spawn(async move { b.analysis_for(&u).await.is_some() })
4802 });
4803 for c in calls {
4804 assert!(
4805 c.await.unwrap(),
4806 "each concurrent request must get an analysis"
4807 );
4808 }
4809
4810 let started_after = backend.test_round_started().await;
4811 assert_eq!(
4812 started_after - started_before,
4813 1,
4814 "five concurrent requests after one edit must share ONE round, not run five",
4815 );
4816 }
4817
4818 #[tokio::test]
4821 async fn a_file_outside_the_project_is_declined() {
4822 let v1 = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
4823 let s = scratch_project(
4824 "fresh_out",
4825 &[("bynk.toml", "[project]\nname=\"q\"\n"), ("src/a.bynk", v1)],
4826 );
4827 let backend = backend_at(&s.0).await;
4828 backend.run_round().await;
4830
4831 let outside = s.0.join("elsewhere.bynk");
4833 std::fs::write(&outside, v1).unwrap();
4834 let uri = Url::from_file_path(outside.canonicalize().unwrap()).unwrap();
4835 backend
4836 .did_open(DidOpenTextDocumentParams {
4837 text_document: TextDocumentItem {
4838 uri: uri.clone(),
4839 language_id: "bynk".into(),
4840 version: 1,
4841 text: v1.to_string(),
4842 },
4843 })
4844 .await;
4845
4846 assert!(
4847 backend.analysis_for(&uri).await.is_none(),
4848 "a file outside the include roots is never a snapshot key — decline, \
4849 don't serve a round that doesn't cover it",
4850 );
4851 }
4852
4853 #[tokio::test]
4861 async fn a_multi_file_rename_stamps_a_dirty_non_cursor_file_at_its_current_version() {
4862 let util = "commons demo.util\n\ntype Money = Int where Positive\n";
4863 let thing = "commons demo.thing\n\nuses demo.util\n\nfn f(m: Money) -> Money {\n m\n}\n";
4864 let s = scratch_project(
4865 "rename_multi",
4866 &[
4867 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4868 ("src/demo/util.bynk", util),
4869 ("src/demo/thing.bynk", thing),
4870 ],
4871 );
4872 let backend = backend_at(&s.0).await;
4873 let uri = |rel: &str| {
4874 let abs = s.0.join(rel);
4875 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
4876 };
4877 let util_uri = uri("src/demo/util.bynk");
4878 let thing_uri = uri("src/demo/thing.bynk");
4879
4880 for (u, text) in [(&util_uri, util), (&thing_uri, thing)] {
4881 backend
4882 .did_open(DidOpenTextDocumentParams {
4883 text_document: TextDocumentItem {
4884 uri: u.clone(),
4885 language_id: "bynk".into(),
4886 version: 1,
4887 text: text.to_string(),
4888 },
4889 })
4890 .await;
4891 }
4892 backend.run_round().await;
4893
4894 let thing_v2 = format!("\n{thing}");
4897 backend
4898 .did_change(DidChangeTextDocumentParams {
4899 text_document: VersionedTextDocumentIdentifier {
4900 uri: thing_uri.clone(),
4901 version: 2,
4902 },
4903 content_changes: vec![TextDocumentContentChangeEvent {
4904 range: None,
4905 range_length: None,
4906 text: thing_v2.clone(),
4907 }],
4908 })
4909 .await;
4910
4911 let money_off = util.find("Money").unwrap();
4913 let pos = crate::position::offset_to_position(util, money_off);
4914 let edit = backend
4915 .rename(RenameParams {
4916 text_document_position: TextDocumentPositionParams {
4917 text_document: TextDocumentIdentifier {
4918 uri: util_uri.clone(),
4919 },
4920 position: pos,
4921 },
4922 new_name: "Amount".into(),
4923 work_done_progress_params: Default::default(),
4924 })
4925 .await
4926 .expect("rename must not error")
4927 .expect("rename must produce edits");
4928
4929 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
4930 panic!("expected document-change edits");
4931 };
4932 let thing_edit = edits
4933 .iter()
4934 .find(|e| e.text_document.uri == thing_uri)
4935 .expect("the rename must edit `thing`, which references the symbol");
4936 assert_eq!(
4937 thing_edit.text_document.version,
4938 Some(2),
4939 "the dirty non-cursor file's edit must carry its current version (2), \
4940 not the stale round's (1) — else the client rejects the whole rename",
4941 );
4942 }
4943
4944 #[tokio::test]
4949 async fn will_rename_files_updates_the_declaration_and_every_reference() {
4950 let charge = "commons billing.charge\n\ntype ChargeId = Int where Positive\n";
4951 let main = "context app.main\n\nuses billing.charge\n";
4952 let s = scratch_project(
4953 "will_rename_basic",
4954 &[
4955 ("bynk.toml", "[project]\nname=\"demo\"\n"),
4956 ("src/billing/charge.bynk", charge),
4957 ("src/app/main.bynk", main),
4958 ],
4959 );
4960 let backend = backend_at(&s.0).await;
4961 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
4962 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
4963 let old_uri = uri("src/billing/charge.bynk");
4964 let new_uri = uri("src/billing/pay.bynk");
4965 let main_uri = uri("src/app/main.bynk");
4966
4967 backend.run_round().await;
4968
4969 let edit = backend
4970 .will_rename_files(RenameFilesParams {
4971 files: vec![FileRename {
4972 old_uri: old_uri.to_string(),
4973 new_uri: new_uri.to_string(),
4974 }],
4975 })
4976 .await
4977 .expect("will_rename_files must not error")
4978 .expect("must produce edits");
4979
4980 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
4981 panic!("expected document-change edits");
4982 };
4983
4984 let own = edits
4985 .iter()
4986 .find(|e| e.text_document.uri == old_uri)
4987 .expect("the moved file's own declaration must be rewritten");
4988 assert_eq!(own.edits.len(), 1);
4989 let OneOf::Left(own_edit) = &own.edits[0] else {
4990 panic!("expected a plain TextEdit");
4991 };
4992 assert_eq!(own_edit.new_text, "billing.pay");
4993
4994 let referencer = edits
4995 .iter()
4996 .find(|e| e.text_document.uri == main_uri)
4997 .expect("the referencing file must be rewritten");
4998 assert_eq!(referencer.edits.len(), 1);
4999 let OneOf::Left(ref_edit) = &referencer.edits[0] else {
5000 panic!("expected a plain TextEdit");
5001 };
5002 assert_eq!(ref_edit.new_text, "billing.pay");
5003 }
5004
5005 #[tokio::test]
5009 async fn will_rename_files_is_a_noop_for_a_multi_file_unit_member() {
5010 let s = scratch_project(
5011 "will_rename_multi_file_noop",
5012 &[
5013 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5014 (
5015 "src/billing/charge/one.bynk",
5016 "context billing.charge\n\ntype ChargeId = Int where Positive\n",
5017 ),
5018 (
5019 "src/billing/charge/two.bynk",
5020 "context billing.charge\n\ntype PaymentId = Int where Positive\n",
5021 ),
5022 ],
5023 );
5024 let backend = backend_at(&s.0).await;
5025 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5026 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5027
5028 backend.run_round().await;
5029
5030 let edit = backend
5031 .will_rename_files(RenameFilesParams {
5032 files: vec![FileRename {
5033 old_uri: uri("src/billing/charge/one.bynk").to_string(),
5034 new_uri: uri("src/billing/charge/renamed.bynk").to_string(),
5035 }],
5036 })
5037 .await
5038 .expect("will_rename_files must not error");
5039 assert!(
5040 edit.is_none(),
5041 "renaming a member file within the same directory must not edit anything"
5042 );
5043 }
5044
5045 #[tokio::test]
5049 async fn will_rename_files_is_a_noop_for_a_suite() {
5050 let s = scratch_project(
5051 "will_rename_suite_noop",
5052 &[
5053 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5054 (
5055 "src/billing/charge.bynk",
5056 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5057 ),
5058 ("tests/billing_charge.bynk", "suite billing.charge\n"),
5059 ],
5060 );
5061 let backend = backend_at(&s.0).await;
5062 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5063 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5064
5065 backend.run_round().await;
5066
5067 let edit = backend
5068 .will_rename_files(RenameFilesParams {
5069 files: vec![FileRename {
5070 old_uri: uri("tests/billing_charge.bynk").to_string(),
5071 new_uri: uri("tests/billing_charge_renamed.bynk").to_string(),
5072 }],
5073 })
5074 .await
5075 .expect("will_rename_files must not error");
5076 assert!(edit.is_none(), "a suite rename must produce no edits");
5077 }
5078
5079 #[tokio::test]
5084 async fn will_rename_files_updates_a_suite_s_target_reference() {
5085 let s = scratch_project(
5086 "will_rename_suite_target",
5087 &[
5088 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5089 (
5090 "src/billing/charge.bynk",
5091 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5092 ),
5093 ("tests/billing_charge.bynk", "suite billing.charge\n"),
5094 ],
5095 );
5096 let backend = backend_at(&s.0).await;
5097 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5098 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5099 let suite_uri = uri("tests/billing_charge.bynk");
5100
5101 backend.run_round().await;
5102
5103 let edit = backend
5104 .will_rename_files(RenameFilesParams {
5105 files: vec![FileRename {
5106 old_uri: uri("src/billing/charge.bynk").to_string(),
5107 new_uri: uri("src/billing/pay.bynk").to_string(),
5108 }],
5109 })
5110 .await
5111 .expect("will_rename_files must not error")
5112 .expect("must produce edits");
5113
5114 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5115 panic!("expected document-change edits");
5116 };
5117 let suite_edit = edits
5118 .iter()
5119 .find(|e| e.text_document.uri == suite_uri)
5120 .expect("the suite's own `suite <target>` header must be rewritten");
5121 assert_eq!(suite_edit.edits.len(), 1);
5122 let OneOf::Left(e) = &suite_edit.edits[0] else {
5123 panic!("expected a plain TextEdit");
5124 };
5125 assert_eq!(e.new_text, "billing.pay");
5126 }
5127
5128 #[tokio::test]
5133 async fn will_rename_files_refuses_a_rename_that_collides_with_an_existing_unit() {
5134 let s = scratch_project(
5135 "will_rename_collision",
5136 &[
5137 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5138 (
5139 "src/billing/charge.bynk",
5140 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5141 ),
5142 (
5143 "src/billing/pay.bynk",
5144 "commons billing.pay\n\ntype PaymentId = Int where Positive\n",
5145 ),
5146 ],
5147 );
5148 let backend = backend_at(&s.0).await;
5149 let root_canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5150 let uri = |rel: &str| Url::from_file_path(root_canon.join(rel)).unwrap();
5151
5152 backend.run_round().await;
5153
5154 let edit = backend
5157 .will_rename_files(RenameFilesParams {
5158 files: vec![FileRename {
5159 old_uri: uri("src/billing/charge.bynk").to_string(),
5160 new_uri: uri("src/billing/pay.bynk").to_string(),
5161 }],
5162 })
5163 .await
5164 .expect("will_rename_files must not error");
5165 assert!(
5166 edit.is_none(),
5167 "a rename that collides with an existing unit name must produce no edits"
5168 );
5169 }
5170
5171 #[cfg(unix)]
5179 #[tokio::test]
5180 async fn will_rename_files_tolerates_a_symlinked_project_root() {
5181 let real = scratch_project(
5182 "will_rename_symlink_real",
5183 &[
5184 ("bynk.toml", "[project]\nname=\"demo\"\n"),
5185 (
5186 "src/billing/charge.bynk",
5187 "commons billing.charge\n\ntype ChargeId = Int where Positive\n",
5188 ),
5189 ],
5190 );
5191 let alias = std::env::temp_dir().join(format!(
5192 "bynk_lsp_sliceA_will_rename_symlink_alias_{}_{:?}",
5193 std::process::id(),
5194 std::thread::current().id()
5195 ));
5196 let _ = std::fs::remove_file(&alias);
5197 std::os::unix::fs::symlink(&real.0, &alias).expect("symlink the scratch root");
5198
5199 let backend = backend_at(&alias).await;
5200 let uri = |rel: &str| Url::from_file_path(alias.join(rel)).unwrap();
5204 let old_uri = uri("src/billing/charge.bynk");
5205 let new_uri = uri("src/billing/pay.bynk"); backend.run_round().await;
5208
5209 let edit = backend
5210 .will_rename_files(RenameFilesParams {
5211 files: vec![FileRename {
5212 old_uri: old_uri.to_string(),
5213 new_uri: new_uri.to_string(),
5214 }],
5215 })
5216 .await
5217 .expect("will_rename_files must not error")
5218 .expect("must produce edits despite the symlinked root");
5219
5220 let DocumentChanges::Edits(edits) = edit.document_changes.unwrap() else {
5221 panic!("expected document-change edits");
5222 };
5223 assert_eq!(
5224 edits.len(),
5225 1,
5226 "only the moved file's own header changes here"
5227 );
5228 let OneOf::Left(e) = &edits[0].edits[0] else {
5229 panic!("expected a plain TextEdit");
5230 };
5231 assert_eq!(e.new_text, "billing.pay");
5232
5233 let _ = std::fs::remove_file(&alias);
5234 }
5235
5236 #[test]
5241 fn find_source_root_walks_up_to_the_nearest_src() {
5242 let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5243 .parent()
5244 .expect("workspace root");
5245 let make = ws.join(
5246 "bynkc/tests/fixtures/positive/\
5247 252_multi_file_commons_dotted_test/src/shipping/rates/make.bynk",
5248 );
5249 assert!(make.is_file(), "fixture present: {}", make.display());
5250
5251 let src = Backend::find_source_root(&make).expect("an ancestor src/");
5252 assert!(
5253 src.ends_with("252_multi_file_commons_dotted_test/src"),
5254 "nearest ancestor src, got {}",
5255 src.display()
5256 );
5257
5258 let (root, _config) = Backend::resolve_root(&make).expect("implicit project");
5269 assert_eq!(root, src.parent().expect("src has a parent"));
5270
5271 let found = bynk_ide::discover_files(
5275 &bynk_ide::AnalysisRoots::Project(root.clone()),
5276 &std::collections::HashMap::new(),
5277 );
5278 let make_canon = make.canonicalize().unwrap_or(make.clone());
5279 assert!(
5280 found
5281 .iter()
5282 .any(|p| p.canonicalize().unwrap_or_else(|_| p.clone()) == make_canon),
5283 "the compiler's own discovery must reach {} from the project root {}; got {found:?}",
5284 make.display(),
5285 root.display(),
5286 );
5287 }
5288
5289 #[test]
5293 fn resolve_root_is_none_without_toml_or_src() {
5294 let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
5296 assert!(p.is_file());
5297 assert!(Backend::find_source_root(&p).is_none());
5298 assert!(Backend::resolve_root(&p).is_none());
5299 }
5300
5301 #[test]
5304 fn is_scrutinee_offset_locates_the_scrutinee() {
5305 let text = " order.status is Pen";
5306 let off = is_scrutinee_offset(text, text.len()).expect("at an is-position");
5307 assert_eq!(&text[off..off + 1], "s");
5309 assert!(off < text.find(" is ").unwrap());
5310 let text2 = " x is ";
5312 let off2 = is_scrutinee_offset(text2, text2.len()).expect("at an is-position");
5313 assert_eq!(&text2[off2..off2 + 1], "x");
5314 assert!(is_scrutinee_offset(" basis ", " basis ".len()).is_none());
5316 assert!(is_scrutinee_offset(" let x = ", " let x = ".len()).is_none());
5318 }
5319
5320 #[test]
5323 fn match_scrutinee_offset_locates_the_scrutinee() {
5324 let t = "match order.status {\n ";
5326 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5327 assert_eq!(&t[off..off + 1], "s"); assert!(off < t.find(" {").unwrap());
5329
5330 let t = "match color { Re";
5332 let off = match_scrutinee_offset(t, t.len()).expect("at an arm-start");
5333 assert_eq!(&t[off..off + 1], "r"); let t = "match c {\n Red => 1,\n Gr";
5337 let off = match_scrutinee_offset(t, t.len()).expect("at a later arm-start");
5338 assert_eq!(&t[off..off + 1], "c");
5339
5340 let t = "match c {\n Red => f(a, b),\n ";
5343 assert!(match_scrutinee_offset(t, t.len()).is_some());
5344
5345 assert!(
5347 match_scrutinee_offset("match c {\n Red => ", "match c {\n Red => ".len()).is_none()
5348 );
5349
5350 assert!(match_scrutinee_offset("fn f() {\n ", "fn f() {\n ".len()).is_none());
5352
5353 assert!(match_scrutinee_offset("match c {\n Ok(", "match c {\n Ok(".len()).is_none());
5355
5356 assert!(match_scrutinee_offset("match c ", "match c ".len()).is_none());
5358 assert!(match_scrutinee_offset("match {\n ", "match {\n ".len()).is_none());
5359 }
5360
5361 #[test]
5365 fn nested_pattern_offset_locates_the_scrutinee_and_variant() {
5366 let t = "match res {\n Some(";
5368 let (off, variant) = nested_pattern_offset(t, t.len()).expect("inside a nested pattern");
5369 assert_eq!(&t[off..off + 1], "s"); assert_eq!(variant, "Some");
5371
5372 let t = "match res {\n Ok(Po";
5374 let (off, variant) = nested_pattern_offset(t, t.len()).expect("mid partial");
5375 assert_eq!(&t[off..off + 1], "s");
5376 assert_eq!(variant, "Ok");
5377
5378 let t = "match r {\n Ok(n) => n,\n Err(";
5380 let (off, variant) = nested_pattern_offset(t, t.len()).expect("later arm");
5381 assert_eq!(&t[off..off + 1], "r");
5382 assert_eq!(variant, "Err");
5383
5384 assert!(nested_pattern_offset("match r {\n ok(", "match r {\n ok(".len()).is_none());
5387
5388 assert!(nested_pattern_offset("match c {\n ", "match c {\n ".len()).is_none());
5390 assert!(nested_pattern_offset("match c {\n Ok", "match c {\n Ok".len()).is_none());
5391
5392 let t = "match c {\n Ok(n) => g(";
5394 assert!(nested_pattern_offset(t, t.len()).is_none());
5395
5396 assert!(nested_pattern_offset("fn f() {\n h(", "fn f() {\n h(".len()).is_none());
5398 }
5399
5400 #[test]
5404 fn is_bynk_toml_matches_only_the_manifest() {
5405 let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
5410 let toml = Url::from_file_path(base.join("bynk.toml")).expect("abs path");
5411 assert!(is_bynk_toml(&toml));
5412 let nested = Url::from_file_path(base.join("sub").join("bynk.toml")).expect("abs path");
5413 assert!(is_bynk_toml(&nested));
5414
5415 let src = Url::from_file_path(base.join("src").join("main.bynk")).expect("abs path");
5417 assert!(!is_bynk_toml(&src));
5418 let decoy = Url::from_file_path(base.join("notbynk.toml")).expect("abs path");
5420 assert!(!is_bynk_toml(&decoy));
5421 let remote = Url::parse("https://example.com/bynk.toml").expect("url");
5423 assert!(!is_bynk_toml(&remote));
5424 }
5425
5426 #[test]
5429 fn advertises_code_actions_and_the_index_riders() {
5430 let caps = server_capabilities();
5431 let Some(CodeActionProviderCapability::Options(opts)) = caps.code_action_provider else {
5432 panic!("codeActionProvider not advertised with options");
5433 };
5434 assert_eq!(
5435 opts.code_action_kinds,
5436 Some(vec![
5437 CodeActionKind::QUICKFIX,
5438 CodeActionKind::REFACTOR,
5439 CodeActionKind::REFACTOR_EXTRACT,
5440 ])
5441 );
5442 assert!(matches!(
5443 caps.workspace_symbol_provider,
5444 Some(OneOf::Left(true))
5445 ));
5446 assert!(matches!(
5447 caps.document_highlight_provider,
5448 Some(OneOf::Left(true))
5449 ));
5450 }
5451
5452 #[test]
5455 fn advertises_save_notifications() {
5456 let caps = server_capabilities();
5459 let Some(TextDocumentSyncCapability::Options(opts)) = caps.text_document_sync else {
5460 panic!("textDocumentSync not advertised with options");
5461 };
5462 assert_eq!(opts.change, Some(TextDocumentSyncKind::FULL));
5463 assert!(matches!(
5464 opts.save,
5465 Some(TextDocumentSyncSaveOptions::Supported(true))
5466 ));
5467 }
5468
5469 #[test]
5470 fn advertises_inlay_hints() {
5471 let caps = server_capabilities();
5472 assert!(matches!(caps.inlay_hint_provider, Some(OneOf::Left(true))));
5473 }
5474
5475 #[test]
5477 fn advertises_type_definition() {
5478 let caps = server_capabilities();
5479 assert!(matches!(
5480 caps.type_definition_provider,
5481 Some(TypeDefinitionProviderCapability::Simple(true))
5482 ));
5483 }
5484
5485 #[test]
5487 fn advertises_document_links() {
5488 let caps = server_capabilities();
5489 assert!(caps.document_link_provider.is_some());
5490 }
5491
5492 #[test]
5494 fn advertises_will_rename_files() {
5495 let caps = server_capabilities();
5496 let file_ops = caps
5497 .workspace
5498 .as_ref()
5499 .and_then(|w| w.file_operations.as_ref())
5500 .expect("workspace.fileOperations advertised");
5501 let will_rename = file_ops
5502 .will_rename
5503 .as_ref()
5504 .expect("willRename registered");
5505 let filter = &will_rename.filters[0];
5506 assert_eq!(filter.pattern.glob, "**/*.bynk");
5507 assert_eq!(filter.pattern.matches, Some(FileOperationPatternKind::File));
5508 }
5509
5510 #[test]
5512 fn advertises_completion_with_dot_trigger_and_resolve() {
5513 let caps = server_capabilities();
5514 let opts = caps.completion_provider.expect("completion advertised");
5515 assert_eq!(opts.resolve_provider, Some(true), "resolve_provider");
5516 assert!(
5517 opts.trigger_characters
5518 .as_deref()
5519 .is_some_and(|t| t.iter().any(|c| c == ".")),
5520 "`.` trigger char"
5521 );
5522 }
5523
5524 #[test]
5527 fn advertises_semantic_tokens() {
5528 let caps = server_capabilities();
5529 let Some(SemanticTokensServerCapabilities::SemanticTokensOptions(opts)) =
5530 caps.semantic_tokens_provider
5531 else {
5532 panic!("semanticTokensProvider not advertised with options");
5533 };
5534 assert_eq!(opts.full, Some(SemanticTokensFullOptions::Bool(true)));
5535 assert_eq!(opts.range, Some(true));
5536 assert_eq!(opts.legend, crate::index_queries::semantic_tokens_legend());
5537 }
5538
5539 async fn bare_backend() -> Backend {
5544 let (service, _socket) = tower_lsp::LspService::new(Backend::new);
5545 service.inner().clone()
5546 }
5547
5548 fn file_uri(root: &std::path::Path, rel: &str) -> Url {
5549 let abs = root.join(rel);
5550 Url::from_file_path(abs.canonicalize().unwrap_or(abs)).unwrap()
5551 }
5552
5553 async fn set_folders(backend: &Backend, roots: &[&std::path::Path]) {
5554 backend.state.write().await.folders = roots
5555 .iter()
5556 .map(|r| r.canonicalize().unwrap_or_else(|_| r.to_path_buf()))
5557 .collect();
5558 }
5559
5560 async fn open(backend: &Backend, uri: &Url, text: &str) {
5561 backend
5562 .did_open(DidOpenTextDocumentParams {
5563 text_document: TextDocumentItem {
5564 uri: uri.clone(),
5565 language_id: "bynk".into(),
5566 version: 1,
5567 text: text.to_string(),
5568 },
5569 })
5570 .await;
5571 }
5572
5573 fn snapshot_keys(a: &Analysis) -> Vec<String> {
5574 let mut keys: Vec<String> = a
5575 .snapshots
5576 .keys()
5577 .map(|p| p.to_string_lossy().replace('\\', "/"))
5578 .collect();
5579 keys.sort();
5580 keys
5581 }
5582
5583 #[tokio::test]
5588 async fn two_projects_under_one_folder_are_two_projects() {
5589 let ax_src = "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n";
5590 let by_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
5591 let s = scratch_project(
5592 "d_two",
5593 &[
5594 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5595 ("a/src/x.bynk", ax_src),
5596 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5597 ("b/src/y.bynk", by_src),
5598 ],
5599 );
5600 let backend = bare_backend().await;
5601 set_folders(&backend, &[&s.0]).await;
5602 let ax = file_uri(&s.0, "a/src/x.bynk");
5603 let by = file_uri(&s.0, "b/src/y.bynk");
5604 open(&backend, &ax, ax_src).await;
5605 open(&backend, &by, by_src).await;
5606
5607 assert_ne!(
5608 Backend::root_for_uri_uncached(&ax).unwrap(),
5609 Backend::root_for_uri_uncached(&by).unwrap(),
5610 "the two files resolve to different project roots",
5611 );
5612 assert_eq!(
5613 backend.state.read().await.projects.len(),
5614 2,
5615 "one entry per project, not one for the shared folder",
5616 );
5617
5618 let a = backend.analysis_for(&ax).await.expect("A analysed");
5619 let b = backend.analysis_for(&by).await.expect("B analysed");
5620 assert_eq!(
5621 snapshot_keys(&a),
5622 vec!["src/x.bynk"],
5623 "A sees only A's file"
5624 );
5625 assert_eq!(
5626 snapshot_keys(&b),
5627 vec!["src/y.bynk"],
5628 "B sees only B's file"
5629 );
5630 }
5631
5632 #[tokio::test]
5636 async fn removing_a_folder_prunes_an_idle_project() {
5637 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5638 let s = scratch_project(
5639 "d_prune",
5640 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5641 );
5642 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5643 let backend = bare_backend().await;
5644 set_folders(&backend, &[&s.0]).await;
5645 let uri = file_uri(&s.0, "src/a.bynk");
5646 open(&backend, &uri, a).await;
5647 backend.analysis_for(&uri).await.expect("analysed");
5648 backend
5650 .did_close(DidCloseTextDocumentParams {
5651 text_document: TextDocumentIdentifier { uri: uri.clone() },
5652 })
5653 .await;
5654 assert_eq!(backend.state.read().await.projects.len(), 1);
5655
5656 backend
5657 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5658 event: WorkspaceFoldersChangeEvent {
5659 added: vec![],
5660 removed: vec![WorkspaceFolder {
5661 uri: Url::from_file_path(&folder).unwrap(),
5662 name: "p".into(),
5663 }],
5664 },
5665 })
5666 .await;
5667 assert!(
5668 backend.state.read().await.projects.is_empty(),
5669 "an idle project is pruned when its last covering folder is removed",
5670 );
5671 }
5672
5673 #[tokio::test]
5676 async fn removing_a_folder_retains_a_project_with_an_open_buffer() {
5677 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5678 let s = scratch_project(
5679 "d_retain",
5680 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5681 );
5682 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5683 let backend = bare_backend().await;
5684 set_folders(&backend, &[&s.0]).await;
5685 let uri = file_uri(&s.0, "src/a.bynk");
5686 open(&backend, &uri, a).await; backend
5689 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5690 event: WorkspaceFoldersChangeEvent {
5691 added: vec![],
5692 removed: vec![WorkspaceFolder {
5693 uri: Url::from_file_path(&folder).unwrap(),
5694 name: "p".into(),
5695 }],
5696 },
5697 })
5698 .await;
5699 assert_eq!(
5700 backend.state.read().await.projects.len(),
5701 1,
5702 "a project with an open buffer must survive folder removal",
5703 );
5704 assert!(
5705 backend.analysis_for(&uri).await.is_some(),
5706 "and it must still answer requests",
5707 );
5708 }
5709
5710 #[tokio::test]
5714 async fn closing_the_last_buffer_of_a_folder_removed_project_prunes_it() {
5715 let a = "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n";
5716 let s = scratch_project(
5717 "d_close_prune",
5718 &[("bynk.toml", "[project]\nname=\"p\"\n"), ("src/a.bynk", a)],
5719 );
5720 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5721 let backend = bare_backend().await;
5722 set_folders(&backend, &[&s.0]).await;
5723 let uri = file_uri(&s.0, "src/a.bynk");
5724 open(&backend, &uri, a).await;
5725 backend.analysis_for(&uri).await.expect("analysed");
5726
5727 backend
5729 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
5730 event: WorkspaceFoldersChangeEvent {
5731 added: vec![],
5732 removed: vec![WorkspaceFolder {
5733 uri: Url::from_file_path(&folder).unwrap(),
5734 name: "p".into(),
5735 }],
5736 },
5737 })
5738 .await;
5739 assert_eq!(
5740 backend.state.read().await.projects.len(),
5741 1,
5742 "retained while its buffer is open",
5743 );
5744
5745 backend
5747 .did_close(DidCloseTextDocumentParams {
5748 text_document: TextDocumentIdentifier { uri: uri.clone() },
5749 })
5750 .await;
5751 assert!(
5752 backend.state.read().await.projects.is_empty(),
5753 "closing the last buffer of a folder-removed project must prune it",
5754 );
5755 }
5756
5757 #[tokio::test]
5761 async fn a_rename_in_one_project_ignores_a_dirty_buffer_in_another() {
5762 let a_src = "commons a.x\n\ntype Money = Int where Positive\n\nfn charge(m: Money) -> Money {\n m\n}\n";
5763 let b_src = "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n";
5764 let s = scratch_project(
5765 "d_rename_iso",
5766 &[
5767 ("a/bynk.toml", "[project]\nname=\"a\"\n"),
5768 ("a/src/x.bynk", a_src),
5769 ("b/bynk.toml", "[project]\nname=\"b\"\n"),
5770 ("b/src/y.bynk", b_src),
5771 ],
5772 );
5773 let backend = bare_backend().await;
5774 set_folders(&backend, &[&s.0]).await;
5775 let ax = file_uri(&s.0, "a/src/x.bynk");
5776 let by = file_uri(&s.0, "b/src/y.bynk");
5777 open(&backend, &ax, a_src).await;
5778 open(&backend, &by, b_src).await;
5779 backend.analysis_for(&ax).await.expect("A analysed");
5780 backend.analysis_for(&by).await.expect("B analysed");
5781
5782 backend
5784 .did_change(DidChangeTextDocumentParams {
5785 text_document: VersionedTextDocumentIdentifier {
5786 uri: by.clone(),
5787 version: 2,
5788 },
5789 content_changes: vec![TextDocumentContentChangeEvent {
5790 range: None,
5791 range_length: None,
5792 text: format!("\n{b_src}"),
5793 }],
5794 })
5795 .await;
5796
5797 let off = a_src.find("Money").unwrap();
5799 let pos = crate::position::offset_to_position(a_src, off);
5800 let edit = backend
5801 .rename(RenameParams {
5802 text_document_position: TextDocumentPositionParams {
5803 text_document: TextDocumentIdentifier { uri: ax.clone() },
5804 position: pos,
5805 },
5806 new_name: "Amount".into(),
5807 work_done_progress_params: Default::default(),
5808 })
5809 .await
5810 .expect("rename must not error");
5811 assert!(
5812 edit.is_some(),
5813 "a rename in project A must not be blocked by a dirty buffer in project B",
5814 );
5815 }
5816
5817 #[tokio::test]
5822 async fn initialize_captures_the_dynamic_watcher_capability() {
5823 let backend = bare_backend().await;
5824 let params = InitializeParams {
5825 capabilities: ClientCapabilities {
5826 workspace: Some(WorkspaceClientCapabilities {
5827 did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
5828 dynamic_registration: Some(true),
5829 relative_pattern_support: None,
5830 }),
5831 ..Default::default()
5832 }),
5833 ..Default::default()
5834 },
5835 ..Default::default()
5836 };
5837 backend.initialize(params).await.expect("initialize");
5838 assert!(
5839 backend.state.read().await.supports_dynamic_watchers,
5840 "the client's dynamic-registration support must be captured for `initialized`",
5841 );
5842 }
5843
5844 #[tokio::test]
5850 async fn initialize_captures_each_decoration_refresh_capability() {
5851 let backend = bare_backend().await;
5852 let params = InitializeParams {
5853 capabilities: ClientCapabilities {
5854 workspace: Some(WorkspaceClientCapabilities {
5855 semantic_tokens: Some(SemanticTokensWorkspaceClientCapabilities {
5857 refresh_support: Some(true),
5858 }),
5859 inlay_hint: Some(InlayHintWorkspaceClientCapabilities {
5861 refresh_support: Some(false),
5862 }),
5863 ..Default::default()
5865 }),
5866 ..Default::default()
5867 },
5868 ..Default::default()
5869 };
5870 backend.initialize(params).await.expect("initialize");
5871 let refresh = backend.state.read().await.supports_refresh;
5872 assert!(
5873 refresh.semantic_tokens,
5874 "semantic tokens: advertised → true"
5875 );
5876 assert!(!refresh.inlay_hints, "inlay hints: withheld → false");
5877 assert!(!refresh.code_lens, "code lens: absent → false");
5878 }
5879
5880 #[tokio::test]
5883 async fn discover_projects_under_finds_nested_projects_and_skips_caches() {
5884 let s = scratch_project(
5885 "e_discover",
5886 &[
5887 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
5888 ("packages/a/src/x.bynk", "commons a.x\n"),
5889 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
5890 ("packages/b/src/y.bynk", "commons b.y\n"),
5891 ("node_modules/dep/bynk.toml", "[project]\nname=\"dep\"\n"),
5893 ],
5894 );
5895 let mut roots = Backend::discover_projects_under(&s.0);
5896 roots.sort();
5897 let names: Vec<String> = roots
5898 .iter()
5899 .map(|r| r.file_name().unwrap().to_string_lossy().into_owned())
5900 .collect();
5901 assert_eq!(
5902 names,
5903 vec!["a", "b"],
5904 "both monorepo projects found, node_modules skipped; got {roots:?}",
5905 );
5906 }
5907
5908 #[tokio::test]
5912 async fn initialized_warms_every_project_under_the_folders() {
5913 let s = scratch_project(
5914 "e_warm",
5915 &[
5916 ("packages/a/bynk.toml", "[project]\nname=\"a\"\n"),
5917 (
5918 "packages/a/src/x.bynk",
5919 "commons a.x\n\nfn ax(n: Int) -> Int {\n n\n}\n",
5920 ),
5921 ("packages/b/bynk.toml", "[project]\nname=\"b\"\n"),
5922 (
5923 "packages/b/src/y.bynk",
5924 "commons b.y\n\nfn by(n: Int) -> Int {\n n\n}\n",
5925 ),
5926 ],
5927 );
5928 let backend = bare_backend().await;
5929 set_folders(&backend, &[&s.0]).await;
5930
5931 backend.initialized(InitializedParams {}).await;
5933
5934 assert_eq!(
5935 backend.state.read().await.projects.len(),
5936 2,
5937 "both monorepo projects are warmed at `initialized`, before any open",
5938 );
5939 let ax = file_uri(&s.0, "packages/a/src/x.bynk");
5941 assert!(
5942 backend.analysis_for(&ax).await.is_some(),
5943 "a warmed project answers index requests with no `did_open`",
5944 );
5945 }
5946
5947 #[tokio::test]
5952 async fn initialized_warms_an_implicit_src_project() {
5953 let s = scratch_project(
5954 "e_implicit",
5955 &[(
5956 "src/a.bynk",
5957 "commons demo.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
5958 )],
5959 );
5960 let backend = bare_backend().await;
5961 set_folders(&backend, &[&s.0]).await;
5962 backend.initialized(InitializedParams {}).await;
5963 assert_eq!(
5964 backend.state.read().await.projects.len(),
5965 1,
5966 "a rootless `src/` project is warmed at startup, not only on open",
5967 );
5968 }
5969
5970 #[cfg(unix)]
5974 #[tokio::test]
5975 async fn discover_projects_under_survives_a_symlink_cycle() {
5976 let s = scratch_project(
5977 "e_cycle",
5978 &[
5979 ("bynk.toml", "[project]\nname=\"p\"\n"),
5980 ("src/a.bynk", "commons p.a\n"),
5981 ],
5982 );
5983 std::os::unix::fs::symlink(&s.0, s.0.join("loop")).ok();
5985 let roots = Backend::discover_projects_under(&s.0); let canon = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
5987 assert!(
5988 roots.contains(&canon),
5989 "the project is found and the walk terminates despite the cycle",
5990 );
5991 }
5992
5993 #[tokio::test]
5997 async fn a_created_manifest_warms_a_new_project() {
5998 let s = scratch_project("e_created", &[("src/a.bynk", "commons p.a\n")]);
5999 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
6000 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6001 let backend = bare_backend().await;
6002 set_folders(&backend, &[&s.0]).await;
6003 assert!(
6004 backend.state.read().await.projects.is_empty(),
6005 "no entry before the watcher fires",
6006 );
6007
6008 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
6009 backend
6010 .did_change_watched_files(DidChangeWatchedFilesParams {
6011 changes: vec![FileEvent {
6012 uri: toml_uri,
6013 typ: FileChangeType::CREATED,
6014 }],
6015 })
6016 .await;
6017 assert_eq!(
6018 backend.state.read().await.projects.len(),
6019 1,
6020 "a created bynk.toml warms its project via the watcher event",
6021 );
6022 }
6023
6024 #[tokio::test]
6028 async fn root_for_uri_populates_the_cache() {
6029 let s = scratch_project("g_cache_hit", &[("a.bynk", "commons demo.a\n")]);
6030 let uri = file_uri(&s.0, "a.bynk");
6031 let backend = bare_backend().await;
6032
6033 assert!(
6034 backend.root_for_uri(&uri).await.is_none(),
6035 "no bynk.toml and no src/ ancestor — routes to no project",
6036 );
6037 assert_eq!(
6038 backend.state.read().await.root_cache.get(&uri),
6039 Some(&None),
6040 "the miss is cached too",
6041 );
6042 }
6043
6044 #[tokio::test]
6048 async fn a_created_manifest_invalidates_the_cached_route() {
6049 let s = scratch_project("g_cache_invalidate", &[("a.bynk", "commons p.a\n")]);
6050 let uri = file_uri(&s.0, "a.bynk");
6051 let backend = bare_backend().await;
6052
6053 assert!(
6054 backend.root_for_uri(&uri).await.is_none(),
6055 "precondition: cached as routing to no project",
6056 );
6057
6058 std::fs::write(s.0.join("bynk.toml"), "[project]\nname=\"p\"\n").unwrap();
6059 let root = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6060 let toml_uri = Url::from_file_path(root.join("bynk.toml")).unwrap();
6061 backend
6062 .did_change_watched_files(DidChangeWatchedFilesParams {
6063 changes: vec![FileEvent {
6064 uri: toml_uri,
6065 typ: FileChangeType::CREATED,
6066 }],
6067 })
6068 .await;
6069
6070 assert_eq!(
6071 backend.root_for_uri(&uri).await,
6072 Some(root),
6073 "re-routes to the new project now the stale cache entry is gone",
6074 );
6075 }
6076
6077 #[test]
6083 fn root_cache_write_is_current_rejects_a_generation_that_moved() {
6084 assert!(
6085 Backend::root_cache_write_is_current(3, 3),
6086 "no clear happened since the read — the write-back applies",
6087 );
6088 assert!(
6089 !Backend::root_cache_write_is_current(3, 4),
6090 "a clear bumped the generation since the read — the write-back must be dropped",
6091 );
6092 }
6093
6094 #[tokio::test]
6101 async fn a_workspace_folder_change_bumps_the_root_cache_generation() {
6102 let s = scratch_project("g_race_folder", &[("bynk.toml", "[project]\nname=\"p\"\n")]);
6103 let backend = bare_backend().await;
6104 let before = backend.state.read().await.root_cache_generation;
6105
6106 backend
6107 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
6108 event: WorkspaceFoldersChangeEvent {
6109 added: vec![WorkspaceFolder {
6110 uri: Url::from_file_path(&s.0).unwrap(),
6111 name: "p".into(),
6112 }],
6113 removed: vec![],
6114 },
6115 })
6116 .await;
6117
6118 assert!(
6119 backend.state.read().await.root_cache_generation > before,
6120 "a workspace-folder change must bump the generation, not just clear the cache",
6121 );
6122 }
6123
6124 #[tokio::test]
6127 async fn an_added_folder_is_warmed() {
6128 let s = scratch_project(
6129 "e_added",
6130 &[
6131 ("bynk.toml", "[project]\nname=\"p\"\n"),
6132 (
6133 "src/a.bynk",
6134 "commons p.a\n\nfn f(n: Int) -> Int {\n n\n}\n",
6135 ),
6136 ],
6137 );
6138 let folder = s.0.canonicalize().unwrap_or_else(|_| s.0.clone());
6139 let backend = bare_backend().await; assert!(backend.state.read().await.projects.is_empty());
6141
6142 backend
6143 .did_change_workspace_folders(DidChangeWorkspaceFoldersParams {
6144 event: WorkspaceFoldersChangeEvent {
6145 added: vec![WorkspaceFolder {
6146 uri: Url::from_file_path(&folder).unwrap(),
6147 name: "p".into(),
6148 }],
6149 removed: vec![],
6150 },
6151 })
6152 .await;
6153
6154 assert_eq!(
6155 backend.state.read().await.projects.len(),
6156 1,
6157 "an added workspace folder's project is warmed proactively",
6158 );
6159 }
6160
6161 fn change_params(uri: &Url, version: i32, text: &str) -> DidChangeTextDocumentParams {
6164 DidChangeTextDocumentParams {
6165 text_document: VersionedTextDocumentIdentifier {
6166 uri: uri.clone(),
6167 version,
6168 },
6169 content_changes: vec![TextDocumentContentChangeEvent {
6170 range: None,
6171 range_length: None,
6172 text: text.to_string(),
6173 }],
6174 }
6175 }
6176
6177 #[tokio::test]
6182 async fn a_single_file_burst_coalesces_by_generation() {
6183 let s = scratch_project("f_single", &[("a.bynk", "commons demo.a\n")]);
6185 let uri = file_uri(&s.0, "a.bynk");
6186 assert!(
6187 Backend::root_for_uri_uncached(&uri).is_none(),
6188 "precondition: the file routes to no project",
6189 );
6190 let backend = bare_backend().await;
6191 for _ in 0..3 {
6192 backend.schedule_single_file(uri.clone()).await;
6193 }
6194 assert_eq!(
6195 backend
6196 .state
6197 .read()
6198 .await
6199 .single_file_generations
6200 .get(&uri)
6201 .copied(),
6202 Some(3),
6203 "each change bumps the generation; only the third task passes its check",
6204 );
6205 }
6206
6207 #[tokio::test]
6210 async fn did_close_clears_the_single_file_generation() {
6211 let s = scratch_project("f_close", &[("a.bynk", "commons demo.a\n")]);
6212 let uri = file_uri(&s.0, "a.bynk");
6213 let backend = bare_backend().await;
6214 backend.schedule_single_file(uri.clone()).await;
6215 assert!(
6216 backend
6217 .state
6218 .read()
6219 .await
6220 .single_file_generations
6221 .contains_key(&uri),
6222 "the generation exists after scheduling",
6223 );
6224 backend
6225 .did_close(DidCloseTextDocumentParams {
6226 text_document: TextDocumentIdentifier { uri: uri.clone() },
6227 })
6228 .await;
6229 assert!(
6230 !backend
6231 .state
6232 .read()
6233 .await
6234 .single_file_generations
6235 .contains_key(&uri),
6236 "did_close clears the single-file generation",
6237 );
6238 }
6239
6240 #[tokio::test]
6245 async fn a_project_change_burst_coalesces_through_did_change() {
6246 let src = "commons q.a\n\nfn f(x: Int) -> Int {\n x\n}\n";
6247 let s = scratch_project(
6248 "f_burst",
6249 &[
6250 ("bynk.toml", "[project]\nname=\"q\"\n"),
6251 ("src/a.bynk", src),
6252 ],
6253 );
6254 let backend = backend_at(&s.0).await;
6255 let root = backend.test_root().await;
6256 let uri = file_uri(&s.0, "src/a.bynk");
6257 open(&backend, &uri, src).await;
6258
6259 let gen_before = {
6260 let state = backend.state.read().await;
6261 state.projects.get(&root).unwrap().analysis_generation
6262 };
6263 for v in 2..=5 {
6264 backend
6265 .did_change(change_params(
6266 &uri,
6267 v,
6268 &format!("{}{src}", "\n".repeat(v as usize)),
6269 ))
6270 .await;
6271 }
6272 let gen_after = {
6273 let state = backend.state.read().await;
6274 state.projects.get(&root).unwrap().analysis_generation
6275 };
6276 assert_eq!(
6277 gen_after - gen_before,
6278 4,
6279 "each of the four changes bumps the generation once — only the last \
6280 scheduled round runs (no per-change round, no stacked debounce)",
6281 );
6282 }
6283
6284 fn completion_labels(response: Option<CompletionResponse>) -> Vec<String> {
6294 match response {
6295 Some(CompletionResponse::Array(items)) => items.into_iter().map(|i| i.label).collect(),
6296 Some(CompletionResponse::List(list)) => {
6297 list.items.into_iter().map(|i| i.label).collect()
6298 }
6299 None => Vec::new(),
6300 }
6301 }
6302
6303 async fn complete_at(backend: &Backend, uri: &Url, text: &str, needle: &str) -> Vec<String> {
6304 let offset = text.find(needle).expect("needle present") + needle.len();
6305 let pos = crate::position::offset_to_position(text, offset);
6306 let response = backend
6307 .completion(CompletionParams {
6308 text_document_position: TextDocumentPositionParams {
6309 text_document: TextDocumentIdentifier { uri: uri.clone() },
6310 position: pos,
6311 },
6312 work_done_progress_params: Default::default(),
6313 partial_result_params: Default::default(),
6314 context: None,
6315 })
6316 .await
6317 .expect("completion must not error");
6318 completion_labels(response)
6319 }
6320
6321 #[tokio::test]
6327 async fn store_map_receiver_completion_merges_both_vocabularies() {
6328 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";
6329 let s = scratch_project(
6330 "store_map_merge",
6331 &[
6332 ("bynk.toml", "[project]\nname=\"shop\"\n"),
6333 ("src/a.bynk", src),
6334 ],
6335 );
6336 let backend = backend_at(&s.0).await;
6337 let uri = file_uri(&s.0, "src/a.bynk");
6338 open(&backend, &uri, src).await;
6339 backend.run_round().await;
6340
6341 let labels = complete_at(&backend, &uri, src, " items.").await;
6342 assert!(
6343 labels.contains(&"filter".to_string()),
6344 "the Query kernel vocabulary: {labels:?}"
6345 );
6346 assert!(
6347 labels.contains(&"collect".to_string()),
6348 "the Query kernel vocabulary: {labels:?}"
6349 );
6350 assert!(
6351 labels.contains(&"put".to_string()),
6352 "the store entry ops: {labels:?}"
6353 );
6354 assert!(
6355 labels.contains(&"entries".to_string()),
6356 "the Map query accessors: {labels:?}"
6357 );
6358 }
6359
6360 #[tokio::test]
6371 async fn store_field_vocabulary_survives_an_unrelated_resolve_failure() {
6372 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";
6373 let s = scratch_project(
6374 "store_map_resolve_gap",
6375 &[
6376 ("bynk.toml", "[project]\nname=\"shop\"\n"),
6377 ("src/a.bynk", src),
6378 ],
6379 );
6380 let backend = backend_at(&s.0).await;
6381 let uri = file_uri(&s.0, "src/a.bynk");
6382 open(&backend, &uri, src).await;
6383 backend.run_round().await;
6384
6385 let analysis = backend.test_analysis().await.expect("a round committed");
6389 let rel = Backend::uri_to_rel(&analysis, &uri).expect("uri resolves");
6390 assert!(
6391 analysis
6392 .diagnostics
6393 .get(&rel)
6394 .is_some_and(|ds| !ds.is_empty()),
6395 "the fixture must actually fail to check — an undeclared return \
6396 type is the trigger this test exercises",
6397 );
6398
6399 let labels = complete_at(&backend, &uri, src, " items.").await;
6400 assert!(
6406 !labels.contains(&"filter".to_string()),
6407 "the fixture must blank the typed half too, or this doesn't test \
6408 the gap: {labels:?}"
6409 );
6410 assert!(
6411 labels.contains(&"put".to_string()),
6412 "store entry ops must survive an unrelated resolve failure: {labels:?}"
6413 );
6414 assert!(
6415 labels.contains(&"entries".to_string()),
6416 "Map query accessors must survive an unrelated resolve failure: {labels:?}"
6417 );
6418 }
6419
6420 #[tokio::test]
6434 async fn an_unsaved_edit_in_one_file_is_visible_to_completion_in_another() {
6435 let widget_v1 = "commons shared.widget\n\ntype Widget = { size: Int }\n";
6436 let widget_v2 = "commons shared.widget\n\ntype Widget = { size: Int, weight: Int }\n";
6437 let use_src =
6438 "commons app.use\n\nuses shared.widget\n\nfn make() -> Widget {\n Widget { \n}\n";
6439 let s = scratch_project(
6440 "cross_file_unsaved",
6441 &[
6442 (
6443 "bynk.toml",
6444 "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
6445 ),
6446 ("shared/widget.bynk", widget_v1),
6447 ("app/use.bynk", use_src),
6448 ],
6449 );
6450 let backend = backend_at(&s.0).await;
6451 let widget_uri = file_uri(&s.0, "shared/widget.bynk");
6452 let use_uri = file_uri(&s.0, "app/use.bynk");
6453 open(&backend, &widget_uri, widget_v1).await;
6454 open(&backend, &use_uri, use_src).await;
6455 backend.run_round().await;
6456
6457 let before = complete_at(&backend, &use_uri, use_src, " Widget { ").await;
6459 assert!(before.contains(&"size".to_string()), "{before:?}");
6460 assert!(
6461 !before.contains(&"weight".to_string()),
6462 "the fixture must not already have `weight` on disk: {before:?}"
6463 );
6464
6465 backend
6467 .did_change(DidChangeTextDocumentParams {
6468 text_document: VersionedTextDocumentIdentifier {
6469 uri: widget_uri.clone(),
6470 version: 2,
6471 },
6472 content_changes: vec![TextDocumentContentChangeEvent {
6473 range: None,
6474 range_length: None,
6475 text: widget_v2.to_string(),
6476 }],
6477 })
6478 .await;
6479
6480 let after = complete_at(&backend, &use_uri, use_src, " Widget { ").await;
6481 assert!(
6482 after.contains(&"weight".to_string()),
6483 "an unsaved edit to `widget.bynk` must be visible to `use.bynk`'s \
6484 cross-file completion in the same round: {after:?}"
6485 );
6486 assert!(after.contains(&"size".to_string()), "{after:?}");
6487
6488 assert_eq!(
6490 std::fs::read_to_string(s.0.join("shared/widget.bynk")).unwrap(),
6491 widget_v1,
6492 "the edit must never have been saved to disk"
6493 );
6494 }
6495
6496 #[tokio::test]
6508 async fn type_receivers_slow_path_sees_a_closed_files_disk_content() {
6509 let widget_src = "commons shared.widget\n\ntype Widget = { size: Int }\n";
6510 let use_src =
6511 "commons app.use\n\nuses shared.widget\n\nfn area(w: Widget) -> Int {\n w.\n}\n";
6512 let s = scratch_project(
6513 "type_receiver_slow_path",
6514 &[
6515 (
6516 "bynk.toml",
6517 "[project]\nname = \"cross\"\n\n[paths]\ninclude = [\"shared\", \"app\"]\n",
6518 ),
6519 ("shared/widget.bynk", widget_src),
6520 ("app/use.bynk", use_src),
6521 ],
6522 );
6523 let backend = backend_at(&s.0).await;
6524 let use_uri = file_uri(&s.0, "app/use.bynk");
6525 open(&backend, &use_uri, use_src).await;
6527
6528 let labels = complete_at(&backend, &use_uri, use_src, " w.").await;
6529 assert!(
6530 labels.contains(&"size".to_string()),
6531 "type_receiver's slow path must resolve `w: Widget` off the \
6532 closed widget.bynk's real disk content: {labels:?}"
6533 );
6534 }
6535}