php-lsp 0.26.0

A PHP Language Server Protocol implementation
Documentation
//! `config/**/*.php` index powering go-to-definition and completion for
//! `config('a.b.c')` calls.
//!
//! Every file under `config/`, including nested subdirectories, is expected
//! to `return [...]` a — possibly nested — associative array. A file's
//! prefix is its path relative to `config/` with the `.php` extension
//! stripped and directory separators replaced by `.` (so
//! `config/services/stripe.php` becomes prefix `services.stripe`). Every
//! string-keyed entry becomes `prefix.path.to.key -> Location`, indexed at
//! every nesting level so both `config('database')` (the whole file) and
//! `config('database.connections.mysql.host')` (a leaf) resolve.

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)
    }

    /// The dotted config key whose declaration in `config/*.php` contains
    /// `position`, if any — the reverse of `get`, used to recognize a
    /// find-references request starting from the definition site.
    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 }
    }
}

/// `path`'s location relative to `config_dir` as a dotted prefix — directory
/// components joined with the file stem, e.g. `config/services/stripe.php`
/// under `config_dir` `config/` becomes `services.stripe`.
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}");
        // `span.start`/`span.end` point at the surrounding quotes (see
        // `editing::document_link::link_from_path_expr`); trim one byte off
        // each side to land on the key text itself.
        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);
        }
    }
}

/// Completion items for dotted config keys starting with `prefix`.
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();
        // Line 2 (0-based): `    'name' => 'Laravel',` — "name" spans cols 5..9.
        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);
        // Positional list entries (no string key) contribute nothing further.
        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"]);
    }
}