From e90a5ed6f2e2fa36090fa0e97ad6d2af363083f3 Mon Sep 17 00:00:00 2001 From: Brody Critchlow Date: Sat, 26 Jul 2025 22:55:51 -0400 Subject: [PATCH 1/6] chore: fix cargo format errors --- src/lib.rs | 132 +++++++++++++++++++++++++-------------------- src/main.rs | 30 +++++------ src/python_type.rs | 6 +-- 3 files changed, 92 insertions(+), 76 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index cccc5fc..f1aa410 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ pub mod python_type; -use std::collections::{HashMap, HashSet}; use python_parser::ast::*; +use std::collections::{HashMap, HashSet}; use std::str::FromStr; pub use crate::python_type::PythonType; @@ -25,15 +25,28 @@ impl std::fmt::Display for TypeCheckError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TypeCheckError::ParseError(msg) => write!(f, "Parse error: {msg}"), - TypeCheckError::UnsupportedAnnotation(msg) => write!(f, "Unsupported annotation: {msg}"), + TypeCheckError::UnsupportedAnnotation(msg) => { + write!(f, "Unsupported annotation: {msg}") + } TypeCheckError::UnknownType(msg) => write!(f, "Unknown type: {msg}"), - TypeCheckError::TypeMismatch { variable: _, expected, actual, literal_value } => { + TypeCheckError::TypeMismatch { + variable: _, + expected, + actual, + literal_value, + } => { if let Some(literal) = literal_value { - write!(f, "Type \"Literal[{literal}]\" is not assignable to declared type \"{expected}\"\n \"Literal[{literal}]\" is not assignable to \"{expected}\"") + write!( + f, + "Type \"Literal[{literal}]\" is not assignable to declared type \"{expected}\"\n \"Literal[{literal}]\" is not assignable to \"{expected}\"" + ) } else { - write!(f, "Type \"{actual}\" is not assignable to declared type \"{expected}\"\n \"{actual}\" is not assignable to \"{expected}\"") + write!( + f, + "Type \"{actual}\" is not assignable to declared type \"{expected}\"\n \"{actual}\" is not assignable to \"{expected}\"" + ) } - }, + } } } } @@ -54,7 +67,7 @@ impl TypeChecker { including_implicit: false, } } - + pub fn with_implicit_checking(mut self) -> Self { self.including_implicit = true; self @@ -74,11 +87,13 @@ impl TypeChecker { fn process_statement(&mut self, statement: Statement) -> TypeCheckResult<()> { match statement { - Statement::TypedAssignment(target, annotation, value) => self.handle_typed_assignment(target, annotation, value)?, + Statement::TypedAssignment(target, annotation, value) => { + self.handle_typed_assignment(target, annotation, value)? + } Statement::Assignment(lhs, rhs_list) => { self.handle_untyped_assignment(lhs, rhs_list)? - }, - _ => {}, + } + _ => {} } Ok(()) } @@ -90,7 +105,7 @@ impl TypeChecker { pub fn get_all_variables(&self) -> &HashMap { &self.assigned_variables } - + pub fn handle_typed_assignment( &mut self, target: Vec, @@ -98,30 +113,32 @@ impl TypeChecker { values: Vec, ) -> TypeCheckResult<()> { let expected_type = self.parse_type_annotation(&annotation)?; - + if let Some(value) = values.first() && let Some(actual_type) = self.infer_type_from_expression(value) - && !self.is_assignable(&actual_type, &expected_type) - && let Some(Expression::Name(var_name)) = target.first() { - let literal_value = self.get_literal_value(value); - return Err(TypeCheckError::TypeMismatch { - variable: var_name.clone(), - expected: expected_type, - actual: actual_type, - literal_value, - }); - } - + && !self.is_assignable(&actual_type, &expected_type) + && let Some(Expression::Name(var_name)) = target.first() + { + let literal_value = self.get_literal_value(value); + return Err(TypeCheckError::TypeMismatch { + variable: var_name.clone(), + expected: expected_type, + actual: actual_type, + literal_value, + }); + } + for expr in target { if let Expression::Name(name) = expr { - self.assigned_variables.insert(name.clone(), expected_type.clone()); + self.assigned_variables + .insert(name.clone(), expected_type.clone()); self.explicitly_typed.insert(name); } } - + Ok(()) } - + fn handle_untyped_assignment( &mut self, lhs: Vec, @@ -131,34 +148,35 @@ impl TypeChecker { Some(v) => v, None => return Ok(()), }; - + let inferred_type = match self.infer_type_from_expression(value) { Some(t) => t, None => return Ok(()), }; - + for expr in lhs { let name = match expr { Expression::Name(n) => n, _ => continue, }; - + if let Some(existing_type) = self.get_enforced_type(&name) - && !self.is_assignable(&inferred_type, existing_type) { - return Err(TypeCheckError::TypeMismatch { - variable: name.clone(), - expected: existing_type.clone(), - actual: inferred_type.clone(), - literal_value: self.get_literal_value(value), - }); - } - + && !self.is_assignable(&inferred_type, existing_type) + { + return Err(TypeCheckError::TypeMismatch { + variable: name.clone(), + expected: existing_type.clone(), + actual: inferred_type.clone(), + literal_value: self.get_literal_value(value), + }); + } + self.assigned_variables.insert(name, inferred_type.clone()); } - + Ok(()) } - + fn get_enforced_type(&self, name: &str) -> Option<&PythonType> { if self.explicitly_typed.contains(name) || self.including_implicit { self.assigned_variables.get(name) @@ -166,17 +184,17 @@ impl TypeChecker { None } } - + fn parse_type_annotation(&self, annotation: &Expression) -> TypeCheckResult { match annotation { - Expression::Name(type_name) => { - PythonType::from_str(type_name) - .map_err(|_| TypeCheckError::UnknownType(type_name.clone())) - } - _ => Err(TypeCheckError::UnsupportedAnnotation(format!("{annotation:?}"))) + Expression::Name(type_name) => PythonType::from_str(type_name) + .map_err(|_| TypeCheckError::UnknownType(type_name.clone())), + _ => Err(TypeCheckError::UnsupportedAnnotation(format!( + "{annotation:?}" + ))), } } - + fn infer_type_from_expression(&self, expr: &Expression) -> Option { match expr { Expression::Int(_) => Some(PythonType::Int), @@ -186,34 +204,33 @@ impl TypeChecker { Expression::ListLiteral(_) => Some(PythonType::List), Expression::DictLiteral(_) => Some(PythonType::Dict), Expression::TupleLiteral(_) => Some(PythonType::Tuple), - _ => None + _ => None, } } - + fn get_literal_value(&self, expr: &Expression) -> Option { match expr { Expression::Int(n) => Some(format!("{n}")), Expression::String(pystrings) => { if !pystrings.is_empty() { - let content: String = pystrings.iter() - .map(|s| { - format!("{:?}", s.content).trim_matches('"').to_string() - }) + let content: String = pystrings + .iter() + .map(|s| format!("{:?}", s.content).trim_matches('"').to_string()) .collect(); Some(format!("'{content}'")) } else { Some("''".to_string()) } - }, - _ => None + } + _ => None, } } - + fn is_assignable(&self, actual: &PythonType, expected: &PythonType) -> bool { if actual == expected { return true; } - + matches!((actual, expected), (PythonType::Bool, PythonType::Int)) } } @@ -223,4 +240,3 @@ impl Default for TypeChecker { Self::new() } } - \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 915eeed..be5a568 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,24 +3,24 @@ use std::{env, fs, process}; fn main() { let args: Vec = env::args().collect(); - + if args.len() < 2 { print_usage(&args[0]); process::exit(1); } - + if args[1] == "--help" || args[1] == "-h" { print_help(&args[0]); process::exit(0); } - + let filename = &args[1]; - + if !std::path::Path::new(filename).exists() { eprintln!("Error: File '{filename}' not found"); process::exit(1); } - + let source = match fs::read_to_string(filename) { Ok(content) => content, Err(e) => { @@ -28,53 +28,53 @@ fn main() { process::exit(1); } }; - + let verbose = args.iter().any(|arg| arg == "--verbose"); let show_types_on_error = args.iter().any(|arg| arg == "--show-types-on-error"); let including_implicit = args.iter().any(|arg| arg == "--including-implicit"); - + let mut checker = if including_implicit { TypeChecker::new().with_implicit_checking() } else { TypeChecker::new() }; - + match checker.analyze_source(&source) { Ok(_) => { println!("✅ Type check passed for '{filename}'"); - + if verbose { let vars = checker.get_all_variables(); if !vars.is_empty() { println!("\nVariable types:"); let mut sorted_vars: Vec<_> = vars.iter().collect(); sorted_vars.sort_by_key(|(name, _)| *name); - + for (name, ty) in sorted_vars { println!(" {name} : {ty:?}"); } } } - + process::exit(0); } Err(e) => { eprintln!("❌ Type check failed for '{filename}'"); eprintln!("Error: {e}"); - + if show_types_on_error || verbose { let vars = checker.get_all_variables(); if !vars.is_empty() { eprintln!("\nVariables typed before error:"); let mut sorted_vars: Vec<_> = vars.iter().collect(); sorted_vars.sort_by_key(|(name, _)| *name); - + for (name, ty) in sorted_vars { eprintln!(" {name} : {ty:?}"); } } } - + process::exit(1); } } @@ -107,4 +107,4 @@ fn print_help(program_name: &str) { println!("Exit codes:"); println!(" 0 - Type check passed"); println!(" 1 - Type check failed or error occurred"); -} \ No newline at end of file +} diff --git a/src/python_type.rs b/src/python_type.rs index 99c4e3d..e146ad7 100644 --- a/src/python_type.rs +++ b/src/python_type.rs @@ -1,5 +1,5 @@ -use std::str::FromStr; use std::fmt; +use std::str::FromStr; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum PythonType { @@ -28,7 +28,7 @@ impl fmt::Display for PythonType { impl FromStr for PythonType { type Err = (); - + fn from_str(s: &str) -> Result { match s { "int" => Ok(PythonType::Int), @@ -44,4 +44,4 @@ impl FromStr for PythonType { } } } -} \ No newline at end of file +} From 49f187a84fd3344f64a940dfe8eb4cf66af858bb Mon Sep 17 00:00:00 2001 From: Brody Critchlow Date: Sat, 26 Jul 2025 22:58:47 -0400 Subject: [PATCH 2/6] chore: Update min rust version --- .github/workflows/ci.yml | 4 ++-- CONTRIBUTING.md | 2 +- README.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5df3ec6..7302bb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,8 +107,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust 1.70 - uses: dtolnay/rust-toolchain@1.70 + - name: Install Rust 1.85.0 + uses: dtolnay/rust-toolchain@1.85.0 - name: Check MSRV run: cargo check --verbose \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 627ebb5..c59cdf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ By participating in this project, you agree to abide by our Code of Conduct: ### Prerequisites -- Rust 1.70 or higher +- Rust 1.85 or higher (for 2024 edition support) - Git - Python 3.8+ (for testing against Python files) diff --git a/README.md b/README.md index 382a822..883af22 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ cargo install --path . ### Requirements -- Rust 1.70+ +- Rust 1.85+ (for 2024 edition support) - Python 3.8+ (for checking Python 3.8+ code) ## 🤝 Contributing From 77ee8b0d3d751e06beb2857308364e82936deebe Mon Sep 17 00:00:00 2001 From: Brody Critchlow Date: Sat, 26 Jul 2025 23:02:45 -0400 Subject: [PATCH 3/6] chore(tests): add unit tests back --- tests/unit_tests.rs | 154 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/unit_tests.rs diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs new file mode 100644 index 0000000..9861168 --- /dev/null +++ b/tests/unit_tests.rs @@ -0,0 +1,154 @@ +use serpentine::{TypeChecker, PythonType, TypeCheckError}; + +#[test] +fn test_basic_type_annotation() { + let mut checker = TypeChecker::new(); + let code = "x: int = 42"; + assert!(checker.analyze_source(code).is_ok()); + assert_eq!(checker.get_variable_type("x"), Some(&PythonType::Int)); +} + +#[test] +fn test_type_inference() { + let mut checker = TypeChecker::new(); + let code = "x = 42\ny = 'hello'\nz = 3.14"; + assert!(checker.analyze_source(code).is_ok()); + assert_eq!(checker.get_variable_type("x"), Some(&PythonType::Int)); + assert_eq!(checker.get_variable_type("y"), Some(&PythonType::String)); + assert_eq!(checker.get_variable_type("z"), Some(&PythonType::Float)); +} + +#[test] +fn test_type_mismatch_error() { + let mut checker = TypeChecker::new(); + let code = "x: int = 42\nx = 'hello'"; + let result = checker.analyze_source(code); + assert!(result.is_err()); + + match result { + Err(TypeCheckError::TypeMismatch { expected, actual, .. }) => { + assert_eq!(expected, PythonType::Int); + assert_eq!(actual, PythonType::String); + } + _ => panic!("Expected TypeMismatch error"), + } +} + +#[test] +fn test_bool_to_int_assignment() { + let mut checker = TypeChecker::new(); + let code = "x: int = True"; + assert!(checker.analyze_source(code).is_ok()); + assert_eq!(checker.get_variable_type("x"), Some(&PythonType::Int)); +} + +#[test] +fn test_int_to_bool_assignment_fails() { + let mut checker = TypeChecker::new(); + let code = "x: bool = 42"; + assert!(checker.analyze_source(code).is_err()); +} + +#[test] +fn test_implicit_type_changes_allowed() { + let mut checker = TypeChecker::new(); + let code = "x = 42\nx = 'hello'"; + assert!(checker.analyze_source(code).is_ok()); + assert_eq!(checker.get_variable_type("x"), Some(&PythonType::String)); +} + +#[test] +fn test_implicit_type_changes_with_flag() { + let mut checker = TypeChecker::new().with_implicit_checking(); + let code = "x = 42\nx = 'hello'"; + assert!(checker.analyze_source(code).is_err()); +} + +#[test] +fn test_literal_value_in_error() { + let mut checker = TypeChecker::new(); + let code = "x: int = 'hello'"; + + match checker.analyze_source(code) { + Err(TypeCheckError::TypeMismatch { literal_value, .. }) => { + assert_eq!(literal_value, Some("'hello'".to_string())); + } + _ => panic!("Expected TypeMismatch error with literal value"), + } +} + +#[test] +fn test_all_basic_types() { + let mut checker = TypeChecker::new(); + let code = r#" +a: int = 1 +b: float = 3.14 +c: str = "test" +d: bool = True +e: list = [] +f: dict = {} +g: tuple = () +"#; + assert!(checker.analyze_source(code).is_ok()); + assert_eq!(checker.get_variable_type("a"), Some(&PythonType::Int)); + assert_eq!(checker.get_variable_type("b"), Some(&PythonType::Float)); + assert_eq!(checker.get_variable_type("c"), Some(&PythonType::String)); + assert_eq!(checker.get_variable_type("d"), Some(&PythonType::Bool)); + assert_eq!(checker.get_variable_type("e"), Some(&PythonType::List)); + assert_eq!(checker.get_variable_type("f"), Some(&PythonType::Dict)); + assert_eq!(checker.get_variable_type("g"), Some(&PythonType::Tuple)); +} + +#[test] +fn test_unknown_type_error() { + let mut checker = TypeChecker::new(); + let code = "x: UnknownType = 42"; + + match checker.analyze_source(code) { + Err(TypeCheckError::UnknownType(type_name)) => { + assert_eq!(type_name, "UnknownType"); + } + _ => panic!("Expected UnknownType error"), + } +} + +#[test] +fn test_multiple_assignments() { + let mut checker = TypeChecker::new(); + let code = "x: int = 1\ny: int = 2\nx = 3\ny = 4"; + assert!(checker.analyze_source(code).is_ok()); + assert_eq!(checker.get_variable_type("x"), Some(&PythonType::Int)); + assert_eq!(checker.get_variable_type("y"), Some(&PythonType::Int)); +} + +#[test] +fn test_literal_integers() { + let mut checker = TypeChecker::new(); + let code = "x: str = 'test'\nx = 42"; + + match checker.analyze_source(code) { + Err(TypeCheckError::TypeMismatch { literal_value, .. }) => { + assert_eq!(literal_value, Some("42".to_string())); + } + _ => panic!("Expected TypeMismatch error with literal value"), + } +} + +#[test] +fn test_empty_source() { + let mut checker = TypeChecker::new(); + assert!(checker.analyze_source("").is_ok()); +} + +#[test] +fn test_parse_error() { + let mut checker = TypeChecker::new(); + let code = "x: = 42"; + + match checker.analyze_source(code) { + Err(TypeCheckError::ParseError(_)) => { + // Expected + } + _ => panic!("Expected ParseError"), + } +} \ No newline at end of file From aed2d030aed476ad6cfa85ce7d89a514fffbb1c3 Mon Sep 17 00:00:00 2001 From: Brody Critchlow Date: Sat, 26 Jul 2025 23:09:02 -0400 Subject: [PATCH 4/6] chore: update min version to 1.88.0 --- .github/workflows/ci.yml | 4 ++-- CONTRIBUTING.md | 2 +- README.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7302bb3..090a94a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,8 +107,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Rust 1.85.0 - uses: dtolnay/rust-toolchain@1.85.0 + - name: Install Rust 1.88.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Check MSRV run: cargo check --verbose \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c59cdf8..0f90cba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ By participating in this project, you agree to abide by our Code of Conduct: ### Prerequisites -- Rust 1.85 or higher (for 2024 edition support) +- Rust 1.88 or higher (for 2024 edition and let-chains support) - Git - Python 3.8+ (for testing against Python files) diff --git a/README.md b/README.md index 883af22..2d3bc9c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ cargo install --path . ### Requirements -- Rust 1.85+ (for 2024 edition support) +- Rust 1.88+ (for 2024 edition and let-chains support) - Python 3.8+ (for checking Python 3.8+ code) ## 🤝 Contributing From 41b97e2f7e3b3251e6a939dad142956044e99498 Mon Sep 17 00:00:00 2001 From: Brody Critchlow Date: Sat, 26 Jul 2025 23:10:37 -0400 Subject: [PATCH 5/6] chore: update ci --- .github/workflows/ci.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 090a94a..844d88e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,29 @@ jobs: components: rustfmt, clippy - name: Check formatting + if: github.event_name != 'pull_request' run: cargo fmt -- --check + - name: Run formatter with auto-fix (PRs only) + if: github.event_name == 'pull_request' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + git fetch origin ${{ github.head_ref }} + git checkout ${{ github.head_ref }} + + cargo fmt + + if [[ -n $(git status -s) ]]; then + echo "Formatter made automatic fixes" + git add -A + git commit -m "🤖 Auto-format code" + git push origin ${{ github.head_ref }} + else + echo "No formatting fixes needed" + fi + - name: Run clippy if: github.event_name != 'pull_request' run: cargo clippy -- -D warnings @@ -67,11 +88,9 @@ jobs: if [[ -n $(git status -s) ]]; then echo "Clippy made automatic fixes" - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" git add -A git commit -m "🤖 Auto-fix clippy warnings" - git push + git push origin ${{ github.head_ref }} else echo "No clippy fixes needed" # Run clippy again to ensure everything passes From 76a950e265482ead0117df3c47083e682ccfee4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 27 Jul 2025 03:12:58 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20Auto-format=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit_tests.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index 9861168..35bf0df 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -1,4 +1,4 @@ -use serpentine::{TypeChecker, PythonType, TypeCheckError}; +use serpentine::{PythonType, TypeCheckError, TypeChecker}; #[test] fn test_basic_type_annotation() { @@ -24,9 +24,11 @@ fn test_type_mismatch_error() { let code = "x: int = 42\nx = 'hello'"; let result = checker.analyze_source(code); assert!(result.is_err()); - + match result { - Err(TypeCheckError::TypeMismatch { expected, actual, .. }) => { + Err(TypeCheckError::TypeMismatch { + expected, actual, .. + }) => { assert_eq!(expected, PythonType::Int); assert_eq!(actual, PythonType::String); } @@ -68,7 +70,7 @@ fn test_implicit_type_changes_with_flag() { fn test_literal_value_in_error() { let mut checker = TypeChecker::new(); let code = "x: int = 'hello'"; - + match checker.analyze_source(code) { Err(TypeCheckError::TypeMismatch { literal_value, .. }) => { assert_eq!(literal_value, Some("'hello'".to_string())); @@ -103,7 +105,7 @@ g: tuple = () fn test_unknown_type_error() { let mut checker = TypeChecker::new(); let code = "x: UnknownType = 42"; - + match checker.analyze_source(code) { Err(TypeCheckError::UnknownType(type_name)) => { assert_eq!(type_name, "UnknownType"); @@ -125,7 +127,7 @@ fn test_multiple_assignments() { fn test_literal_integers() { let mut checker = TypeChecker::new(); let code = "x: str = 'test'\nx = 42"; - + match checker.analyze_source(code) { Err(TypeCheckError::TypeMismatch { literal_value, .. }) => { assert_eq!(literal_value, Some("42".to_string())); @@ -144,11 +146,11 @@ fn test_empty_source() { fn test_parse_error() { let mut checker = TypeChecker::new(); let code = "x: = 42"; - + match checker.analyze_source(code) { Err(TypeCheckError::ParseError(_)) => { // Expected } _ => panic!("Expected ParseError"), } -} \ No newline at end of file +}