A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improved LSP support

+1697 -174
+50
Cargo.lock
··· 417 417 ] 418 418 419 419 [[package]] 420 + name = "dirs" 421 + version = "5.0.1" 422 + source = "registry+https://github.com/rust-lang/crates.io-index" 423 + checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" 424 + dependencies = [ 425 + "dirs-sys", 426 + ] 427 + 428 + [[package]] 429 + name = "dirs-sys" 430 + version = "0.4.1" 431 + source = "registry+https://github.com/rust-lang/crates.io-index" 432 + checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 433 + dependencies = [ 434 + "libc", 435 + "option-ext", 436 + "redox_users", 437 + "windows-sys 0.48.0", 438 + ] 439 + 440 + [[package]] 420 441 name = "displaydoc" 421 442 version = "0.2.5" 422 443 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1111 1132 checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" 1112 1133 1113 1134 [[package]] 1135 + name = "libredox" 1136 + version = "0.1.10" 1137 + source = "registry+https://github.com/rust-lang/crates.io-index" 1138 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 1139 + dependencies = [ 1140 + "bitflags 2.9.4", 1141 + "libc", 1142 + ] 1143 + 1144 + [[package]] 1114 1145 name = "linked-hash-map" 1115 1146 version = "0.5.6" 1116 1147 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1332 1363 name = "mlf-lsp" 1333 1364 version = "0.1.0" 1334 1365 dependencies = [ 1366 + "dirs", 1367 + "include_dir", 1335 1368 "mlf-diagnostics", 1336 1369 "mlf-lang", 1337 1370 "serde", ··· 1508 1541 ] 1509 1542 1510 1543 [[package]] 1544 + name = "option-ext" 1545 + version = "0.2.0" 1546 + source = "registry+https://github.com/rust-lang/crates.io-index" 1547 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1548 + 1549 + [[package]] 1511 1550 name = "owo-colors" 1512 1551 version = "4.2.3" 1513 1552 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1695 1734 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1696 1735 dependencies = [ 1697 1736 "bitflags 2.9.4", 1737 + ] 1738 + 1739 + [[package]] 1740 + name = "redox_users" 1741 + version = "0.4.6" 1742 + source = "registry+https://github.com/rust-lang/crates.io-index" 1743 + checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 1744 + dependencies = [ 1745 + "getrandom 0.2.16", 1746 + "libredox", 1747 + "thiserror 1.0.69", 1698 1748 ] 1699 1749 1700 1750 [[package]]
+105 -42
mlf-cli/src/fetch.rs
··· 100 100 } 101 101 102 102 #[derive(Debug, Deserialize)] 103 - struct AtProtoRepoListRecords { 104 - records: Vec<AtProtoRecord>, 105 - } 106 - 107 - #[derive(Debug, Deserialize)] 108 103 struct AtProtoRecord { 109 104 uri: String, 110 105 value: serde_json::Value, ··· 249 244 return Ok(()); 250 245 } 251 246 252 - // Extract authority from NSID (e.g., "place.stream" from "place.stream.key") 253 - let authority = extract_authority(nsid_pattern)?; 247 + // Extract authority and name segments from NSID 248 + // For "app.bsky.actor.profile", authority is "app.bsky", name is "actor.profile" 249 + // For DNS lookup, we need "_lexicon.actor.bsky.app" 250 + let (authority, name_segments) = extract_authority_and_name(nsid_pattern)?; 254 251 println!("Fetching lexicons for pattern: {}", nsid); 255 252 256 253 // Step 1: DNS TXT lookup 257 - let did = resolve_lexicon_did(&authority)?; 254 + let did = resolve_lexicon_did(&authority, &name_segments)?; 258 255 println!(" → Resolved DID: {}", did); 259 256 260 257 // Step 2: Query ATProto repo for lexicon schemas ··· 278 275 // Match against pattern 279 276 let matches = if is_wildcard { 280 277 // Wildcard: match all records starting with the pattern 281 - record_nsid.starts_with(nsid_pattern) && record_nsid.len() > nsid_pattern.len() 278 + // For "app.bsky.actor.*", nsid_pattern is "app.bsky.actor" 279 + // Should match "app.bsky.actor.defs", "app.bsky.actor.profile", etc. 280 + let starts_with_pattern = record_nsid.starts_with(nsid_pattern); 281 + let has_more_segments = record_nsid.len() > nsid_pattern.len(); 282 + let is_direct_child = if starts_with_pattern && has_more_segments { 283 + // Check if the next character after the pattern is a dot 284 + record_nsid.chars().nth(nsid_pattern.len()) == Some('.') 285 + } else { 286 + false 287 + }; 288 + 289 + starts_with_pattern && has_more_segments && is_direct_child 282 290 } else { 283 291 // Specific: exact match only 284 292 record_nsid == nsid ··· 355 363 356 364 let parts: Vec<&str> = nsid_base.split('.').collect(); 357 365 358 - // NSID must have at least 3 segments (authority + name) 359 - // e.g., "place.stream.key" or "place.stream.*" 360 - if parts.len() < 3 { 366 + // NSID must have at least 2 segments (authority) 367 + // e.g., "place.stream", "place.stream.key", or "place.stream.*" 368 + if parts.len() < 2 { 361 369 return Err(FetchError::InvalidNsid(format!( 362 - "NSID must have at least 3 segments or use wildcard (e.g., 'place.stream.key' or 'place.stream.*'): {}", 370 + "NSID must have at least 2 segments (e.g., 'place.stream' or 'com.atproto.repo.strongRef'): {}", 363 371 nsid 364 372 ))); 365 373 } ··· 367 375 Ok(()) 368 376 } 369 377 370 - fn extract_authority(nsid_pattern: &str) -> Result<String, FetchError> { 378 + fn extract_authority_and_name(nsid_pattern: &str) -> Result<(String, String), FetchError> { 371 379 // NSID format: authority.name(.name)* 372 - // For "place.stream.key", authority is "place.stream" 373 - // Typically authority is the first 2 segments (reversed domain) 380 + // For "place.stream.key", authority is "place.stream" (first 2), name is "key" 381 + // For "app.bsky.actor.profile", authority is "app.bsky" (first 2), name is "actor.profile" 374 382 let parts: Vec<&str> = nsid_pattern.split('.').collect(); 375 383 376 384 if parts.len() < 2 { ··· 380 388 ))); 381 389 } 382 390 383 - // Take first 2 segments as authority (reversed domain) 384 - Ok(format!("{}.{}", parts[0], parts[1])) 391 + // Authority is first 2 segments (reversed domain) 392 + let authority = format!("{}.{}", parts[0], parts[1]); 393 + 394 + // Name segments are everything after the authority 395 + let name_segments = if parts.len() > 2 { 396 + parts[2..].join(".") 397 + } else { 398 + String::new() 399 + }; 400 + 401 + Ok((authority, name_segments)) 385 402 } 386 403 387 - fn resolve_lexicon_did(authority: &str) -> Result<String, FetchError> { 388 - // Reverse the authority for DNS lookup 389 - // "stream.place" -> "place.stream" -> "_lexicon.place.stream" 390 - let parts: Vec<&str> = authority.split('.').collect(); 391 - let reversed_parts: Vec<&str> = parts.iter().rev().copied().collect(); 392 - let dns_name = format!("_lexicon.{}", reversed_parts.join(".")); 404 + fn resolve_lexicon_did(authority: &str, name_segments: &str) -> Result<String, FetchError> { 405 + // Reverse the authority for DNS lookup and prepend name segments 406 + // For "app.bsky" + "actor": "_lexicon.actor.bsky.app" 407 + // For "place.stream" + "key": "_lexicon.key.stream.place" 408 + let auth_parts: Vec<&str> = authority.split('.').collect(); 409 + let reversed_auth: Vec<&str> = auth_parts.iter().rev().copied().collect(); 410 + 411 + let dns_name = if name_segments.is_empty() { 412 + // No name segments, just use reversed authority 413 + // For "place.stream": "_lexicon.stream.place" 414 + format!("_lexicon.{}", reversed_auth.join(".")) 415 + } else { 416 + // Prepend name segments before reversed authority 417 + // For "app.bsky" + "actor": "_lexicon.actor.bsky.app" 418 + format!("_lexicon.{}.{}", name_segments, reversed_auth.join(".")) 419 + }; 393 420 394 421 println!(" Looking up DNS TXT record: {}", dns_name); 395 422 ··· 422 449 fn fetch_lexicon_records(did: &str) -> Result<Vec<AtProtoRecord>, FetchError> { 423 450 // Query the ATProto repo for records in com.atproto.lexicon.schema collection 424 451 // We need to use the repo.listRecords XRPC endpoint 452 + // Note: This API is paginated, so we need to fetch all pages 425 453 426 454 // First, resolve the DID to a PDS endpoint 427 455 let pds_url = resolve_did_to_pds(did)?; 428 456 429 457 println!(" → Using PDS: {}", pds_url); 430 458 431 - // Query listRecords endpoint 432 - let url = format!( 433 - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=com.atproto.lexicon.schema", 434 - pds_url, did 435 - ); 459 + let mut all_records = Vec::new(); 460 + let mut cursor: Option<String> = None; 461 + let mut page_num = 1; 462 + 463 + loop { 464 + // Build URL with optional cursor 465 + let url = if let Some(ref c) = cursor { 466 + format!( 467 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=com.atproto.lexicon.schema&cursor={}", 468 + pds_url, did, c 469 + ) 470 + } else { 471 + format!( 472 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=com.atproto.lexicon.schema", 473 + pds_url, did 474 + ) 475 + }; 476 + 477 + println!(" Fetching lexicon records (page {})...", page_num); 478 + 479 + let response = reqwest::blocking::get(&url) 480 + .map_err(|e| FetchError::HttpError(format!("Failed to fetch records: {}", e)))?; 481 + 482 + if !response.status().is_success() { 483 + return Err(FetchError::HttpError(format!( 484 + "HTTP {} when fetching records", 485 + response.status() 486 + ))); 487 + } 488 + 489 + let mut list_response: serde_json::Value = response 490 + .json() 491 + .map_err(|e| FetchError::HttpError(format!("Failed to parse response: {}", e)))?; 492 + 493 + // Extract records 494 + if let Some(records_array) = list_response.get_mut("records") { 495 + if let Some(records) = records_array.as_array_mut() { 496 + for record_value in records.drain(..) { 497 + let record: AtProtoRecord = serde_json::from_value(record_value) 498 + .map_err(|e| FetchError::HttpError(format!("Failed to parse record: {}", e)))?; 499 + all_records.push(record); 500 + } 501 + } 502 + } 436 503 437 - println!(" Fetching lexicon records from: {}", url); 504 + // Check for cursor to continue pagination 505 + cursor = list_response.get("cursor") 506 + .and_then(|c| c.as_str()) 507 + .map(|s| s.to_string()); 438 508 439 - let response = reqwest::blocking::get(&url) 440 - .map_err(|e| FetchError::HttpError(format!("Failed to fetch records: {}", e)))?; 509 + if cursor.is_none() { 510 + break; 511 + } 441 512 442 - if !response.status().is_success() { 443 - return Err(FetchError::HttpError(format!( 444 - "HTTP {} when fetching records", 445 - response.status() 446 - ))); 513 + page_num += 1; 447 514 } 448 515 449 - let list_response: AtProtoRepoListRecords = response 450 - .json() 451 - .map_err(|e| FetchError::HttpError(format!("Failed to parse response: {}", e)))?; 452 - 453 - Ok(list_response.records) 516 + Ok(all_records) 454 517 } 455 518 456 519 fn resolve_did_to_pds(did: &str) -> Result<String, FetchError> {
+22
mlf-lang/src/error.rs
··· 10 10 InvalidIdentifier { name: String, span: Span }, 11 11 } 12 12 13 + impl ParseError { 14 + pub fn message(&self) -> String { 15 + match self { 16 + ParseError::Syntax { message, .. } => message.clone(), 17 + ParseError::UnexpectedEof { expected, .. } => { 18 + alloc::format!("Unexpected end of file, expected {}", expected) 19 + } 20 + ParseError::InvalidIdentifier { name, .. } => { 21 + alloc::format!("Invalid identifier: {}", name) 22 + } 23 + } 24 + } 25 + 26 + pub fn span(&self) -> Option<Span> { 27 + match self { 28 + ParseError::Syntax { span, .. } => Some(*span), 29 + ParseError::UnexpectedEof { span, .. } => Some(*span), 30 + ParseError::InvalidIdentifier { span, .. } => Some(*span), 31 + } 32 + } 33 + } 34 + 13 35 #[derive(Debug, Clone, PartialEq)] 14 36 pub enum ValidationError { 15 37 DuplicateDefinition { name: String, first_span: Span, second_span: Span, module_namespace: String },
+14 -16
mlf-lang/src/parser.rs
··· 147 147 segments.push(self.parse_ident_or_keyword()?); 148 148 149 149 while matches!(self.current().token, LexToken::Dot) { 150 + // Check if next token is LeftBrace (for use namespace.{ syntax) 151 + if let Some(next) = self.peek(1) { 152 + if matches!(next.token, LexToken::LeftBrace) { 153 + break; 154 + } 155 + } 150 156 self.advance(); 151 157 segments.push(self.parse_ident_or_keyword()?); 152 158 } ··· 570 576 let start = self.expect(LexToken::Use)?; 571 577 let path = self.parse_path()?; 572 578 573 - let imports = if matches!(self.current().token, LexToken::LeftBrace) { 574 - // use namespace { items } 575 - self.advance(); // consume { 579 + let imports = if matches!(self.current().token, LexToken::Dot) { 580 + // use namespace.{ items } 581 + self.advance(); // consume . 582 + self.expect(LexToken::LeftBrace)?; // consume { 576 583 let mut items = Vec::new(); 577 584 578 585 while !matches!(self.current().token, LexToken::RightBrace) { ··· 818 825 let span = path.span; 819 826 Type::Reference { path, span } 820 827 } 821 - LexToken::LeftBracket => { 822 - self.advance(); 823 - let inner = self.parse_type()?; 824 - let end = self.expect(LexToken::RightBracket)?; 825 - Type::Array { 826 - inner: alloc::boxed::Box::new(inner), 827 - span: Span::new(start, end.end), 828 - } 829 - } 830 828 LexToken::LeftBrace => { 831 829 self.advance(); 832 830 let mut fields = Vec::new(); ··· 1419 1417 1420 1418 #[test] 1421 1419 fn test_parse_array_type() { 1422 - let input = "inline type userList = [user];"; 1420 + let input = "inline type userList = user[];"; 1423 1421 let result = parse_lexicon(input); 1424 1422 assert!(result.is_ok()); 1425 1423 let lexicon = result.unwrap(); ··· 1597 1595 1598 1596 #[test] 1599 1597 fn test_parse_use_with_main() { 1600 - let input = "use com.example.thread { main };"; 1598 + let input = "use com.example.thread.{ main };"; 1601 1599 let result = parse_lexicon(input); 1602 1600 assert!(result.is_ok()); 1603 1601 let lexicon = result.unwrap(); ··· 1620 1618 1621 1619 #[test] 1622 1620 fn test_parse_use_with_main_and_alias() { 1623 - let input = "use com.example.thread { main as ThreadRecord };"; 1621 + let input = "use com.example.thread.{ main as ThreadRecord };"; 1624 1622 let result = parse_lexicon(input); 1625 1623 assert!(result.is_ok()); 1626 1624 let lexicon = result.unwrap(); ··· 1643 1641 1644 1642 #[test] 1645 1643 fn test_parse_use_with_multiple_items() { 1646 - let input = "use com.example { main, foo, bar as Baz };"; 1644 + let input = "use com.example.{ main, foo, bar as Baz };"; 1647 1645 let result = parse_lexicon(input); 1648 1646 assert!(result.is_ok()); 1649 1647 let lexicon = result.unwrap();
+157 -15
mlf-lang/src/workspace.rs
··· 1 + use alloc::collections::{BTreeMap, BTreeSet}; 1 2 use alloc::string::String; 2 3 use alloc::vec::Vec; 3 - use alloc::collections::BTreeMap; 4 4 use crate::{ast::*, error::{ValidationError, ValidationErrors}, span::Span}; 5 5 6 6 #[derive(Debug, Clone, PartialEq)] ··· 24 24 #[derive(Debug, Clone, PartialEq, Default)] 25 25 struct ImportTable { 26 26 mappings: BTreeMap<String, ImportedSymbol>, 27 + used_imports: BTreeSet<String>, 27 28 } 28 29 29 30 #[derive(Debug, Clone, PartialEq)] 30 31 struct ImportedSymbol { 31 32 original_path: Vec<String>, 32 33 local_name: String, 34 + span: Span, 33 35 } 34 36 35 37 #[derive(Debug, Clone, PartialEq)] ··· 153 155 errors.append(&mut import_errors); 154 156 } 155 157 156 - let modules: Vec<(String, Module)> = self.modules.iter() 157 - .map(|(k, v)| (k.clone(), v.clone())) 158 - .collect(); 158 + let namespaces: Vec<String> = self.modules.keys().cloned().collect(); 159 159 160 - for (namespace, module) in modules { 160 + for namespace in namespaces { 161 + let module = self.modules[&namespace].clone(); 161 162 if let Err(mut module_errors) = self.resolve_module(&namespace, &module) { 162 163 errors.append(&mut module_errors); 163 164 } ··· 167 168 return Err(errors); 168 169 } 169 170 171 + // Check for unused imports 172 + if let Err(mut unused_import_errors) = self.check_unused_imports() { 173 + errors.append(&mut unused_import_errors); 174 + } 175 + 170 176 if let Err(mut typecheck_errors) = self.typecheck() { 171 177 errors.append(&mut typecheck_errors); 178 + } 179 + 180 + if errors.is_empty() { 181 + Ok(()) 182 + } else { 183 + Err(errors) 184 + } 185 + } 186 + 187 + fn check_unused_imports(&self) -> Result<(), ValidationErrors> { 188 + let mut errors = ValidationErrors::new(); 189 + 190 + for (namespace, module) in &self.modules { 191 + // Skip prelude module 192 + if namespace == "prelude" { 193 + continue; 194 + } 195 + 196 + for (local_name, imported) in &module.imports.mappings { 197 + if !module.imports.used_imports.contains(local_name) { 198 + errors.push(ValidationError::UnusedImport { 199 + name: local_name.clone(), 200 + span: imported.span, 201 + module_namespace: namespace.clone(), 202 + }); 203 + } 204 + } 172 205 } 173 206 174 207 if errors.is_empty() { ··· 665 698 false 666 699 } 667 700 701 + /// Get all imported types for a given namespace 702 + /// Returns a vector of (local_name, original_path) tuples 703 + pub fn get_imports(&self, namespace: &str) -> Vec<(String, Vec<String>)> { 704 + if let Some(module) = self.modules.get(namespace) { 705 + module.imports.mappings.iter() 706 + .map(|(local_name, imported)| { 707 + (local_name.clone(), imported.original_path.clone()) 708 + }) 709 + .collect() 710 + } else { 711 + Vec::new() 712 + } 713 + } 714 + 715 + /// Get all type names defined in a given namespace 716 + /// Returns a vector of type names 717 + pub fn get_namespace_types(&self, namespace: &str) -> Vec<String> { 718 + if let Some(module) = self.modules.get(namespace) { 719 + module.symbols.types.keys().cloned().collect() 720 + } else { 721 + Vec::new() 722 + } 723 + } 724 + 725 + /// Get all namespaces in the workspace 726 + /// Returns a vector of namespace strings 727 + pub fn get_all_namespaces(&self) -> Vec<String> { 728 + self.modules.keys().cloned().collect() 729 + } 730 + 731 + /// Get all fully-qualified type references (NSIDs) in the workspace 732 + /// Returns a vector of (nsid, namespace, typename) tuples 733 + /// Example: ("com.atproto.repo.strongRef", "com.atproto.repo", "strongRef") 734 + pub fn get_all_nsids(&self) -> Vec<(String, String, String)> { 735 + let mut nsids = Vec::new(); 736 + 737 + for (namespace, module) in &self.modules { 738 + // Skip prelude since it's auto-imported 739 + if namespace == "prelude" { 740 + continue; 741 + } 742 + 743 + for type_name in module.symbols.types.keys() { 744 + let nsid = if namespace.is_empty() { 745 + type_name.clone() 746 + } else { 747 + alloc::format!("{}.{}", namespace, type_name) 748 + }; 749 + nsids.push((nsid, namespace.clone(), type_name.clone())); 750 + } 751 + } 752 + 753 + nsids 754 + } 755 + 668 756 /// Resolve a type reference to its actual namespace 669 757 /// Returns the namespace where the type is defined, or None if not found 670 758 pub fn resolve_reference_namespace(&self, path: &Path, current_namespace: &str) -> Option<String> { ··· 808 896 .chain(core::iter::once(type_name.clone())) 809 897 .collect(), 810 898 local_name: type_name.clone(), 899 + span: use_stmt.path.span, 811 900 }; 812 901 (type_name.clone(), imported) 813 902 }) ··· 871 960 ImportedSymbol { 872 961 original_path, 873 962 local_name, 963 + span: item.name.span, 874 964 }, 875 965 )); 876 966 } ··· 1172 1262 } 1173 1263 } 1174 1264 1175 - fn resolve_module(&self, namespace: &str, module: &Module) -> Result<(), ValidationErrors> { 1265 + fn resolve_module(&mut self, namespace: &str, module: &Module) -> Result<(), ValidationErrors> { 1176 1266 let mut errors = ValidationErrors::new(); 1177 1267 1178 1268 for item in &module.lexicon.items { ··· 1188 1278 } 1189 1279 } 1190 1280 1191 - fn resolve_item(&self, namespace: &str, item: &Item) -> Result<(), ValidationErrors> { 1281 + fn resolve_item(&mut self, namespace: &str, item: &Item) -> Result<(), ValidationErrors> { 1192 1282 match item { 1193 1283 Item::Record(r) => self.resolve_record(namespace, r), 1194 1284 Item::InlineType(i) => self.resolve_inline_type(namespace, i), ··· 1200 1290 } 1201 1291 } 1202 1292 1203 - fn resolve_record(&self, namespace: &str, record: &Record) -> Result<(), ValidationErrors> { 1293 + fn resolve_record(&mut self, namespace: &str, record: &Record) -> Result<(), ValidationErrors> { 1204 1294 let mut errors = ValidationErrors::new(); 1205 1295 1206 1296 for field in &record.fields { ··· 1216 1306 } 1217 1307 } 1218 1308 1219 - fn resolve_inline_type(&self, namespace: &str, inline_type: &InlineType) -> Result<(), ValidationErrors> { 1309 + fn resolve_inline_type(&mut self, namespace: &str, inline_type: &InlineType) -> Result<(), ValidationErrors> { 1220 1310 self.resolve_type(namespace, &inline_type.ty) 1221 1311 } 1222 1312 1223 - fn resolve_def_type(&self, namespace: &str, def_type: &DefType) -> Result<(), ValidationErrors> { 1313 + fn resolve_def_type(&mut self, namespace: &str, def_type: &DefType) -> Result<(), ValidationErrors> { 1224 1314 self.resolve_type(namespace, &def_type.ty) 1225 1315 } 1226 1316 1227 - fn resolve_query(&self, namespace: &str, query: &Query) -> Result<(), ValidationErrors> { 1317 + fn resolve_query(&mut self, namespace: &str, query: &Query) -> Result<(), ValidationErrors> { 1228 1318 let mut errors = ValidationErrors::new(); 1229 1319 1230 1320 for param in &query.params { ··· 1256 1346 } 1257 1347 } 1258 1348 1259 - fn resolve_procedure(&self, namespace: &str, procedure: &Procedure) -> Result<(), ValidationErrors> { 1349 + fn resolve_procedure(&mut self, namespace: &str, procedure: &Procedure) -> Result<(), ValidationErrors> { 1260 1350 let mut errors = ValidationErrors::new(); 1261 1351 1262 1352 for param in &procedure.params { ··· 1288 1378 } 1289 1379 } 1290 1380 1291 - fn resolve_subscription(&self, namespace: &str, subscription: &Subscription) -> Result<(), ValidationErrors> { 1381 + fn resolve_subscription(&mut self, namespace: &str, subscription: &Subscription) -> Result<(), ValidationErrors> { 1292 1382 let mut errors = ValidationErrors::new(); 1293 1383 1294 1384 for param in &subscription.params { ··· 1310 1400 } 1311 1401 } 1312 1402 1313 - fn resolve_type(&self, namespace: &str, ty: &Type) -> Result<(), ValidationErrors> { 1403 + fn resolve_type(&mut self, namespace: &str, ty: &Type) -> Result<(), ValidationErrors> { 1314 1404 match ty { 1315 1405 Type::Primitive { .. } | Type::Unknown { .. } => Ok(()), 1316 1406 Type::Reference { path, span } => { ··· 1354 1444 } 1355 1445 } 1356 1446 1357 - fn resolve_reference(&self, current_namespace: &str, path: &Path, span: Span) -> Result<(), ValidationErrors> { 1447 + fn resolve_reference(&mut self, current_namespace: &str, path: &Path, span: Span) -> Result<(), ValidationErrors> { 1358 1448 let full_path = path.to_string(); 1359 1449 1360 1450 if path.segments.len() == 1 { ··· 1366 1456 } 1367 1457 1368 1458 if module.imports.mappings.contains_key(name) { 1459 + // Mark this import as used 1460 + if let Some(module_mut) = self.modules.get_mut(current_namespace) { 1461 + module_mut.imports.used_imports.insert(name.clone()); 1462 + } 1369 1463 return Ok(()); 1370 1464 } 1371 1465 } ··· 2000 2094 let lexicon = parse_lexicon(input).unwrap(); 2001 2095 let result = ws.add_module("com.example.thread".into(), lexicon); 2002 2096 assert!(result.is_ok()); 2097 + } 2098 + 2099 + #[test] 2100 + fn test_unused_import() { 2101 + let mut ws = Workspace::new(); 2102 + 2103 + let a = parse_lexicon("record foo {}").unwrap(); 2104 + ws.add_module("a".into(), a).unwrap(); 2105 + 2106 + let b = parse_lexicon("use a.foo as Foo; record bar {}").unwrap(); 2107 + ws.add_module("b".into(), b).unwrap(); 2108 + 2109 + let result = ws.resolve(); 2110 + assert!(result.is_err()); 2111 + let errors = result.unwrap_err(); 2112 + assert!(errors.errors.iter().any(|e| matches!(e, ValidationError::UnusedImport { .. }))); 2113 + } 2114 + 2115 + #[test] 2116 + fn test_used_import() { 2117 + let mut ws = Workspace::new(); 2118 + 2119 + let a = parse_lexicon("record foo {}").unwrap(); 2120 + ws.add_module("a".into(), a).unwrap(); 2121 + 2122 + let b = parse_lexicon("use a.foo as Foo; record bar { baz: Foo, }").unwrap(); 2123 + ws.add_module("b".into(), b).unwrap(); 2124 + 2125 + let result = ws.resolve(); 2126 + assert!(result.is_ok()); 2127 + } 2128 + 2129 + #[test] 2130 + fn test_unused_import_all() { 2131 + let mut ws = Workspace::new(); 2132 + 2133 + let a = parse_lexicon("record foo {} record bar {}").unwrap(); 2134 + ws.add_module("a".into(), a).unwrap(); 2135 + 2136 + let b = parse_lexicon("use a; record baz {}").unwrap(); 2137 + ws.add_module("b".into(), b).unwrap(); 2138 + 2139 + let result = ws.resolve(); 2140 + assert!(result.is_err()); 2141 + let errors = result.unwrap_err(); 2142 + // Should have 2 unused import errors (foo and bar) 2143 + let unused_count = errors.errors.iter().filter(|e| matches!(e, ValidationError::UnusedImport { .. })).count(); 2144 + assert_eq!(unused_count, 2); 2003 2145 } 2004 2146 }
+4
mlf-lsp/Cargo.toml
··· 8 8 mlf-lang = { path = "../mlf-lang" } 9 9 mlf-diagnostics = { path = "../mlf-diagnostics" } 10 10 11 + # For accessing embedded std library 12 + include_dir = "0.7" 13 + dirs = "5.0" 14 + 11 15 # LSP 12 16 tower-lsp = "0.20" 13 17 tokio = { version = "1", features = ["full"] }
+130
mlf-lsp/src/context.rs
··· 1 + /// Context detection for smart completions 2 + 3 + #[derive(Debug, PartialEq)] 4 + pub enum CompletionContext { 5 + /// Top-level (suggesting keywords like record, query, etc.) 6 + TopLevel, 7 + /// After "use" keyword (suggesting module paths) 8 + UseStatement, 9 + /// In a type position (field type, parameter type, etc.) 10 + TypePosition, 11 + /// Inside constrained { } block 12 + ConstraintBlock, 13 + /// Unknown context 14 + Unknown, 15 + } 16 + 17 + /// Detect the completion context based on the text before the cursor 18 + pub fn detect_context(text_before_cursor: &str) -> CompletionContext { 19 + // Check if we're in a use statement (check before trimming!) 20 + if let Some(last_line) = text_before_cursor.lines().last() { 21 + let last_line = last_line.trim_start(); // Only trim left side 22 + 23 + // "use ", "use com.", "use com.atproto." 24 + if last_line.starts_with("use ") && !last_line.contains(';') { 25 + return CompletionContext::UseStatement; 26 + } 27 + } 28 + 29 + let trimmed = text_before_cursor.trim_end(); 30 + 31 + // Check if we're inside a constrained block 32 + // Count open and closed braces after "constrained" 33 + if let Some(constrained_pos) = trimmed.rfind("constrained") { 34 + let after_constrained = &trimmed[constrained_pos..]; 35 + let open_braces = after_constrained.matches('{').count(); 36 + let close_braces = after_constrained.matches('}').count(); 37 + 38 + if open_braces > close_braces { 39 + return CompletionContext::ConstraintBlock; 40 + } 41 + } 42 + 43 + // Check if we're in a type position (after : or after field name) 44 + // Look for patterns like "field:" or "field :" or "field: " or "(param:" 45 + if trimmed.ends_with(':') || trimmed.ends_with(": ") { 46 + return CompletionContext::TypePosition; 47 + } 48 + 49 + // Check if the last "word" before cursor looks like we're after a colon 50 + // This handles "field:string" where cursor is after string 51 + let words: Vec<&str> = trimmed.split_whitespace().collect(); 52 + if let Some(last_word) = words.last() { 53 + if last_word.contains(':') && !last_word.ends_with(':') { 54 + // We're potentially completing a type that was already started 55 + return CompletionContext::TypePosition; 56 + } 57 + } 58 + 59 + // Check if we're in a record/query/procedure body (inside braces or parens) 60 + let open_braces = trimmed.matches('{').count(); 61 + let close_braces = trimmed.matches('}').count(); 62 + let open_parens = trimmed.matches('(').count(); 63 + let close_parens = trimmed.matches(')').count(); 64 + 65 + if (open_braces > close_braces) || (open_parens > close_parens) { 66 + // We're inside a block - could be fields or parameters 67 + // If the line contains a colon, we're likely after it (type position) 68 + if let Some(last_line) = trimmed.lines().last() { 69 + if last_line.contains(':') { 70 + return CompletionContext::TypePosition; 71 + } 72 + } 73 + // Otherwise, might be starting a new field (but not type position yet) 74 + return CompletionContext::Unknown; 75 + } 76 + 77 + // Default to top-level 78 + CompletionContext::TopLevel 79 + } 80 + 81 + #[cfg(test)] 82 + mod tests { 83 + use super::*; 84 + 85 + #[test] 86 + fn test_use_statement() { 87 + assert_eq!( 88 + detect_context("use "), 89 + CompletionContext::UseStatement 90 + ); 91 + assert_eq!( 92 + detect_context("use com."), 93 + CompletionContext::UseStatement 94 + ); 95 + assert_eq!( 96 + detect_context("use com.atproto."), 97 + CompletionContext::UseStatement 98 + ); 99 + } 100 + 101 + #[test] 102 + fn test_type_position() { 103 + assert_eq!( 104 + detect_context("record foo {\n bar:"), 105 + CompletionContext::TypePosition 106 + ); 107 + assert_eq!( 108 + detect_context("record foo {\n bar: "), 109 + CompletionContext::TypePosition 110 + ); 111 + } 112 + 113 + #[test] 114 + fn test_constraint_block() { 115 + assert_eq!( 116 + detect_context("field: string constrained {"), 117 + CompletionContext::ConstraintBlock 118 + ); 119 + assert_eq!( 120 + detect_context("field: string constrained {\n maxLength: 100,"), 121 + CompletionContext::ConstraintBlock 122 + ); 123 + } 124 + 125 + #[test] 126 + fn test_top_level() { 127 + assert_eq!(detect_context(""), CompletionContext::TopLevel); 128 + assert_eq!(detect_context("rec"), CompletionContext::TopLevel); 129 + } 130 + }
+2
mlf-lsp/src/lib.rs
··· 1 + pub mod context; 2 + pub mod namespace_completion; 1 3 pub mod server; 2 4 pub mod utils; 3 5
+22 -2
mlf-lsp/src/main.rs
··· 4 4 5 5 #[tokio::main] 6 6 async fn main() { 7 - // Initialize logging 7 + // Set up panic hook to log panics 8 + std::panic::set_hook(Box::new(|panic_info| { 9 + eprintln!("LSP PANIC: {:?}", panic_info); 10 + if let Some(location) = panic_info.location() { 11 + eprintln!(" at {}:{}:{}", location.file(), location.line(), location.column()); 12 + } 13 + if let Some(message) = panic_info.payload().downcast_ref::<&str>() { 14 + eprintln!(" message: {}", message); 15 + } else if let Some(message) = panic_info.payload().downcast_ref::<String>() { 16 + eprintln!(" message: {}", message); 17 + } 18 + })); 19 + 20 + // Initialize logging with debug level 8 21 tracing_subscriber::fmt() 9 - .with_env_filter(EnvFilter::from_default_env()) 22 + .with_env_filter( 23 + EnvFilter::try_from_default_env() 24 + .unwrap_or_else(|_| EnvFilter::new("debug")) 25 + ) 10 26 .with_writer(std::io::stderr) 11 27 .init(); 12 28 ··· 17 33 18 34 let (service, socket) = LspService::new(|client| MlfLanguageServer::new(client)); 19 35 36 + tracing::info!("Server created, starting to serve..."); 37 + 20 38 Server::new(stdin, stdout, socket).serve(service).await; 39 + 40 + tracing::info!("Server stopped"); 21 41 }
+162
mlf-lsp/src/namespace_completion.rs
··· 1 + /// Shared namespace path completion logic 2 + /// 3 + /// This module provides utilities for completing namespace paths in both 4 + /// `use` statements and type positions (e.g., `com.atproto.repo.strongRef`). 5 + 6 + use std::collections::{HashMap, HashSet}; 7 + use tower_lsp::lsp_types::*; 8 + use mlf_lang::Workspace; 9 + 10 + use crate::server::DocumentState; 11 + 12 + /// Complete a namespace path based on a partial input 13 + /// 14 + /// This function handles intelligent namespace completion: 15 + /// - If partial_path is empty: suggests top-level namespaces only (e.g., "com", "app") 16 + /// - If partial_path ends with '.': suggests next-level segments or types in that namespace 17 + /// - Otherwise: suggests matching namespace extensions 18 + /// 19 + /// Returns a vector of completion items with proper text edits 20 + pub fn complete_namespace_path( 21 + workspace: &Workspace, 22 + documents: &HashMap<Url, DocumentState>, 23 + current_namespace: Option<&String>, 24 + partial_path: &str, 25 + has_trailing_dot: bool, 26 + replace_start: Position, 27 + replace_end: Position, 28 + _full_document_text: &str, 29 + suggest_types_after_dot: bool, // Whether to suggest { }, *, and types after a trailing dot 30 + ) -> Vec<CompletionItem> { 31 + let mut completions = vec![]; 32 + let mut seen = HashSet::new(); 33 + 34 + // If user typed "namespace." - suggest items from that namespace (and { } and *) 35 + if has_trailing_dot && !partial_path.is_empty() && suggest_types_after_dot { 36 + let types = workspace.get_namespace_types(partial_path); 37 + 38 + if !types.is_empty() { 39 + // Suggest wildcard 40 + completions.push(CompletionItem { 41 + label: "*".to_string(), 42 + kind: Some(CompletionItemKind::KEYWORD), 43 + detail: Some("Import all types".to_string()), 44 + insert_text: Some("*".to_string()), 45 + ..Default::default() 46 + }); 47 + 48 + // Suggest braces for item list 49 + completions.push(CompletionItem { 50 + label: "{ }".to_string(), 51 + kind: Some(CompletionItemKind::SNIPPET), 52 + detail: Some("Import specific items".to_string()), 53 + insert_text: Some("{ $0 }".to_string()), 54 + insert_text_format: Some(InsertTextFormat::SNIPPET), 55 + ..Default::default() 56 + }); 57 + 58 + // Suggest individual types 59 + for type_name in types { 60 + completions.push(CompletionItem { 61 + label: type_name.clone(), 62 + kind: Some(CompletionItemKind::CLASS), 63 + detail: Some(format!("from {}", partial_path)), 64 + insert_text: Some(format!("{{ {} }}", type_name)), 65 + ..Default::default() 66 + }); 67 + } 68 + } 69 + } 70 + 71 + // Get all module namespaces from open documents 72 + for (_, doc) in documents.iter() { 73 + if let Some(ns) = &doc.namespace { 74 + // Skip self 75 + if Some(ns) == current_namespace { 76 + continue; 77 + } 78 + 79 + // Skip prelude (it's auto-imported) 80 + if ns == "prelude" { 81 + continue; 82 + } 83 + 84 + // Determine what to suggest based on partial path 85 + let suggestion = if partial_path.is_empty() { 86 + // No path yet - suggest top-level modules only 87 + ns.split('.').next().map(|s| s.to_string()) 88 + } else { 89 + // Check if this namespace matches or extends the partial path 90 + if ns.starts_with(partial_path) { 91 + // Full match - include it 92 + if ns == partial_path { 93 + Some(ns.clone()) 94 + } else if ns.chars().nth(partial_path.len()) == Some('.') { 95 + // Namespace continues after partial path with a dot 96 + // Suggest the next level: com.atproto.server -> suggest "com.atproto" when partial is "com" 97 + let after_partial = &ns[partial_path.len() + 1..]; // Skip the dot 98 + if let Some(next_segment) = after_partial.split('.').next() { 99 + Some(format!("{}.{}", partial_path, next_segment)) 100 + } else { 101 + None 102 + } 103 + } else { 104 + // Partial path is a prefix but not at a segment boundary 105 + None 106 + } 107 + } else { 108 + None 109 + } 110 + }; 111 + 112 + if let Some(label) = suggestion { 113 + if seen.insert(label.clone()) { 114 + let text_edit = TextEdit { 115 + range: Range { 116 + start: replace_start, 117 + end: replace_end, 118 + }, 119 + new_text: label.clone(), 120 + }; 121 + 122 + completions.push(CompletionItem { 123 + label: label.clone(), 124 + kind: Some(CompletionItemKind::MODULE), 125 + detail: Some("module".to_string()), 126 + text_edit: Some(CompletionTextEdit::Edit(text_edit)), 127 + ..Default::default() 128 + }); 129 + } 130 + } 131 + } 132 + } 133 + 134 + // If we're completing a path with a trailing dot, also suggest types from that namespace 135 + if has_trailing_dot && !partial_path.is_empty() && !suggest_types_after_dot { 136 + let types = workspace.get_namespace_types(partial_path); 137 + for type_name in types { 138 + let full_nsid = format!("{}.{}", partial_path, type_name); 139 + if seen.insert(full_nsid.clone()) { 140 + let text_edit = TextEdit { 141 + range: Range { 142 + start: replace_start, 143 + end: replace_end, 144 + }, 145 + new_text: full_nsid.clone(), 146 + }; 147 + 148 + completions.push(CompletionItem { 149 + label: full_nsid.clone(), 150 + kind: Some(CompletionItemKind::REFERENCE), 151 + detail: Some(format!("{} from {}", type_name, partial_path)), 152 + text_edit: Some(CompletionTextEdit::Edit(text_edit)), 153 + filter_text: Some(full_nsid.clone()), 154 + sort_text: Some(format!("a{}", type_name)), // Sort types first 155 + ..Default::default() 156 + }); 157 + } 158 + } 159 + } 160 + 161 + completions 162 + }
+1023 -93
mlf-lsp/src/server.rs
··· 6 6 use tower_lsp::lsp_types::*; 7 7 use tower_lsp::{Client, LanguageServer}; 8 8 9 + use crate::context::{detect_context, CompletionContext as MlfCompletionContext}; 10 + use crate::namespace_completion; 9 11 use crate::utils::*; 10 12 11 13 pub struct MlfLanguageServer { ··· 14 16 workspace: tokio::sync::RwLock<Option<Workspace>>, 15 17 } 16 18 17 - struct DocumentState { 18 - text: String, 19 - lexicon: Option<Lexicon>, 20 - namespace: Option<String>, 19 + pub struct DocumentState { 20 + pub text: String, 21 + pub lexicon: Option<Lexicon>, 22 + pub namespace: Option<String>, 21 23 } 22 24 23 25 impl MlfLanguageServer { ··· 30 32 } 31 33 32 34 async fn parse_document(&self, uri: &Url, text: &str) { 35 + // Extract namespace from file path (always available, even on parse error) 36 + let namespace = extract_namespace_from_uri(uri); 37 + 33 38 // Parse the document 34 39 match mlf_lang::parser::parse_lexicon(text) { 35 40 Ok(lexicon) => { 36 - // Extract namespace from file path 37 - let namespace = extract_namespace_from_uri(uri); 38 - 39 41 // Store parsed state 40 42 self.documents.write().await.insert( 41 43 uri.clone(), ··· 46 48 }, 47 49 ); 48 50 49 - // Update workspace 50 - self.update_workspace(uri, lexicon, namespace).await; 51 + // Update workspace and get validation diagnostics 52 + let mut diagnostics = self.update_workspace(uri, lexicon, namespace, text).await; 51 53 52 - // Clear diagnostics on success 54 + // Add read-only warning for std library files 55 + if is_std_library_file(uri) { 56 + diagnostics.insert(0, Diagnostic { 57 + range: Range { 58 + start: Position { line: 0, character: 0 }, 59 + end: Position { line: 0, character: 0 }, 60 + }, 61 + severity: Some(DiagnosticSeverity::INFORMATION), 62 + code: None, 63 + code_description: None, 64 + source: Some("mlf".to_string()), 65 + message: "This is a standard library file and should not be edited. Changes may be overwritten.".to_string(), 66 + related_information: None, 67 + tags: None, 68 + data: None, 69 + }); 70 + } 71 + 72 + // Publish diagnostics (empty if no errors) 53 73 self.client 54 - .publish_diagnostics(uri.clone(), vec![], None) 74 + .publish_diagnostics(uri.clone(), diagnostics, None) 55 75 .await; 56 76 } 57 77 Err(err) => { 58 - // Store failed state 78 + // Even on parse error, try to extract partial information 79 + // Parse individual top-level items and keep what works 80 + let partial_lexicon = self.parse_partial_lexicon(text); 81 + 82 + // Store partial state (with namespace so completion still works) 59 83 self.documents.write().await.insert( 60 84 uri.clone(), 61 85 DocumentState { 62 86 text: text.to_string(), 63 - lexicon: None, 64 - namespace: None, 87 + lexicon: partial_lexicon.clone(), 88 + namespace: namespace.clone(), 65 89 }, 66 90 ); 67 91 68 - // Convert parse error to LSP diagnostic 69 - let diagnostic = Diagnostic { 70 - range: Range { 71 - start: Position { 72 - line: 0, 73 - character: 0, 74 - }, 75 - end: Position { 76 - line: 0, 77 - character: 0, 78 - }, 79 - }, 92 + // Try to update workspace with partial lexicon if available 93 + let mut diagnostics = if let Some(ref lex) = partial_lexicon { 94 + self.update_workspace(uri, lex.clone(), namespace, text).await 95 + } else { 96 + vec![] 97 + }; 98 + 99 + // Add parse error diagnostic 100 + let error_range = if let Some(span) = err.span() { 101 + span_to_range(text, span) 102 + } else { 103 + Range { 104 + start: Position { line: 0, character: 0 }, 105 + end: Position { line: 0, character: 0 }, 106 + } 107 + }; 108 + 109 + diagnostics.push(Diagnostic { 110 + range: error_range, 80 111 severity: Some(DiagnosticSeverity::ERROR), 81 112 code: None, 82 113 code_description: None, 83 114 source: Some("mlf".to_string()), 84 - message: format!("{:?}", err), 115 + message: err.message(), 85 116 related_information: None, 86 117 tags: None, 87 118 data: None, 88 - }; 119 + }); 89 120 90 121 self.client 91 - .publish_diagnostics(uri.clone(), vec![diagnostic], None) 122 + .publish_diagnostics(uri.clone(), diagnostics, None) 92 123 .await; 93 124 } 94 125 } 95 126 } 96 127 97 - async fn update_workspace(&self, _uri: &Url, lexicon: Lexicon, namespace: Option<String>) { 128 + /// Try to parse individual items from a document, skipping errors 129 + /// This allows LSP features to work on the valid parts of a document 130 + fn parse_partial_lexicon(&self, _text: &str) -> Option<Lexicon> { 131 + // Split by top-level keywords and try to parse each section 132 + // For now, just return None - a full implementation would parse 133 + // line-by-line or use error recovery in the parser 134 + // TODO: Implement proper error recovery 135 + None 136 + } 137 + 138 + async fn update_workspace(&self, _uri: &Url, lexicon: Lexicon, namespace: Option<String>, text: &str) -> Vec<Diagnostic> { 139 + let mut diagnostics = vec![]; 140 + 98 141 if let Some(ns) = namespace { 99 142 let mut workspace_guard = self.workspace.write().await; 100 143 101 - // Initialize workspace if needed 144 + // Initialize workspace if needed (with full std library) 102 145 if workspace_guard.is_none() { 103 146 match Workspace::with_std() { 104 - Ok(ws) => *workspace_guard = Some(ws), 147 + Ok(ws) => { 148 + // Populate document storage with std library files for navigation 149 + self.load_std_documents().await; 150 + *workspace_guard = Some(ws); 151 + } 105 152 Err(_) => { 106 - *workspace_guard = Some(Workspace::new()); 153 + // Fallback to prelude if std loading fails 154 + match Workspace::with_prelude() { 155 + Ok(ws) => { 156 + self.load_prelude_document().await; 157 + *workspace_guard = Some(ws); 158 + } 159 + Err(_) => *workspace_guard = Some(Workspace::new()), 160 + } 107 161 } 108 162 } 109 163 } ··· 111 165 // Add or update module in workspace 112 166 if let Some(workspace) = workspace_guard.as_mut() { 113 167 let _ = workspace.add_module(ns.clone(), lexicon); 114 - let _ = workspace.resolve(); 168 + 169 + // Resolve and collect validation errors 170 + if let Err(errors) = workspace.resolve() { 171 + use mlf_lang::error::ValidationError; 172 + 173 + for error in errors.errors { 174 + // Only show diagnostics for the current file 175 + if mlf_diagnostics::get_error_module_namespace_str(&error) == ns { 176 + // Extract span and message from error variant 177 + let (span, message, is_unused) = match &error { 178 + ValidationError::DuplicateDefinition { name, second_span, .. } => { 179 + (*second_span, format!("Duplicate definition: {}", name), false) 180 + } 181 + ValidationError::UndefinedReference { name, span, .. } => { 182 + (*span, format!("Undefined reference: {}", name), false) 183 + } 184 + ValidationError::InvalidConstraint { message, span, .. } => { 185 + (*span, format!("Invalid constraint: {}", message), false) 186 + } 187 + ValidationError::TypeMismatch { expected, found, span, .. } => { 188 + (*span, format!("Type mismatch: expected {}, found {}", expected, found), false) 189 + } 190 + ValidationError::ConstraintTooPermissive { message, span, .. } => { 191 + (*span, format!("Constraint too permissive: {}", message), false) 192 + } 193 + ValidationError::ReservedName { name, span, .. } => { 194 + (*span, format!("Reserved name: {}", name), false) 195 + } 196 + ValidationError::AmbiguousMain { name, first_span, .. } => { 197 + (*first_span, format!("Ambiguous main: {}", name), false) 198 + } 199 + ValidationError::MultipleMain { name, first_span, .. } => { 200 + (*first_span, format!("Multiple @main annotations: {}", name), false) 201 + } 202 + ValidationError::ConflictNotAllowed { name, span, .. } => { 203 + (*span, format!("Conflict not allowed: {}", name), false) 204 + } 205 + ValidationError::CircularImport { cycle, span, .. } => { 206 + (*span, format!("Circular import: {}", cycle.join(" -> ")), false) 207 + } 208 + ValidationError::UnusedImport { name, span, .. } => { 209 + (*span, format!("Unused import: {}", name), true) 210 + } 211 + }; 212 + 213 + let range = span_to_range(text, span); 214 + 215 + diagnostics.push(Diagnostic { 216 + range, 217 + severity: Some(if is_unused { DiagnosticSeverity::HINT } else { DiagnosticSeverity::ERROR }), 218 + code: None, 219 + code_description: None, 220 + source: Some("mlf".to_string()), 221 + message, 222 + related_information: None, 223 + tags: if is_unused { 224 + Some(vec![DiagnosticTag::UNNECESSARY]) 225 + } else { 226 + None 227 + }, 228 + data: None, 229 + }); 230 + } 231 + } 232 + } 233 + } 234 + } 235 + 236 + diagnostics 237 + } 238 + 239 + async fn load_std_documents(&self) { 240 + self.client 241 + .log_message(MessageType::INFO, "Loading std library documents...") 242 + .await; 243 + 244 + // Use global ~/.mlf/lexicons/mlf/ directory (matches project structure) 245 + let global_mlf_dir = dirs::home_dir() 246 + .map(|h| h.join(".mlf")) 247 + .unwrap_or_else(|| PathBuf::from(".mlf")); 248 + 249 + let std_dir = global_mlf_dir.join("lexicons").join("mlf"); 250 + 251 + self.client 252 + .log_message(MessageType::INFO, format!("Global MLF directory: {}", global_mlf_dir.display())) 253 + .await; 254 + 255 + // Ensure std directory exists and has files 256 + if !std_dir.join("prelude.mlf").exists() { 257 + self.client 258 + .log_message(MessageType::INFO, "Std library not found in ~/.mlf/lexicons/mlf/, extracting embedded files...") 259 + .await; 260 + 261 + // Create directory 262 + if let Err(e) = std::fs::create_dir_all(&std_dir) { 263 + self.client 264 + .log_message(MessageType::ERROR, format!("Failed to create ~/.mlf/lexicons/mlf/: {}", e)) 265 + .await; 266 + return; 267 + } 268 + 269 + // Extract embedded files 270 + self.extract_embedded_std_to_directory(&std_dir).await; 271 + } else { 272 + self.client 273 + .log_message(MessageType::INFO, "Using existing std library from ~/.mlf/lexicons/mlf/") 274 + .await; 275 + } 276 + 277 + // Load std files from ~/.mlf/lexicons/mlf/ 278 + self.load_std_from_directory(&std_dir).await; 279 + 280 + self.client 281 + .log_message(MessageType::INFO, "Finished loading std library documents") 282 + .await; 283 + } 284 + 285 + /// Load fetched lexicons from project's .mlf cache directory 286 + async fn load_project_lexicons(&self, workspace: &mut Workspace) -> std::result::Result<(), String> { 287 + // Try to find project root by looking for mlf.toml 288 + // We'll check a few common locations 289 + let possible_roots = vec![ 290 + std::env::current_dir().ok(), 291 + // Could add more heuristics here 292 + ]; 293 + 294 + for maybe_root in possible_roots { 295 + if let Some(root) = maybe_root { 296 + if let Some(project_root) = find_mlf_project_root(&root) { 297 + let mlf_cache_dir = project_root.join(".mlf"); 298 + let lexicons_dir = mlf_cache_dir.join("lexicons").join("mlf"); 299 + 300 + if lexicons_dir.exists() { 301 + self.client 302 + .log_message(MessageType::INFO, format!("Loading project lexicons from {}", lexicons_dir.display())) 303 + .await; 304 + 305 + self.load_lexicons_from_directory(workspace, &lexicons_dir, &lexicons_dir).await?; 306 + 307 + self.client 308 + .log_message(MessageType::INFO, "Finished loading project lexicons") 309 + .await; 310 + 311 + return Ok(()); 312 + } 313 + } 115 314 } 116 315 } 316 + 317 + Ok(()) 318 + } 319 + 320 + /// Recursively load .mlf files from a directory into the workspace 321 + async fn load_lexicons_from_directory( 322 + &self, 323 + workspace: &mut Workspace, 324 + dir: &PathBuf, 325 + base_dir: &PathBuf, 326 + ) -> std::result::Result<(), String> { 327 + if !dir.exists() { 328 + return Ok(()); 329 + } 330 + 331 + let entries = std::fs::read_dir(dir) 332 + .map_err(|e| format!("Failed to read directory {}: {}", dir.display(), e))?; 333 + 334 + for entry in entries.flatten() { 335 + let path = entry.path(); 336 + 337 + if path.is_file() && path.extension().map_or(false, |e| e == "mlf") { 338 + // Extract namespace from file path 339 + if let Ok(rel_path) = path.strip_prefix(base_dir) { 340 + if let Some(path_str) = rel_path.to_str() { 341 + let namespace = path_str 342 + .strip_suffix(".mlf") 343 + .unwrap_or(path_str) 344 + .replace('/', ".") 345 + .replace('\\', "."); 346 + 347 + // Read and parse the file 348 + if let Ok(contents) = std::fs::read_to_string(&path) { 349 + if let Ok(lexicon) = mlf_lang::parser::parse_lexicon(&contents) { 350 + // Add to workspace (skip if already exists to avoid overwriting std) 351 + if let Err(e) = workspace.add_module(namespace.clone(), lexicon) { 352 + self.client 353 + .log_message( 354 + MessageType::WARNING, 355 + format!("Failed to add module {}: {:?}", namespace, e) 356 + ) 357 + .await; 358 + } 359 + 360 + // Also add to documents for navigation 361 + if let Ok(uri) = Url::from_file_path(&path) { 362 + self.documents.write().await.insert( 363 + uri.clone(), 364 + DocumentState { 365 + text: contents.clone(), 366 + lexicon: Some(mlf_lang::parser::parse_lexicon(&contents).unwrap()), 367 + namespace: Some(namespace.clone()), 368 + }, 369 + ); 370 + 371 + self.client 372 + .log_message( 373 + MessageType::INFO, 374 + format!("Loaded project lexicon: {} (namespace: {})", uri, namespace) 375 + ) 376 + .await; 377 + } 378 + } 379 + } 380 + } 381 + } 382 + } else if path.is_dir() { 383 + // Recurse into subdirectory 384 + Box::pin(self.load_lexicons_from_directory(workspace, &path, base_dir)).await?; 385 + } 386 + } 387 + 388 + Ok(()) 389 + } 390 + 391 + async fn load_std_from_directory(&self, std_dir: &PathBuf) { 392 + // Read real files from directory 393 + fn load_dir_recursive<'a>( 394 + server: &'a MlfLanguageServer, 395 + dir: &'a PathBuf, 396 + base_dir: &'a PathBuf, 397 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> { 398 + Box::pin(async move { 399 + if let Ok(entries) = std::fs::read_dir(dir) { 400 + for entry in entries.flatten() { 401 + let path = entry.path(); 402 + if path.is_file() && path.extension().map_or(false, |e| e == "mlf") { 403 + if let Ok(contents) = std::fs::read_to_string(&path) { 404 + if let Ok(lexicon) = mlf_lang::parser::parse_lexicon(&contents) { 405 + if let Ok(uri) = Url::from_file_path(&path) { 406 + // Extract relative path for namespace 407 + if let Ok(rel_path) = path.strip_prefix(base_dir) { 408 + if let Some(path_str) = rel_path.to_str() { 409 + let namespace = path_str 410 + .strip_suffix(".mlf") 411 + .unwrap_or(path_str) 412 + .replace('/', ".") 413 + .replace('\\', "."); 414 + 415 + server.client 416 + .log_message( 417 + MessageType::INFO, 418 + format!("Loaded std document: {} (namespace: {})", uri, namespace) 419 + ) 420 + .await; 421 + 422 + server.documents.write().await.insert( 423 + uri, 424 + DocumentState { 425 + text: contents, 426 + lexicon: Some(lexicon), 427 + namespace: Some(namespace), 428 + }, 429 + ); 430 + } 431 + } 432 + } 433 + } 434 + } 435 + } else if path.is_dir() { 436 + load_dir_recursive(server, &path, base_dir).await; 437 + } 438 + } 439 + } 440 + }) 441 + } 442 + 443 + load_dir_recursive(self, std_dir, std_dir).await; 444 + } 445 + 446 + async fn extract_embedded_std_to_directory(&self, std_dir: &PathBuf) { 447 + // Extract embedded files to ~/.mlf/std/ 448 + fn extract_embedded_dir<'a>( 449 + server: &'a MlfLanguageServer, 450 + dir: &'static include_dir::Dir<'static>, 451 + base_std_dir: &'a PathBuf, 452 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> { 453 + Box::pin(async move { 454 + for file in dir.files() { 455 + if let Some(path_str) = file.path().to_str() { 456 + if path_str.ends_with(".mlf") { 457 + if let Some(contents_str) = file.contents_utf8() { 458 + let file_path = base_std_dir.join(path_str); 459 + 460 + // Create parent directories 461 + if let Some(parent) = file_path.parent() { 462 + let _ = std::fs::create_dir_all(parent); 463 + } 464 + 465 + // Write file 466 + if let Err(e) = std::fs::write(&file_path, contents_str) { 467 + server.client 468 + .log_message( 469 + MessageType::ERROR, 470 + format!("Failed to write std file {}: {}", file_path.display(), e) 471 + ) 472 + .await; 473 + continue; 474 + } 475 + 476 + server.client 477 + .log_message( 478 + MessageType::INFO, 479 + format!("Extracted: {}", file_path.display()) 480 + ) 481 + .await; 482 + } 483 + } 484 + } 485 + } 486 + 487 + // Recursively process subdirectories 488 + for subdir in dir.dirs() { 489 + extract_embedded_dir(server, subdir, base_std_dir).await; 490 + } 491 + }) 492 + } 493 + 494 + extract_embedded_dir(self, &mlf_lang::STD_DIR, std_dir).await; 495 + } 496 + 497 + async fn load_prelude_document(&self) { 498 + // Prelude is already included in STD_DIR, so this is handled by load_std_documents 499 + // This function kept for backward compatibility but does nothing 117 500 } 118 501 119 502 async fn find_definition_in_workspace( ··· 122 505 current_namespace: &str, 123 506 path: &Path, 124 507 ) -> Option<(Url, mlf_lang::span::Span)> { 508 + self.client 509 + .log_message( 510 + MessageType::INFO, 511 + format!("find_definition_in_workspace: target_name={}, current_namespace={}, path={}", 512 + target_name, current_namespace, path.to_string()) 513 + ) 514 + .await; 515 + 125 516 let workspace_guard = self.workspace.read().await; 126 517 let workspace = workspace_guard.as_ref()?; 127 518 128 519 // Resolve the reference to find which namespace it's in 129 520 let target_namespace = workspace.resolve_reference_namespace(path, current_namespace)?; 130 521 522 + self.client 523 + .log_message( 524 + MessageType::INFO, 525 + format!("Resolved target namespace: {}", target_namespace) 526 + ) 527 + .await; 528 + 131 529 // Find the document with that namespace 132 530 let documents = self.documents.read().await; 531 + 532 + self.client 533 + .log_message( 534 + MessageType::INFO, 535 + format!("Searching {} documents for namespace '{}'", documents.len(), target_namespace) 536 + ) 537 + .await; 538 + 133 539 for (doc_uri, doc_state) in documents.iter() { 134 540 if let Some(doc_ns) = &doc_state.namespace { 541 + self.client 542 + .log_message( 543 + MessageType::INFO, 544 + format!("Checking document {} with namespace '{}'", doc_uri, doc_ns) 545 + ) 546 + .await; 547 + 135 548 if doc_ns == &target_namespace { 549 + self.client 550 + .log_message( 551 + MessageType::INFO, 552 + format!("Found matching namespace! Searching for item '{}'", target_name) 553 + ) 554 + .await; 555 + 136 556 if let Some(lexicon) = &doc_state.lexicon { 137 557 // Find the item in this lexicon 138 558 for item in &lexicon.items { 139 - if get_item_name(item) == target_name { 559 + let item_name = get_item_name(item); 560 + if item_name == target_name { 140 561 let def_span = match item { 141 562 Item::Record(r) => r.name.span, 142 563 Item::InlineType(i) => i.name.span, ··· 147 568 Item::Subscription(s) => s.name.span, 148 569 Item::Use(_) => continue, 149 570 }; 571 + 572 + self.client 573 + .log_message( 574 + MessageType::INFO, 575 + format!("Found definition of '{}' in {}", target_name, doc_uri) 576 + ) 577 + .await; 150 578 151 579 return Some((doc_uri.clone(), def_span)); 152 580 } ··· 156 584 } 157 585 } 158 586 587 + self.client 588 + .log_message( 589 + MessageType::INFO, 590 + format!("Definition not found for '{}'", target_name) 591 + ) 592 + .await; 593 + 594 + None 595 + } 596 + 597 + /// Find the definition for a use statement 598 + /// For "use a.b.c", navigate to the definition of c in namespace a.b 599 + /// For "use a.b", navigate to the first definition in namespace a.b 600 + async fn find_definition_in_workspace_for_use( 601 + &self, 602 + use_stmt: &Use, 603 + _current_namespace: &str, 604 + ) -> Option<(Url, mlf_lang::span::Span)> { 605 + // For "use a.b.c" or "use a.b.c { ... }", we want to navigate to the namespace a.b 606 + // and find the type c (or the first type if it's just "use a.b") 607 + 608 + let path = &use_stmt.path; 609 + 610 + // Check if this looks like "use namespace.typename" (old syntax) 611 + // or "use namespace" (new syntax) 612 + let (target_namespace, target_type) = if let UseImports::Items(items) = &use_stmt.imports { 613 + if items.len() == 1 && path.segments.len() >= 2 614 + && items[0].name.name == path.segments.last().unwrap().name { 615 + // Old syntax: use a.b.c as Foo 616 + // Navigate to type "c" in namespace "a.b" 617 + let ns = path.segments[..path.segments.len() - 1] 618 + .iter() 619 + .map(|s| s.name.as_str()) 620 + .collect::<Vec<_>>() 621 + .join("."); 622 + let type_name = path.segments.last().unwrap().name.clone(); 623 + (ns, Some(type_name)) 624 + } else { 625 + // New syntax: use a.b { c, d } 626 + // Navigate to namespace a.b 627 + (path.to_string(), None) 628 + } 629 + } else { 630 + // use a.b; or use a.b.*; 631 + (path.to_string(), None) 632 + }; 633 + 634 + self.find_definition_in_namespace(&target_namespace, target_type.as_deref().unwrap_or("")).await 635 + } 636 + 637 + /// Find a definition in a specific namespace 638 + async fn find_definition_in_namespace( 639 + &self, 640 + target_namespace: &str, 641 + target_name: &str, 642 + ) -> Option<(Url, mlf_lang::span::Span)> { 643 + let documents = self.documents.read().await; 644 + 645 + self.client 646 + .log_message( 647 + MessageType::INFO, 648 + format!("find_definition_in_namespace: namespace='{}', name='{}'", target_namespace, target_name) 649 + ) 650 + .await; 651 + 652 + for (doc_uri, doc_state) in documents.iter() { 653 + if let Some(doc_ns) = &doc_state.namespace { 654 + if doc_ns == target_namespace { 655 + if let Some(lexicon) = &doc_state.lexicon { 656 + // If target_name is empty, return the first definition 657 + if target_name.is_empty() { 658 + for item in &lexicon.items { 659 + let def_span = match item { 660 + Item::Record(r) => Some(r.name.span), 661 + Item::InlineType(i) => Some(i.name.span), 662 + Item::DefType(d) => Some(d.name.span), 663 + Item::Token(t) => Some(t.name.span), 664 + Item::Query(q) => Some(q.name.span), 665 + Item::Procedure(p) => Some(p.name.span), 666 + Item::Subscription(s) => Some(s.name.span), 667 + Item::Use(_) => None, 668 + }; 669 + 670 + if let Some(span) = def_span { 671 + return Some((doc_uri.clone(), span)); 672 + } 673 + } 674 + } else { 675 + // Find specific item by name 676 + for item in &lexicon.items { 677 + let item_name = get_item_name(item); 678 + if item_name == target_name { 679 + let def_span = match item { 680 + Item::Record(r) => r.name.span, 681 + Item::InlineType(i) => i.name.span, 682 + Item::DefType(d) => d.name.span, 683 + Item::Token(t) => t.name.span, 684 + Item::Query(q) => q.name.span, 685 + Item::Procedure(p) => p.name.span, 686 + Item::Subscription(s) => s.name.span, 687 + Item::Use(_) => continue, 688 + }; 689 + 690 + return Some((doc_uri.clone(), def_span)); 691 + } 692 + } 693 + } 694 + } 695 + } 696 + } 697 + } 698 + 159 699 None 160 700 } 161 701 } ··· 201 741 Some(components.join(".")) 202 742 } 203 743 744 + /// Check if a URI points to a std library file that shouldn't be edited 745 + fn is_std_library_file(uri: &Url) -> bool { 746 + if let Ok(path) = uri.to_file_path() { 747 + // Check if the path contains ~/.mlf/lexicons/mlf/ 748 + if let Some(home_dir) = dirs::home_dir() { 749 + let std_dir = home_dir.join(".mlf").join("lexicons").join("mlf"); 750 + if let Ok(canonical_path) = path.canonicalize() { 751 + if let Ok(canonical_std_dir) = std_dir.canonicalize() { 752 + return canonical_path.starts_with(canonical_std_dir); 753 + } 754 + } 755 + } 756 + } 757 + false 758 + } 759 + 760 + /// Find the project root by looking for mlf.toml 761 + fn find_mlf_project_root(start_path: &std::path::Path) -> Option<PathBuf> { 762 + let mut current = start_path; 763 + 764 + loop { 765 + if current.join("mlf.toml").exists() { 766 + return Some(current.to_path_buf()); 767 + } 768 + 769 + current = current.parent()?; 770 + 771 + // Stop at filesystem root 772 + if current.parent().is_none() { 773 + break; 774 + } 775 + } 776 + 777 + None 778 + } 779 + 204 780 #[tower_lsp::async_trait] 205 781 impl LanguageServer for MlfLanguageServer { 206 782 async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> { ··· 212 788 hover_provider: Some(HoverProviderCapability::Simple(true)), 213 789 completion_provider: Some(CompletionOptions { 214 790 resolve_provider: Some(false), 215 - trigger_characters: Some(vec![".".to_string(), ":".to_string()]), 791 + trigger_characters: Some(vec![ 792 + ".".to_string(), 793 + ":".to_string(), 794 + " ".to_string(), 795 + ]), 216 796 ..Default::default() 217 797 }), 218 798 definition_provider: Some(OneOf::Left(true)), ··· 232 812 self.client 233 813 .log_message(MessageType::INFO, "MLF Language Server initialized") 234 814 .await; 815 + 816 + // Initialize workspace with std library immediately on startup 817 + // This ensures completion works even if did_open hasn't been called yet (e.g., after lsp-restart) 818 + let mut workspace_guard = self.workspace.write().await; 819 + if workspace_guard.is_none() { 820 + tracing::info!("Initializing workspace with std library on startup"); 821 + match Workspace::with_std() { 822 + Ok(mut ws) => { 823 + // Populate document storage with std library files for navigation 824 + self.load_std_documents().await; 825 + 826 + // Load project lexicons from .mlf cache 827 + if let Err(e) = self.load_project_lexicons(&mut ws).await { 828 + tracing::warn!("Failed to load project lexicons: {}", e); 829 + self.client 830 + .log_message(MessageType::WARNING, format!("Failed to load project lexicons: {}", e)) 831 + .await; 832 + } 833 + 834 + *workspace_guard = Some(ws); 835 + tracing::info!("Workspace initialized successfully"); 836 + } 837 + Err(e) => { 838 + tracing::error!("Failed to initialize workspace: {:?}", e); 839 + // Fallback to prelude if std loading fails 840 + match Workspace::with_prelude() { 841 + Ok(ws) => { 842 + self.load_prelude_document().await; 843 + *workspace_guard = Some(ws); 844 + tracing::info!("Workspace initialized with prelude only"); 845 + } 846 + Err(_) => { 847 + *workspace_guard = Some(Workspace::new()); 848 + tracing::warn!("Workspace initialized empty (no std or prelude)"); 849 + } 850 + } 851 + } 852 + } 853 + } 235 854 } 236 855 237 856 async fn shutdown(&self) -> Result<()> { ··· 399 1018 } 400 1019 401 1020 async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> { 1021 + tracing::debug!("Completion request received"); 1022 + 402 1023 let uri = params.text_document_position.text_document.uri; 403 1024 let position = params.text_document_position.position; 404 1025 405 1026 let documents = self.documents.read().await; 1027 + tracing::debug!("Got documents lock"); 1028 + 406 1029 if let Some(doc_state) = documents.get(&uri) { 407 1030 let mut completions = vec![]; 408 1031 409 - // Always provide keywords 410 - let keywords = vec![ 411 - ("record", CompletionItemKind::KEYWORD, "Define a record type"), 412 - ("inline type", CompletionItemKind::KEYWORD, "Define an inline type"), 413 - ("def type", CompletionItemKind::KEYWORD, "Define a def type"), 414 - ("token", CompletionItemKind::KEYWORD, "Define a token"), 415 - ("query", CompletionItemKind::KEYWORD, "Define a query"), 416 - ("procedure", CompletionItemKind::KEYWORD, "Define a procedure"), 417 - ("subscription", CompletionItemKind::KEYWORD, "Define a subscription"), 418 - ("use", CompletionItemKind::KEYWORD, "Import types from another module"), 419 - ("constrained", CompletionItemKind::KEYWORD, "Add constraints to a type"), 420 - ]; 1032 + // Detect context 1033 + let text_before_cursor = if let Some(offset) = position_to_offset(&doc_state.text, position) { 1034 + &doc_state.text[..offset] 1035 + } else { 1036 + "" 1037 + }; 421 1038 422 - for (label, kind, detail) in keywords { 423 - completions.push(CompletionItem { 424 - label: label.to_string(), 425 - kind: Some(kind), 426 - detail: Some(detail.to_string()), 427 - ..Default::default() 428 - }); 429 - } 1039 + let context = detect_context(text_before_cursor); 1040 + tracing::debug!("Context detected: {:?}", context); 430 1041 431 - // Primitive types 432 - let primitives = vec![ 433 - "null", "boolean", "integer", "string", "bytes", "blob", 434 - ]; 1042 + match context { 1043 + MlfCompletionContext::UseStatement => { 1044 + tracing::debug!("UseStatement completion"); 435 1045 436 - for prim in primitives { 437 - completions.push(CompletionItem { 438 - label: prim.to_string(), 439 - kind: Some(CompletionItemKind::TYPE_PARAMETER), 440 - detail: Some("Primitive type".to_string()), 441 - ..Default::default() 442 - }); 443 - } 1046 + // Extract the partial path already typed (e.g., "use com.atproto." -> "com.atproto") 1047 + let last_line = text_before_cursor.lines().last().unwrap_or(""); 1048 + let after_use = last_line 1049 + .trim_start() 1050 + .strip_prefix("use ") 1051 + .unwrap_or("") 1052 + .trim_end(); 1053 + 1054 + // Track if there's a trailing dot 1055 + let has_trailing_dot = after_use.ends_with('.'); 1056 + let partial_path = after_use.trim_end_matches('.'); // Remove trailing dot for matching 1057 + 1058 + tracing::debug!("Partial path: '{}', has_trailing_dot: {}", partial_path, has_trailing_dot); 444 1059 445 - // If we have a parsed lexicon, provide type completions from current file 446 - if let Some(lexicon) = &doc_state.lexicon { 447 - for item in &lexicon.items { 448 - let (name, kind, detail) = match item { 449 - Item::Record(r) => (r.name.name.as_str(), CompletionItemKind::CLASS, "record"), 450 - Item::InlineType(i) => (i.name.name.as_str(), CompletionItemKind::TYPE_PARAMETER, "inline type"), 451 - Item::DefType(d) => (d.name.name.as_str(), CompletionItemKind::TYPE_PARAMETER, "def type"), 452 - Item::Token(t) => (t.name.name.as_str(), CompletionItemKind::ENUM, "token"), 453 - Item::Query(q) => (q.name.name.as_str(), CompletionItemKind::FUNCTION, "query"), 454 - Item::Procedure(p) => (p.name.name.as_str(), CompletionItemKind::FUNCTION, "procedure"), 455 - Item::Subscription(s) => (s.name.name.as_str(), CompletionItemKind::EVENT, "subscription"), 456 - Item::Use(_) => continue, 457 - }; 1060 + // Calculate the range to replace 1061 + let use_start_offset = text_before_cursor.rfind("use ").map(|i| i + 4).unwrap_or(0); 1062 + let use_start_pos = offset_to_position(&doc_state.text, use_start_offset); 458 1063 459 - completions.push(CompletionItem { 460 - label: name.to_string(), 461 - kind: Some(kind), 462 - detail: Some(detail.to_string()), 463 - ..Default::default() 464 - }); 1064 + // Use shared namespace completion logic 1065 + let workspace_guard = self.workspace.read().await; 1066 + if let Some(workspace) = workspace_guard.as_ref() { 1067 + completions.extend(namespace_completion::complete_namespace_path( 1068 + workspace, 1069 + &documents, 1070 + doc_state.namespace.as_ref(), 1071 + partial_path, 1072 + has_trailing_dot, 1073 + use_start_pos, 1074 + position, 1075 + &doc_state.text, 1076 + true, // Suggest { }, *, and types after trailing dot in use statements 1077 + )); 1078 + } 465 1079 } 466 - } 467 1080 468 - // Check if we're at a field position to provide constraint completions 469 - if let Some(offset) = position_to_offset(&doc_state.text, position) { 470 - // Check if "constrained {" appears before cursor 471 - if doc_state.text[..offset].ends_with("constrained {") 472 - || doc_state.text[..offset].contains("constrained {") { 1081 + MlfCompletionContext::ConstraintBlock => { 1082 + // Only suggest constraint names 473 1083 let constraint_items = vec![ 474 1084 ("maxLength", "Maximum string/array length"), 475 1085 ("minLength", "Minimum string/array length"), ··· 496 1106 }); 497 1107 } 498 1108 } 1109 + 1110 + MlfCompletionContext::TypePosition => { 1111 + // Extract text after the last ':' to detect if user is typing a namespace path 1112 + let last_line = text_before_cursor.lines().last().unwrap_or(""); 1113 + let after_colon = last_line.rfind(':') 1114 + .map(|idx| &last_line[idx + 1..]) 1115 + .unwrap_or(""); 1116 + 1117 + // Trim only for detection purposes, but preserve space in offset calculation 1118 + let after_colon_trimmed = after_colon.trim_start(); 1119 + 1120 + // Check if user is typing a path (contains a dot) 1121 + let is_typing_path = after_colon_trimmed.contains('.'); 1122 + 1123 + tracing::debug!("Type position: after_colon_trimmed='{}', is_typing_path={}", after_colon_trimmed, is_typing_path); 1124 + 1125 + if is_typing_path { 1126 + // User is typing a namespace path like "com.atproto." 1127 + // Only suggest namespace path completions 1128 + let has_trailing_dot = after_colon_trimmed.ends_with('.'); 1129 + let partial_path = after_colon_trimmed.trim_end_matches('.'); 1130 + 1131 + tracing::debug!("Namespace path mode: partial_path='{}', has_trailing_dot={}", partial_path, has_trailing_dot); 1132 + 1133 + // Calculate the range to replace (from after ':' and any spaces to cursor) 1134 + let colon_offset = text_before_cursor.rfind(':').map(|i| i + 1).unwrap_or(0); 1135 + // Skip leading spaces to get to where the actual path starts 1136 + let space_count = after_colon.len() - after_colon_trimmed.len(); 1137 + let replace_start_pos = offset_to_position(&doc_state.text, colon_offset + space_count); 1138 + 1139 + let workspace_guard = self.workspace.read().await; 1140 + if let Some(workspace) = workspace_guard.as_ref() { 1141 + completions.extend(namespace_completion::complete_namespace_path( 1142 + workspace, 1143 + &documents, 1144 + doc_state.namespace.as_ref(), 1145 + partial_path, 1146 + has_trailing_dot, 1147 + replace_start_pos, 1148 + position, 1149 + &doc_state.text, 1150 + false, // Don't suggest { } or * in type positions 1151 + )); 1152 + } 1153 + } else { 1154 + // Not typing a path - suggest local types, primitives, imports, prelude 1155 + tracing::debug!("Local type mode"); 1156 + 1157 + // Primitive types 1158 + let primitives = vec![ 1159 + "null", "boolean", "integer", "string", "bytes", "blob", 1160 + ]; 1161 + 1162 + for prim in primitives { 1163 + completions.push(CompletionItem { 1164 + label: prim.to_string(), 1165 + kind: Some(CompletionItemKind::TYPE_PARAMETER), 1166 + detail: Some("Primitive type".to_string()), 1167 + ..Default::default() 1168 + }); 1169 + } 1170 + 1171 + // Add defined types (records, inline types, def types, tokens only) 1172 + if let Some(lexicon) = &doc_state.lexicon { 1173 + for item in &lexicon.items { 1174 + match item { 1175 + Item::Record(r) => { 1176 + completions.push(CompletionItem { 1177 + label: r.name.name.clone(), 1178 + kind: Some(CompletionItemKind::CLASS), 1179 + detail: Some("record".to_string()), 1180 + ..Default::default() 1181 + }); 1182 + } 1183 + Item::InlineType(i) => { 1184 + completions.push(CompletionItem { 1185 + label: i.name.name.clone(), 1186 + kind: Some(CompletionItemKind::TYPE_PARAMETER), 1187 + detail: Some("inline type".to_string()), 1188 + ..Default::default() 1189 + }); 1190 + } 1191 + Item::DefType(d) => { 1192 + completions.push(CompletionItem { 1193 + label: d.name.name.clone(), 1194 + kind: Some(CompletionItemKind::TYPE_PARAMETER), 1195 + detail: Some("def type".to_string()), 1196 + ..Default::default() 1197 + }); 1198 + } 1199 + Item::Token(t) => { 1200 + completions.push(CompletionItem { 1201 + label: t.name.name.clone(), 1202 + kind: Some(CompletionItemKind::ENUM), 1203 + detail: Some("token".to_string()), 1204 + ..Default::default() 1205 + }); 1206 + } 1207 + // Don't suggest queries, procedures, subscriptions as types 1208 + _ => {} 1209 + } 1210 + } 1211 + } 1212 + 1213 + // Add imported types 1214 + if let Some(current_namespace) = &doc_state.namespace { 1215 + let workspace_guard = self.workspace.read().await; 1216 + if let Some(workspace) = workspace_guard.as_ref() { 1217 + let imports = workspace.get_imports(current_namespace); 1218 + tracing::debug!("Found {} imports for namespace '{}'", imports.len(), current_namespace); 1219 + 1220 + for (local_name, original_path) in imports { 1221 + // Format the original path for display 1222 + let path_str = original_path.join("."); 1223 + tracing::debug!(" - {} from {}", local_name, path_str); 1224 + completions.push(CompletionItem { 1225 + label: local_name.clone(), 1226 + kind: Some(CompletionItemKind::TYPE_PARAMETER), 1227 + detail: Some(format!("imported from {}", path_str)), 1228 + ..Default::default() 1229 + }); 1230 + } 1231 + 1232 + // Add prelude types 1233 + let prelude_types = workspace.get_namespace_types("prelude"); 1234 + tracing::debug!("Found {} prelude types", prelude_types.len()); 1235 + for type_name in prelude_types { 1236 + completions.push(CompletionItem { 1237 + label: type_name.clone(), 1238 + kind: Some(CompletionItemKind::TYPE_PARAMETER), 1239 + detail: Some("from prelude".to_string()), 1240 + ..Default::default() 1241 + }); 1242 + } 1243 + 1244 + // Add NSIDs (fully qualified type references) 1245 + // This allows users to type full NSIDs like "com.atproto.repo.strongRef" 1246 + let nsids = workspace.get_all_nsids(); 1247 + tracing::debug!("Found {} NSIDs", nsids.len()); 1248 + for (nsid, namespace, type_name) in nsids { 1249 + completions.push(CompletionItem { 1250 + label: nsid.clone(), 1251 + kind: Some(CompletionItemKind::REFERENCE), 1252 + detail: Some(format!("{} from {}", type_name, namespace)), 1253 + filter_text: Some(nsid.clone()), 1254 + sort_text: Some(format!("z{}", nsid)), // Sort NSIDs after local types 1255 + ..Default::default() 1256 + }); 1257 + } 1258 + } 1259 + } 1260 + } 1261 + 1262 + tracing::debug!("Total completions: {}", completions.len()); 1263 + } 1264 + 1265 + MlfCompletionContext::TopLevel => { 1266 + // Suggest keywords for top-level declarations 1267 + let keywords = vec![ 1268 + ("record", CompletionItemKind::KEYWORD, "Define a record type"), 1269 + ("inline type", CompletionItemKind::KEYWORD, "Define an inline type"), 1270 + ("def type", CompletionItemKind::KEYWORD, "Define a def type"), 1271 + ("token", CompletionItemKind::KEYWORD, "Define a token"), 1272 + ("query", CompletionItemKind::KEYWORD, "Define a query"), 1273 + ("procedure", CompletionItemKind::KEYWORD, "Define a procedure"), 1274 + ("subscription", CompletionItemKind::KEYWORD, "Define a subscription"), 1275 + ("use", CompletionItemKind::KEYWORD, "Import types from another module"), 1276 + ]; 1277 + 1278 + for (label, kind, detail) in keywords { 1279 + completions.push(CompletionItem { 1280 + label: label.to_string(), 1281 + kind: Some(kind), 1282 + detail: Some(detail.to_string()), 1283 + ..Default::default() 1284 + }); 1285 + } 1286 + } 1287 + 1288 + MlfCompletionContext::Unknown => { 1289 + // Unknown context - don't suggest anything to avoid incorrect completions 1290 + // For example, when user is typing a field name but hasn't typed ':' yet 1291 + tracing::debug!("Unknown context - no completions"); 1292 + } 499 1293 } 500 1294 501 1295 return Ok(Some(CompletionResponse::Array(completions))); ··· 534 1328 535 1329 if let Some(lexicon) = lexicon { 536 1330 if let Some(offset) = position_to_offset(&text, position) { 1331 + // Check if cursor is on a use statement 1332 + for item in &lexicon.items { 1333 + if let Item::Use(use_stmt) = item { 1334 + // Check if the cursor is within the use statement's path 1335 + if use_stmt.path.span.start <= offset && offset <= use_stmt.path.span.end { 1336 + self.client 1337 + .log_message( 1338 + MessageType::INFO, 1339 + format!("Found use statement at cursor: {}", use_stmt.path.to_string()) 1340 + ) 1341 + .await; 1342 + 1343 + // Determine which type is being referenced 1344 + // For "use a.b.c", we navigate to the definition of c in namespace a.b 1345 + // For "use a.b { c }", we would navigate to c in namespace a.b 1346 + 1347 + // Handle go-to-definition for use statements 1348 + if let Some(ref current_ns) = current_namespace { 1349 + if let Some((def_uri, def_span)) = 1350 + self.find_definition_in_workspace_for_use(use_stmt, current_ns).await { 1351 + 1352 + let documents = self.documents.read().await; 1353 + if let Some(target_doc) = documents.get(&def_uri) { 1354 + let range = span_to_range(&target_doc.text, def_span); 1355 + 1356 + self.client 1357 + .log_message( 1358 + MessageType::INFO, 1359 + format!("Returning use statement definition from: {}", def_uri) 1360 + ) 1361 + .await; 1362 + 1363 + return Ok(Some(GotoDefinitionResponse::Scalar( 1364 + Location { 1365 + uri: def_uri, 1366 + range, 1367 + }, 1368 + ))); 1369 + } 1370 + } 1371 + } 1372 + } 1373 + 1374 + // Check if cursor is on an imported item name 1375 + if let UseImports::Items(items) = &use_stmt.imports { 1376 + for import_item in items { 1377 + if import_item.name.span.start <= offset && offset <= import_item.name.span.end { 1378 + self.client 1379 + .log_message( 1380 + MessageType::INFO, 1381 + format!("Found use item at cursor: {}", import_item.name.name) 1382 + ) 1383 + .await; 1384 + 1385 + // Navigate to the definition of this specific item 1386 + if current_namespace.is_some() { 1387 + // Construct the full path to the item 1388 + let target_namespace = use_stmt.path.to_string(); 1389 + let item_name = if import_item.name.name == "main" { 1390 + // Special case: "main" resolves to namespace suffix 1391 + target_namespace.split('.').last().unwrap_or(&import_item.name.name) 1392 + } else { 1393 + &import_item.name.name 1394 + }; 1395 + 1396 + if let Some((def_uri, def_span)) = 1397 + self.find_definition_in_namespace(&target_namespace, item_name).await { 1398 + 1399 + let documents = self.documents.read().await; 1400 + if let Some(target_doc) = documents.get(&def_uri) { 1401 + let range = span_to_range(&target_doc.text, def_span); 1402 + 1403 + return Ok(Some(GotoDefinitionResponse::Scalar( 1404 + Location { 1405 + uri: def_uri, 1406 + range, 1407 + }, 1408 + ))); 1409 + } 1410 + } 1411 + } 1412 + } 1413 + } 1414 + } 1415 + } 1416 + } 1417 + 537 1418 // Find type reference at this position 538 1419 for item in &lexicon.items { 539 1420 let type_to_check = match item { ··· 553 1434 554 1435 if let Some(ty) = type_to_check { 555 1436 if let Some(Type::Reference { path, .. }) = find_type_at_offset(ty, offset) { 1437 + self.client 1438 + .log_message( 1439 + MessageType::INFO, 1440 + format!("Found reference at cursor: {}", path.to_string()) 1441 + ) 1442 + .await; 1443 + 556 1444 // Find the definition of this type 557 1445 let target_name = if path.segments.len() == 1 { 558 1446 &path.segments[0].name ··· 560 1448 &path.segments.last().unwrap().name 561 1449 }; 562 1450 1451 + self.client 1452 + .log_message( 1453 + MessageType::INFO, 1454 + format!("Target name: {}, path segments: {}", target_name, path.segments.len()) 1455 + ) 1456 + .await; 1457 + 563 1458 // First try to find in current file 564 1459 for target_item in &lexicon.items { 565 1460 if get_item_name(target_item) == target_name { ··· 576 1471 577 1472 let range = span_to_range(&text, def_span); 578 1473 1474 + self.client 1475 + .log_message( 1476 + MessageType::INFO, 1477 + format!("Found definition in current file") 1478 + ) 1479 + .await; 1480 + 579 1481 return Ok(Some(GotoDefinitionResponse::Scalar( 580 1482 Location { 581 1483 uri: uri.clone(), ··· 585 1487 } 586 1488 } 587 1489 1490 + self.client 1491 + .log_message( 1492 + MessageType::INFO, 1493 + format!("Not found in current file, searching workspace...") 1494 + ) 1495 + .await; 1496 + 588 1497 // Not found in current file - try workspace 589 1498 if let Some(ref current_ns) = current_namespace { 1499 + self.client 1500 + .log_message( 1501 + MessageType::INFO, 1502 + format!("Current namespace: {}", current_ns) 1503 + ) 1504 + .await; 1505 + 590 1506 if let Some((def_uri, def_span)) = 591 1507 self.find_definition_in_workspace(target_name, current_ns, path).await { 592 1508 ··· 595 1511 if let Some(target_doc) = documents.get(&def_uri) { 596 1512 let range = span_to_range(&target_doc.text, def_span); 597 1513 1514 + self.client 1515 + .log_message( 1516 + MessageType::INFO, 1517 + format!("Returning definition from workspace: {}", def_uri) 1518 + ) 1519 + .await; 1520 + 598 1521 return Ok(Some(GotoDefinitionResponse::Scalar( 599 1522 Location { 600 1523 uri: def_uri, ··· 603 1526 ))); 604 1527 } 605 1528 } 1529 + } else { 1530 + self.client 1531 + .log_message( 1532 + MessageType::WARNING, 1533 + format!("No current namespace available") 1534 + ) 1535 + .await; 606 1536 } 607 1537 } 608 1538 }
+3 -3
std/com/atproto/admin/defs.mlf
··· 9 9 did!: Did, 10 10 handle!: Handle, 11 11 email: string, 12 - relatedRecords: [unknown], 12 + relatedRecords: unknown[], 13 13 indexedAt!: Datetime, 14 14 invitedBy: com.atproto.server.defs.inviteCode, 15 - invites: [com.atproto.server.defs.inviteCode], 15 + invites: com.atproto.server.defs.inviteCode[], 16 16 invitesDisabled: boolean, 17 17 emailConfirmedAt: Datetime, 18 18 inviteNote: string, 19 19 deactivatedAt: Datetime, 20 - threatSignatures: [threatSignature], 20 + threatSignatures: threatSignature[], 21 21 }; 22 22 23 23 def type repoRef = {
+2 -2
std/com/atproto/label/defs.mlf
··· 13 13 }; 14 14 15 15 def type selfLabels = { 16 - values!: [selfLabel] constrained { maxLength: 10 }, 16 + values!: selfLabel[] constrained { maxLength: 10 }, 17 17 }; 18 18 19 19 def type selfLabel = { ··· 26 26 blurs!: string constrained { knownValues: ["content", "media", "none"] }, 27 27 defaultSetting: string constrained { knownValues: ["ignore", "warn", "hide"], default: "warn" }, 28 28 adultOnly: boolean, 29 - locales!: [labelValueDefinitionStrings], 29 + locales!: labelValueDefinitionStrings[], 30 30 }; 31 31 32 32 def type labelValueDefinitionStrings = {
+1 -1
std/com/atproto/server/defs.mlf
··· 7 7 forAccount!: string, 8 8 createdBy!: string, 9 9 createdAt!: Datetime, 10 - uses!: [inviteCodeUse], 10 + uses!: inviteCodeUse[], 11 11 }; 12 12 13 13 def type inviteCodeUse = {