use super::*;
use crate::editing::use_import::{build_use_import_edit, find_use_insert_line};
use crate::lang::config::{DiagnosticsConfig, FeaturesConfig, MAX_INDEXED_FILES};
use std::sync::Arc;
use tower_lsp_server::ls_types::{Position, Range, Uri};
#[tokio::test]
async fn dependent_reanalysis_is_skipped_before_index_ready() {
let docs = Arc::new(DocumentStore::new());
let open_files = OpenFiles::new();
let changed_uri = "file:///changed.php".parse::<Uri>().unwrap();
let dependent_uri = "file:///dependent.php".parse::<Uri>().unwrap();
open_files.set_open_text(
&docs,
changed_uri.clone(),
"<?php\nfunction changed(): void {}\n".to_owned(),
);
open_files.set_open_text(&docs, dependent_uri, "<?php\nchanged();\n".to_owned());
let publishes = compute_dependent_publishes_owned(
Arc::clone(&docs),
open_files,
changed_uri,
DiagnosticsConfig::default(),
false,
)
.await;
assert!(
publishes.is_empty(),
"dependent reanalysis must wait until the workspace index is ready"
);
}
#[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_parses_disabled_list() {
let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
"disabled": ["MissingThrowsDocblock", 42, "UnusedParam"],
}));
assert_eq!(cfg.disabled, vec!["MissingThrowsDocblock", "UnusedParam"]);
assert!(DiagnosticsConfig::default().disabled.is_empty());
}
#[test]
fn diagnostics_config_parses_exclude_paths() {
let cfg = DiagnosticsConfig::from_value(&serde_json::json!({
"excludePaths": ["vendor/*", 1, "src/Generated"],
}));
assert_eq!(cfg.exclude_paths, vec!["vendor/*", "src/Generated"]);
assert!(DiagnosticsConfig::default().exclude_paths.is_empty());
}
#[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::lang::autoload::PHP_8_2}));
assert_eq!(
cfg.php_version.as_deref(),
Some(crate::lang::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 lsp_config_default_debounce_ms() {
let cfg = LspConfig::default();
assert_eq!(cfg.debounce_ms, 100);
}
#[test]
fn lsp_config_parses_debounce_ms() {
let cfg = LspConfig::from_value(&serde_json::json!({"debounceMs": 150}));
assert_eq!(cfg.debounce_ms, 150);
}
#[test]
fn lsp_config_debounce_ms_minimum_one() {
let cfg = LspConfig::from_value(&serde_json::json!({"debounceMs": 0}));
assert_eq!(cfg.debounce_ms, 1);
}
#[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 indexed_file_op_matches_php_files_by_default() {
let op = indexed_file_op(&["php".to_string()]);
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 indexed_file_op_adds_a_filter_per_configured_extension() {
let op = indexed_file_op(&["php".to_string(), "phpt".to_string()]);
let globs: Vec<&str> = op.filters.iter().map(|f| f.pattern.glob.as_str()).collect();
assert_eq!(globs, vec!["**/*.php", "**/*.phpt"]);
}
#[test]
fn has_indexed_extension_matches_configured_extensions_only() {
let exts = vec!["php".to_string(), "phpt".to_string()];
assert!(has_indexed_extension("/src/Foo.php", &exts));
assert!(has_indexed_extension("/tests/bug12345.phpt", &exts));
assert!(!has_indexed_extension("/AGENTS.md", &exts));
}
#[test]
fn uri_from_file_path_wire_string_roundtrips_for_space_and_unicode_paths() {
let base = std::env::temp_dir();
for path in [
base.join("php-lsp test dir").join("Foo.php"),
base.join("php-lsp-é-测试").join("Bar.php"),
base.join("has spaces")
.join("and-é-unicode")
.join("Baz.php"),
] {
let from_path = Uri::from_file_path(&path)
.unwrap_or_else(|| panic!("from_file_path failed for {path:?}"));
let wire_string = from_path.as_str().to_string();
let parsed: Uri = wire_string
.parse()
.unwrap_or_else(|e| panic!("re-parsing {wire_string:?} (from {path:?}) failed: {e:?}"));
assert_eq!(
from_path, parsed,
"Uri::from_file_path and re-parsing its own wire string disagree for {path:?}"
);
let mut hasher_a = std::collections::hash_map::DefaultHasher::new();
let mut hasher_b = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&from_path, &mut hasher_a);
std::hash::Hash::hash(&parsed, &mut hasher_b);
assert_eq!(
std::hash::Hasher::finish(&hasher_a),
std::hash::Hasher::finish(&hasher_b),
"hash mismatch between equal Uris for {path:?}"
);
}
}
#[test]
fn uri_to_file_path_roundtrips_space_and_unicode_paths() {
let base = std::env::temp_dir();
for path in [
base.join("php-lsp test dir").join("Foo.php"),
base.join("php-lsp-é-测试").join("Bar.php"),
] {
let uri = Uri::from_file_path(&path)
.unwrap_or_else(|| panic!("from_file_path failed for {path:?}"));
let back = uri
.to_file_path()
.unwrap_or_else(|| panic!("to_file_path failed for {path:?}"));
assert_eq!(
back.as_ref(),
path.as_path(),
"round-trip mismatch for {path:?}"
);
}
}
#[test]
fn defer_actions_strips_edit_and_adds_data() {
let uri = ("file:///test.php").parse::<Uri>().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 = ("file:///test.php").parse::<Uri>().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 = ("file:///test.php").parse::<Uri>().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_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_strict(
src,
Position {
line: 0,
character: 0
}
),
Some(0)
);
assert_eq!(
position_to_byte_offset_strict(
src,
Position {
line: 0,
character: 4
}
),
Some(4)
);
assert_eq!(
position_to_byte_offset_strict(
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_strict(
src,
Position {
line: 1,
character: 0
}
),
Some(6)
);
assert_eq!(
position_to_byte_offset_strict(
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_strict(
src,
Position {
line: 1,
character: 0
}
),
None
);
assert_eq!(
position_to_byte_offset_strict(
src,
Position {
line: 5,
character: 0
}
),
None
);
}
#[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/*"]);
}