php-lsp 0.5.0

A PHP Language Server Protocol implementation
Documentation
//! Document link resolution: require/require_once paths and @link docblocks.

use super::*;

use expect_test::expect;
use serde_json::{Value, json};

#[tokio::test]
async fn document_link_multiple_requires_produce_multiple_links() {
    let mut server = TestServer::new().await;
    server
        .open(
            "multi.php",
            "<?php\nrequire_once 'vendor/autoload.php';\nrequire 'lib/helper.php';\n",
        )
        .await;
    let resp = server.document_link("multi.php").await;
    assert!(resp["error"].is_null(), "error: {resp:?}");
    let links = resp["result"].as_array().expect("expected array of links");
    assert_eq!(links.len(), 2, "expected 2 links, got: {links:?}");
}

#[tokio::test]
async fn document_link_docblock_at_link_produces_http_link() {
    let mut server = TestServer::new().await;
    server
        .open(
            "doclink.php",
            "<?php\n/** @link https://php.net/array_map */\nfunction f() {}\n",
        )
        .await;
    let resp = server.document_link("doclink.php").await;
    assert!(resp["error"].is_null(), "error: {resp:?}");
    let links = resp["result"].as_array().expect("array");
    assert_eq!(links.len(), 1, "expected 1 link: {links:?}");
    assert_eq!(
        links[0]["target"].as_str().unwrap_or(""),
        "https://php.net/array_map",
        "link target mismatch: {:?}",
        links[0]
    );
}

#[tokio::test]
async fn document_link_at_see_class_ref_produces_no_link() {
    let mut server = TestServer::new().await;
    server
        .open(
            "nosee.php",
            "<?php\n/** @see SomeClass::method */\nfunction g() {}\n",
        )
        .await;
    let resp = server.document_link("nosee.php").await;
    assert!(resp["error"].is_null(), "error: {resp:?}");
    assert!(
        resp["result"].is_null()
            || resp["result"]
                .as_array()
                .map(|a| a.is_empty())
                .unwrap_or(false),
        "expected no link for non-HTTP @see, got: {:?}",
        resp["result"]
    );
}

#[tokio::test]
async fn document_link_plain_file_returns_null() {
    let mut server = TestServer::new().await;
    server.open("empty.php", "<?php\n$x = 1;\n").await;
    let resp = server.document_link("empty.php").await;
    assert!(resp["error"].is_null(), "error: {resp:?}");
    assert!(
        resp["result"].is_null(),
        "expected null for file with no links: {:?}",
        resp["result"]
    );
}

#[tokio::test]
async fn document_link_require_target_is_file_uri() {
    let mut server = TestServer::new().await;
    server
        .open("req.php", "<?php\nrequire 'helpers/utils.php';\n")
        .await;
    let resp = server.document_link("req.php").await;
    assert!(resp["error"].is_null(), "error: {resp:?}");
    let links = resp["result"].as_array().expect("array");
    assert_eq!(links.len(), 1);
    let target = links[0]["target"].as_str().unwrap_or("");
    assert!(
        target.starts_with("file://") && target.contains("helpers/utils.php"),
        "expected file:// URI with path, got: {target:?}"
    );
}

#[tokio::test]
async fn document_link_range_is_inside_quotes() {
    let mut server = TestServer::new().await;
    server.open("rng.php", "<?php\nrequire 'abc.php';\n").await;
    let link = server.document_link("rng.php").await["result"][0].clone();
    let resp = server.client().request("documentLink/resolve", link).await;
    expect!["1:9-16 target=abc.php"].assert_eq(&render_resolved_link(&resp, &server.uri("")));
}

fn render_resolved_link(resp: &Value, root_uri: &str) -> String {
    if let Some(err) = resp.get("error").filter(|e| !e.is_null()) {
        return format!("error: {err}");
    }
    let l = &resp["result"];
    let sl = l["range"]["start"]["line"].as_u64().unwrap_or(0);
    let sc = l["range"]["start"]["character"].as_u64().unwrap_or(0);
    let ec = l["range"]["end"]["character"].as_u64().unwrap_or(0);
    let prefix = if root_uri.ends_with('/') {
        root_uri.to_owned()
    } else {
        format!("{root_uri}/")
    };
    let target = l["target"].as_str().unwrap_or("");
    let target = target.strip_prefix(&prefix).unwrap_or(target).to_owned();
    let tooltip = l["tooltip"]
        .as_str()
        .map(|t| format!(" tooltip={t}"))
        .unwrap_or_default();
    let data = if l.get("data").map(|d| !d.is_null()).unwrap_or(false) {
        format!(" data={}", l["data"])
    } else {
        String::new()
    };
    format!("{sl}:{sc}-{ec} target={target}{tooltip}{data}")
}

