pipeline.rs
1 use crate::analysis::graph::BindingGraph; 2 use crate::analysis::query::QueryEngine; 3 use crate::languages::LanguageSupport; 4 use crate::types::{ 5 ImportContext, Scope, ScopeId, Symbol, SymbolId, SymbolKind, SymbolOrigin, SymbolUsage, 6 }; 7 use compact_str::CompactString; 8 use tower_lsp::lsp_types::{Position, Range}; 9 use tree_sitter::Tree; 10 11 #[derive(Debug)] 12 struct PropertyAccessCandidate { 13 object_name: CompactString, 14 15 property_name: CompactString, 16 17 usage_range: Range, 18 19 property_range: Range, 20 21 object_position: Position, 22 } 23 24 pub struct AnalysisPipeline; 25 26 impl AnalysisPipeline { 27 pub async fn analyze( 28 query_engine: &QueryEngine, 29 language: &dyn LanguageSupport, 30 tree: &Tree, 31 source: &[u8], 32 import_context: &ImportContext, 33 ) -> BindingGraph { 34 let mut graph = BindingGraph::new(); 35 36 let root_range = ts_to_lsp_range(tree.root_node().range()); 37 graph.set_root_range(root_range); 38 39 let property_candidates = 40 Self::extract_scopes_and_collect_property_accesses(language, tree, source, &mut graph); 41 42 // Build scope tree early so scope_at_position works correctly during binding extraction 43 graph.rebuild_scope_range_index(); 44 45 Self::extract_direct_references( 46 query_engine, 47 language, 48 tree, 49 source, 50 import_context, 51 &mut graph, 52 ) 53 .await; 54 55 Self::extract_bindings(query_engine, language, tree, source, &mut graph).await; 56 57 Self::resolve_origins(&mut graph); 58 59 Self::extract_usages(query_engine, language, tree, source, &mut graph).await; 60 61 Self::process_property_access_candidates(&property_candidates, &mut graph); 62 63 Self::process_reassignments(query_engine, language, tree, source, &mut graph).await; 64 65 graph.rebuild_range_index(); 66 67 graph 68 } 69 70 fn extract_scopes_and_collect_property_accesses( 71 language: &dyn LanguageSupport, 72 tree: &Tree, 73 source: &[u8], 74 graph: &mut BindingGraph, 75 ) -> Vec<PropertyAccessCandidate> { 76 let mut candidates = Vec::new(); 77 Self::walk_combined( 78 tree.root_node(), 79 language, 80 source, 81 graph, 82 ScopeId::root(), 83 &mut candidates, 84 ); 85 candidates 86 } 87 88 fn walk_combined( 89 node: tree_sitter::Node, 90 language: &dyn LanguageSupport, 91 source: &[u8], 92 graph: &mut BindingGraph, 93 parent_scope: ScopeId, 94 candidates: &mut Vec<PropertyAccessCandidate>, 95 ) { 96 let current_scope = if language.is_scope_node(node) && !language.is_root_node(node) { 97 let scope_kind = language.node_to_scope_kind(node.kind()); 98 let scope = Scope { 99 id: ScopeId::root(), 100 parent: Some(parent_scope), 101 range: ts_to_lsp_range(node.range()), 102 kind: scope_kind, 103 }; 104 graph.add_scope(scope) 105 } else { 106 parent_scope 107 }; 108 109 if node.kind() == "member_expression" { 110 if let Some(candidate) = Self::extract_member_expression_candidate(node, source) { 111 candidates.push(candidate); 112 } 113 } 114 115 if node.kind() == "subscript_expression" { 116 if let Some(candidate) = 117 Self::extract_subscript_expression_candidate(node, source, language) 118 { 119 candidates.push(candidate); 120 } 121 } 122 123 let mut cursor = node.walk(); 124 for child in node.children(&mut cursor) { 125 Self::walk_combined(child, language, source, graph, current_scope, candidates); 126 } 127 } 128 129 fn extract_member_expression_candidate( 130 node: tree_sitter::Node, 131 source: &[u8], 132 ) -> Option<PropertyAccessCandidate> { 133 let object = node.child_by_field_name("object")?; 134 let property = node.child_by_field_name("property")?; 135 136 if object.kind() != "identifier" { 137 return None; 138 } 139 140 let obj_name = object.utf8_text(source).ok()?; 141 let prop_name = property.utf8_text(source).ok()?; 142 143 Some(PropertyAccessCandidate { 144 object_name: obj_name.into(), 145 property_name: prop_name.into(), 146 usage_range: ts_to_lsp_range(node.range()), 147 property_range: ts_to_lsp_range(property.range()), 148 object_position: Position::new( 149 object.start_position().row as u32, 150 object.start_position().column as u32, 151 ), 152 }) 153 } 154 155 fn extract_subscript_expression_candidate( 156 node: tree_sitter::Node, 157 source: &[u8], 158 language: &dyn LanguageSupport, 159 ) -> Option<PropertyAccessCandidate> { 160 let object = node.child_by_field_name("object")?; 161 let index = node.child_by_field_name("index")?; 162 163 if object.kind() != "identifier" { 164 return None; 165 } 166 167 if index.kind() != "string" { 168 return None; 169 } 170 171 let obj_name = object.utf8_text(source).ok()?; 172 let raw = index.utf8_text(source).ok()?; 173 let prop_name = language.strip_quotes(raw); 174 175 let index_range = index.range(); 176 let prop_range = Range { 177 start: Position { 178 line: index_range.start_point.row as u32, 179 character: index_range.start_point.column as u32 + 1, 180 }, 181 end: Position { 182 line: index_range.end_point.row as u32, 183 character: index_range.end_point.column as u32 - 1, 184 }, 185 }; 186 187 Some(PropertyAccessCandidate { 188 object_name: obj_name.into(), 189 property_name: prop_name.into(), 190 usage_range: ts_to_lsp_range(node.range()), 191 property_range: prop_range, 192 object_position: Position::new( 193 object.start_position().row as u32, 194 object.start_position().column as u32, 195 ), 196 }) 197 } 198 199 fn process_property_access_candidates( 200 candidates: &[PropertyAccessCandidate], 201 graph: &mut BindingGraph, 202 ) { 203 for candidate in candidates { 204 let scope = graph.scope_at_position(candidate.object_position); 205 206 if let Some(symbol) = graph.lookup_symbol(&candidate.object_name, scope) { 207 if graph.resolves_to_env_object(symbol.id) { 208 let usage = SymbolUsage { 209 symbol_id: symbol.id, 210 range: candidate.usage_range, 211 scope, 212 property_access: Some(candidate.property_name.clone()), 213 property_access_range: Some(candidate.property_range), 214 }; 215 graph.add_usage(usage); 216 } 217 } 218 } 219 } 220 221 async fn extract_direct_references( 222 query_engine: &QueryEngine, 223 language: &dyn LanguageSupport, 224 tree: &Tree, 225 source: &[u8], 226 import_context: &ImportContext, 227 graph: &mut BindingGraph, 228 ) { 229 let references = query_engine 230 .extract_references(language, tree, source, import_context) 231 .await; 232 233 for reference in references { 234 graph.add_direct_reference(reference); 235 } 236 } 237 238 async fn extract_bindings( 239 query_engine: &QueryEngine, 240 language: &dyn LanguageSupport, 241 tree: &Tree, 242 source: &[u8], 243 graph: &mut BindingGraph, 244 ) { 245 let bindings = query_engine.extract_bindings(language, tree, source).await; 246 247 for binding in bindings { 248 let scope = graph.scope_at_position(binding.binding_range.start); 249 250 let (origin, kind) = match binding.kind { 251 crate::types::BindingKind::Object => { 252 let is_env_object = language 253 .default_env_object_name() 254 .map(|name| binding.env_var_name == name) 255 .unwrap_or(false); 256 257 if is_env_object { 258 ( 259 SymbolOrigin::EnvObject { 260 canonical_name: binding.env_var_name.clone(), 261 }, 262 SymbolKind::EnvObject, 263 ) 264 } else { 265 ( 266 SymbolOrigin::EnvVar { 267 name: binding.env_var_name.clone(), 268 }, 269 SymbolKind::DestructuredProperty, 270 ) 271 } 272 } 273 crate::types::BindingKind::Value => ( 274 SymbolOrigin::EnvVar { 275 name: binding.env_var_name.clone(), 276 }, 277 SymbolKind::Value, 278 ), 279 }; 280 281 let symbol = Symbol { 282 id: SymbolId::new(1).unwrap(), 283 name: binding.binding_name.clone(), 284 declaration_range: binding.declaration_range, 285 name_range: binding.binding_range, 286 scope, 287 origin, 288 kind, 289 is_valid: true, 290 destructured_key_range: binding.destructured_key_range, 291 }; 292 293 graph.add_symbol(symbol); 294 } 295 296 let assignments = query_engine 297 .extract_assignments(language, tree, source) 298 .await; 299 300 for (target_name, target_range, source_name) in assignments { 301 let scope = graph.scope_at_position(target_range.start); 302 303 let symbol = Symbol { 304 id: SymbolId::new(1).unwrap(), 305 name: target_name, 306 declaration_range: target_range, 307 name_range: target_range, 308 scope, 309 origin: SymbolOrigin::Unknown, 310 kind: SymbolKind::Variable, 311 is_valid: true, 312 destructured_key_range: None, 313 }; 314 315 let symbol_id = graph.add_symbol(symbol); 316 317 let source_id = graph.lookup_symbol_id(&source_name, scope); 318 if let Some(target_id) = source_id { 319 graph.update_symbol_origin(symbol_id, SymbolOrigin::Symbol { target: target_id }); 320 } else { 321 graph.update_symbol_origin( 322 symbol_id, 323 SymbolOrigin::UnresolvedSymbol { source_name }, 324 ); 325 } 326 } 327 328 let destructures = query_engine 329 .extract_destructures(language, tree, source) 330 .await; 331 332 for (target_name, target_range, key_name, key_range, source_name) in destructures { 333 let scope = graph.scope_at_position(target_range.start); 334 335 let source_id = graph.lookup_symbol_id(&source_name, scope); 336 337 let origin = if let Some(src_id) = source_id { 338 SymbolOrigin::DestructuredProperty { 339 source: src_id, 340 key: key_name, 341 } 342 } else { 343 SymbolOrigin::UnresolvedDestructure { 344 source_name, 345 key: key_name, 346 } 347 }; 348 349 let symbol = Symbol { 350 id: SymbolId::new(1).unwrap(), 351 name: target_name, 352 declaration_range: target_range, 353 name_range: target_range, 354 scope, 355 origin, 356 kind: SymbolKind::DestructuredProperty, 357 is_valid: true, 358 destructured_key_range: Some(key_range), 359 }; 360 361 graph.add_symbol(symbol); 362 } 363 } 364 365 fn resolve_origins(graph: &mut BindingGraph) { 366 let symbols_to_resolve: Vec<(SymbolId, ScopeId, SymbolOrigin)> = graph 367 .symbols() 368 .iter() 369 .filter(|s| { 370 matches!( 371 s.origin, 372 SymbolOrigin::UnresolvedSymbol { .. } 373 | SymbolOrigin::UnresolvedDestructure { .. } 374 ) 375 }) 376 .map(|s| (s.id, s.scope, s.origin.clone())) 377 .collect(); 378 379 for (symbol_id, scope, origin) in symbols_to_resolve { 380 let new_origin = match origin { 381 SymbolOrigin::UnresolvedSymbol { source_name } => graph 382 .lookup_symbol_id(&source_name, scope) 383 .map(|target| SymbolOrigin::Symbol { target }) 384 .unwrap_or(SymbolOrigin::Unresolvable), 385 SymbolOrigin::UnresolvedDestructure { source_name, key } => graph 386 .lookup_symbol_id(&source_name, scope) 387 .map(|source| SymbolOrigin::DestructuredProperty { source, key }) 388 .unwrap_or(SymbolOrigin::Unresolvable), 389 _ => continue, 390 }; 391 392 graph.update_symbol_origin(symbol_id, new_origin); 393 } 394 } 395 396 async fn extract_usages( 397 query_engine: &QueryEngine, 398 language: &dyn LanguageSupport, 399 tree: &Tree, 400 source: &[u8], 401 graph: &mut BindingGraph, 402 ) { 403 let identifiers = query_engine 404 .extract_identifiers(language, tree, source) 405 .await; 406 407 for (name, range) in identifiers { 408 let scope = graph.scope_at_position(range.start); 409 410 if let Some(symbol) = graph.lookup_symbol(&name, scope) { 411 if (range.start.line > symbol.declaration_range.end.line 412 || (range.start.line == symbol.declaration_range.end.line 413 && range.start.character > symbol.declaration_range.end.character)) 414 && range != symbol.name_range 415 { 416 let usage = SymbolUsage { 417 symbol_id: symbol.id, 418 range, 419 scope, 420 property_access: None, 421 property_access_range: None, 422 }; 423 graph.add_usage(usage); 424 } 425 } 426 } 427 } 428 429 async fn process_reassignments( 430 query_engine: &QueryEngine, 431 language: &dyn LanguageSupport, 432 tree: &Tree, 433 source: &[u8], 434 graph: &mut BindingGraph, 435 ) { 436 let reassignments = query_engine 437 .extract_reassignments_with_positions(language, tree, source) 438 .await; 439 440 let mut symbols_to_invalidate: Vec<SymbolId> = Vec::new(); 441 442 for (name, range) in &reassignments { 443 let reassignment_scope = graph.scope_at_position(range.start); 444 445 // Use name-only index for O(1) lookup instead of scanning all symbols 446 for symbol_id in graph.lookup_symbols_by_name(name) { 447 if let Some(symbol) = graph.get_symbol(symbol_id) { 448 if Self::is_scope_visible(graph, symbol.scope, reassignment_scope) { 449 symbols_to_invalidate.push(symbol_id); 450 } 451 } 452 } 453 } 454 455 for symbol_id in symbols_to_invalidate { 456 graph.invalidate_symbol(symbol_id); 457 } 458 } 459 460 fn is_scope_visible(graph: &BindingGraph, from_scope: ScopeId, target_scope: ScopeId) -> bool { 461 let mut current = Some(from_scope); 462 while let Some(scope_id) = current { 463 if scope_id == target_scope { 464 return true; 465 } 466 current = graph.get_scope(scope_id).and_then(|s| s.parent); 467 } 468 false 469 } 470 } 471 472 #[inline] 473 pub fn ts_to_lsp_range(range: tree_sitter::Range) -> Range { 474 Range::new( 475 Position::new( 476 range.start_point.row as u32, 477 range.start_point.column as u32, 478 ), 479 Position::new(range.end_point.row as u32, range.end_point.column as u32), 480 ) 481 } 482 483 #[cfg(test)] 484 mod tests { 485 use super::*; 486 use crate::analysis::QueryEngine; 487 use crate::languages::javascript::JavaScript; 488 use crate::languages::typescript::TypeScript; 489 use crate::languages::LanguageSupport; 490 use crate::types::{ResolvedEnv, ScopeKind}; 491 492 #[test] 493 fn test_ts_to_lsp_range() { 494 let ts_range = tree_sitter::Range { 495 start_byte: 0, 496 end_byte: 10, 497 start_point: tree_sitter::Point { row: 5, column: 10 }, 498 end_point: tree_sitter::Point { row: 5, column: 20 }, 499 }; 500 501 let lsp_range = ts_to_lsp_range(ts_range); 502 503 assert_eq!(lsp_range.start.line, 5); 504 assert_eq!(lsp_range.start.character, 10); 505 assert_eq!(lsp_range.end.line, 5); 506 assert_eq!(lsp_range.end.character, 20); 507 } 508 509 #[test] 510 fn test_node_to_scope_kind() { 511 let js = JavaScript; 512 513 assert_eq!( 514 js.node_to_scope_kind("function_declaration"), 515 ScopeKind::Function 516 ); 517 assert_eq!(js.node_to_scope_kind("arrow_function"), ScopeKind::Function); 518 assert_eq!(js.node_to_scope_kind("class_declaration"), ScopeKind::Class); 519 assert_eq!(js.node_to_scope_kind("for_statement"), ScopeKind::Loop); 520 assert_eq!( 521 js.node_to_scope_kind("if_statement"), 522 ScopeKind::Conditional 523 ); 524 assert_eq!(js.node_to_scope_kind("statement_block"), ScopeKind::Block); 525 } 526 527 fn parse_with_lang<L: LanguageSupport>(lang: &L, code: &str) -> Tree { 528 let mut parser = tree_sitter::Parser::new(); 529 parser.set_language(&lang.grammar()).unwrap(); 530 parser.parse(code, None).unwrap() 531 } 532 533 #[tokio::test] 534 async fn test_analyze_direct_reference() { 535 let query_engine = QueryEngine::new(); 536 let js = JavaScript; 537 let code = "const db = process.env.DATABASE_URL;"; 538 let tree = parse_with_lang(&js, code); 539 let import_ctx = ImportContext::new(); 540 541 let graph = 542 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 543 .await; 544 545 assert_eq!(graph.direct_references().len(), 1); 546 assert_eq!(graph.direct_references()[0].name, "DATABASE_URL"); 547 } 548 549 #[tokio::test] 550 async fn test_analyze_multiple_references() { 551 let query_engine = QueryEngine::new(); 552 let js = JavaScript; 553 let code = r#"const db = process.env.DATABASE_URL; 554 const api = process.env.API_KEY; 555 const secret = process.env.SECRET;"#; 556 let tree = parse_with_lang(&js, code); 557 let import_ctx = ImportContext::new(); 558 559 let graph = 560 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 561 .await; 562 563 assert_eq!(graph.direct_references().len(), 3); 564 } 565 566 #[tokio::test] 567 async fn test_analyze_object_binding() { 568 let query_engine = QueryEngine::new(); 569 let js = JavaScript; 570 let code = "const env = process.env;"; 571 let tree = parse_with_lang(&js, code); 572 let import_ctx = ImportContext::new(); 573 574 let graph = 575 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 576 .await; 577 578 assert!(!graph.symbols().is_empty()); 579 let env_symbol = graph.symbols().iter().find(|s| s.name == "env"); 580 assert!(env_symbol.is_some()); 581 582 let env_symbol = env_symbol.unwrap(); 583 let resolved = graph.resolve_to_env(env_symbol.id); 584 assert!(matches!(resolved, Some(ResolvedEnv::Object(_)))); 585 } 586 587 #[tokio::test] 588 async fn test_analyze_destructuring() { 589 let query_engine = QueryEngine::new(); 590 let js = JavaScript; 591 let code = "const { DATABASE_URL } = process.env;"; 592 let tree = parse_with_lang(&js, code); 593 let import_ctx = ImportContext::new(); 594 595 let graph = 596 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 597 .await; 598 599 let db_symbol = graph.symbols().iter().find(|s| s.name == "DATABASE_URL"); 600 assert!(db_symbol.is_some()); 601 602 let db_symbol = db_symbol.unwrap(); 603 let resolved = graph.resolve_to_env(db_symbol.id); 604 assert!(matches!(resolved, Some(ResolvedEnv::Variable(name)) if name == "DATABASE_URL")); 605 } 606 607 #[tokio::test] 608 async fn test_analyze_chain_binding() { 609 let query_engine = QueryEngine::new(); 610 let js = JavaScript; 611 let code = r#"const env = process.env; 612 const config = env;"#; 613 let tree = parse_with_lang(&js, code); 614 let import_ctx = ImportContext::new(); 615 616 let graph = 617 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 618 .await; 619 620 let env_symbol = graph.symbols().iter().find(|s| s.name == "env"); 621 let config_symbol = graph.symbols().iter().find(|s| s.name == "config"); 622 assert!(env_symbol.is_some()); 623 assert!(config_symbol.is_some()); 624 625 let config_symbol = config_symbol.unwrap(); 626 let resolved = graph.resolve_to_env(config_symbol.id); 627 assert!(matches!(resolved, Some(ResolvedEnv::Object(_)))); 628 } 629 630 #[tokio::test] 631 async fn test_analyze_destructure_from_chain() { 632 let query_engine = QueryEngine::new(); 633 let js = JavaScript; 634 let code = r#"const env = process.env; 635 const { API_KEY } = env;"#; 636 let tree = parse_with_lang(&js, code); 637 let import_ctx = ImportContext::new(); 638 639 let graph = 640 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 641 .await; 642 643 let api_symbol = graph.symbols().iter().find(|s| s.name == "API_KEY"); 644 assert!(api_symbol.is_some()); 645 646 let api_symbol = api_symbol.unwrap(); 647 let resolved = graph.resolve_to_env(api_symbol.id); 648 assert!(matches!(resolved, Some(ResolvedEnv::Variable(name)) if name == "API_KEY")); 649 } 650 651 #[tokio::test] 652 async fn test_analyze_scopes() { 653 let query_engine = QueryEngine::new(); 654 let js = JavaScript; 655 let code = r#"function test() { 656 const db = process.env.DATABASE_URL; 657 } 658 const api = process.env.API_KEY;"#; 659 let tree = parse_with_lang(&js, code); 660 let import_ctx = ImportContext::new(); 661 662 let graph = 663 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 664 .await; 665 666 assert!(graph.scopes().len() >= 2); 667 668 assert_eq!(graph.direct_references().len(), 2); 669 } 670 671 #[tokio::test] 672 async fn test_analyze_usages() { 673 let query_engine = QueryEngine::new(); 674 let js = JavaScript; 675 let code = r#"const env = process.env; 676 console.log(env.DATABASE_URL);"#; 677 let tree = parse_with_lang(&js, code); 678 let import_ctx = ImportContext::new(); 679 680 let graph = 681 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 682 .await; 683 684 assert!(!graph.usages().is_empty()); 685 } 686 687 #[tokio::test] 688 async fn test_analyze_reassignment_invalidates() { 689 let query_engine = QueryEngine::new(); 690 let js = JavaScript; 691 let code = r#"let db = process.env.DATABASE_URL; 692 db = "new_value";"#; 693 let tree = parse_with_lang(&js, code); 694 let import_ctx = ImportContext::new(); 695 696 let graph = 697 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 698 .await; 699 700 let db_symbol = graph.symbols().iter().find(|s| s.name == "db"); 701 702 assert!(db_symbol.is_none() || !db_symbol.unwrap().is_valid); 703 } 704 705 #[tokio::test] 706 async fn test_analyze_typescript() { 707 let query_engine = QueryEngine::new(); 708 let ts = TypeScript; 709 let code = "const db: string = process.env.DATABASE_URL || '';"; 710 let tree = parse_with_lang(&ts, code); 711 let import_ctx = ImportContext::new(); 712 713 let graph = 714 AnalysisPipeline::analyze(&query_engine, &ts, &tree, code.as_bytes(), &import_ctx) 715 .await; 716 717 assert_eq!(graph.direct_references().len(), 1); 718 assert_eq!(graph.direct_references()[0].name, "DATABASE_URL"); 719 } 720 721 #[tokio::test] 722 async fn test_analyze_empty_source() { 723 let query_engine = QueryEngine::new(); 724 let js = JavaScript; 725 let code = ""; 726 let tree = parse_with_lang(&js, code); 727 let import_ctx = ImportContext::new(); 728 729 let graph = 730 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 731 .await; 732 733 assert!(graph.direct_references().is_empty()); 734 assert!(graph.symbols().is_empty()); 735 } 736 737 #[tokio::test] 738 async fn test_analyze_no_env_vars() { 739 let query_engine = QueryEngine::new(); 740 let js = JavaScript; 741 let code = "const x = 1 + 2; const y = 'hello';"; 742 let tree = parse_with_lang(&js, code); 743 let import_ctx = ImportContext::new(); 744 745 let graph = 746 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 747 .await; 748 749 assert!(graph.direct_references().is_empty()); 750 } 751 752 #[tokio::test] 753 async fn test_analyze_nested_functions() { 754 let query_engine = QueryEngine::new(); 755 let js = JavaScript; 756 let code = r#"function outer() { 757 const env = process.env; 758 function inner() { 759 const db = env.DATABASE_URL; 760 } 761 }"#; 762 let tree = parse_with_lang(&js, code); 763 let import_ctx = ImportContext::new(); 764 765 let graph = 766 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 767 .await; 768 769 assert!(graph.scopes().len() >= 3); 770 771 let env_symbol = graph.symbols().iter().find(|s| s.name == "env"); 772 assert!(env_symbol.is_some()); 773 } 774 775 #[tokio::test] 776 async fn test_analyze_destructure_with_rename() { 777 let query_engine = QueryEngine::new(); 778 let js = JavaScript; 779 let code = "const { DATABASE_URL: dbUrl } = process.env;"; 780 let tree = parse_with_lang(&js, code); 781 let import_ctx = ImportContext::new(); 782 783 let graph = 784 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 785 .await; 786 787 let db_symbol = graph.symbols().iter().find(|s| s.name == "dbUrl"); 788 assert!(db_symbol.is_some()); 789 790 let db_symbol = db_symbol.unwrap(); 791 let resolved = graph.resolve_to_env(db_symbol.id); 792 assert!(matches!(resolved, Some(ResolvedEnv::Variable(name)) if name == "DATABASE_URL")); 793 794 assert!(db_symbol.destructured_key_range.is_some()); 795 } 796 797 #[tokio::test] 798 async fn test_analyze_subscript_access() { 799 let query_engine = QueryEngine::new(); 800 let js = JavaScript; 801 let code = r#"const env = process.env; 802 const db = env["DATABASE_URL"];"#; 803 let tree = parse_with_lang(&js, code); 804 let import_ctx = ImportContext::new(); 805 806 let graph = 807 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 808 .await; 809 810 assert!(!graph.usages().is_empty()); 811 let usage = graph.usages().iter().find(|u| u.property_access.is_some()); 812 assert!(usage.is_some()); 813 assert_eq!( 814 usage.unwrap().property_access.as_ref().unwrap(), 815 "DATABASE_URL" 816 ); 817 } 818 819 #[test] 820 fn test_is_scope_visible() { 821 let mut graph = BindingGraph::new(); 822 graph.set_root_range(Range::new(Position::new(0, 0), Position::new(100, 0))); 823 824 let func_scope = graph.add_scope(Scope { 825 id: ScopeId::root(), 826 parent: Some(ScopeId::root()), 827 range: Range::new(Position::new(1, 0), Position::new(10, 0)), 828 kind: ScopeKind::Function, 829 }); 830 831 let inner_scope = graph.add_scope(Scope { 832 id: ScopeId::root(), 833 parent: Some(func_scope), 834 range: Range::new(Position::new(2, 0), Position::new(8, 0)), 835 kind: ScopeKind::Block, 836 }); 837 838 assert!(AnalysisPipeline::is_scope_visible( 839 &graph, 840 inner_scope, 841 ScopeId::root() 842 )); 843 assert!(AnalysisPipeline::is_scope_visible( 844 &graph, 845 func_scope, 846 ScopeId::root() 847 )); 848 849 assert!(AnalysisPipeline::is_scope_visible( 850 &graph, 851 inner_scope, 852 func_scope 853 )); 854 855 assert!(AnalysisPipeline::is_scope_visible( 856 &graph, func_scope, func_scope 857 )); 858 } 859 860 // ========================================================================= 861 // Edge case tests 862 // ========================================================================= 863 864 #[tokio::test] 865 async fn test_analyze_multiple_destructuring_same_line() { 866 let query_engine = QueryEngine::new(); 867 let js = JavaScript; 868 let code = "const { API_KEY, DB_URL, DEBUG } = process.env;"; 869 let tree = parse_with_lang(&js, code); 870 let import_ctx = ImportContext::new(); 871 872 let graph = 873 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 874 .await; 875 876 // Should have 3 symbols for destructured properties 877 let env_var_symbols: Vec<_> = graph 878 .symbols() 879 .iter() 880 .filter(|s| matches!(&s.origin, SymbolOrigin::EnvVar { .. })) 881 .collect(); 882 assert_eq!(env_var_symbols.len(), 3); 883 } 884 885 #[tokio::test] 886 async fn test_analyze_deep_chain() { 887 let query_engine = QueryEngine::new(); 888 let js = JavaScript; 889 let code = r#"const env = process.env; 890 const cfg = env; 891 const settings = cfg; 892 const opts = settings; 893 const { PORT } = opts;"#; 894 let tree = parse_with_lang(&js, code); 895 let import_ctx = ImportContext::new(); 896 897 let graph = 898 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 899 .await; 900 901 let port = graph.symbols().iter().find(|s| s.name == "PORT"); 902 assert!(port.is_some()); 903 904 let port = port.unwrap(); 905 let resolved = graph.resolve_to_env(port.id); 906 assert!(matches!(resolved, Some(ResolvedEnv::Variable(name)) if name == "PORT")); 907 } 908 909 #[tokio::test] 910 async fn test_analyze_comments_ignored() { 911 let query_engine = QueryEngine::new(); 912 let js = JavaScript; 913 let code = r#"// process.env.COMMENTED 914 /* process.env.BLOCK_COMMENT */ 915 const real = process.env.REAL_VAR;"#; 916 let tree = parse_with_lang(&js, code); 917 let import_ctx = ImportContext::new(); 918 919 let graph = 920 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 921 .await; 922 923 // Should only find REAL_VAR 924 assert_eq!(graph.direct_references().len(), 1); 925 assert_eq!(graph.direct_references()[0].name, "REAL_VAR"); 926 } 927 928 #[tokio::test] 929 async fn test_analyze_template_literal_env_access() { 930 let query_engine = QueryEngine::new(); 931 let js = JavaScript; 932 let code = r#"const url = `${process.env.BASE_URL}/api`;"#; 933 let tree = parse_with_lang(&js, code); 934 let import_ctx = ImportContext::new(); 935 936 let graph = 937 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 938 .await; 939 940 assert_eq!(graph.direct_references().len(), 1); 941 assert_eq!(graph.direct_references()[0].name, "BASE_URL"); 942 } 943 944 #[tokio::test] 945 async fn test_analyze_ternary_env_access() { 946 let query_engine = QueryEngine::new(); 947 let js = JavaScript; 948 let code = "const val = process.env.VAR1 ? process.env.VAR1 : process.env.VAR2;"; 949 let tree = parse_with_lang(&js, code); 950 let import_ctx = ImportContext::new(); 951 952 let graph = 953 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 954 .await; 955 956 // Should find VAR1 twice (condition and true branch) and VAR2 once 957 let var1_refs: Vec<_> = graph 958 .direct_references() 959 .iter() 960 .filter(|r| r.name == "VAR1") 961 .collect(); 962 let var2_refs: Vec<_> = graph 963 .direct_references() 964 .iter() 965 .filter(|r| r.name == "VAR2") 966 .collect(); 967 assert_eq!(var1_refs.len(), 2); 968 assert_eq!(var2_refs.len(), 1); 969 } 970 971 #[tokio::test] 972 async fn test_analyze_logical_or_default() { 973 let query_engine = QueryEngine::new(); 974 let js = JavaScript; 975 let code = "const port = process.env.PORT || 3000;"; 976 let tree = parse_with_lang(&js, code); 977 let import_ctx = ImportContext::new(); 978 979 let graph = 980 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 981 .await; 982 983 assert_eq!(graph.direct_references().len(), 1); 984 assert_eq!(graph.direct_references()[0].name, "PORT"); 985 } 986 987 #[tokio::test] 988 async fn test_analyze_nullish_coalescing_default() { 989 let query_engine = QueryEngine::new(); 990 let js = JavaScript; 991 let code = "const port = process.env.PORT ?? 3000;"; 992 let tree = parse_with_lang(&js, code); 993 let import_ctx = ImportContext::new(); 994 995 let graph = 996 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 997 .await; 998 999 assert_eq!(graph.direct_references().len(), 1); 1000 assert_eq!(graph.direct_references()[0].name, "PORT"); 1001 } 1002 1003 #[tokio::test] 1004 async fn test_analyze_arrow_function_scope() { 1005 let query_engine = QueryEngine::new(); 1006 let js = JavaScript; 1007 let code = r#"const outer = process.env.OUTER; 1008 const fn = () => { 1009 const inner = process.env.INNER; 1010 };"#; 1011 let tree = parse_with_lang(&js, code); 1012 let import_ctx = ImportContext::new(); 1013 1014 let graph = 1015 AnalysisPipeline::analyze(&query_engine, &js, &tree, code.as_bytes(), &import_ctx) 1016 .await; 1017 1018 assert_eq!(graph.direct_references().len(), 2); 1019 // Should have arrow function scope 1020 assert!(graph.scopes().len() >= 2); 1021 } 1022 }