use super::helpers::*;
use super::*;
use crate::ast::ParsedDoc;
use crate::config::{DiagnosticsConfig, FeaturesConfig, MAX_INDEXED_FILES};
use crate::use_import::{build_use_import_edit, find_use_insert_line};
use tower_lsp::lsp_types::{Position, Range, Url};
#[test]
fn diagnostics_config_default_is_enabled() {
let cfg = DiagnosticsConfig::default();
assert!(cfg.enabled);
assert!(cfg.undefined_variables);
assert!(cfg.undefined_functions);
assert!(cfg.undefined_classes);
assert!(cfg.arity_errors);
assert!(cfg.type_errors);
assert!(cfg.deprecated_calls);
assert!(cfg.duplicate_declarations);
}
#[test]
fn diagnostics_config_from_empty_object_is_enabled() {
let cfg = DiagnosticsConfig::from_value(&serde_json::json!({}));
assert!(cfg.enabled);
assert!(cfg.undefined_variables);
}
#[test]
fn diagnostics_config_from_non_object_uses_defaults() {
let cfg = DiagnosticsConfig::from_value(&serde_json::json!(null));
assert!(cfg.enabled);
}
#[test]
fn diagnostics_config_can_disable_individual_flags() {
let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
"enabled": true,
"undefinedVariables": false,
"undefinedFunctions": false,
"undefinedClasses": true,
"arityErrors": false,
"typeErrors": true,
"deprecatedCalls": false,
"duplicateDeclarations": true,
}));
assert!(cfg.enabled);
assert!(!cfg.undefined_variables);
assert!(!cfg.undefined_functions);
assert!(cfg.undefined_classes);
assert!(!cfg.arity_errors);
assert!(cfg.type_errors);
assert!(!cfg.deprecated_calls);
assert!(cfg.duplicate_declarations);
}
#[test]
fn diagnostics_config_master_switch_disables_all() {
let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": false}));
assert!(!cfg.enabled);
assert!(cfg.undefined_variables);
}
#[test]
fn diagnostics_config_master_switch_enables_all() {
let cfg = DiagnosticsConfig::from_value(&serde_json::json!({"enabled": true}));
assert!(cfg.enabled);
assert!(cfg.undefined_variables);
}
#[test]
fn lsp_config_default_is_empty() {
let cfg = LspConfig::default();
assert!(cfg.php_version.is_none());
assert!(cfg.exclude_paths.is_empty());
assert!(cfg.diagnostics.enabled);
}
#[test]
fn lsp_config_parses_php_version() {
let cfg = LspConfig::from_value(&serde_json::json!({"phpVersion": crate::autoload::PHP_8_2}));
assert_eq!(cfg.php_version.as_deref(), Some(crate::autoload::PHP_8_2));
}
#[test]
fn lsp_config_parses_exclude_paths() {
let cfg = LspConfig::from_value(&serde_json::json!({
"excludePaths": ["cache/*", "generated/*"]
}));
assert_eq!(cfg.exclude_paths, vec!["cache/*", "generated/*"]);
}
#[test]
fn lsp_config_parses_include_paths() {
let cfg = LspConfig::from_value(&serde_json::json!({
"includePaths": ["vendor/yiisoft"]
}));
assert_eq!(cfg.include_paths, vec!["vendor/yiisoft"]);
}
#[test]
fn lsp_config_parses_both_exclude_and_include_paths() {
let cfg = LspConfig::from_value(&serde_json::json!({
"excludePaths": ["cache/*", "logs/*"],
"includePaths": ["vendor/yiisoft"]
}));
assert_eq!(cfg.exclude_paths, vec!["cache/*", "logs/*"]);
assert_eq!(cfg.include_paths, vec!["vendor/yiisoft"]);
}
#[test]
fn lsp_config_parses_diagnostics_section() {
let cfg = LspConfig::from_value(&serde_json::json!({
"diagnostics": {"enabled": false}
}));
assert!(!cfg.diagnostics.enabled);
}
#[test]
fn lsp_config_ignores_missing_fields() {
let cfg = LspConfig::from_value(&serde_json::json!({}));
assert!(cfg.php_version.is_none());
assert!(cfg.exclude_paths.is_empty());
}
#[test]
fn lsp_config_parses_max_indexed_files() {
let cfg = LspConfig::from_value(&serde_json::json!({"maxIndexedFiles": 5000}));
assert_eq!(cfg.max_indexed_files, 5000);
}
#[test]
fn lsp_config_default_max_indexed_files() {
let cfg = LspConfig::default();
assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
}
#[test]
fn features_config_default_all_enabled() {
let cfg = FeaturesConfig::default();
assert!(cfg.completion);
assert!(cfg.hover);
assert!(cfg.definition);
assert!(cfg.declaration);
assert!(cfg.references);
assert!(cfg.document_symbols);
assert!(cfg.workspace_symbols);
assert!(cfg.rename);
assert!(cfg.signature_help);
assert!(cfg.inlay_hints);
assert!(cfg.semantic_tokens);
assert!(cfg.selection_range);
assert!(cfg.call_hierarchy);
assert!(cfg.document_highlight);
assert!(cfg.implementation);
assert!(cfg.code_action);
assert!(cfg.type_definition);
assert!(cfg.code_lens);
assert!(cfg.formatting);
assert!(cfg.range_formatting);
assert!(cfg.on_type_formatting);
assert!(cfg.document_link);
assert!(cfg.linked_editing_range);
assert!(cfg.inline_values);
}
#[test]
fn features_config_from_empty_object_all_enabled() {
let cfg = FeaturesConfig::from_value(&serde_json::json!({}));
assert!(cfg.completion);
assert!(cfg.hover);
assert!(cfg.call_hierarchy);
assert!(cfg.inline_values);
}
#[test]
fn features_config_can_disable_individual_flags() {
let cfg = FeaturesConfig::from_value(&serde_json::json!({
"callHierarchy": false,
}));
assert!(!cfg.call_hierarchy);
assert!(cfg.completion);
assert!(cfg.hover);
assert!(cfg.definition);
assert!(cfg.inline_values);
}
#[test]
fn lsp_config_parses_features_section() {
let cfg = LspConfig::from_value(&serde_json::json!({
"features": {"callHierarchy": false}
}));
assert!(!cfg.features.call_hierarchy);
assert!(cfg.features.completion);
assert!(cfg.features.hover);
}
#[test]
fn find_use_insert_line_after_php_open_tag() {
let src = "<?php\nfunction foo() {}";
assert_eq!(find_use_insert_line(src), 1);
}
#[test]
fn find_use_insert_line_after_existing_use() {
let src = "<?php\nuse Foo\\Bar;\nuse Baz\\Qux;\nclass Impl {}";
assert_eq!(find_use_insert_line(src), 3);
}
#[test]
fn find_use_insert_line_after_namespace() {
let src = "<?php\nnamespace App\\Services;\nclass Service {}";
assert_eq!(find_use_insert_line(src), 2);
}
#[test]
fn find_use_insert_line_after_namespace_and_use() {
let src = "<?php\nnamespace App;\nuse Foo\\Bar;\nclass Impl {}";
assert_eq!(find_use_insert_line(src), 3);
}
#[test]
fn find_use_insert_line_empty_file() {
assert_eq!(find_use_insert_line(""), 0);
}
#[test]
fn is_after_arrow_with_method_call() {
let src = "<?php\n$obj->method();\n";
let pos = Position {
line: 1,
character: 6,
};
assert!(is_after_arrow(src, pos));
}
#[test]
fn is_after_arrow_without_arrow() {
let src = "<?php\n$obj->method();\n";
let pos = Position {
line: 1,
character: 1,
};
assert!(!is_after_arrow(src, pos));
}
#[test]
fn is_after_arrow_on_standalone_identifier() {
let src = "<?php\nfunction greet() {}\n";
let pos = Position {
line: 1,
character: 10,
};
assert!(!is_after_arrow(src, pos));
}
#[test]
fn is_after_arrow_out_of_bounds_line() {
let src = "<?php\n$x = 1;\n";
let pos = Position {
line: 99,
character: 0,
};
assert!(!is_after_arrow(src, pos));
}
#[test]
fn is_after_arrow_at_start_of_property() {
let src = "<?php\n$this->name;\n";
let pos = Position {
line: 1,
character: 7,
};
assert!(is_after_arrow(src, pos));
}
#[test]
fn php_file_op_matches_php_files() {
let op = php_file_op();
assert_eq!(op.filters.len(), 1);
let filter = &op.filters[0];
assert_eq!(filter.scheme.as_deref(), Some("file"));
assert_eq!(filter.pattern.glob, "**/*.php");
}
#[test]
fn defer_actions_strips_edit_and_adds_data() {
let uri = Url::parse("file:///test.php").unwrap();
let range = Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 5,
},
};
let actions = vec![CodeActionOrCommand::CodeAction(CodeAction {
title: "My Action".to_string(),
kind: Some(CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit::default()),
data: None,
..Default::default()
})];
let deferred = defer_actions(actions, "test_kind", &uri, range);
assert_eq!(deferred.len(), 1);
if let CodeActionOrCommand::CodeAction(ca) = &deferred[0] {
assert!(ca.edit.is_none(), "edit should be stripped");
assert!(ca.data.is_some(), "data payload should be set");
let data = ca.data.as_ref().unwrap();
assert_eq!(data["php_lsp_resolve"], "test_kind");
assert_eq!(data["uri"], uri.to_string());
} else {
panic!("expected CodeAction");
}
}
#[test]
fn build_use_import_edit_inserts_after_php_tag() {
let src = "<?php\nclass Foo {}";
let uri = Url::parse("file:///test.php").unwrap();
let edit = build_use_import_edit(src, &uri, "App\\Services\\Bar");
let changes = edit.changes.unwrap();
let edits = changes.get(&uri).unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "use App\\Services\\Bar;\n");
assert_eq!(edits[0].range.start.line, 1);
}
#[test]
fn build_use_import_edit_inserts_after_existing_use() {
let src = "<?php\nuse Foo\\Bar;\nclass Impl {}";
let uri = Url::parse("file:///test.php").unwrap();
let edit = build_use_import_edit(src, &uri, "Baz\\Qux");
let changes = edit.changes.unwrap();
let edits = changes.get(&uri).unwrap();
assert_eq!(edits[0].range.start.line, 2);
assert_eq!(edits[0].new_text, "use Baz\\Qux;\n");
}
#[test]
fn undefined_class_name_extracted_from_message() {
let msg = "Class MyService does not exist";
let name = msg
.strip_prefix("Class ")
.and_then(|s| s.strip_suffix(" does not exist"))
.unwrap_or("")
.trim();
assert_eq!(name, "MyService");
}
#[test]
fn undefined_function_message_not_matched_by_extraction() {
let msg = "Function myHelper() is not defined";
let name = msg
.strip_prefix("Class ")
.and_then(|s| s.strip_suffix(" does not exist"))
.unwrap_or("")
.trim();
assert!(
name.is_empty(),
"function diagnostic should not extract a class name"
);
}
#[test]
fn position_to_byte_offset_first_line() {
let src = "<?php\nfoo();";
assert_eq!(
position_to_byte_offset(
src,
Position {
line: 0,
character: 0
}
),
Some(0)
);
assert_eq!(
position_to_byte_offset(
src,
Position {
line: 0,
character: 4
}
),
Some(4)
);
assert_eq!(
position_to_byte_offset(
src,
Position {
line: 0,
character: 5
}
),
Some(5)
);
}
#[test]
fn position_to_byte_offset_second_line() {
let src = "<?php\nfoo();";
assert_eq!(
position_to_byte_offset(
src,
Position {
line: 1,
character: 0
}
),
Some(6)
);
assert_eq!(
position_to_byte_offset(
src,
Position {
line: 1,
character: 3
}
),
Some(9)
);
}
#[test]
fn position_to_byte_offset_line_boundary_returns_none() {
let src = "<?php";
assert_eq!(
position_to_byte_offset(
src,
Position {
line: 1,
character: 0
}
),
None
);
assert_eq!(
position_to_byte_offset(
src,
Position {
line: 5,
character: 0
}
),
None
);
}
#[test]
fn cursor_on_method_decl_name_returns_true() {
let doc = ParsedDoc::parse("<?php\nclass C {\n public function add() {}\n}".to_string());
let source = doc.source();
let stmts = &doc.program().stmts;
for col in 20u32..=22 {
assert!(
cursor_is_on_method_decl(
source,
stmts,
Position {
line: 2,
character: col
}
),
"expected true at col {col}"
);
}
assert!(!cursor_is_on_method_decl(
source,
stmts,
Position {
line: 2,
character: 19
}
));
assert!(!cursor_is_on_method_decl(
source,
stmts,
Position {
line: 2,
character: 23
}
));
}
#[test]
fn cursor_on_free_function_decl_returns_false() {
let doc = ParsedDoc::parse("<?php\nfunction add() {}".to_string());
let source = doc.source();
let stmts = &doc.program().stmts;
assert!(!cursor_is_on_method_decl(
source,
stmts,
Position {
line: 1,
character: 9
}
));
}
#[test]
fn cursor_on_method_call_site_returns_false() {
let doc = ParsedDoc::parse(
"<?php\nclass C { public function add() {} }\n$c = new C();\n$c->add();".to_string(),
);
let source = doc.source();
let stmts = &doc.program().stmts;
assert!(!cursor_is_on_method_decl(
source,
stmts,
Position {
line: 3,
character: 4
}
));
}
#[test]
fn cursor_on_interface_method_decl_returns_true() {
let doc =
ParsedDoc::parse("<?php\ninterface I {\n public function add(): void;\n}".to_string());
let source = doc.source();
let stmts = &doc.program().stmts;
assert!(cursor_is_on_method_decl(
source,
stmts,
Position {
line: 2,
character: 20
}
));
}
#[test]
fn cursor_on_trait_method_decl_returns_true() {
let doc = ParsedDoc::parse("<?php\ntrait T {\n public function add() {}\n}".to_string());
let source = doc.source();
let stmts = &doc.program().stmts;
assert!(cursor_is_on_method_decl(
source,
stmts,
Position {
line: 2,
character: 20
}
));
}
#[test]
fn cursor_on_enum_method_decl_returns_true() {
let doc = ParsedDoc::parse(
"<?php\nenum Status {\n public function label(): string { return 'x'; }\n}".to_string(),
);
let source = doc.source();
let stmts = &doc.program().stmts;
assert!(cursor_is_on_method_decl(
source,
stmts,
Position {
line: 2,
character: 20
}
));
}
#[test]
fn cursor_on_method_decl_in_unbraced_namespace_returns_true() {
let doc = ParsedDoc::parse(
"<?php\nnamespace App;\nclass C {\n public function add() {}\n}".to_string(),
);
let source = doc.source();
let stmts = &doc.program().stmts;
assert!(
cursor_is_on_method_decl(
source,
stmts,
Position {
line: 3,
character: 20
}
),
"method in unbraced namespace must be detected"
);
}
#[test]
fn cursor_on_method_decl_in_braced_namespace_returns_true() {
let doc = ParsedDoc::parse(
"<?php\nnamespace App {\n class C {\n public function add() {}\n }\n}"
.to_string(),
);
let source = doc.source();
let stmts = &doc.program().stmts;
assert!(
cursor_is_on_method_decl(
source,
stmts,
Position {
line: 3,
character: 24
}
),
"method in braced namespace must be detected"
);
}
#[test]
fn merge_file_only_uses_file_values() {
let file = serde_json::json!({
"phpVersion": "8.1",
"excludePaths": ["vendor/*"],
"maxIndexedFiles": 500,
});
let merged = LspConfig::merge_project_configs(Some(&file), None);
let cfg = LspConfig::from_value(&merged);
assert_eq!(cfg.php_version, Some("8.1".to_string()));
assert_eq!(cfg.exclude_paths, vec!["vendor/*"]);
assert_eq!(cfg.max_indexed_files, 500);
}
#[test]
fn merge_editor_wins_per_key_over_file() {
let file = serde_json::json!({"phpVersion": "8.1", "maxIndexedFiles": 100});
let editor = serde_json::json!({"phpVersion": "8.3", "maxIndexedFiles": 200});
let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
let cfg = LspConfig::from_value(&merged);
assert_eq!(cfg.php_version, Some("8.3".to_string()));
assert_eq!(cfg.max_indexed_files, 200);
}
#[test]
fn merge_exclude_paths_concat_not_replace() {
let file = serde_json::json!({"excludePaths": ["cache/*"]});
let editor = serde_json::json!({"excludePaths": ["logs/*"]});
let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
let cfg = LspConfig::from_value(&merged);
assert_eq!(cfg.exclude_paths, vec!["cache/*", "logs/*"]);
}
#[test]
fn merge_include_paths_concat_not_replace() {
let file = serde_json::json!({"includePaths": ["vendor/yiisoft"]});
let editor = serde_json::json!({"includePaths": ["vendor/symfony"]});
let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
let cfg = LspConfig::from_value(&merged);
assert_eq!(cfg.include_paths, vec!["vendor/yiisoft", "vendor/symfony"]);
}
#[test]
fn merge_no_file_uses_editor_only() {
let editor = serde_json::json!({"phpVersion": "8.2", "excludePaths": ["tmp/*"]});
let merged = LspConfig::merge_project_configs(None, Some(&editor));
let cfg = LspConfig::from_value(&merged);
assert_eq!(cfg.php_version, Some("8.2".to_string()));
assert_eq!(cfg.exclude_paths, vec!["tmp/*"]);
}
#[test]
fn merge_both_none_returns_defaults() {
let merged = LspConfig::merge_project_configs(None, None);
let cfg = LspConfig::from_value(&merged);
assert!(cfg.php_version.is_none());
assert!(cfg.exclude_paths.is_empty());
assert_eq!(cfg.max_indexed_files, MAX_INDEXED_FILES);
}
#[test]
fn merge_file_editor_both_have_exclude_paths_all_present() {
let file = serde_json::json!({"excludePaths": ["a/*", "b/*"]});
let editor = serde_json::json!({"excludePaths": ["c/*"]});
let merged = LspConfig::merge_project_configs(Some(&file), Some(&editor));
let cfg = LspConfig::from_value(&merged);
assert_eq!(cfg.exclude_paths, vec!["a/*", "b/*", "c/*"]);
}