Merge pull request #17 from levineuwirth/lsp-symbols-highlight

T M4.5: documentSymbol / workspace symbol / documentHighlight
This commit is contained in:
Levi Neuwirth 2026-05-19 02:17:46 +00:00 committed by GitHub
commit a1a2369d3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1023 additions and 19 deletions

View File

@ -259,6 +259,9 @@ pmacs.lsp.request_references = wrap_request(pmacs.lsp._request_references_raw)
pmacs.lsp.request_declaration = wrap_request(pmacs.lsp._request_declaration_raw)
pmacs.lsp.request_type_definition = wrap_request(pmacs.lsp._request_type_definition_raw)
pmacs.lsp.request_implementation = wrap_request(pmacs.lsp._request_implementation_raw)
pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_symbol_raw)
pmacs.lsp.request_workspace_symbol = wrap_request(pmacs.lsp._request_workspace_symbol_raw)
pmacs.lsp.request_document_highlight = wrap_request(pmacs.lsp._request_document_highlight_raw)
-- Render an `:await()` failure into a modeline-friendly reason.
-- `Handle:await()` raises `{ tag = "cancelled", ... }` when the
@ -417,6 +420,37 @@ function pmacs.lsp.find_references()
end)
end
function pmacs.lsp.document_symbols()
local rec = attached_for_active()
if not rec then
pmacs.editor.set_status("LSP: no server for active buffer")
return
end
pmacs.document_symbol.clear(rec.server, rec.uri)
pmacs.async(function()
local ok, err = pcall(function()
pmacs.lsp.request_document_symbol(rec.server, rec.uri):await()
end)
if not ok then
pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
return
end
local syms = pmacs.document_symbol.symbols(rec.server, rec.uri)
if not syms or #syms == 0 then
pmacs.editor.set_status("LSP: no symbols")
return
end
-- v1 modeline summary (count + first symbol); a structured
-- outline buffer driven off this store is future UX work, like
-- the references list and hover panel.
local first = syms[1]
pmacs.editor.set_status(string.format(
"LSP: %d symbol%s; first '%s' at %d:%d",
#syms, (#syms == 1 and "" or "s"),
first.name, first.line + 1, first.col + 1))
end)
end
function pmacs.lsp.format_buffer()
local rec = attached_for_active()
if not rec then
@ -531,12 +565,19 @@ pmacs.command.define {
fn = pmacs.lsp.find_references,
}
pmacs.command.define {
name = "lsp.document-symbols",
description = "List the symbols (outline) of the active buffer (LSP).",
fn = pmacs.lsp.document_symbols,
}
-- Default chords. M-. follows the cross-editor convention for
-- go-to-definition; the others sit on `C-c` to keep printable letters
-- self-inserting. The user can override or unbind any of these from
-- init.lua.
pmacs.keymap.bind { scope = "global", sequence = "M-.", command = "lsp.go-to-definition" }
pmacs.keymap.bind { scope = "global", sequence = "M-?", command = "lsp.find-references" }
pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" }
pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" }
pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" }
pmacs.keymap.bind { scope = "global", sequence = "C-c f", command = "lsp.format-buffer" }

View File

