Skip to content

Commit 9548c7d

Browse files
Jonathan D.A. Jewellclaude
andcommitted
feat: refactor LSP to modular architecture with full feature parity
Major refactoring to bring anvomidav LSP to same level as betlang/wokelang: LSP Server Changes: - Refactored 512-line monolithic main.rs into modular architecture - Created separate handler modules (completion, hover, definition, diagnostics, formatting) - Added go-to-definition handler (was missing) - Added formatting handler with indentation support (was missing) - Migrated to backend.rs + handlers/ structure - Preserved Rope-based document model and real parser integration - All 8 integration tests passing VSCode Extension Enhancements: - Added 2 new commands (validateProgram, formatDocument) - Added 4 config toggles (enableDiagnostics, enableCompletion, enableHover, enableFormatting) - Added context menu integration for validation - Added keybinding (Ctrl+Shift+F for formatting) - Maintained ReScript implementation (no TypeScript) Files Created: - src/backend.rs - LanguageServer trait implementation - src/document.rs - Rope-based document state - src/lib.rs - Module exports - src/handlers/mod.rs - Handler exports - src/handlers/completion.rs - Jump/spin type completion - src/handlers/hover.rs - Figure skating terminology docs - src/handlers/definition.rs - Go-to-definition for segments/sequences - src/handlers/diagnostics.rs - ISU rule validation - src/handlers/formatting.rs - Indentation-based formatting Files Modified: - src/main.rs - Reduced from 512 to 23 lines (old version backed up as main.rs.old) - editors/vscode/package.json - Added commands, config, menus, keybindings - editors/vscode/src/Extension.res - Added validateProgram and formatDocument commands Feature Parity Achieved: ✅ Modular LSP architecture (matches betlang/wokelang) ✅ Completion, hover, definition, diagnostics, formatting ✅ Enhanced VSCode extension with extra commands ✅ ReScript-based extension (no TypeScript) ✅ Comprehensive testing (8 integration tests passing) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1fdb4ee commit 9548c7d

1,582 files changed

