Skip to main content

cooklang_language_server/
backend.rs

1use std::path::PathBuf;
2
3use tower_lsp::jsonrpc::Result;
4use tower_lsp::lsp_types::*;
5use tower_lsp::{Client, LanguageServer};
6
7use crate::completion;
8use crate::diagnostics;
9use crate::hover;
10use crate::semantic_tokens;
11use crate::state::ServerState;
12use crate::symbols;
13
14pub struct Backend {
15    client: Client,
16    state: ServerState,
17    /// Workspace root path for loading configuration files
18    workspace_root: std::sync::RwLock<Option<PathBuf>>,
19}
20
21impl Backend {
22    pub fn new(client: Client) -> Self {
23        Self {
24            client,
25            state: ServerState::new(),
26            workspace_root: std::sync::RwLock::new(None),
27        }
28    }
29
30    /// Try to load aisle.conf from the workspace
31    fn load_aisle_config(&self) {
32        if let Ok(guard) = self.workspace_root.read() {
33            if let Some(ref path) = *guard {
34                self.state.load_aisle_config(path);
35            }
36        }
37    }
38
39    async fn publish_diagnostics(&self, uri: &Url) {
40        let diagnostics = if let Some(doc) = self.state.get_document(uri) {
41            diagnostics::get_diagnostics(&doc)
42        } else {
43            vec![]
44        };
45
46        self.client
47            .publish_diagnostics(uri.clone(), diagnostics, None)
48            .await;
49    }
50}
51
52#[tower_lsp::async_trait]
53impl LanguageServer for Backend {
54    async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
55        // Extract workspace root from initialization params
56        let workspace_path = params
57            .workspace_folders
58            .as_ref()
59            .and_then(|folders| folders.first())
60            .and_then(|folder| folder.uri.to_file_path().ok())
61            .or_else(|| {
62                #[allow(deprecated)]
63                params
64                    .root_uri
65                    .as_ref()
66                    .and_then(|uri| uri.to_file_path().ok())
67            })
68            .or_else(|| {
69                #[allow(deprecated)]
70                params.root_path.as_ref().map(PathBuf::from)
71            });
72
73        if let Some(path) = workspace_path {
74            tracing::info!("Workspace root: {:?}", path);
75            if let Ok(mut guard) = self.workspace_root.write() {
76                *guard = Some(path);
77            }
78        }
79
80        Ok(InitializeResult {
81            capabilities: ServerCapabilities {
82                text_document_sync: Some(TextDocumentSyncCapability::Options(
83                    TextDocumentSyncOptions {
84                        open_close: Some(true),
85                        change: Some(TextDocumentSyncKind::FULL),
86                        save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
87                            include_text: Some(false),
88                        })),
89                        ..Default::default()
90                    },
91                )),
92                completion_provider: Some(CompletionOptions {
93                    trigger_characters: Some(vec![
94                        "@".into(),
95                        "#".into(),
96                        "~".into(),
97                        "%".into(),
98                        "{".into(),
99                        ".".into(),
100                        "/".into(),
101                    ]),
102                    resolve_provider: Some(false),
103                    ..Default::default()
104                }),
105                hover_provider: Some(HoverProviderCapability::Simple(true)),
106                document_symbol_provider: Some(OneOf::Left(true)),
107                semantic_tokens_provider: Some(semantic_tokens::capabilities()),
108                workspace: Some(WorkspaceServerCapabilities {
109                    workspace_folders: Some(WorkspaceFoldersServerCapabilities {
110                        supported: Some(true),
111                        change_notifications: Some(OneOf::Left(true)),
112                    }),
113                    ..Default::default()
114                }),
115                ..Default::default()
116            },
117            server_info: Some(ServerInfo {
118                name: "cooklang-language-server".into(),
119                version: Some(env!("CARGO_PKG_VERSION").into()),
120            }),
121        })
122    }
123
124    async fn initialized(&self, _: InitializedParams) {
125        tracing::info!("Cooklang LSP initialized");
126
127        // Load aisle.conf if available in workspace
128        self.load_aisle_config();
129
130        self.client
131            .log_message(MessageType::INFO, "Cooklang Language Server initialized")
132            .await;
133    }
134
135    async fn shutdown(&self) -> Result<()> {
136        tracing::info!("Cooklang LSP shutting down");
137        Ok(())
138    }
139
140    async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) {
141        // Prefer the first "added" folder, fall back to dropping the root when
142        // all workspaces were removed.
143        let new_root = params
144            .event
145            .added
146            .first()
147            .and_then(|folder| folder.uri.to_file_path().ok());
148
149        if let Ok(mut guard) = self.workspace_root.write() {
150            match (&new_root, guard.as_ref()) {
151                (Some(new), Some(current)) if new == current => return,
152                (None, None) => return,
153                _ => {}
154            }
155            tracing::info!("Workspace root changed to: {:?}", new_root);
156            *guard = new_root;
157        }
158
159        // Reload aisle.conf for the new workspace (or clear it if the root is gone).
160        self.load_aisle_config();
161    }
162
163    async fn did_open(&self, params: DidOpenTextDocumentParams) {
164        let uri = params.text_document.uri;
165        let version = params.text_document.version;
166        let content = params.text_document.text;
167
168        tracing::debug!("Document opened: {}", uri);
169        self.state.open_document(uri.clone(), version, content);
170        self.publish_diagnostics(&uri).await;
171    }
172
173    async fn did_change(&self, params: DidChangeTextDocumentParams) {
174        let uri = params.text_document.uri;
175        let version = params.text_document.version;
176
177        if let Some(change) = params.content_changes.into_iter().last() {
178            tracing::debug!("Document changed: {}", uri);
179            self.state.update_document(&uri, version, change.text);
180            self.publish_diagnostics(&uri).await;
181        }
182    }
183
184    async fn did_save(&self, params: DidSaveTextDocumentParams) {
185        tracing::debug!("Document saved: {}", params.text_document.uri);
186        self.publish_diagnostics(&params.text_document.uri).await;
187    }
188
189    async fn did_close(&self, params: DidCloseTextDocumentParams) {
190        let uri = params.text_document.uri;
191        tracing::debug!("Document closed: {}", uri);
192        self.state.close_document(&uri);
193        self.client.publish_diagnostics(uri, vec![], None).await;
194    }
195
196    async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
197        let uri = &params.text_document_position.text_document.uri;
198        let workspace_root = self
199            .workspace_root
200            .read()
201            .ok()
202            .and_then(|guard| guard.clone())
203            .or_else(|| {
204                // Fall back to the document's parent directory when no workspace root
205                // is provided (e.g. when launched via cookcli web)
206                uri.to_file_path()
207                    .ok()
208                    .and_then(|p| p.parent().map(|p| p.to_path_buf()))
209            });
210
211        let response = if let Some(doc) = self.state.get_document(uri) {
212            completion::get_completions(&doc, &params, &self.state, workspace_root.as_deref())
213        } else {
214            None
215        };
216
217        Ok(response)
218    }
219
220    async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
221        let uri = &params.text_document_position_params.text_document.uri;
222
223        let response = if let Some(doc) = self.state.get_document(uri) {
224            hover::get_hover(&doc, &params)
225        } else {
226            None
227        };
228
229        Ok(response)
230    }
231
232    async fn document_symbol(
233        &self,
234        params: DocumentSymbolParams,
235    ) -> Result<Option<DocumentSymbolResponse>> {
236        let uri = &params.text_document.uri;
237
238        let response = if let Some(doc) = self.state.get_document(uri) {
239            symbols::get_document_symbols(&doc)
240        } else {
241            None
242        };
243
244        Ok(response)
245    }
246
247    async fn semantic_tokens_full(
248        &self,
249        params: SemanticTokensParams,
250    ) -> Result<Option<SemanticTokensResult>> {
251        let uri = &params.text_document.uri;
252
253        let tokens = if let Some(doc) = self.state.get_document(uri) {
254            semantic_tokens::get_semantic_tokens(&doc)
255        } else {
256            vec![]
257        };
258
259        Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
260            result_id: None,
261            data: tokens,
262        })))
263    }
264}