use std::sync::Arc;
use lex_core::lex::ast::{
Annotation, ContentItem, Document, Position as AstPosition, Session, Verbatim,
};
use lex_core::lex::wire::to_wire_node;
use lex_extension::wire::{HostNodeKind, WireNode};
use lex_extension::{AnnotationBody, LabelCtx, NodeRef};
use lex_extension_host::Registry;
use lex_fmt::{BootDiagnostic, BootOutcome, RegisteredNamespace};
use tower_lsp::lsp_types::{
self as lsp, CodeAction as LspCodeAction, CompletionItem as LspCompletionItem,
Hover as LspHover, HoverContents, MarkupContent, MarkupKind, Range as LspRange, TextEdit, Url,
WorkspaceEdit,
};
pub struct LspExtensionState {
pub registry: Arc<Registry>,
#[allow(dead_code)]
pub boot_diagnostics: Vec<BootDiagnostic>,
#[allow(dead_code)]
pub registered: Vec<RegisteredNamespace>,
}
impl From<BootOutcome> for LspExtensionState {
fn from(outcome: BootOutcome) -> Self {
Self {
registry: outcome.registry,
boot_diagnostics: outcome.diagnostics,
registered: outcome.registered,
}
}
}
pub fn dispatch_hover(
document: &Document,
position: AstPosition,
registry: &Registry,
) -> Option<LspHover> {
if registry.namespace_count() == 0 {
return None;
}
let ctx = build_ctx_at_position(document, position)?;
registry.schema_for(&ctx.label)?;
match registry.dispatch_hover(&ctx) {
Ok(Some(h)) => Some(translate_hover(h)),
_ => None,
}
}
pub fn dispatch_completion(
document: &Document,
position: AstPosition,
registry: &Registry,
) -> Vec<LspCompletionItem> {
if registry.namespace_count() == 0 {
return Vec::new();
}
let Some(ctx) = build_ctx_at_position(document, position) else {
return Vec::new();
};
if registry.schema_for(&ctx.label).is_none() {
return Vec::new();
}
registry
.dispatch_completion(&ctx)
.into_iter()
.map(translate_completion)
.collect()
}
pub fn dispatch_code_action(
document: &Document,
start_position: AstPosition,
document_uri: &Url,
registry: &Registry,
) -> Vec<LspCodeAction> {
if registry.namespace_count() == 0 {
return Vec::new();
}
let Some(ctx) = build_ctx_at_position(document, start_position) else {
return Vec::new();
};
if registry.schema_for(&ctx.label).is_none() {
return Vec::new();
}
registry
.dispatch_code_action(&ctx)
.into_iter()
.map(|a| translate_code_action(a, document_uri))
.collect()
}
enum LabelledHit<'a> {
Annotation {
ann: &'a Annotation,
host_kind: HostNodeKind,
},
Verbatim {
v: &'a Verbatim,
},
}
fn build_ctx_at_position(document: &Document, position: AstPosition) -> Option<LabelCtx> {
let hit = find_labelled_at_position(document, position)?;
match hit {
LabelledHit::Annotation { ann, host_kind } => {
let label = ann.data.label.value.clone();
if label.is_empty() {
return None;
}
let wire = to_wire_node(&ContentItem::Annotation(ann.clone()));
let WireNode::Annotation {
params,
body,
range,
origin,
..
} = wire
else {
return None;
};
let body =
serde_json::from_value::<AnnotationBody>(body).unwrap_or(AnnotationBody::None);
Some(LabelCtx {
label,
params,
body,
node: NodeRef {
kind: host_kind.as_str().to_string(),
range,
origin,
},
})
}
LabelledHit::Verbatim { v } => {
let label = v.closing_data.label.value.clone();
if label.is_empty() {
return None;
}
let wire = to_wire_node(&ContentItem::VerbatimBlock(Box::new(v.clone())));
let WireNode::Verbatim {
params,
body_text,
range,
origin,
..
} = wire
else {
return None;
};
Some(LabelCtx {
label,
params,
body: AnnotationBody::Text(body_text),
node: NodeRef {
kind: HostNodeKind::Verbatim.as_str().to_string(),
range,
origin,
},
})
}
}
}
fn find_labelled_at_position(
document: &Document,
position: AstPosition,
) -> Option<LabelledHit<'_>> {
for ann in document.annotations() {
if let Some(hit) = visit_annotation(ann, HostNodeKind::Document, position) {
return Some(hit);
}
}
walk_session(&document.root, HostNodeKind::Session, position)
}
fn walk_session(
s: &Session,
self_kind: HostNodeKind,
position: AstPosition,
) -> Option<LabelledHit<'_>> {
for ann in s.annotations() {
if let Some(hit) = visit_annotation(ann, self_kind, position) {
return Some(hit);
}
}
for child in s.children.iter() {
if let Some(hit) = visit_content(child, position) {
return Some(hit);
}
}
None
}
fn visit_content(item: &ContentItem, position: AstPosition) -> Option<LabelledHit<'_>> {
match item {
ContentItem::Paragraph(p) => {
for ann in p.annotations() {
if let Some(hit) = visit_annotation(ann, HostNodeKind::Paragraph, position) {
return Some(hit);
}
}
}
ContentItem::Session(s) => return walk_session(s, HostNodeKind::Session, position),
ContentItem::Definition(d) => {
for ann in d.annotations() {
if let Some(hit) = visit_annotation(ann, HostNodeKind::Definition, position) {
return Some(hit);
}
}
for child in d.children.iter() {
if let Some(hit) = visit_content(child, position) {
return Some(hit);
}
}
}
ContentItem::List(list) => {
for ann in list.annotations() {
if let Some(hit) = visit_annotation(ann, HostNodeKind::List, position) {
return Some(hit);
}
}
for entry in &list.items {
if let ContentItem::ListItem(li) = entry {
for ann in li.annotations() {
if let Some(hit) = visit_annotation(ann, HostNodeKind::ListItem, position) {
return Some(hit);
}
}
for child in li.children.iter() {
if let Some(hit) = visit_content(child, position) {
return Some(hit);
}
}
}
}
}
ContentItem::Annotation(a) => {
if let Some(hit) = visit_annotation(a, HostNodeKind::Annotation, position) {
return Some(hit);
}
}
ContentItem::Table(table) => {
for child in table.cell_children_iter() {
if let Some(hit) = visit_content(child, position) {
return Some(hit);
}
}
}
ContentItem::VerbatimBlock(v) => {
if v.location.contains(position) {
return Some(LabelledHit::Verbatim { v: v.as_ref() });
}
}
_ => {}
}
None
}
fn visit_annotation<'a>(
ann: &'a Annotation,
host_kind: HostNodeKind,
position: AstPosition,
) -> Option<LabelledHit<'a>> {
if ann.header_location().contains(position) {
return Some(LabelledHit::Annotation { ann, host_kind });
}
for child in ann.children.iter() {
if let Some(hit) = visit_content(child, position) {
return Some(hit);
}
}
None
}
fn translate_hover(h: lex_extension::wire::Hover) -> LspHover {
use lex_extension::wire::HoverFormat;
let kind = match h.format {
HoverFormat::Markdown => MarkupKind::Markdown,
_ => MarkupKind::PlainText,
};
LspHover {
contents: HoverContents::Markup(MarkupContent {
kind,
value: h.contents,
}),
range: h.range.map(to_lsp_range),
}
}
fn translate_completion(c: lex_extension::wire::Completion) -> LspCompletionItem {
use lex_extension::wire::CompletionKind;
let kind = match c.kind {
CompletionKind::Value => Some(lsp::CompletionItemKind::VALUE),
CompletionKind::Param => Some(lsp::CompletionItemKind::PROPERTY),
CompletionKind::Namespace => Some(lsp::CompletionItemKind::MODULE),
CompletionKind::Snippet => Some(lsp::CompletionItemKind::SNIPPET),
_ => Some(lsp::CompletionItemKind::VALUE),
};
LspCompletionItem {
label: c.label,
kind,
detail: c.detail,
documentation: c.doc.map(|d| {
lsp::Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: d,
})
}),
insert_text: Some(c.insert),
..Default::default()
}
}
fn translate_code_action(a: lex_extension::wire::CodeAction, document_uri: &Url) -> LspCodeAction {
use lex_extension::wire::CodeActionKind as WireKind;
let kind = match a.kind {
WireKind::Quickfix => Some(lsp::CodeActionKind::QUICKFIX),
WireKind::Refactor => Some(lsp::CodeActionKind::REFACTOR),
WireKind::Source => Some(lsp::CodeActionKind::SOURCE),
_ => Some(lsp::CodeActionKind::REFACTOR),
};
let mut changes: std::collections::HashMap<Url, Vec<TextEdit>> =
std::collections::HashMap::new();
for e in a.edits {
let target = match &e.uri {
Some(s) => match Url::parse(s) {
Ok(u) => u,
Err(parse_err) => {
eprintln!(
"[lexd-lsp] dropping code-action edit with invalid uri `{s}`: {parse_err}"
);
continue;
}
},
None => document_uri.clone(),
};
changes.entry(target).or_default().push(TextEdit {
range: to_lsp_range(e.range),
new_text: e.new_text,
});
}
let edit = if changes.is_empty() {
None
} else {
Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
})
};
LspCodeAction {
title: a.title,
kind,
edit,
..Default::default()
}
}
fn to_lsp_range(r: lex_extension::wire::Range) -> LspRange {
LspRange {
start: lsp::Position {
line: r.start.0,
character: r.start.1,
},
end: lsp::Position {
line: r.end.0,
character: r.end.1,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use lex_core::lex::parsing::parse_document;
use lex_extension::schema::Schema;
use lex_extension::wire::{
CodeActionKind as WireCodeActionKind, Completion, CompletionKind, HoverFormat,
Position as WirePosition, Range as WireRange, TextEdit as WireTextEdit,
};
use lex_extension::{HandlerError, LexHandler};
fn r(s_l: u32, s_c: u32, e_l: u32, e_c: u32) -> WireRange {
WireRange {
start: WirePosition(s_l, s_c),
end: WirePosition(e_l, e_c),
}
}
struct FixtureHandler;
impl LexHandler for FixtureHandler {
fn on_hover(
&self,
ctx: &LabelCtx,
) -> Result<Option<lex_extension::wire::Hover>, HandlerError> {
Ok(Some(lex_extension::wire::Hover {
contents: format!("hover for `{}`", ctx.label),
format: HoverFormat::Markdown,
range: Some(ctx.node.range),
}))
}
fn on_completion(&self, _ctx: &LabelCtx) -> Result<Vec<Completion>, HandlerError> {
Ok(vec![Completion {
label: "fixture-item".into(),
detail: None,
doc: None,
insert: "fixture-insert".into(),
kind: CompletionKind::Snippet,
}])
}
fn on_code_action(
&self,
_ctx: &LabelCtx,
) -> Result<Vec<lex_extension::wire::CodeAction>, HandlerError> {
Ok(vec![lex_extension::wire::CodeAction {
title: "Fix it".into(),
kind: WireCodeActionKind::Quickfix,
edits: vec![WireTextEdit {
range: r(0, 0, 0, 5),
new_text: "x".into(),
uri: None,
}],
}])
}
}
fn registry_with_fixture() -> (Registry, String) {
let yaml = "schema_version: 1\n\
label: acme.task\n\
hooks: { hover: true, completion: true, code_action: true }\n";
let schema: Schema = serde_yaml::from_str(yaml).expect("parse fixture schema");
let registry = Registry::new();
registry
.register_namespace("acme", vec![schema], Box::new(FixtureHandler))
.expect("register acme");
let source = ":: acme.task ::\n";
(registry, source.into())
}
#[test]
fn dispatch_hover_at_labelled_annotation_returns_translated_content() {
let (registry, source) = registry_with_fixture();
let document = parse_document(&source).expect("parse");
let position = AstPosition::new(0, 5); let hover = dispatch_hover(&document, position, ®istry).expect("hover content");
match hover.contents {
HoverContents::Markup(MarkupContent { kind, value }) => {
assert_eq!(kind, MarkupKind::Markdown);
assert!(value.contains("acme.task"), "got: {value}");
}
_ => panic!("expected markup"),
}
}
#[test]
fn dispatch_hover_off_labelled_annotation_returns_none() {
let (registry, _) = registry_with_fixture();
let document = parse_document("Plain paragraph.\n").expect("parse");
let position = AstPosition::new(0, 0);
assert!(dispatch_hover(&document, position, ®istry).is_none());
}
#[test]
fn dispatch_completion_at_labelled_annotation_returns_handler_items() {
let (registry, source) = registry_with_fixture();
let document = parse_document(&source).expect("parse");
let position = AstPosition::new(0, 5);
let items = dispatch_completion(&document, position, ®istry);
assert_eq!(items.len(), 1);
assert_eq!(items[0].label, "fixture-item");
assert_eq!(items[0].kind, Some(lsp::CompletionItemKind::SNIPPET));
}
#[test]
fn dispatch_code_action_at_labelled_annotation_returns_handler_actions() {
let (registry, source) = registry_with_fixture();
let document = parse_document(&source).expect("parse");
let document_uri = Url::parse("file:///workspace/host.lex").unwrap();
let actions =
dispatch_code_action(&document, AstPosition::new(0, 5), &document_uri, ®istry);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].title, "Fix it");
let changes = actions[0]
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.expect("changes");
assert!(changes.contains_key(&document_uri));
}
#[test]
fn host_kind_is_document_for_top_level_annotation() {
let document = parse_document(":: acme.task ::\n").expect("parse");
let hit = find_labelled_at_position(&document, AstPosition::new(0, 5)).expect("hit");
match hit {
LabelledHit::Annotation { host_kind, .. } => {
assert_eq!(host_kind, HostNodeKind::Document);
}
_ => panic!("expected Annotation, got Verbatim"),
}
}
#[test]
fn host_kind_is_paragraph_for_paragraph_annotation() {
let source = "Some paragraph text.\n:: acme.task ::\n";
let document = parse_document(source).expect("parse");
let hit = find_labelled_at_position(&document, AstPosition::new(1, 5));
if let Some(LabelledHit::Annotation { host_kind, .. }) = hit {
assert_ne!(
host_kind,
HostNodeKind::Annotation,
"annotation host_kind must reflect the AST parent, not the literal tag"
);
}
}
#[test]
fn dispatch_with_empty_registry_returns_nothing() {
let registry = Registry::new();
let document = parse_document(":: acme.task ::\n").expect("parse");
let position = AstPosition::new(0, 5);
assert!(dispatch_hover(&document, position, ®istry).is_none());
assert!(dispatch_completion(&document, position, ®istry).is_empty());
let document_uri = Url::parse("file:///workspace/host.lex").unwrap();
assert!(dispatch_code_action(&document, position, &document_uri, ®istry).is_empty());
}
#[test]
fn translate_hover_markdown_preserves_format_and_range() {
let h = lex_extension::wire::Hover {
contents: "**bold**".into(),
format: HoverFormat::Markdown,
range: Some(r(0, 0, 0, 5)),
};
let out = translate_hover(h);
match out.contents {
HoverContents::Markup(MarkupContent { kind, value }) => {
assert_eq!(kind, MarkupKind::Markdown);
assert_eq!(value, "**bold**");
}
_ => panic!("expected markup"),
}
assert!(out.range.is_some());
}
#[test]
fn translate_completion_preserves_label_insert_kind() {
let c = Completion {
label: "task".into(),
detail: Some("acme.task annotation".into()),
doc: Some("docs".into()),
insert: ":: acme.task ::".into(),
kind: CompletionKind::Snippet,
};
let out = translate_completion(c);
assert_eq!(out.label, "task");
assert_eq!(out.kind, Some(lsp::CompletionItemKind::SNIPPET));
assert_eq!(out.insert_text.as_deref(), Some(":: acme.task ::"));
assert!(out.documentation.is_some());
}
#[test]
fn translate_code_action_groups_edits_by_uri() {
let document_uri = Url::parse("file:///workspace/host.lex").unwrap();
let other_uri = "file:///workspace/other.lex";
let a = lex_extension::wire::CodeAction {
title: "Fix it".into(),
kind: WireCodeActionKind::Quickfix,
edits: vec![
WireTextEdit {
range: r(0, 0, 0, 5),
new_text: "x".into(),
uri: None,
},
WireTextEdit {
range: r(1, 0, 1, 5),
new_text: "y".into(),
uri: Some(other_uri.into()),
},
],
};
let out = translate_code_action(a, &document_uri);
assert_eq!(out.title, "Fix it");
assert_eq!(out.kind, Some(lsp::CodeActionKind::QUICKFIX));
let changes = out
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.expect("changes set");
assert_eq!(changes.len(), 2);
assert!(changes.contains_key(&document_uri));
assert!(changes.contains_key(&Url::parse(other_uri).unwrap()));
}
#[test]
fn translate_code_action_drops_edits_with_invalid_uri() {
let document_uri = Url::parse("file:///workspace/host.lex").unwrap();
let a = lex_extension::wire::CodeAction {
title: "Mixed".into(),
kind: WireCodeActionKind::Quickfix,
edits: vec![
WireTextEdit {
range: r(0, 0, 0, 5),
new_text: "x".into(),
uri: None,
},
WireTextEdit {
range: r(1, 0, 1, 5),
new_text: "y".into(),
uri: Some("not a url at all".into()),
},
],
};
let out = translate_code_action(a, &document_uri);
let changes = out
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.expect("changes set");
assert_eq!(changes.len(), 1, "garbage URI should be dropped");
let edits = changes.get(&document_uri).expect("document URI present");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "x");
}
#[test]
fn translate_code_action_no_edits_yields_no_workspace_edit() {
let document_uri = Url::parse("file:///workspace/host.lex").unwrap();
let a = lex_extension::wire::CodeAction {
title: "Refactor".into(),
kind: WireCodeActionKind::Refactor,
edits: vec![],
};
let out = translate_code_action(a, &document_uri);
assert!(out.edit.is_none());
}
}