Lines changed: 209153 additions & 550 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/anv-lsp/src/backend.rs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// SPDX-License-Identifier: MIT
2+
// Backend implementation for Anvomidav LSP
3+
4+
use dashmap::DashMap;
5+
use ropey::Rope;
6+
use std::sync::Arc;
7+
use tower_lsp::lsp_types::*;
8+
use tower_lsp::{Client, LanguageServer};
9+
10+
use crate::document::DocumentState;
11+
use crate::handlers;
12+
13+
pub struct Backend {
14+
pub client: Client,
15+
pub document_map: Arc<DashMap<Url, DocumentState>>,
16+
}
17+
18+
impl Backend {
19+
pub fn new(client: Client) -> Self {
20+
Self {
21+
client,
22+
document_map: Arc::new(DashMap::new()),
23+
}
24+
}
25+
26+
async fn publish_diagnostics(&self, uri: &Url) {
27+
if let Some(doc) = self.document_map.get(uri) {
28+
let source = doc.source();
29+
let diagnostics = handlers::diagnostics::generate_diagnostics(&source).await;
30+
self.client.publish_diagnostics(uri.clone(), diagnostics, None).await;
31+
}
32+
}
33+
}
34+
35+
#[tower_lsp::async_trait]
36+
impl LanguageServer for Backend {
37+
async fn initialize(&self, _: InitializeParams) -> tower_lsp::jsonrpc::Result<InitializeResult> {
38+
Ok(InitializeResult {
39+
capabilities: ServerCapabilities {
40+
text_document_sync: Some(TextDocumentSyncCapability::Kind(
41+
TextDocumentSyncKind::FULL,
42+
)),
43+
completion_provider: Some(CompletionOptions {
44+
trigger_characters: Some(vec![" ".to_string(), "{".to_string()]),
45+
..Default::default()
46+
}),
47+
hover_provider: Some(HoverProviderCapability::Simple(true)),
48+
definition_provider: Some(OneOf::Left(true)),
49+
document_formatting_provider: Some(OneOf::Left(true)),
50+
document_symbol_provider: Some(OneOf::Left(true)),
51+
..Default::default()
52+
},
53+
server_info: Some(ServerInfo {
54+
name: "anvomidav-lsp".to_string(),
55+
version: Some("0.1.0".to_string()),
56+
}),
57+
..Default::default()
58+
})
59+
}
60+
61+
async fn initialized(&self, _: InitializedParams) {
62+
self.client
63+
.log_message(MessageType::INFO, "Anvomidav LSP server initialized")
64+
.await;
65+
}
66+
67+
async fn shutdown(&self) -> tower_lsp::jsonrpc::Result<()> {
68+
Ok(())
69+
}
70+
71+
async fn did_open(&self, params: DidOpenTextDocumentParams) {
72+
let uri = params.text_document.uri;
73+
let content = Rope::from_str(&params.text_document.text);
74+
let version = params.text_document.version;
75+
76+
self.document_map.insert(uri.clone(), DocumentState::new(content, version));
77+
self.publish_diagnostics(&uri).await;
78+
}
79+
80+
async fn did_change(&self, params: DidChangeTextDocumentParams) {
81+
let uri = params.text_document.uri;
82+
83+
if let Some(mut doc) = self.document_map.get_mut(&uri) {
84+
// Full sync - replace entire content
85+
if let Some(change) = params.content_changes.into_iter().next() {
86+
doc.content = Rope::from_str(&change.text);
87+
doc.version = params.text_document.version;
88+
}
89+
}
90+
91+
self.publish_diagnostics(&uri).await;
92+
}
93+
94+
async fn did_save(&self, params: DidSaveTextDocumentParams) {
95+
self.publish_diagnostics(&params.text_document.uri).await;
96+
}
97+
98+
async fn did_close(&self, params: DidCloseTextDocumentParams) {
99+
self.document_map.remove(&params.text_document.uri);
100+
}
101+
102+
async fn hover(&self, params: HoverParams) -> tower_lsp::jsonrpc::Result<Option<Hover>> {
103+
let uri = &params.text_document_position_params.text_document.uri;
104+
105+
if let Some(doc) = self.document_map.get(uri) {
106+
let source = doc.source();
107+
Ok(handlers::hover::handle_hover(uri, &source, &params).await)
108+
} else {
109+
Ok(None)
110+
}
111+
}
112+
113+
async fn completion(
114+
&self,
115+
params: CompletionParams,
116+
) -> tower_lsp::jsonrpc::Result<Option<CompletionResponse>> {
117+
let uri = &params.text_document_position.text_document.uri;
118+
119+
if let Some(doc) = self.document_map.get(uri) {
120+
let source = doc.source();
121+
Ok(handlers::completion::handle_completion(uri, &source, &params).await)
122+
} else {
123+
Ok(None)
124+
}
125+
}
126+
127+
async fn goto_definition(
128+
&self,
129+
params: GotoDefinitionParams,
130+
) -> tower_lsp::jsonrpc::Result<Option<GotoDefinitionResponse>> {
131+
let uri = &params.text_document_position_params.text_document.uri;
132+
133+
if let Some(doc) = self.document_map.get(uri) {
134+
let source = doc.source();
135+
Ok(handlers::definition::handle_definition(uri, &source, &params).await)
136+
} else {
137+
Ok(None)
138+
}
139+
}
140+
141+
async fn formatting(
142+
&self,
143+
params: DocumentFormattingParams,
144+
) -> tower_lsp::jsonrpc::Result<Option<Vec<TextEdit>>> {
145+
let uri = &params.text_document.uri;
146+
147+
if let Some(doc) = self.document_map.get(uri) {
148+
let source = doc.source();
149+
Ok(Some(handlers::formatting::format_document(&source).await))
150+
} else {
151+
Ok(None)
152+
}
153+
}
154+
}

