diff --git a/src/nodekind.rs b/src/nodekind.rs index 4575e6a..141ac63 100644 --- a/src/nodekind.rs +++ b/src/nodekind.rs @@ -111,3 +111,354 @@ impl NodeKind { } } } + +#[cfg(test)] +mod test { + use super::*; + use tree_sitter::Parser; + + fn parse_proto(source: &str) -> tree_sitter::Tree { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_proto::LANGUAGE.into()) + .unwrap(); + parser.parse(source, None).unwrap() + } + + #[test] + fn test_is_identifier() { + let tree = parse_proto("message Foo { string name = 1; }"); + let mut cursor = tree.root_node().walk(); + // Find the "name" identifier node (child of field) + let mut found = false; + loop { + let n = cursor.node(); + if n.kind() == "identifier" { + assert!(NodeKind::is_identifier(&n)); + found = true; + } else { + assert!(!NodeKind::is_identifier(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + assert!(found, "expected at least one identifier node"); + } + + #[test] + fn test_is_message_name() { + let tree = parse_proto("message Foo {}"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "message_name" { + assert!(NodeKind::is_message_name(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_enum_name() { + let tree = parse_proto("enum Color { RED = 0; }"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "enum_name" { + assert!(NodeKind::is_enum_name(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_field_name() { + let tree = parse_proto("message Foo { string bar = 1; }"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "message_or_enum_type" { + assert!(NodeKind::is_field_name(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_rpc_name() { + let tree = parse_proto("service S { rpc Foo(Empty) returns (Empty); }"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "rpc_name" { + assert!(NodeKind::is_rpc_name(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_service_name_node_kind() { + let tree = parse_proto("service MyService {}"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "service_name" { + assert_eq!(n.kind(), "service_name"); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_package_name() { + let tree = parse_proto("package foo.bar;"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "full_ident" { + assert!(NodeKind::is_package_name(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_import_path() { + let tree = parse_proto("import \"foo/bar.proto\";"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "import" { + assert!(NodeKind::is_import_path(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_error() { + let tree = parse_proto("message Foo { invalid_syntax }"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "ERROR" { + assert!(NodeKind::is_error(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_message() { + let tree = parse_proto("message Foo {}"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "message" { + assert!(NodeKind::is_message(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_userdefined() { + let tree = parse_proto("message Foo { enum Bar { X = 0; } }"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + if n.kind() == "message_name" || n.kind() == "enum_name" { + assert!(NodeKind::is_userdefined(&n)); + } else { + assert!(!NodeKind::is_userdefined(&n)); + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_renameable() { + let tree = parse_proto( + "message Foo { string bar = 1; } enum E { X = 0; } service S { rpc F(Empty) returns (Empty); }", + ); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + match n.kind() { + "message_name" + | "enum_name" + | "service_name" + | "rpc_name" + | "message_or_enum_type" + | "field" + | "map_field" + | "oneof_field" + | "oneof" + | "enum_field" => { + assert!( + NodeKind::is_renameable(&n), + "expected {} to be renameable", + n.kind() + ); + } + _ => { + assert!( + !NodeKind::is_renameable(&n), + "expected {} to NOT be renameable", + n.kind() + ); + } + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_field_decl_parent() { + let tree = parse_proto( + "message Foo { string a = 1; map m = 2; oneof o { string b = 3; } } enum E { X = 0; }", + ); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + match n.kind() { + "field" | "map_field" | "oneof_field" | "oneof" | "enum_field" => { + assert!(NodeKind::is_field_decl_parent(&n)); + } + _ => { + assert!(!NodeKind::is_field_decl_parent(&n)); + } + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_is_actionable() { + let tree = parse_proto( + "message Foo { string bar = 1; } service S { rpc F(Empty) returns (Empty); }", + ); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + match n.kind() { + "message_name" + | "enum_name" + | "message_or_enum_type" + | "full_ident" + | "service_name" + | "rpc_name" => { + assert!(NodeKind::is_actionable(&n)); + } + _ => { + assert!(!NodeKind::is_actionable(&n)); + } + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_to_symbolkind() { + let tree = parse_proto("message Foo {} enum Bar { X = 0; } syntax = \"proto3\";"); + let mut cursor = tree.root_node().walk(); + loop { + let n = cursor.node(); + match n.kind() { + "message_name" => assert_eq!(NodeKind::to_symbolkind(&n), SymbolKind::STRUCT), + "enum_name" => assert_eq!(NodeKind::to_symbolkind(&n), SymbolKind::ENUM), + _ => assert_eq!(NodeKind::to_symbolkind(&n), SymbolKind::NULL), + } + if cursor.goto_first_child() { + continue; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + #[test] + fn test_as_str() { + assert_eq!(NodeKind::Identifier.as_str(), "identifier"); + assert_eq!(NodeKind::Error.as_str(), "ERROR"); + assert_eq!(NodeKind::MessageName.as_str(), "message_name"); + assert_eq!(NodeKind::Message.as_str(), "message"); + assert_eq!(NodeKind::EnumName.as_str(), "enum_name"); + assert_eq!(NodeKind::FieldName.as_str(), "message_or_enum_type"); + assert_eq!(NodeKind::ServiceName.as_str(), "service_name"); + assert_eq!(NodeKind::RpcName.as_str(), "rpc_name"); + assert_eq!(NodeKind::PackageName.as_str(), "full_ident"); + assert_eq!(NodeKind::PackageImport.as_str(), "import"); + } +} diff --git a/src/protoc.rs b/src/protoc.rs index deb9bce..c4f0983 100644 --- a/src/protoc.rs +++ b/src/protoc.rs @@ -47,6 +47,7 @@ impl ProtocDiagnostics { } } + // Visible for testing fn parse_protoc_output(&self, output: &str) -> Vec { let mut diagnostics = Vec::new(); @@ -82,3 +83,76 @@ impl ProtocDiagnostics { diagnostics } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_parse_protoc_output_single_error() { + let d = ProtocDiagnostics::new(); + let output = "foo.proto:5:3: Expected field name.\n"; + let diags = d.parse_protoc_output(output); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].message, "Expected field name."); + assert_eq!(diags[0].source, Some("protoc".to_string())); + assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR)); + assert_eq!(diags[0].range.start.line, 4); + assert_eq!(diags[0].range.start.character, 2); + assert_eq!(diags[0].range.end.line, 4); + assert_eq!(diags[0].range.end.character, 3); + } + + #[test] + fn test_parse_protoc_output_multiple_errors() { + let d = ProtocDiagnostics::new(); + let output = "a.proto:1:1: Syntax error.\nb.proto:2:3: Unknown type.\n"; + let diags = d.parse_protoc_output(output); + assert_eq!(diags.len(), 2); + assert_eq!(diags[0].message, "Syntax error."); + assert_eq!(diags[1].message, "Unknown type."); + } + + #[test] + fn test_parse_protoc_output_empty() { + let d = ProtocDiagnostics::new(); + assert!(d.parse_protoc_output("").is_empty()); + } + + #[test] + fn test_parse_protoc_output_malformed_line() { + let d = ProtocDiagnostics::new(); + let output = "not a valid protoc error line\n"; + let diags = d.parse_protoc_output(output); + assert!(diags.is_empty()); + } + + #[test] + fn test_parse_protoc_output_partial_format() { + let d = ProtocDiagnostics::new(); + // Missing column number + let output = "foo.proto:5: Expected field name.\n"; + let diags = d.parse_protoc_output(output); + assert!(diags.is_empty()); + } + + #[test] + fn test_parse_protoc_output_non_numeric_line_col() { + let d = ProtocDiagnostics::new(); + let output = "foo.proto:abc:def: some message\n"; + let diags = d.parse_protoc_output(output); + assert!(diags.is_empty()); + } + + #[test] + fn test_parse_protoc_output_message_with_colons() { + let d = ProtocDiagnostics::new(); + let output = "foo.proto:3:1: 'Foo' is not defined. It could be a typo for 'Bar'.\n"; + let diags = d.parse_protoc_output(output); + assert_eq!(diags.len(), 1); + assert_eq!( + diags[0].message, + "'Foo' is not defined. It could be a typo for 'Bar'." + ); + } +} diff --git a/src/state.rs b/src/state.rs index 100c1e7..e00e9f8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -339,17 +339,21 @@ impl ProtoLanguageState { } pub fn rename_file(&mut self, new_uri: &Url, old_uri: &Url) { - info!(%new_uri, %new_uri, "renaming file"); + info!(%new_uri, %old_uri, "renaming file"); - if let Some(v) = self.documents.write().expect("poison").remove(old_uri) { + let content = self.documents.write().expect("poison").remove(old_uri); + if let Some(v) = content { self.documents .write() .expect("poison") .insert(new_uri.clone(), v); } - if let Some(mut v) = self.trees.write().expect("poison").remove(old_uri) { + let mut tree = self.trees.write().expect("poison").remove(old_uri); + if let Some(ref mut v) = tree { v.uri = new_uri.clone(); + } + if let Some(v) = tree { self.trees .write() .expect("poison") @@ -384,3 +388,299 @@ impl ProtoLanguageState { result } } + +#[cfg(test)] +mod test { + use super::*; + use async_lsp::lsp_types::Url; + use std::path::PathBuf; + + fn uri(s: &str) -> Url { + Url::parse(s).unwrap() + } + + fn setup_state() -> ProtoLanguageState { + let mut state = ProtoLanguageState::new(); + let ipath: &[PathBuf] = &[]; + + state.upsert_content( + &uri("file:///test.proto"), + "syntax = \"proto3\";\npackage com.test;\nmessage Book { string title = 1; }\nenum Color { RED = 0; }\n".into(), + ipath, + 1, + ); + state.upsert_content( + &uri("file:///other.proto"), + "syntax = \"proto3\";\npackage com.test;\nmessage Author { string name = 1; }\n".into(), + ipath, + 1, + ); + state.upsert_content( + &uri("file:///diff.proto"), + "syntax = \"proto3\";\npackage com.other;\nmessage Foo { int32 bar = 1; }\n".into(), + ipath, + 1, + ); + state + } + + #[test] + fn test_get_content() { + let state = setup_state(); + assert_eq!( + state.get_content(&uri("file:///test.proto")), + "syntax = \"proto3\";\npackage com.test;\nmessage Book { string title = 1; }\nenum Color { RED = 0; }\n" + ); + assert_eq!(state.get_content(&uri("file:///nonexistent.proto")), ""); + } + + #[test] + fn test_get_tree() { + let state = setup_state(); + assert!(state.get_tree(&uri("file:///test.proto")).is_some()); + assert!(state.get_tree(&uri("file:///nonexistent.proto")).is_none()); + } + + #[test] + fn test_get_trees() { + let state = setup_state(); + let trees = state.get_trees(); + assert_eq!(trees.len(), 3); + } + + #[test] + fn test_get_trees_for_package() { + let state = setup_state(); + let test_trees = state.get_trees_for_package("com.test"); + assert_eq!(test_trees.len(), 2); + + let other_trees = state.get_trees_for_package("com.other"); + assert_eq!(other_trees.len(), 1); + + let empty_trees = state.get_trees_for_package("com.nonexistent"); + assert!(empty_trees.is_empty()); + } + + #[test] + fn test_completion_items() { + let state = setup_state(); + let items = state.completion_items("com.test"); + let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect(); + assert!(labels.contains(&"Book")); + assert!(labels.contains(&"Color")); + assert!(labels.contains(&"Author")); + assert!(!labels.contains(&"Foo")); + + let other_items = state.completion_items("com.other"); + let other_labels: Vec<&str> = other_items.iter().map(|i| i.label.as_str()).collect(); + assert!(other_labels.contains(&"Foo")); + assert!(!other_labels.contains(&"Book")); + } + + #[test] + fn test_completion_items_empty_package() { + let state = setup_state(); + let items = state.completion_items("com.nonexistent"); + assert!(items.is_empty()); + } + + #[test] + fn test_find_workspace_symbols_empty_query() { + let state = setup_state(); + let symbols = state.find_workspace_symbols(""); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Book")); + assert!(names.contains(&"Author")); + assert!(names.contains(&"Color")); + assert!(names.contains(&"Foo")); + } + + #[test] + fn test_find_workspace_symbols_partial_query() { + let state = setup_state(); + let symbols = state.find_workspace_symbols("oo"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Book")); + assert!(names.contains(&"Foo")); + assert!(!names.contains(&"Author")); + } + + #[test] + fn test_find_workspace_symbols_case_insensitive() { + let state = setup_state(); + let symbols = state.find_workspace_symbols("book"); + let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"Book")); + } + + #[test] + fn test_find_workspace_symbols_no_match() { + let state = setup_state(); + let symbols = state.find_workspace_symbols("zzzzz"); + assert!(symbols.is_empty()); + } + + #[test] + fn test_delete_file() { + let mut state = setup_state(); + let test_uri = uri("file:///test.proto"); + assert!(state.get_tree(&test_uri).is_some()); + state.delete_file(&test_uri); + assert!(state.get_tree(&test_uri).is_none()); + assert_eq!(state.get_content(&test_uri), ""); + } + + #[test] + fn test_rename_file() { + let mut state = setup_state(); + let old_uri = uri("file:///test.proto"); + let new_uri = uri("file:///renamed.proto"); + + assert!(state.get_tree(&old_uri).is_some()); + assert!(state.get_tree(&new_uri).is_none()); + + state.rename_file(&new_uri, &old_uri); + + assert!(state.get_tree(&old_uri).is_none()); + assert!(state.get_tree(&new_uri).is_some()); + assert_eq!( + state.get_content(&new_uri), + "syntax = \"proto3\";\npackage com.test;\nmessage Book { string title = 1; }\nenum Color { RED = 0; }\n" + ); + } + + #[test] + fn test_upsert_content_tracks_unresolved_imports() { + let mut state = ProtoLanguageState::new(); + let ipath: &[PathBuf] = &[]; + let unresolved = state.upsert_content( + &uri("file:///importing.proto"), + "syntax = \"proto3\";\nimport \"nonexistent.proto\";\npackage com.test;\n".into(), + ipath, + 1, + ); + assert_eq!(unresolved, vec!["nonexistent.proto"]); + } + + #[test] + fn test_upsert_content_resolved_imports() { + let mut state = ProtoLanguageState::new(); + let dir = tempfile::tempdir().unwrap(); + let dep_path = dir.path().join("dep.proto"); + std::fs::write(&dep_path, "syntax = \"proto3\";\npackage com.dep;\n").unwrap(); + let ipath = vec![dir.path().to_path_buf()]; + + let unresolved = state.upsert_content( + &uri("file:///main.proto"), + "syntax = \"proto3\";\nimport \"dep.proto\";\npackage com.main;\n".into(), + &ipath, + 1, + ); + assert!(unresolved.is_empty()); + assert!(state.get_tree(&uri("file:///main.proto")).is_some()); + } + + #[test] + fn test_upsert_content_depth_limit() { + let dir = tempfile::tempdir().unwrap(); + let a_path = dir.path().join("a.proto"); + let b_path = dir.path().join("b.proto"); + std::fs::write( + &a_path, + "syntax = \"proto3\";\nimport \"b.proto\";\npackage com.a;\n", + ) + .unwrap(); + std::fs::write( + &b_path, + "syntax = \"proto3\";\nimport \"a.proto\";\npackage com.b;\n", + ) + .unwrap(); + let ipath = vec![dir.path().to_path_buf()]; + + // depth=0 should not parse anything + let mut state0 = ProtoLanguageState::new(); + state0.upsert_content( + &uri("file:///a.proto"), + std::fs::read_to_string(&a_path).unwrap(), + &ipath, + 0, + ); + assert!(state0.get_tree(&uri("file:///a.proto")).is_none()); + + // depth=1 should parse a.proto but not follow imports + let mut state1 = ProtoLanguageState::new(); + state1.upsert_content( + &uri("file:///a.proto"), + std::fs::read_to_string(&a_path).unwrap(), + &ipath, + 1, + ); + assert!(state1.get_tree(&uri("file:///a.proto")).is_some()); + assert!(state1.get_tree(&uri("file:///b.proto")).is_none()); + } + + #[test] + fn test_parse_all_from_workspace() { + let mut state = ProtoLanguageState::new(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("a.proto"), + "syntax = \"proto3\";\npackage com.a;\nmessage A {}\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("b.proto"), + "syntax = \"proto3\";\npackage com.b;\nmessage B {}\n", + ) + .unwrap(); + // Non-proto file should be ignored + std::fs::write(dir.path().join("notes.txt"), "hello").unwrap(); + + state.parse_all_from_workspace(dir.path().to_path_buf(), None); + assert_eq!(state.get_trees().len(), 2); + + // Second call should be idempotent + state.parse_all_from_workspace(dir.path().to_path_buf(), None); + assert_eq!(state.get_trees().len(), 2); + } + + #[test] + fn test_upsert_file_returns_diagnostics() { + let mut state = ProtoLanguageState::new(); + let ipath: &[PathBuf] = &[]; + let result = state.upsert_file( + &uri("file:///test.proto"), + "syntax = \"proto3\";\npackage com.test;\nmessage Book {}\n".into(), + ipath, + 1, + &Config::default(), + false, + ); + assert!(result.is_some()); + let params = result.unwrap(); + assert_eq!(params.uri.as_str(), "file:///test.proto"); + // Should have no diagnostics for valid proto + assert!(params.diagnostics.is_empty()); + } + + #[test] + fn test_upsert_file_returns_parse_diagnostics() { + let mut state = ProtoLanguageState::new(); + let ipath: &[PathBuf] = &[]; + let result = state.upsert_file( + &uri("file:///bad.proto"), + "syntax = \"proto3\";\npackage com.test;\nmessage Book { invalid syntax here }\n" + .into(), + ipath, + 1, + &Config::default(), + false, + ); + assert!(result.is_some()); + let params = result.unwrap(); + assert!( + !params.diagnostics.is_empty(), + "expected parse diagnostics for invalid proto" + ); + } +} diff --git a/src/utils.rs b/src/utils.rs index 2ab843a..4606d72 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -65,7 +65,63 @@ pub fn split_identifier_package(s: &str) -> (&str, &str) { #[cfg(test)] mod test { - use crate::utils::{is_inner_identifier, split_identifier_package, trailing_segment}; + use crate::utils::{ + is_inner_identifier, lsp_to_ts_point, split_identifier_package, trailing_segment, + ts_to_lsp_position, + }; + use async_lsp::lsp_types::Position; + use tree_sitter::Point; + + #[test] + fn test_ts_to_lsp_position() { + let p = Point { row: 5, column: 10 }; + let pos = ts_to_lsp_position(&p); + assert_eq!(pos.line, 5); + assert_eq!(pos.character, 10); + } + + #[test] + fn test_lsp_to_ts_point() { + let pos = Position { + line: 3, + character: 7, + }; + let p = lsp_to_ts_point(&pos); + assert_eq!(p.row, 3); + assert_eq!(p.column, 7); + } + + #[test] + fn test_position_roundtrip() { + let original = Point { + row: 42, + column: 15, + }; + let pos = ts_to_lsp_position(&original); + let back = lsp_to_ts_point(&pos); + assert_eq!(original, back); + } + + #[test] + fn test_position_zero() { + let p = Point { row: 0, column: 0 }; + let pos = ts_to_lsp_position(&p); + assert_eq!(pos.line, 0); + assert_eq!(pos.character, 0); + let back = lsp_to_ts_point(&pos); + assert_eq!(p, back); + } + + #[test] + fn test_position_large_values() { + let p = Point { + row: 999999, + column: 999999, + }; + let pos = ts_to_lsp_position(&p); + assert_eq!(pos.line, 999999); + assert_eq!(pos.character, 999999); + } #[test] fn test_trailing_segment() { @@ -106,4 +162,41 @@ mod test { assert_eq!(split_identifier_package("Book.Author"), ("", "Book.Author")); assert_eq!(split_identifier_package("com.book"), ("com.book", "")); } + + #[test] + fn test_split_identifier_package_single_segment_package() { + assert_eq!(split_identifier_package("foo.Bar"), ("foo", "Bar")); + assert_eq!(split_identifier_package("a.B.C"), ("a", "B.C")); + } + + #[test] + fn test_split_identifier_package_leading_dot() { + assert_eq!(split_identifier_package(".foo.bar.Baz"), ("foo.bar", "Baz")); + assert_eq!(split_identifier_package(".Bar"), ("", "Bar")); + } + + #[test] + fn test_split_identifier_package_all_lowercase() { + assert_eq!( + split_identifier_package("com.example.package"), + ("com.example.package", "") + ); + } + + #[test] + fn test_split_identifier_package_all_uppercase() { + assert_eq!(split_identifier_package("Foo.Bar"), ("", "Foo.Bar")); + } + + #[test] + fn test_split_identifier_package_mixed_case_segments() { + assert_eq!( + split_identifier_package("my.pkg.MyMessage"), + ("my.pkg", "MyMessage") + ); + assert_eq!( + split_identifier_package("org.example.api.V1.Request"), + ("org.example.api", "V1.Request") + ); + } }