#[tokio::test]
async fn document_link_resolve_round_trips_real_link() {
    let mut server = TestServer::new().await;
    server
        .open("res.php", "<?php\nrequire 'helpers/utils.php';\n")
        .await;

    let link = server.document_link("res.php").await["result"][0].clone();
    assert!(link.is_object(), "expected at least one document link");

    let root = server.uri("");
    let resp = server.client().request("documentLink/resolve", link).await;
    expect!["1:9-26 target=helpers/utils.php"].assert_eq(&render_resolved_link(&resp, &root));
}

#[tokio::test]
async fn document_link_resolve_preserves_target_tooltip_and_data() {
    let mut server = TestServer::new().await;
    let link = json!({
        "range": {
            "start": { "line": 3, "character": 5 },
            "end":   { "line": 3, "character": 20 }
        },
        "target": "https://example.test/some/path",
        "tooltip": "synthetic link",
        "data": { "marker": "preserve" }
    });

    let resp = server.client().request("documentLink/resolve", link).await;
    expect![[
        r#"3:5-20 target=https://example.test/some/path tooltip=synthetic link data={"marker":"preserve"}"#
    ]]
    .assert_eq(&render_resolved_link(&resp, "ignored://"));
}

#[tokio::test]
async fn document_link_resolve_external_url_roundtrips() {
    let mut server = TestServer::new().await;
    let link = json!({
        "range": {
            "start": { "line": 5, "character": 10 },
            "end": { "line": 5, "character": 30 }
        },
        "target": "https://docs.example.com/api"
    });

    let resp = server.client().request("documentLink/resolve", link).await;
    expect!["5:10-30 target=https://docs.example.com/api"]
        .assert_eq(&render_resolved_link(&resp, "file://"));
}

#[tokio::test]
async fn document_link_resolve_link_without_target_field() {
    let mut server = TestServer::new().await;
    let link = json!({
        "range": {
            "start": { "line": 0, "character": 5 },
            "end": { "line": 0, "character": 15 }
        }
    });

    let resp = server.client().request("documentLink/resolve", link).await;
    assert!(resp["error"].is_null(), "no error for link without target");
    let result = &resp["result"];
    assert!(
        result["range"].is_object(),
        "range should be preserved: {result:?}"
    );
    // target may be null or absent — document_link_resolve is a passthrough
    assert!(result["target"].is_null() || result.get("target").is_none());
}

#[tokio::test]
async fn document_link_resolve_with_null_target() {
    let mut server = TestServer::new().await;
    let link = json!({
        "range": {
            "start": { "line": 2, "character": 5 },
            "end": { "line": 2, "character": 15 }
        },
        "target": null
    });

    let resp = server.client().request("documentLink/resolve", link).await;
    assert!(resp["error"].is_null());
    let result = &resp["result"];
    assert!(result["target"].is_null(), "null target must be preserved");
}

#[tokio::test]
async fn document_link_resolve_preserves_data_field() {
    let mut server = TestServer::new().await;
    let link = json!({
        "range": {
            "start": { "line": 0, "character": 5 },
            "end": { "line": 0, "character": 15 }
        },
        "target": "file://example.php",
        "tooltip": "A file link",
        "data": { "linkId": "456", "metadata": { "resolved": false } }
    });

    let resp = server
        .client()
        .request("documentLink/resolve", link.clone())
        .await;
    assert!(resp["error"].is_null(), "error: {:?}", resp.get("error"));
    let result = &resp["result"];
    assert_eq!(
        result["data"], link["data"],
        "data field must be preserved exactly"
    );
}

#[tokio::test]
async fn document_link_resolve_is_idempotent() {
    let mut server = TestServer::new().await;
    server
        .open("require.php", "<?php\nrequire 'helpers.php';\n")
        .await;

    let links = server.document_link("require.php").await["result"]
        .as_array()
        .cloned()
        .expect("expected links");
    let first_link = links[0].clone();

    let resolved_once = server
        .client()
        .request("documentLink/resolve", first_link.clone())
        .await;
    let resolved_twice = server
        .client()
        .request("documentLink/resolve", resolved_once["result"].clone())
        .await;

    assert_eq!(
        resolved_once["result"], resolved_twice["result"],
        "calling resolve twice must return identical results (idempotent)"
    );
}