Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions pydance/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion pydance/src/test/suite/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
),
]);
});
});
Expand Down
1 change: 1 addition & 0 deletions pylight/src/lsp/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions pylight/src/parser/extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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("")
Expand Down
78 changes: 77 additions & 1 deletion pylight/src/parser/ruff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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),
}
}
Expand Down
89 changes: 89 additions & 0 deletions pylight/src/parser/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
1 change: 1 addition & 0 deletions pylight/src/symbols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub enum SymbolKind {
Method,
NestedFunction,
NestedClass,
Variable,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
Expand Down
39 changes: 39 additions & 0 deletions pylight/tests/fixtures/variables.py
Original file line number Diff line number Diff line change
@@ -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"
Loading