1#![expect(clippy::wildcard_imports, reason = "ls_types has no prelude")]
12use ls_types::*;
13use ls_types::{notification::Notification as _, request::Request as _};
14use lsp_server::{Connection, ExtractError, Message, Notification, Request, RequestId};
15
16const OPEN_DASHBOARD_ACTION: &str = "Open wakatime.com dashboard";
19const SHOW_TIME_PAST_ACTION: &str = "Show time logged today";
21
22const PLUGIN_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
24
25pub struct LanguageServer {
26 connection: Connection,
27
28 user_agent: String,
29}
30
31impl LanguageServer {
32 #[must_use]
33 pub fn new(connection: Connection) -> Self {
34 Self {
35 connection,
36 user_agent: PLUGIN_USER_AGENT.into(),
37 }
38 }
39
40 fn capabilities() -> ServerCapabilities {
41 ServerCapabilities {
42 text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::NONE)),
43 execute_command_provider: Some(ExecuteCommandOptions {
44 commands: vec![OPEN_DASHBOARD_ACTION.into(), SHOW_TIME_PAST_ACTION.into()],
45 work_done_progress_options: WorkDoneProgressOptions::default(),
46 }),
47 ..Default::default()
48 }
49 }
50
51 pub fn start(&mut self) -> Result<(), Box<dyn std::error::Error>> {
57 let server_capabilities = serde_json::to_value(Self::capabilities())?;
58 let init_params = self.connection.initialize(server_capabilities)?;
59 let init_params = serde_json::from_value::<InitializeParams>(init_params)?;
60
61 if let Some(info) = &init_params.client_info {
62 self.user_agent = format!(
63 "{}/{} {} {}-wakatime/{}",
64 info.name,
66 info.version
67 .as_ref()
68 .map_or_else(|| "unknown", |version| version.as_str()),
69 self.user_agent,
71 info.name,
77 env!("CARGO_PKG_VERSION"),
78 );
79 }
80
81 self.main_loop()?;
82
83 Ok(())
84 }
85
86 fn main_loop(&self) -> Result<(), Box<dyn std::error::Error>> {
87 for msg in &self.connection.receiver {
88 match msg {
89 Message::Request(req) => {
90 if self.connection.handle_shutdown(&req)? {
91 return Ok(());
92 }
93
94 self.handle_request(req)?;
95 }
96
97 Message::Notification(notification) => {
98 self.handle_notification(notification)?;
99 }
100
101 Message::Response(response) => {
102 eprintln!("{response:?}");
103 }
104 }
105 }
106
107 Ok(())
108 }
109
110 fn handle_request(&self, req: Request) -> Result<(), Box<dyn std::error::Error>> {
111 let req = match try_cast_r::<request::ExecuteCommand>(req)? {
112 Ok((id, params)) => {
113 self.execute_command(id, ¶ms)?;
114 return Ok(());
115 }
116 Err(req) => req,
117 };
118
119 let _ = req;
120
121 Ok(())
122 }
123
124 fn handle_notification(
125 &self,
126 notification: Notification,
127 ) -> Result<(), Box<dyn std::error::Error>> {
128 let notification = match try_cast_n::<notification::DidOpenTextDocument>(notification)? {
129 Ok(params) => {
130 self.on_change(¶ms.text_document.uri, false)?;
131 return Ok(());
132 }
133 Err(req) => req,
134 };
135
136 let notification = match try_cast_n::<notification::DidChangeTextDocument>(notification)? {
137 Ok(params) => {
138 self.on_change(¶ms.text_document.uri, false)?;
139 return Ok(());
140 }
141 Err(req) => req,
142 };
143
144 let notification = match try_cast_n::<notification::DidCloseTextDocument>(notification)? {
145 Ok(params) => {
146 self.on_change(¶ms.text_document.uri, false)?;
147 return Ok(());
148 }
149 Err(req) => req,
150 };
151
152 let notification = match try_cast_n::<notification::DidSaveTextDocument>(notification)? {
153 Ok(params) => {
154 self.on_change(¶ms.text_document.uri, true)?;
155 return Ok(());
156 }
157 Err(req) => req,
158 };
159
160 let _ = notification;
161
162 Ok(())
163 }
164
165 fn on_change(&self, uri: &Uri, is_write: bool) -> Result<(), Box<dyn std::error::Error>> {
166 let mut cmd = std::process::Command::new("wakatime-cli");
167
168 cmd.args(["--plugin", &self.user_agent]);
169
170 cmd.args(["--entity", uri.path().as_str()]);
171
172 if is_write {
181 cmd.arg("--write");
182 }
183
184 let status = cmd.status()?;
185 let error_code = CliErrorCode::from(status.code().unwrap_or(0));
186
187 if error_code.is_err() {
188 let notification = Message::Notification(Notification::new(
189 notification::ShowMessage::METHOD.into(),
190 ShowMessageParams {
191 typ: MessageType::WARNING,
192 message: format!(
193 "`wakatime-cli` exited with error code: {error_code}. Check your configuration.",
194 ),
195 },
196 ));
197 self.connection.sender.send(notification)?;
198 }
199
200 Ok(())
201 }
202
203 fn execute_command(
204 &self,
205 id: RequestId,
206 params: &ExecuteCommandParams,
207 ) -> Result<(), Box<dyn std::error::Error>> {
208 match params.command.as_str() {
209 OPEN_DASHBOARD_ACTION => {
210 let show_documents_params = ShowDocumentParams {
211 uri: "https://wakatime.com/dashboard"
212 .parse()
213 .expect("url is valid"),
214 external: Some(true),
215 take_focus: None,
216 selection: None,
217 };
218
219 let req = Message::Request(Request::new(
220 id,
221 request::ShowDocument::METHOD.into(),
222 show_documents_params,
223 ));
224 self.connection.sender.send(req)?;
225 }
226 SHOW_TIME_PAST_ACTION => {
227 let output = std::process::Command::new("wakatime-cli")
228 .arg("--today")
229 .output()?;
230
231 let time_past = String::from_utf8_lossy(&output.stdout);
232
233 let notification = Message::Notification(Notification::new(
234 notification::ShowMessage::METHOD.into(),
235 ShowMessageParams {
236 typ: MessageType::INFO,
237 message: time_past.to_string(),
238 },
239 ));
240 self.connection.sender.send(notification)?;
241 }
242 unknown_cmd_id => {
243 let message = format!("Unknown workspace command received: `{unknown_cmd_id}`");
244
245 let notification = Message::Notification(Notification::new(
246 notification::ShowMessage::METHOD.into(),
247 ShowMessageParams {
248 typ: MessageType::ERROR,
249 message,
250 },
251 ));
252 self.connection.sender.send(notification)?;
253 }
254 }
255
256 Ok(())
257 }
258}
259
260type CastResult<Payload, Type> = Result<Result<Payload, Type>, ExtractError<Type>>;
262
263fn try_cast_r<R>(req: Request) -> CastResult<(RequestId, R::Params), Request>
264where
265 R: ls_types::request::Request,
266 R::Params: serde::de::DeserializeOwned,
267{
268 match req.extract(R::METHOD) {
269 Ok(params) => Ok(Ok(params)),
270 Err(ExtractError::MethodMismatch(req)) => Ok(Err(req)),
271 Err(err) => Err(err),
272 }
273}
274
275fn try_cast_n<N>(notif: Notification) -> CastResult<N::Params, Notification>
276where
277 N: ls_types::notification::Notification,
278 N::Params: serde::de::DeserializeOwned,
279{
280 match notif.extract(N::METHOD) {
281 Ok(params) => Ok(Ok(params)),
282 Err(ExtractError::MethodMismatch(notif)) => Ok(Err(notif)),
283 Err(err) => Err(err),
284 }
285}
286
287#[derive(Debug)]
290enum CliErrorCode {
291 Success,
292 Generic,
293 Api,
294 Auth,
295 ConfigFileParse,
296 ConfigFileRead,
297 ConfigFileWrite,
298 Backoff,
299 Unknown(i32),
300}
301
302impl CliErrorCode {
303 const fn is_err(&self) -> bool {
304 !matches!(self, Self::Success | Self::Backoff)
305 }
306}
307
308impl From<i32> for CliErrorCode {
309 fn from(value: i32) -> Self {
310 match value {
311 0 => Self::Success,
312 1 => Self::Generic,
313 102 => Self::Api,
314 104 => Self::Auth,
315 103 => Self::ConfigFileParse,
316 110 => Self::ConfigFileRead,
317 111 => Self::ConfigFileWrite,
318 112 => Self::Backoff,
319 _ => Self::Unknown(value),
320 }
321 }
322}
323
324impl std::fmt::Display for CliErrorCode {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 match self {
327 Self::Success => write!(f, "Heartbeat sent successfully"),
328 Self::Generic => write!(f, "a generic error happened"),
329 Self::Api => write!(f, "api returned an error"),
330 Self::Auth => write!(f, "invalid api key"),
331 Self::ConfigFileParse => write!(f, "config file could not be parsed"),
332 Self::ConfigFileRead => write!(f, "config read command"),
333 Self::ConfigFileWrite => write!(f, "config write command"),
334 Self::Backoff => write!(f, "sending heartbeats postponed because of rate limit"),
335 Self::Unknown(code) => write!(f, "unknown error code {code}"),
336 }
337 }
338}