use std::collections::HashMap;
use std::path::Path;
use php_ast::{ArrayElement, ExprKind, StmtKind};
use tower_lsp_server::ls_types::{
CompletionItem, CompletionItemKind, Location, Position, Range, Uri,
};
use crate::analysis::diagnostics::parse_document_no_diags;
use crate::document::ast::SourceView;
#[derive(Debug, Default, Clone)]
pub struct ConfigIndex {
keys: HashMap<String, Location>,
}
impl ConfigIndex {
pub fn get(&self, key: &str) -> Option<&Location> {
self.keys.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.keys.keys().map(String::as_str)
}
pub fn key_at(&self, uri: &Uri, position: Position) -> Option<&str> {
crate::laravel::location_lookup::key_at(&self.keys, uri, position)
}
pub(super) fn load(root: &Path) -> Self {
let mut keys = HashMap::new();
let config_dir = root.join("config");
for path in super::fs_walk::php_files_recursive(&config_dir) {
let Some(prefix) = dotted_prefix(&config_dir, &path) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Some(uri) = Uri::from_file_path(&path) else {
continue;
};
let doc = parse_document_no_diags(&text);
let sv = doc.view();
for stmt in doc.program().stmts.iter() {
if let StmtKind::Return(Some(expr)) = &stmt.kind
&& let ExprKind::Array(elements) = &expr.kind
{
collect_array_keys(elements, sv, &uri, &prefix, &mut keys);
}
}
}
Self { keys }
}
}
fn dotted_prefix(config_dir: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(config_dir).ok()?;
let mut components: Vec<&str> = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
let stem = Path::new(components.pop()?).file_stem()?.to_str()?;
components.push(stem);
Some(components.join("."))
}
fn collect_array_keys(
elements: &[ArrayElement<'_, '_>],
sv: SourceView<'_>,
uri: &Uri,
prefix: &str,
out: &mut HashMap<String, Location>,
) {
for el in elements {
let Some(key_expr) = &el.key else { continue };
let ExprKind::String(key) = &key_expr.kind else {
continue;
};
let dotted = format!("{prefix}.{key}");
let range = Range {
start: sv.position_of(key_expr.span.start + 1),
end: sv.position_of(key_expr.span.end - 1),
};
out.entry(dotted.clone()).or_insert_with(|| Location {
uri: uri.clone(),
range,
});
if let ExprKind::Array(nested) = &el.value.kind {
collect_array_keys(nested, sv, uri, &dotted, out);
}
}
}
pub(crate) fn config_completions(index: &ConfigIndex, prefix: &str) -> Vec<CompletionItem> {
let mut items: Vec<CompletionItem> = index
.keys()
.filter(|key| key.starts_with(prefix))
.map(|key| CompletionItem {
label: key.to_string(),
kind: Some(CompletionItemKind::PROPERTY),
insert_text: Some(key.to_string()),
..Default::default()
})
.collect();
items.sort_by(|a, b| a.label.cmp(&b.label));
items
}
#[cfg(test)]
mod tests {
use super::*;
fn write_config(root: &Path, name: &str, contents: &str) {
std::fs::create_dir_all(root.join("config")).unwrap();
std::fs::write(root.join("config").join(name), contents).unwrap();
}
#[test]
fn indexes_top_level_and_nested_keys() {
let tmp = tempfile::tempdir().unwrap();
write_config(
tmp.path(),
"database.php",
"<?php\nreturn [\n 'default' => 'mysql',\n 'connections' => [\n 'mysql' => [\n 'host' => '127.0.0.1',\n ],\n ],\n];\n",
);
let idx = ConfigIndex::load(tmp.path());
let default = idx.get("database.default").unwrap();
assert_eq!(
(default.range.start.line, default.range.start.character),
(2, 5)
);
assert_eq!(default.range.end.character, 12);
let connections = idx.get("database.connections").unwrap();
assert_eq!(
(
connections.range.start.line,
connections.range.start.character
),
(3, 5)
);
assert_eq!(connections.range.end.character, 16);
let mysql = idx.get("database.connections.mysql").unwrap();
assert_eq!(
(mysql.range.start.line, mysql.range.start.character),
(4, 9)
);
assert_eq!(mysql.range.end.character, 14);
let host = idx.get("database.connections.mysql.host").unwrap();
assert_eq!((host.range.start.line, host.range.start.character), (5, 13));
assert_eq!(host.range.end.character, 17);
assert!(idx.get("database.nonexistent").is_none());
}
#[test]
fn range_excludes_surrounding_quotes() {
let tmp = tempfile::tempdir().unwrap();
write_config(
tmp.path(),
"app.php",
"<?php\nreturn [\n 'name' => 'Laravel',\n];\n",
);
let idx = ConfigIndex::load(tmp.path());
let loc = idx.get("app.name").unwrap();
assert_eq!(loc.range.start.line, 2);
assert_eq!(loc.range.start.character, 5);
assert_eq!(loc.range.end.character, 9);
}
#[test]
fn indexes_nested_config_directory() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("config").join("services")).unwrap();
std::fs::write(
tmp.path()
.join("config")
.join("services")
.join("stripe.php"),
"<?php\nreturn [\n 'key' => 'abc',\n];\n",
)
.unwrap();
let idx = ConfigIndex::load(tmp.path());
let loc = idx.get("services.stripe.key").unwrap();
assert!(loc.uri.as_str().ends_with("config/services/stripe.php"));
}
#[test]
fn ignores_non_php_files_and_missing_config_dir() {
let tmp = tempfile::tempdir().unwrap();
let idx = ConfigIndex::load(tmp.path());
assert_eq!(idx.keys().count(), 0);
write_config(tmp.path(), "readme.md", "not php");
let idx = ConfigIndex::load(tmp.path());
assert_eq!(idx.keys().count(), 0);
}
#[test]
fn skips_positional_and_non_string_keys() {
let tmp = tempfile::tempdir().unwrap();
write_config(
tmp.path(),
"app.php",
"<?php\nreturn [\n 'providers' => [\n Foo::class,\n Bar::class,\n ],\n];\n",
);
let idx = ConfigIndex::load(tmp.path());
let providers = idx.get("app.providers").unwrap();
assert_eq!(
(providers.range.start.line, providers.range.start.character),
(2, 5)
);
assert_eq!(providers.range.end.character, 14);
assert_eq!(idx.keys().count(), 1);
}
#[test]
fn config_completions_filters_by_dotted_prefix_and_sorts() {
let tmp = tempfile::tempdir().unwrap();
write_config(
tmp.path(),
"app.php",
"<?php\nreturn [\n 'name' => 'x',\n 'env' => 'y',\n];\n",
);
let idx = ConfigIndex::load(tmp.path());
let items = config_completions(&idx, "app.");
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert_eq!(labels, vec!["app.env", "app.name"]);
}
}