diff --git a/pydance/package-lock.json b/pydance/package-lock.json index 86071c1..7d00c5e 100644 --- a/pydance/package-lock.json +++ b/pydance/package-lock.json @@ -1,12 +1,12 @@ { "name": "pydance", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pydance", - "version": "0.2.1", + "version": "0.3.0", "license": "ISC", "dependencies": { "vscode-languageclient": "^9.0.1" @@ -757,6 +757,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -959,6 +960,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1498,6 +1500,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3112,6 +3115,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/pydance/src/test/suite/extension.test.ts b/pydance/src/test/suite/extension.test.ts index 47b5f0f..6d39044 100644 --- a/pydance/src/test/suite/extension.test.ts +++ b/pydance/src/test/suite/extension.test.ts @@ -159,7 +159,18 @@ suite("Extension Test Suite", () => { ) ) ), - // Note: TEST_CONSTANT is not returned by the server when searching for "test" + new vscode.SymbolInformation( + "TEST_CONSTANT", + vscode.SymbolKind.Variable, + "", + new vscode.Location( + docUri, + new vscode.Range( + new vscode.Position(13, 0), // Line 14 (0-indexed) + new vscode.Position(13, 0) + ) + ) + ), ]); }); }); diff --git a/pylight/src/lsp/handlers.rs b/pylight/src/lsp/handlers.rs index 4e6b2b3..0a461cb 100644 --- a/pylight/src/lsp/handlers.rs +++ b/pylight/src/lsp/handlers.rs @@ -66,6 +66,7 @@ pub fn handle_workspace_symbol( crate::SymbolKind::Class | crate::SymbolKind::NestedClass => { LspSymbolKind::CLASS } + crate::SymbolKind::Variable => LspSymbolKind::VARIABLE, }, tags: None, location: Location { diff --git a/pylight/src/parser/extractor.rs b/pylight/src/parser/extractor.rs index 9f9f8d1..4b119c6 100644 --- a/pylight/src/parser/extractor.rs +++ b/pylight/src/parser/extractor.rs @@ -44,6 +44,9 @@ impl<'a> SymbolExtractor<'a> { "decorated_definition" => { self.extract_decorated(node)?; } + "assignment" => { + self.extract_assignment(node)?; + } _ => { // Recursively visit children for other node types let mut cursor = node.walk(); @@ -193,6 +196,59 @@ impl<'a> SymbolExtractor<'a> { Ok(()) } + fn extract_assignment(&mut self, node: Node) -> Result<()> { + // Only extract module-level assignments + if !self.context_stack.is_empty() { + return Ok(()); + } + + if let Some(left) = node.child_by_field_name("left") { + self.extract_assignment_targets(left)?; + } + + // Handle chained assignments: a = b = 10 + // tree-sitter represents this as assignment(left=a, right=assignment(left=b, right=10)) + if let Some(right) = node.child_by_field_name("right") { + if right.kind() == "assignment" { + self.extract_assignment(right)?; + } + } + + Ok(()) + } + + fn extract_assignment_targets(&mut self, node: Node) -> Result<()> { + match node.kind() { + "identifier" => { + let name = self.get_node_text(node); + let line = node.start_position().row + 1; + let column = node.start_position().column; + + let module = self + .path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + let symbol = + Symbol::new(name, SymbolKind::Variable, self.path.clone(), line, column) + .with_module(module); + self.symbols.push(symbol); + } + "pattern_list" | "tuple_pattern" => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.extract_assignment_targets(child)?; + } + } + _ => { + // Skip attribute access (obj.attr), subscripts (d["key"]), etc. + } + } + Ok(()) + } + fn get_node_text(&self, node: Node) -> String { std::str::from_utf8(&self.source[node.byte_range()]) .unwrap_or("") diff --git a/pylight/src/parser/ruff.rs b/pylight/src/parser/ruff.rs index c1b7551..553b6c8 100644 --- a/pylight/src/parser/ruff.rs +++ b/pylight/src/parser/ruff.rs @@ -3,10 +3,11 @@ use crate::{Error, Result, Symbol, SymbolKind}; use ruff_python_ast::{ visitor::{self, Visitor}, - Mod, Stmt, + Expr, Mod, Stmt, }; use ruff_python_parser::{parse, Mode}; use ruff_source_file::{LineIndex, SourceCode}; +use ruff_text_size::Ranged; use std::path::{Path, PathBuf}; use super::r#trait::Parser; @@ -104,6 +105,41 @@ impl<'a> SymbolExtractor<'a> { // Ruff returns 1-based line and column, but we need 0-based column for compatibility (location.line.get(), location.character_offset.get() - 1) } + + fn extract_assign_target(&mut self, target: &Expr) { + match target { + Expr::Name(name) => { + let name_str = name.id.to_string(); + let (line, column) = self.get_line_column(name.range.start().to_u32()); + + let module_path = self + .file_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let symbol = Symbol::new( + name_str, + SymbolKind::Variable, + self.file_path.clone(), + line, + column, + ) + .with_module(module_path); + + self.symbols.push(symbol); + } + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + self.extract_assign_target(elt); + } + } + _ => { + // Skip attribute access, subscript, starred, etc. + } + } + } } impl<'a> Visitor<'a> for SymbolExtractor<'a> { @@ -170,6 +206,46 @@ impl<'a> Visitor<'a> for SymbolExtractor<'a> { visitor::walk_stmt(self, stmt); self.context_stack.pop(); } + Stmt::Assign(assign) => { + if self.context_stack.is_empty() { + for target in &assign.targets { + self.extract_assign_target(target); + } + } + } + Stmt::AnnAssign(ann_assign) => { + if self.context_stack.is_empty() { + self.extract_assign_target(&ann_assign.target); + } + } + Stmt::TypeAlias(type_alias) => { + if self.context_stack.is_empty() { + let name_str = match type_alias.name.as_ref() { + Expr::Name(name) => name.id.to_string(), + _ => return, + }; + let (line, column) = + self.get_line_column(type_alias.name.range().start().to_u32()); + + let module_path = self + .file_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let symbol = Symbol::new( + name_str, + SymbolKind::Variable, + self.file_path.clone(), + line, + column, + ) + .with_module(module_path); + + self.symbols.push(symbol); + } + } _ => visitor::walk_stmt(self, stmt), } } diff --git a/pylight/src/parser/tests.rs b/pylight/src/parser/tests.rs index 607d0de..9c86055 100644 --- a/pylight/src/parser/tests.rs +++ b/pylight/src/parser/tests.rs @@ -126,6 +126,95 @@ class MyClass: .any(|s| s.name == "value" && s.kind == SymbolKind::Method)); } + #[test] + fn test_parse_simple_variable() { + let mut parser = PythonParser::new().unwrap(); + let code = "MY_VAR = 42"; + let symbols = parser.parse_file(Path::new("test.py"), code).unwrap(); + + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "MY_VAR"); + assert_eq!(symbols[0].kind, SymbolKind::Variable); + assert_eq!(symbols[0].line, 1); + } + + #[test] + fn test_parse_annotated_variable() { + let mut parser = PythonParser::new().unwrap(); + let code = "name: str = \"hello\""; + let symbols = parser.parse_file(Path::new("test.py"), code).unwrap(); + + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "name"); + assert_eq!(symbols[0].kind, SymbolKind::Variable); + } + + #[test] + fn test_parse_tuple_unpacking() { + let mut parser = PythonParser::new().unwrap(); + let code = "a, b = 1, 2"; + let symbols = parser.parse_file(Path::new("test.py"), code).unwrap(); + + assert_eq!(symbols.len(), 2); + assert!(symbols + .iter() + .any(|s| s.name == "a" && s.kind == SymbolKind::Variable)); + assert!(symbols + .iter() + .any(|s| s.name == "b" && s.kind == SymbolKind::Variable)); + } + + #[test] + fn test_no_variable_inside_function() { + let mut parser = PythonParser::new().unwrap(); + let code = r#" +def my_func(): + local_var = 1 +"#; + let symbols = parser.parse_file(Path::new("test.py"), code).unwrap(); + + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "my_func"); + assert_eq!(symbols[0].kind, SymbolKind::Function); + assert!(!symbols.iter().any(|s| s.name == "local_var")); + } + + #[test] + fn test_no_variable_inside_class() { + let mut parser = PythonParser::new().unwrap(); + let code = r#" +class MyClass: + class_attr = 1 +"#; + let symbols = parser.parse_file(Path::new("test.py"), code).unwrap(); + + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "MyClass"); + assert_eq!(symbols[0].kind, SymbolKind::Class); + assert!(!symbols.iter().any(|s| s.name == "class_attr")); + } + + #[test] + fn test_variable_column_positions() { + use crate::parser::{create_parser, ParserBackend}; + + for backend in [ParserBackend::TreeSitter, ParserBackend::Ruff] { + let parser = create_parser(backend).unwrap(); + + let code = "MY_VAR = 42"; + let symbols = parser.parse_file(Path::new("test.py"), code).unwrap(); + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "MY_VAR"); + assert_eq!(symbols[0].kind, SymbolKind::Variable); + assert_eq!(symbols[0].line, 1); + assert_eq!( + symbols[0].column, 0, + "Variable name should start at column 0 for parser {:?}", + backend + ); + } + } + #[test] fn test_column_positions() { use crate::parser::{create_parser, ParserBackend}; diff --git a/pylight/src/symbols.rs b/pylight/src/symbols.rs index e639fd8..cae359e 100644 --- a/pylight/src/symbols.rs +++ b/pylight/src/symbols.rs @@ -10,6 +10,7 @@ pub enum SymbolKind { Method, NestedFunction, NestedClass, + Variable, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/pylight/tests/fixtures/variables.py b/pylight/tests/fixtures/variables.py new file mode 100644 index 0000000..1e8e00e --- /dev/null +++ b/pylight/tests/fixtures/variables.py @@ -0,0 +1,39 @@ +"""Test file for global variable extraction.""" + +# Simple assignments +MY_CONST = 42 +config = {} +name = "hello" + +# Annotated assignments +MY_VAR: int = 100 +server_name: str + +# Multi-target assignment +a = b = 10 + +# Tuple unpacking +x, y = 1, 2 + +# Type alias (old-style, appears as regular assignment) +from typing import Union +MyType = Union[int, str] + +# Augmented assignment (should NOT be extracted) +MY_CONST += 1 + +# Attribute assignment (should NOT be extracted) +import os +os.environ["KEY"] = "value" + +# Variable inside a function (should NOT be extracted) +def some_function(): + local_var = "not extracted" + return local_var + +# Variable inside a class (should NOT be extracted) +class SomeClass: + class_attr = "not extracted" + + def method(self): + self.instance_attr = "not extracted" diff --git a/pylight/tests/integration/test_symbol_extraction.rs b/pylight/tests/integration/test_symbol_extraction.rs index cefb443..0f657b8 100644 --- a/pylight/tests/integration/test_symbol_extraction.rs +++ b/pylight/tests/integration/test_symbol_extraction.rs @@ -1,4 +1,7 @@ -use pylight::{PythonParser, SymbolKind}; +use pylight::{ + parser::{create_parser, ParserBackend}, + PythonParser, SymbolKind, +}; use std::path::Path; #[test] @@ -130,3 +133,71 @@ fn test_decorated_symbols() { ); } } + +#[test] +fn test_extract_global_variables() { + let content = include_str!("../fixtures/variables.py"); + let path = Path::new("tests/fixtures/variables.py"); + + // Test both parser backends + for backend in [ParserBackend::TreeSitter, ParserBackend::Ruff] { + let parser = create_parser(backend).unwrap(); + let symbols = parser + .parse_file(path, content) + .expect("Failed to parse file"); + + // These should be extracted as Variable + let expected_variables = vec![ + "MY_CONST", + "config", + "name", + "MY_VAR", + "server_name", + "a", + "b", + "x", + "y", + "MyType", + ]; + + for var_name in &expected_variables { + assert!( + symbols + .iter() + .any(|s| s.name == *var_name && s.kind == SymbolKind::Variable), + "Missing variable '{}' for parser {:?}", + var_name, + backend + ); + } + + // These should NOT be extracted as Variable + let excluded = vec!["local_var", "class_attr", "instance_attr"]; + for name in &excluded { + assert!( + !symbols + .iter() + .any(|s| s.name == *name && s.kind == SymbolKind::Variable), + "Should not extract '{}' as variable for parser {:?}", + name, + backend + ); + } + + // Functions and classes should still work + assert!( + symbols + .iter() + .any(|s| s.name == "some_function" && s.kind == SymbolKind::Function), + "Missing function 'some_function' for parser {:?}", + backend + ); + assert!( + symbols + .iter() + .any(|s| s.name == "SomeClass" && s.kind == SymbolKind::Class), + "Missing class 'SomeClass' for parser {:?}", + backend + ); + } +}