Skip to main content

wakatime_ls/
lib.rs

1//! Wakatime LS implementation
2//!
3//! Entrypoint is [`Backend::new`]
4
5// TODO: check options for additional ideas <https://github.com/wakatime/wakatime-cli/blob/develop/USAGE.md#ini-config-file>
6
7// TODO: implement debouncing ourselves to avoid wkcli roundtrips
8// TODO: read wakatime config
9// TODO: do not log when out of dev folder
10
11#![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
16// TODO: support independent backends
17/// Open the Wakatime web dashboard in a browser
18const OPEN_DASHBOARD_ACTION: &str = "Open wakatime.com dashboard";
19/// Log the time past today in an editor
20const SHOW_TIME_PAST_ACTION: &str = "Show time logged today";
21
22/// Base plugin user agent
23const 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	/// Entrypoint
52	///
53	/// # Errors
54	///
55	/// - For kindof everything that went wrong
56	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				// Editor part
65				info.name,
66				info.version
67					.as_ref()
68					.map_or_else(|| "unknown", |version| version.as_str()),
69				// Plugin part
70				self.user_agent,
71				// Last part is the one parsed by `wakatime` servers
72				// It follows `{editor}-wakatime/{version}` where `editor` is
73				// registered in intern. Works when `info.name` matches what the
74				// wakatime dev choose.
75				// IDEA: rely less on luck
76				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, &params)?;
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(&params.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(&params.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(&params.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(&params.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		// cmd.args(["--lineno", ""]);
173		// cmd.args(["--cursorno", ""]);
174		// cmd.args(["--lines-in-file", ""]);
175		// cmd.args(["--category", ""]);
176
177		// cmd.args(["--alternate-project", ""]);
178		// cmd.args(["--project-folder", ""]);
179
180		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
260// first result if for json decoding error, second is for method mismatch
261type 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// error codes are available at
288// https://github.com/wakatime/wakatime-cli/blob/develop/pkg/exitcode/exitcode.go
289#[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}