/ src / analysis / pipeline.rs
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  }