crates/anv-lsp/src/document.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: MIT
2+
// Document state management for Anvomidav LSP
3+
4+
use ropey::Rope;
5+
6+
pub struct DocumentState {
7+
pub content: Rope,
8+
pub version: i32,
9+
}
10+
11+
impl DocumentState {
12+
pub fn new(content: Rope, version: i32) -> Self {
13+
Self { content, version }
14+
}
15+
16+
pub fn source(&self) -> String {
17+
self.content.to_string()
18+
}
19+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// SPDX-License-Identifier: MIT
2+
// Completion handler for Anvomidav LSP
3+
4+
use tower_lsp::lsp_types::{CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse, Position, Url};
5+
6+
/// Keywords for Anvomidav choreography DSL
7+
const KEYWORDS: &[(&str, &str)] = &[
8+
("program", "Define a choreography program"),
9+
("segment", "Define a program segment"),
10+
("sequence", "Define a sequence of elements"),
11+
("transition", "Define a transition between elements"),
12+
("choreography", "Define choreography structure"),
13+
("spiral", "Define a spiral sequence"),
14+
("step", "Define a step sequence"),
15+
("turn", "Define a turn sequence"),
16+
("jump", "Define a jump element"),
17+
("spin", "Define a spin element"),
18+
("lift", "Define a lift element (pairs)"),
19+
("throw", "Define a throw element (pairs)"),
20+
("twist", "Define a twist element (pairs)"),
21+
("death_spiral", "Define a death spiral (pairs)"),
22+
("pattern", "Define an ice dance pattern"),
23+
("timing", "Specify element timing"),
24+
("music", "Music reference"),
25+
("tempo", "Tempo specification"),
26+
("short", "Short program segment"),
27+
("free", "Free program segment"),
28+
("rhythm", "Rhythm dance segment"),
29+
];
30+
31+
/// Jump types
32+
const JUMPS: &[(&str, &str)] = &[
33+
("axel", "Axel jump (1.5 rotations forward takeoff)"),
34+
("lutz", "Lutz jump (toe-assisted, outside edge)"),
35+
("flip", "Flip jump (toe-assisted, inside edge)"),
36+
("loop", "Loop jump (edge jump)"),
37+
("salchow", "Salchow jump (edge jump, inside edge)"),
38+
("toe_loop", "Toe loop jump (toe-assisted)"),
39+
];
40+
41+
/// Spin types
42+
const SPINS: &[(&str, &str)] = &[
43+
("camel", "Camel spin (arabesque position)"),
44+
("sit", "Sit spin (sitting position)"),
45+
("upright", "Upright spin"),
46+
("layback", "Layback spin"),
47+
("biellmann", "Biellmann spin (leg held overhead)"),
48+
("combination", "Combination spin"),
49+
("flying", "Flying spin (jump entry)"),
50+
];
51+
52+
/// Rotation qualifiers
53+
const ROTATIONS: &[(&str, &str)] = &[
54+
("single", "Single rotation"),
55+
("double", "Double rotation"),
56+
("triple", "Triple rotation"),
57+
("quad", "Quadruple rotation"),
58+
];
59+
60+
/// Edge qualifiers
61+
const EDGES: &[(&str, &str)] = &[
62+
("inside", "Inside edge"),
63+
("outside", "Outside edge"),
64+
("forward", "Forward direction"),
65+
("backward", "Backward direction"),
66+
];
67+
68+
pub async fn handle_completion(
69+
_uri: &Url,
70+
source: &str,
71+
params: &CompletionParams,
72+
) -> Option<CompletionResponse> {
73+
let position = params.text_document_position.position;
74+
let context = get_context_at_position(source, position);
75+
76+
let mut items = Vec::new();
77+
78+
match context.as_str() {
79+
"jump" => {
80+
// Suggest jump types
81+
for (name, detail) in JUMPS {
82+
items.push(CompletionItem {
83+
label: name.to_string(),
84+
kind: Some(CompletionItemKind::ENUM_MEMBER),
85+
detail: Some(detail.to_string()),
86+
..Default::default()
87+
});
88+
}
89+
// Add rotation qualifiers
90+
for (name, detail) in ROTATIONS {
91+
items.push(CompletionItem {
92+
label: name.to_string(),
93+
kind: Some(CompletionItemKind::KEYWORD),
94+
detail: Some(detail.to_string()),
95+
..Default::default()
96+
});
97+
}
98+
}
99+
"spin" => {
100+
// Suggest spin types
101+
for (name, detail) in SPINS {
102+
items.push(CompletionItem {
103+
label: name.to_string(),
104+
kind: Some(CompletionItemKind::ENUM_MEMBER),
105+
detail: Some(detail.to_string()),
106+
..Default::default()
107+
});
108+
}
109+
}
110+
_ => {
111+
// Default: suggest all keywords
112+
for (name, detail) in KEYWORDS {
113+
items.push(CompletionItem {
114+
label: name.to_string(),
115+
kind: Some(CompletionItemKind::KEYWORD),
116+
detail: Some(detail.to_string()),
117+
..Default::default()
118+
});
119+
}
120+
}
121+
}
122+
123+
Some(CompletionResponse::Array(items))
124+
}
125+
126+
fn get_context_at_position(source: &str, position: Position) -> String {
127+
let lines: Vec<&str> = source.lines().collect();
128+
if position.line as usize >= lines.len() {
129+
return String::new();
130+
}
131+
132+
let line = lines[position.line as usize];
133+
let before_cursor = &line[..position.character.min(line.len() as u32) as usize];
134+
135+
// Detect if we're after "jump" or "spin" keywords
136+
if before_cursor.contains("jump") {
137+
"jump".to_string()
138+
} else if before_cursor.contains("spin") {
139+
"spin".to_string()
140+
} else {
141+
String::new()
142+
}
143+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// SPDX-License-Identifier: MIT
2+
// Go-to-definition handler for Anvomidav LSP
3+
4+
use tower_lsp::lsp_types::{GotoDefinitionParams, GotoDefinitionResponse, Location, Position, Range, Url};
5+
6+
pub async fn handle_definition(
7+
uri: &Url,
8+
source: &str,
9+
params: &GotoDefinitionParams,
10+
) -> Option<GotoDefinitionResponse> {
11+
let position = params.text_document_position_params.position;
12+
let word = get_word_at_position(source, position)?;
13+
14+
// Search for definition
15+
let definition_location = find_definition(source, &word, uri)?;
16+
17+
Some(GotoDefinitionResponse::Scalar(definition_location))
18+
}
19+
20+
fn get_word_at_position(source: &str, position: Position) -> Option<String> {
21+
let lines: Vec<&str> = source.lines().collect();
22+
if position.line as usize >= lines.len() {
23+
return None;
24+
}
25+
26+
let line = lines[position.line as usize];
27+
let char_pos = position.character as usize;
28+
if char_pos >= line.len() {
29+
return None;
30+
}
31+
32+
// Find word boundaries
33+
let start = line[..char_pos]
34+
.rfind(|c: char| !c.is_alphanumeric() && c != '_')
35+
.map(|i| i + 1)
36+
.unwrap_or(0);
37+
38+
let end = line[char_pos..]
39+
.find(|c: char| !c.is_alphanumeric() && c != '_')
40+
.map(|i| char_pos + i)
41+
.unwrap_or(line.len());
42+
43+
Some(line[start..end].to_string())
44+
}
45+
46+
fn find_definition(source: &str, target: &str, uri: &Url) -> Option<Location> {
47+
// Search for definitions of segments, sequences, etc.
48+
for (line_num, line) in source.lines().enumerate() {
49+
// Match: "segment <name>:", "sequence <name> {", "program <name> {"
50+
if let Some(pos) = line.find(target) {
51+
// Check if this is a definition (has "segment", "sequence", "program" before it)
52+
let before = &line[..pos];
53+
if before.contains("segment ")
54+
|| before.contains("sequence ")
55+
|| before.contains("program ")
56+
|| before.contains("transition ")
57+
{
58+
return Some(Location {
59+
uri: uri.clone(),
60+
range: Range {
61+
start: Position {
62+
line: line_num as u32,
63+
character: pos as u32,
64+
},
65+
end: Position {
66+
line: line_num as u32,
67+
character: (pos + target.len()) as u32,
68+
},
69+
},
70+
});
71+
}
72+
}
73+
}
74+
75+
None
76+
}

0 commit comments

Comments
 (0)