@ -387,6 +387,54 @@ fn main() {
});
write_frame(&mut stdout, &resp);
}
// T M4.5 symbols/highlight. documentSymbol returns the
// *hierarchical* DocumentSymbol shape (exercises tree
// flatten + depth + parent); workspace/symbol the flat
// SymbolInformation shape (exercises location.uri);
// documentHighlight a two-occurrence list.
("textDocument/documentSymbol", Some(idv)) => {
let resp = serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"result": [{
"name": "Outer", "kind": 5,
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 9, "character": 0 } },
"selectionRange": { "start": { "line": 1, "character": 6 }, "end": { "line": 1, "character": 11 } },
"children": [{
"name": "inner", "kind": 6,
"range": { "start": { "line": 3, "character": 2 }, "end": { "line": 5, "character": 2 } },
"selectionRange": { "start": { "line": 3, "character": 7 }, "end": { "line": 3, "character": 12 } }
}]
}]
});
write_frame(&mut stdout, &resp);
}
("workspace/symbol", Some(idv)) => {
let resp = serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"result": [{
"name": "WsThing", "kind": 12,
"location": {
"uri": "file:///ws.rs",
"range": { "start": { "line": 7, "character": 3 }, "end": { "line": 7, "character": 10 } }
},
"containerName": "modw"
}]
});
write_frame(&mut stdout, &resp);
}
("textDocument/documentHighlight", Some(idv)) => {
let resp = serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"result": [
{ "range": { "start": { "line": 2, "character": 4 }, "end": { "line": 2, "character": 9 } }, "kind": 2 },
{ "range": { "start": { "line": 6, "character": 0 }, "end": { "line": 6, "character": 5 } } }
]
});
write_frame(&mut stdout, &resp);
}
(_, Some(idv)) => {
// Generic echo response.
let resp = serde_json::json!({

190
src/document_highlight.rs Normal file
View File

@ -0,0 +1,190 @@
// document_highlight.rs --- T M4.5: textDocument/documentHighlight.
//! Per-`(server, uri)` store for `textDocument/documentHighlight`:
//! the ranges in the current document that refer to the same symbol
//! as the cursor (the basis for "highlight all occurrences"). Same
//! request/store/Lua shape as the other M4.5 features; the inbound
//! position codec rewrites the ranges to byte offsets before this
//! parses, so consumers stay byte-uniform.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde_json::Value;
/// One highlighted occurrence. `kind` is the raw LSP
/// `DocumentHighlightKind` (1 = Text, 2 = Read, 3 = Write); absent
/// defaults to 1 (Text) per spec.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Highlight {
/// Zero-based start line.
pub start_line: u32,
/// Zero-based start column.
pub start_col: u32,
/// Zero-based end line.
pub end_line: u32,
/// Zero-based end column.
pub end_col: u32,
/// `DocumentHighlightKind` (1 Text / 2 Read / 3 Write).
pub kind: i64,
}
/// Parsed response: zero or more occurrences in source order.
#[derive(Clone, Debug, Default)]
pub struct DocumentHighlightResponse {
/// Occurrences the server returned.
pub highlights: Vec<Highlight>,
}
impl DocumentHighlightResponse {
/// Parse `DocumentHighlight[] | null`.
#[must_use]
pub fn from_lsp_value(v: &Value) -> Self {
let mut out = Vec::new();
if let Some(arr) = v.as_array() {
for item in arr {
let Some(range) = item.get("range") else {
continue;
};
let (Some(start), Some(end)) = (range.get("start"), range.get("end")) else {
continue;
};
let g = |p: &Value, k: &str| p.get(k).and_then(Value::as_u64).map(|n| n as u32);
let (Some(sl), Some(sc), Some(el), Some(ec)) = (
g(start, "line"),
g(start, "character"),
g(end, "line"),
g(end, "character"),
) else {
continue;
};
out.push(Highlight {
start_line: sl,
start_col: sc,
end_line: el,
end_col: ec,
kind: item.get("kind").and_then(Value::as_i64).unwrap_or(1),
});
}
}
Self { highlights: out }
}
/// True iff the server returned nothing.
#[must_use]
pub fn is_empty(&self) -> bool {
self.highlights.is_empty()
}
}
/// Key into [`DocumentHighlightStore`].
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct DocumentHighlightKey {
/// Decimal LSP server id.
pub server: String,
/// Document URI.
pub uri: String,
}
impl DocumentHighlightKey {
/// Construct a key.
#[must_use]
pub fn new(server: impl Into<String>, uri: impl Into<String>) -> Self {
Self {
server: server.into(),
uri: uri.into(),
}
}
}
/// Per-`(server, uri)` highlight state.
#[derive(Default)]
pub struct DocumentHighlightStore {
by_key: HashMap<DocumentHighlightKey, DocumentHighlightResponse>,
}
impl DocumentHighlightStore {
/// Empty store.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Replace the response at `key`.
pub fn set(&mut self, key: DocumentHighlightKey, response: DocumentHighlightResponse) {
self.by_key.insert(key, response);
}
/// Drop the entry at `key`.
pub fn clear(&mut self, key: &DocumentHighlightKey) {
self.by_key.remove(key);
}
/// Look up the entry at `key`.
#[must_use]
pub fn get(&self, key: &DocumentHighlightKey) -> Option<&DocumentHighlightResponse> {
self.by_key.get(key)
}
}
/// Cheaply-cloneable shared handle.
pub type SharedDocumentHighlightStore = Arc<Mutex<DocumentHighlightStore>>;
/// Build a fresh shared store.
#[must_use]
pub fn make_shared_store() -> SharedDocumentHighlightStore {
Arc::new(Mutex::new(DocumentHighlightStore::new()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_highlights_and_defaults_kind_to_text() {
let v = json!([
{
"range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 5 } },
"kind": 3
},
{
"range": { "start": { "line": 4, "character": 0 }, "end": { "line": 4, "character": 3 } }
}
]);
let r = DocumentHighlightResponse::from_lsp_value(&v);
assert_eq!(r.highlights.len(), 2);
assert_eq!(r.highlights[0].kind, 3); // Write
assert_eq!(
(
r.highlights[0].start_line,
r.highlights[0].start_col,
r.highlights[0].end_col
),
(1, 2, 5)
);
assert_eq!(r.highlights[1].kind, 1); // absent ⇒ Text
}
#[test]
fn null_is_empty_and_store_round_trips() {
assert!(DocumentHighlightResponse::from_lsp_value(&Value::Null).is_empty());
let mut s = DocumentHighlightStore::new();
let key = DocumentHighlightKey::new("1", "file:///a");
s.set(
key.clone(),
DocumentHighlightResponse {
highlights: vec![Highlight {
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 1,
kind: 2,
}],
},
);
assert_eq!(s.get(&key).unwrap().highlights.len(), 1);
s.clear(&key);
assert!(s.get(&key).is_none());
}
}

View File

@ -51,6 +51,7 @@ pub mod daemon;
pub mod daemon_attach;
pub mod definition;
pub mod diag;
pub mod document_highlight;
pub mod editor;
pub mod editor_core;
pub mod file_io;
@ -100,6 +101,7 @@ pub mod semantic_client;
pub mod semantic_render;
pub mod signature;
pub mod socket_path;
pub mod symbol;
pub mod syntax;
pub mod text_view;
pub mod transport;

View File

@ -749,6 +749,10 @@ pub struct LspManager {
/// implementation. Same Location shape as `definition`, keyed
/// additionally by kind so the four don't collide.
locations_store: crate::locations::SharedLocationsStore,
/// T M4.5: documentSymbol / workspace symbol, scope-keyed.
symbol_store: crate::symbol::SharedSymbolStore,
/// T M4.5: textDocument/documentHighlight, per `(server, uri)`.
document_highlight_store: crate::document_highlight::SharedDocumentHighlightStore,
/// T M4.12 formatting store. Populated when a
/// `textDocument/formatting` response lands.
formatting_store: crate::formatting::SharedFormattingStore,
@ -818,6 +822,26 @@ enum ResponseRoute {
/// Which nav request this answers.
kind: crate::locations::LocationKind,
},
/// Absorb a `textDocument/documentSymbol` response into
/// [`crate::symbol::SymbolStore`] at `(server, document uri)`.
DocumentSymbol {
/// The requested document URI (also the codec doc key, and
/// the fallback URI for `DocumentSymbol` items which carry
/// none).
uri: String,
},
/// Absorb a `workspace/symbol` response into
/// [`crate::symbol::SymbolStore`] at `(server, query)`.
WorkspaceSymbol {
/// The query string this answers.
query: String,
},
/// Absorb a `textDocument/documentHighlight` response into
/// [`crate::document_highlight::DocumentHighlightStore`].
DocumentHighlight {
/// Document URI.
uri: String,
},
}
impl ResponseRoute {
@ -830,7 +854,14 @@ impl ResponseRoute {
| ResponseRoute::Signature { uri }
| ResponseRoute::Definition { uri }
| ResponseRoute::Formatting { uri }
| ResponseRoute::Locations { uri, .. } => uri,
| ResponseRoute::Locations { uri, .. }
| ResponseRoute::DocumentSymbol { uri }
| ResponseRoute::DocumentHighlight { uri } => uri,
// workspace/symbol results span arbitrary files we have
// not cached — no doc to convert against, so the inbound
// codec must pass coordinates through untouched (same
// non-destructive rule as cross-file definition).
ResponseRoute::WorkspaceSymbol { .. } => "",
}
}
}
@ -901,6 +932,8 @@ impl LspManager {
signature_store: crate::signature::make_shared_store(),
definition_store: crate::definition::make_shared_store(),
locations_store: crate::locations::make_shared_store(),
symbol_store: crate::symbol::make_shared_store(),
document_highlight_store: crate::document_highlight::make_shared_store(),
formatting_store: crate::formatting::make_shared_store(),
pending_routes: HashMap::new(),
status_tracker: crate::lsp_status::LspStatusTracker::new(),
@ -948,6 +981,20 @@ impl LspManager {
self.locations_store.clone()
}
/// Shared symbol store — documentSymbol / workspace symbol (T M4.5).
#[must_use]
pub fn symbol_store(&self) -> crate::symbol::SharedSymbolStore {
self.symbol_store.clone()
}
/// Shared documentHighlight store (T M4.5).
#[must_use]
pub fn document_highlight_store(
&self,
) -> crate::document_highlight::SharedDocumentHighlightStore {
self.document_highlight_store.clone()
}
/// Shared formatting store (T M4.12).
#[must_use]
pub fn formatting_store(&self) -> crate::formatting::SharedFormattingStore {
@ -1590,6 +1637,63 @@ impl LspManager {
)
}
/// Send `textDocument/documentSymbol` (no position — whole doc).
/// Returns the async-runtime [`JobId`].
pub fn request_document_symbol(
&mut self,
sid: LspServerId,
uri: impl Into<String>,
) -> Result<JobId, String> {
let uri = uri.into();
let params = json!({ "textDocument": { "uri": uri.clone() } });
let method = "textDocument/documentSymbol";
let req_id = self.send_request(sid, method, params)?;
let job_id = self.register_awaiter(sid, req_id, method, &uri);
self.pending_routes
.insert((sid, req_id), ResponseRoute::DocumentSymbol { uri });
Ok(job_id)
}
/// Send `workspace/symbol` for `query`. Returns the [`JobId`].
pub fn request_workspace_symbol(
&mut self,
sid: LspServerId,
query: impl Into<String>,
) -> Result<JobId, String> {
let query = query.into();
let params = json!({ "query": query.clone() });
let method = "workspace/symbol";
let req_id = self.send_request(sid, method, params)?;
// The query stands in for the doc URI in the supersede key:
// a newer query for the same string supersedes the prior.
let job_id = self.register_awaiter(sid, req_id, method, &query);
self.pending_routes
.insert((sid, req_id), ResponseRoute::WorkspaceSymbol { query });
Ok(job_id)
}
/// Send `textDocument/documentHighlight` at `(line, col)`.
/// Returns the [`JobId`].
pub fn request_document_highlight(
&mut self,
sid: LspServerId,
uri: impl Into<String>,
line: u32,
col: u32,
) -> Result<JobId, String> {
let uri = uri.into();
let params = json!({
"textDocument": { "uri": uri.clone() },
"position": self.outbound_position(sid, &uri, line, col)
});
let method = "textDocument/documentHighlight";
let req_id = self.send_request(sid, method, params)?;
let job_id = self.register_awaiter(sid, req_id, method, &uri);
self.pending_routes
.insert((sid, req_id), ResponseRoute::DocumentHighlight { uri });
Ok(job_id)
}
/// Send `textDocument/formatting` for `uri` with `tab_size` /
/// `insert_spaces` formatting options. The response is absorbed
/// into the formatting store at `(sid, uri)`. Returns the
@ -2153,6 +2257,37 @@ impl LspManager {
.expect("locations store mutex poisoned");
guard.set(key, resp);
}
ResponseRoute::DocumentSymbol { uri } => {
// DocumentSymbol items carry no URI — they belong to
// the requested document; pass it as the fallback.
let resp = crate::symbol::SymbolResponse::from_lsp_value(result, uri);
let key = crate::symbol::SymbolKey::document(server_key, uri.clone());
let mut guard = self
.symbol_store
.lock()
.expect("symbol store mutex poisoned");
guard.set(key, resp);
}
ResponseRoute::WorkspaceSymbol { query } => {
let resp = crate::symbol::SymbolResponse::from_lsp_value(result, "");
let key = crate::symbol::SymbolKey::workspace(server_key, query.clone());
let mut guard = self
.symbol_store
.lock()
.expect("symbol store mutex poisoned");
guard.set(key, resp);
}
ResponseRoute::DocumentHighlight { uri } => {
let resp =
crate::document_highlight::DocumentHighlightResponse::from_lsp_value(result);
let key =
crate::document_highlight::DocumentHighlightKey::new(server_key, uri.clone());
let mut guard = self
.document_highlight_store
.lock()
.expect("document highlight store mutex poisoned");
guard.set(key, resp);
}
ResponseRoute::Formatting { uri } => {
let resp = crate::formatting::FormattingResponse::from_lsp_value(result);
let key = crate::formatting::FormattingKey::new(server_key, uri.clone());

View File

@ -7264,6 +7264,50 @@ pub fn install_lsp(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
)?;
}
{
let m = manager.clone();
lsp_mod.set(
"_request_document_symbol_raw",
lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| {
let job_id = m
.borrow_mut()
.request_document_symbol(id.0, uri)
.map_err(mlua::Error::external)?;
Ok(job_id)
})?,
)?;
}
{
let m = manager.clone();
lsp_mod.set(
"_request_workspace_symbol_raw",
lua.create_function(move |_, (id, query): (LspServerIdLua, String)| {
let job_id = m
.borrow_mut()
.request_workspace_symbol(id.0, query)
.map_err(mlua::Error::external)?;
Ok(job_id)
})?,
)?;
}
{
let m = manager.clone();
lsp_mod.set(
"_request_document_highlight_raw",
lua.create_function(
move |_, (id, uri, line, col): (LspServerIdLua, String, u32, u32)| {
let job_id = m
.borrow_mut()
.request_document_highlight(id.0, uri, line, col)
.map_err(mlua::Error::external)?;
Ok(job_id)
},
)?,
)?;
}
{
let m = manager.clone();
lsp_mod.set(
@ -7518,6 +7562,8 @@ pub fn make_lsp_manager(
install_signature(lua, &manager)?;
install_definition(lua, &manager)?;
install_locations(lua, &manager)?;
install_symbol(lua, &manager)?;
install_document_highlight(lua, &manager)?;
install_formatting(lua, &manager)?;
Ok(manager)
}
@ -8315,10 +8361,12 @@ pub fn install_diag(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
use crate::completion::{CompletionItem, CompletionItemKind, CompletionKey, CompletionTriggers};
use crate::definition::{DefinitionKey, DefinitionLocation, DefinitionResponse};
use crate::document_highlight::{DocumentHighlightKey, Highlight};
use crate::formatting::{FormattingKey, FormattingResponse, TextEdit};
use crate::hover::{Hover, HoverKey};
use crate::locations::{LocationKind, LocationsKey};
use crate::signature::{Signature, SignatureHelp, SignatureKey, SignatureParameter};
use crate::symbol::{Symbol as LspSymbol, SymbolKey};
fn completion_item_to_lua(lua: &Lua, item: &CompletionItem) -> mlua::Result<Table> {
let t = lua.create_table_with_capacity(0, 7)?;
@ -8784,6 +8832,147 @@ pub fn install_locations(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<
Ok(())
}
fn symbol_to_lua(lua: &Lua, s: &LspSymbol) -> mlua::Result<Table> {
let t = lua.create_table_with_capacity(0, 7)?;
t.set("name", s.name.as_str())?;
t.set("kind", s.kind)?;
t.set("uri", s.uri.as_str())?;
t.set("line", s.line)?;
t.set("col", s.col)?;
t.set("depth", s.depth)?;
if let Some(c) = &s.container {
t.set("container", c.as_str())?;
}
Ok(t)
}
fn highlight_to_lua(lua: &Lua, h: &Highlight) -> mlua::Result<Table> {
let t = lua.create_table_with_capacity(0, 5)?;
t.set("start_line", h.start_line)?;
t.set("start_col", h.start_col)?;
t.set("end_line", h.end_line)?;
t.set("end_col", h.end_col)?;
t.set("kind", h.kind)?;
Ok(t)
}
/// Install `pmacs.document_symbol` (`symbols(sid,uri)` / `clear`) and
/// `pmacs.workspace_symbol` (`symbols(sid,query)` / `clear`) over the
/// scope-keyed symbol store. T M4.5.
pub fn install_symbol(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
let pmacs: Table = lua.globals().get("pmacs")?;
let doc = lua.create_table()?;
{
let mgr = manager.clone();
doc.set(
"symbols",
lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| {
let store = mgr.borrow().symbol_store();
let guard = store.lock().expect("symbol store mutex poisoned");
let key = SymbolKey::document(id.0.raw().to_string(), uri);
let out = lua.create_table()?;
if let Some(r) = guard.get(&key) {
for (i, s) in r.symbols.iter().enumerate() {
out.set(i + 1, symbol_to_lua(lua, s)?)?;
}
}
Ok(Value::Table(out))
})?,
)?;
}
{
let mgr = manager.clone();
doc.set(
"clear",
lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| {
let store = mgr.borrow().symbol_store();
let mut guard = store.lock().expect("symbol store mutex poisoned");
guard.clear(&SymbolKey::document(id.0.raw().to_string(), uri));
Ok(())
})?,
)?;
}
pmacs.set("document_symbol", doc)?;
let ws = lua.create_table()?;
{
let mgr = manager.clone();
ws.set(
"symbols",
lua.create_function(move |lua, (id, query): (LspServerIdLua, String)| {
let store = mgr.borrow().symbol_store();
let guard = store.lock().expect("symbol store mutex poisoned");
let key = SymbolKey::workspace(id.0.raw().to_string(), query);
let out = lua.create_table()?;
if let Some(r) = guard.get(&key) {
for (i, s) in r.symbols.iter().enumerate() {
out.set(i + 1, symbol_to_lua(lua, s)?)?;
}
}
Ok(Value::Table(out))
})?,
)?;
}
{
let mgr = manager.clone();
ws.set(
"clear",
lua.create_function(move |_, (id, query): (LspServerIdLua, String)| {
let store = mgr.borrow().symbol_store();
let mut guard = store.lock().expect("symbol store mutex poisoned");
guard.clear(&SymbolKey::workspace(id.0.raw().to_string(), query));
Ok(())
})?,
)?;
}
pmacs.set("workspace_symbol", ws)?;
Ok(())
}
/// Install `pmacs.document_highlight` (`highlights(sid,uri)` /
/// `clear`). T M4.5.
pub fn install_document_highlight(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
let pmacs: Table = lua.globals().get("pmacs")?;
let m = lua.create_table()?;
{
let mgr = manager.clone();
m.set(
"highlights",
lua.create_function(move |lua, (id, uri): (LspServerIdLua, String)| {
let store = mgr.borrow().document_highlight_store();
let guard = store
.lock()
.expect("document highlight store mutex poisoned");
let key = DocumentHighlightKey::new(id.0.raw().to_string(), uri);
let out = lua.create_table()?;
if let Some(r) = guard.get(&key) {
for (i, h) in r.highlights.iter().enumerate() {
out.set(i + 1, highlight_to_lua(lua, h)?)?;
}
}
Ok(Value::Table(out))
})?,
)?;
}
{
let mgr = manager.clone();
m.set(
"clear",
lua.create_function(move |_, (id, uri): (LspServerIdLua, String)| {
let store = mgr.borrow().document_highlight_store();
let mut guard = store
.lock()
.expect("document highlight store mutex poisoned");
guard.clear(&DocumentHighlightKey::new(id.0.raw().to_string(), uri));
Ok(())
})?,
)?;
}
pmacs.set("document_highlight", m)?;
Ok(())
}
fn text_edit_to_lua(lua: &Lua, edit: &TextEdit) -> mlua::Result<Table> {
let t = lua.create_table_with_capacity(0, 5)?;
t.set("start_line", edit.start_line)?;

View File

@ -3943,13 +3943,19 @@ mod tests {
buffer_id: bid,
items: vec![BlockAdornment {
at: 64,
replaces: Some(ByteRange { start: 64, end: 256 }),
replaces: Some(ByteRange {
start: 64,
end: 256,
}),
content: AdornmentContent::Resource { handle: 1 },
}],
},
InstanceMessage::FoldState {
buffer_id: bid,
folds: vec![ByteRange { start: 100, end: 400 }],
folds: vec![ByteRange {
start: 100,
end: 400,
}],
},
InstanceMessage::ResourceOffer {
handle: 1,

View File

@ -332,10 +332,12 @@ impl SemanticClient {
/// declared viewport, or no styling there).
#[must_use]
pub fn effective_style_at(&self, buffer_id: BufferId, byte: u64) -> Style {
self.styles.get(&buffer_id).map_or_else(Style::default, |m| {
m.items_at(byte)
.fold(Style::default(), |acc, s| merge_styles(acc, s.style))
})
self.styles
.get(&buffer_id)
.map_or_else(Style::default, |m| {
m.items_at(byte)
.fold(Style::default(), |acc, s| merge_styles(acc, s.style))
})
}
/// The decoration kinds covering `byte`, in instance order
@ -343,9 +345,9 @@ impl SemanticClient {
/// selection and a diagnostic).
#[must_use]
pub fn decoration_kinds_at(&self, buffer_id: BufferId, byte: u64) -> Vec<DecorationKind> {
self.decos.get(&buffer_id).map_or_else(Vec::new, |m| {
m.items_at(byte).map(|d| d.kind).collect()
})
self.decos
.get(&buffer_id)
.map_or_else(Vec::new, |m| m.items_at(byte).map(|d| d.kind).collect())
}
/// Reconstructed styling tile ranges for `buffer_id` — for

View File

@ -246,11 +246,7 @@ impl SemanticRenderState {
/// frontend already owns (it has `CursorByte`) — emitting it would
/// couple a visual-motion concern to the instance, against the
/// contract boundary.
fn scoped_decorations(
&self,
state: &EditorState,
vp: &DeclaredViewport,
) -> Vec<Decoration> {
fn scoped_decorations(&self, state: &EditorState, vp: &DeclaredViewport) -> Vec<Decoration> {
let core = state.core.borrow();
let mut out = Vec::new();
@ -585,7 +581,10 @@ mod tests {
msgs.iter().find_map(|m| match m {
InstanceMessage::Decorations { full, segments, .. } => Some((
*full,
segments.iter().flat_map(|s| s.decorations.clone()).collect(),
segments
.iter()
.flat_map(|s| s.decorations.clone())
.collect(),
)),
_ => None,
})
@ -655,7 +654,14 @@ mod tests {
let state = empty_state();
let mut s = local();
let buffer_id = active_buffer(&state);
s.set_viewport(buffer_id, ByteRange { start: 0, end: 4096 }, 0);
s.set_viewport(
buffer_id,
ByteRange {
start: 0,
end: 4096,
},
0,
);
// Empty scratch: no spans, no selection, no diagnostics — but
// the first frame is a `full` resync for both families (the
@ -745,11 +751,21 @@ mod tests {
// Declaring a different on-screen range forces a full resync:
// prior styling/decorations are positioned for the old window.
s.set_viewport(buffer_id, ByteRange { start: 200, end: 264 }, 0);
s.set_viewport(
buffer_id,
ByteRange {
start: 200,
end: 264,
},
0,
);
let msgs = s.render_frame(&state);
let (style_full, _) = style_segments(&msgs).expect("StyleSpans");
let (deco_full, _) = decorations_of(&msgs).expect("Decorations");
assert!(style_full && deco_full, "viewport jump must be a full resync");
assert!(
style_full && deco_full,
"viewport jump must be a full resync"
);
}
#[test]

311
src/symbol.rs Normal file
View File

@ -0,0 +1,311 @@
// symbol.rs --- T M4.5: documentSymbol / workspace symbol.
//! Shared store for `textDocument/documentSymbol` and
//! `workspace/symbol`. Both ultimately describe "a named program
//! entity at a location", so one flat [`Symbol`] type serves both;
//! the only differences are (a) the request response can arrive in
//! two LSP shapes — hierarchical `DocumentSymbol[]` or flat
//! `SymbolInformation[]` / `WorkspaceSymbol[]` — handled by
//! [`SymbolResponse::from_lsp_value`], and (b) the store is keyed by
//! a [`SymbolScope`] so a per-document outline and a workspace query
//! don't collide.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde_json::Value;
/// One symbol, flattened. `kind` is the raw LSP `SymbolKind` integer
/// (1..=26); consumers map it to a label. Coordinates are LSP-native
/// (the inbound position codec rewrites them to byte offsets before
/// this parses, for the document-symbol case where they belong to
/// the requested doc; workspace-symbol locations are cross-file and
/// pass through unconverted).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Symbol {
/// Symbol name.
pub name: String,
/// Raw LSP `SymbolKind` (1..=26).
pub kind: i64,
/// Containing document URI.
pub uri: String,
/// Zero-based line of the symbol's name.
pub line: u32,
/// Zero-based column of the symbol's name.
pub col: u32,
/// `containerName` (flat shapes) or the parent chain joined with
/// `::` (hierarchical), if any.
pub container: Option<String>,
/// Nesting depth in a hierarchical `DocumentSymbol` tree; 0 for
/// flat shapes.
pub depth: u32,
}
/// Parsed symbol response: a flat, source-ordered list.
#[derive(Clone, Debug, Default)]
pub struct SymbolResponse {
/// Symbols in document/source order, parents before children.
pub symbols: Vec<Symbol>,
}
fn range_start(v: &Value, key: &str) -> Option<(u32, u32)> {
let start = v.get(key)?.get("start")?;
Some((
start.get("line")?.as_u64()? as u32,
start.get("character")?.as_u64()? as u32,
))
}
impl SymbolResponse {
/// Parse `DocumentSymbol[] | SymbolInformation[] |
/// WorkspaceSymbol[] | null`. `default_uri` is the requested
/// document — used for `DocumentSymbol`, which carries no URI
/// (its positions are relative to the requested document).
#[must_use]
pub fn from_lsp_value(v: &Value, default_uri: &str) -> Self {
let mut out = Vec::new();
if let Some(arr) = v.as_array() {
for item in arr {
if item.get("location").is_some() {
// SymbolInformation / WorkspaceSymbol (flat).
Self::push_flat(item, &mut out);
} else {
// DocumentSymbol (hierarchical).
Self::push_hier(item, default_uri, None, 0, &mut out);
}
}
}
Self { symbols: out }
}
fn push_flat(item: &Value, out: &mut Vec<Symbol>) {
let Some(name) = item.get("name").and_then(Value::as_str) else {
return;
};
let kind = item.get("kind").and_then(Value::as_i64).unwrap_or(0);
let loc = item.get("location");
let uri = loc
.and_then(|l| l.get("uri"))
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
// WorkspaceSymbol may carry `location: { uri }` with no range.
let (line, col) = loc.and_then(|l| range_start(l, "range")).unwrap_or((0, 0));
let container = item
.get("containerName")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
out.push(Symbol {
name: name.to_owned(),
kind,
uri,
line,
col,
container,
depth: 0,
});
}
fn push_hier(item: &Value, uri: &str, parent: Option<&str>, depth: u32, out: &mut Vec<Symbol>) {
let Some(name) = item.get("name").and_then(Value::as_str) else {
return;
};
let kind = item.get("kind").and_then(Value::as_i64).unwrap_or(0);
// `selectionRange` is the name; `range` the whole body.
let (line, col) = range_start(item, "selectionRange")
.or_else(|| range_start(item, "range"))
.unwrap_or((0, 0));
out.push(Symbol {
name: name.to_owned(),
kind,
uri: uri.to_owned(),
line,
col,
container: parent.map(ToOwned::to_owned),
depth,
});
if let Some(children) = item.get("children").and_then(Value::as_array) {
let child_parent = match parent {
Some(p) => format!("{p}::{name}"),
None => name.to_owned(),
};
for c in children {
Self::push_hier(c, uri, Some(&child_parent), depth + 1, out);
}
}
}
/// True iff the server returned no symbols.
#[must_use]
pub fn is_empty(&self) -> bool {
self.symbols.is_empty()
}
}
/// What a stored [`SymbolResponse`] answers.
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub enum SymbolScope {
/// `textDocument/documentSymbol` for a document URI.
Document(String),
/// `workspace/symbol` for a query string.
Workspace(String),
}
/// Key into [`SymbolStore`].
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct SymbolKey {
/// Decimal LSP server id.
pub server: String,
/// Document URI or workspace query.
pub scope: SymbolScope,
}
impl SymbolKey {
/// Key for a document outline.
#[must_use]
pub fn document(server: impl Into<String>, uri: impl Into<String>) -> Self {
Self {
server: server.into(),
scope: SymbolScope::Document(uri.into()),
}
}
/// Key for a workspace query.
#[must_use]
pub fn workspace(server: impl Into<String>, query: impl Into<String>) -> Self {
Self {
server: server.into(),
scope: SymbolScope::Workspace(query.into()),
}
}
}
/// Per-`(server, scope)` symbol state.
#[derive(Default)]
pub struct SymbolStore {
by_key: HashMap<SymbolKey, SymbolResponse>,
}
impl SymbolStore {
/// Empty store.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Replace the response at `key`.
pub fn set(&mut self, key: SymbolKey, response: SymbolResponse) {
self.by_key.insert(key, response);
}
/// Drop the entry at `key`.
pub fn clear(&mut self, key: &SymbolKey) {
self.by_key.remove(key);
}
/// Look up the entry at `key`.
#[must_use]
pub fn get(&self, key: &SymbolKey) -> Option<&SymbolResponse> {
self.by_key.get(key)
}
}
/// Cheaply-cloneable shared handle.
pub type SharedSymbolStore = Arc<Mutex<SymbolStore>>;
/// Build a fresh shared store.
#[must_use]
pub fn make_shared_store() -> SharedSymbolStore {
Arc::new(Mutex::new(SymbolStore::new()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_hierarchical_document_symbols_with_depth_and_parent() {
let v = json!([{
"name": "Outer", "kind": 5,
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 9, "character": 0 } },
"selectionRange": { "start": { "line": 0, "character": 6 }, "end": { "line": 0, "character": 11 } },
"children": [{
"name": "method", "kind": 6,
"range": { "start": { "line": 2, "character": 2 }, "end": { "line": 4, "character": 2 } },
"selectionRange": { "start": { "line": 2, "character": 7 }, "end": { "line": 2, "character": 13 } }
}]
}]);
let r = SymbolResponse::from_lsp_value(&v, "file:///m.rs");
assert_eq!(r.symbols.len(), 2);
assert_eq!(r.symbols[0].name, "Outer");
assert_eq!(r.symbols[0].depth, 0);
assert_eq!((r.symbols[0].line, r.symbols[0].col), (0, 6)); // selectionRange
assert_eq!(r.symbols[0].uri, "file:///m.rs"); // DocumentSymbol carries none
assert_eq!(r.symbols[1].name, "method");
assert_eq!(r.symbols[1].depth, 1);
assert_eq!(r.symbols[1].container.as_deref(), Some("Outer"));
}
#[test]
fn parses_flat_symbol_information() {
let v = json!([{
"name": "Thing", "kind": 23,
"location": {
"uri": "file:///a.rs",
"range": { "start": { "line": 4, "character": 8 }, "end": { "line": 4, "character": 13 } }
},
"containerName": "modx"
}]);
let r = SymbolResponse::from_lsp_value(&v, "file:///ignored");
assert_eq!(r.symbols.len(), 1);
assert_eq!(r.symbols[0].uri, "file:///a.rs"); // from location, not default
assert_eq!((r.symbols[0].line, r.symbols[0].col), (4, 8));
assert_eq!(r.symbols[0].container.as_deref(), Some("modx"));
assert_eq!(r.symbols[0].depth, 0);
}
#[test]
fn workspace_symbol_without_range_defaults_to_origin() {
// WorkspaceSymbol permits `location: { uri }` with no range.
let v = json!([{ "name": "Z", "kind": 12, "location": { "uri": "file:///z.go" } }]);
let r = SymbolResponse::from_lsp_value(&v, "");
assert_eq!(r.symbols.len(), 1);
assert_eq!(r.symbols[0].uri, "file:///z.go");
assert_eq!((r.symbols[0].line, r.symbols[0].col), (0, 0));
}
#[test]
fn null_is_empty_and_scopes_do_not_collide() {
assert!(SymbolResponse::from_lsp_value(&Value::Null, "x").is_empty());
let mut s = SymbolStore::new();
let one = |n: &str| SymbolResponse {
symbols: vec![Symbol {
name: n.into(),
kind: 1,
uri: "u".into(),
line: 0,
col: 0,
container: None,
depth: 0,
}],
};
s.set(SymbolKey::document("1", "file:///a"), one("doc"));
s.set(SymbolKey::workspace("1", "file:///a"), one("ws"));
assert_eq!(
s.get(&SymbolKey::document("1", "file:///a"))
.unwrap()
.symbols[0]
.name,
"doc"
);
assert_eq!(
s.get(&SymbolKey::workspace("1", "file:///a"))
.unwrap()
.symbols[0]
.name,
"ws"
);
}
}

View File

@ -4015,3 +4015,67 @@ fn m4_5_location_nav_requests_route_by_kind() {
assert_eq!(tdef, 31, "typeDefinition must route to its own slot");
assert_eq!(impl_, 41, "implementation must route to its own slot");
}
/// T M4.5 symbols/highlight: documentSymbol (hierarchical → flattened
/// with depth + parent), workspace/symbol (flat, location.uri), and
/// documentHighlight (range + kind, default 1) each await end-to-end
/// and land in their store with the right shape.
#[test]
fn m4_5_symbols_and_highlight_round_trip() {
use pmacs::editor::EditorState;
let mut state = EditorState::new();
spawn_lsp_and_init(&mut state, None);
state
.lua_host
.lua()
.load(
"local uri='file:///s.rs'
pmacs.lsp.did_open(_G._lsp, uri, 1, 'mod m {}\\n')
_G._done=false
pmacs.async(function()
pmacs.lsp.request_document_symbol(_G._lsp, uri):await()
pmacs.lsp.request_workspace_symbol(_G._lsp, 'q'):await()
pmacs.lsp.request_document_highlight(_G._lsp, uri, 0, 4):await()
local ds = pmacs.document_symbol.symbols(_G._lsp, uri)
local ws = pmacs.workspace_symbol.symbols(_G._lsp, 'q')
local dh = pmacs.document_highlight.highlights(_G._lsp, uri)
_G._ds_n = ds and #ds or 0
_G._ds1 = ds and ds[1] and ds[1].name or ''
_G._ds2 = ds and ds[2] and ds[2].name or ''
_G._ds2d = ds and ds[2] and ds[2].depth or -1
_G._ds2c = ds and ds[2] and ds[2].container or ''
_G._ws_uri = ws and ws[1] and ws[1].uri or ''
_G._ws_ctr = ws and ws[1] and ws[1].container or ''
_G._dh_n = dh and #dh or 0
_G._dh1k = dh and dh[1] and dh[1].kind or -1
_G._dh2k = dh and dh[2] and dh[2].kind or -1
_G._done=true
end)",
)
.exec()
.expect("dispatch symbols/highlight coroutine");
assert!(
pump_lua_flag(&mut state, "_G._done", 5),
"symbols/highlight coroutine never completed"
);
let lua = state.lua_host.lua();
let g = |k: &str| -> String {
lua.load(format!("return tostring(_G.{k})"))
.eval()
.unwrap_or_default()
};
assert_eq!(g("_ds_n"), "2", "documentSymbol flattens parent+child");
assert_eq!(g("_ds1"), "Outer");
assert_eq!(g("_ds2"), "inner");
assert_eq!(g("_ds2d"), "1", "child depth is 1");
assert_eq!(g("_ds2c"), "Outer", "child container is the parent");
assert_eq!(
g("_ws_uri"),
"file:///ws.rs",
"workspace symbol location.uri"
);
assert_eq!(g("_ws_ctr"), "modw");
assert_eq!(g("_dh_n"), "2");
assert_eq!(g("_dh1k"), "2", "explicit DocumentHighlightKind (Read)");
assert_eq!(g("_dh2k"), "1", "absent kind defaults to Text(1)");
}