1#![allow(clippy::multiple_crate_versions)]
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::sync::{Arc, LazyLock};
5
6use polyfont_config::{ConfigLoader, PolyfontConfig};
7use polyfont_core::{PolyfontEngine, ScopeMatchEngine, TokenInfo};
8use polyfont_parse::{OffsetEncoding, TokenParser};
9use serde::{Deserialize, Serialize};
10use tokio::sync::RwLock;
11use tower_lsp::jsonrpc::Result as LspResult;
12use tower_lsp::lsp_types::{
13 DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams,
14 DidOpenTextDocumentParams, InitializeParams, InitializeResult, InitializedParams,
15 ServerCapabilities, ServerInfo, TextDocumentSyncCapability, TextDocumentSyncKind,
16};
17use tower_lsp::{Client, ClientSocket, LanguageServer, LspService};
18use tracing::{info, warn};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct FontAssignmentNotification {
22 pub uri: String,
23 pub assignments: Vec<FontAssignmentEntry>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct FontAssignmentEntry {
28 pub scope: String,
29 pub range: LspRange,
30 pub font: FontInfo,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct LspRange {
35 pub start: LspPosition,
36 pub end: LspPosition,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct LspPosition {
41 pub line: u32,
42 pub character: u32,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct FontInfo {
47 pub family: String,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub fallbacks: Vec<String>,
50 #[serde(default = "default_weight", skip_serializing_if = "is_default_weight")]
51 pub weight: String,
52 #[serde(default = "default_style", skip_serializing_if = "is_default_style")]
53 pub style: String,
54}
55
56fn default_weight() -> String {
57 "regular".to_string()
58}
59
60fn is_default_weight(s: &str) -> bool {
61 s == "regular"
62}
63
64fn default_style() -> String {
65 "normal".to_string()
66}
67
68fn is_default_style(s: &str) -> bool {
69 s == "normal"
70}
71
72impl From<&polyfont_core::FontSpec> for FontInfo {
73 fn from(spec: &polyfont_core::FontSpec) -> Self {
74 Self {
75 family: spec.family.clone(),
76 fallbacks: spec.fallbacks.clone(),
77 weight: spec.weight.to_string(),
78 style: spec.style.to_string(),
79 }
80 }
81}
82
83#[derive(Debug)]
84struct DocumentState {
85 version: i32,
86 text: String,
87 cached_tokens: Vec<TokenInfo>,
89}
90
91struct PolyfontFontAssignments;
92
93impl tower_lsp::lsp_types::notification::Notification for PolyfontFontAssignments {
94 type Params = FontAssignmentNotification;
95 const METHOD: &'static str = "polyfont/fontAssignments";
96}
97
98pub struct PolyfontLanguageServer {
99 client: Client,
100 state: Arc<RwLock<ServerState>>,
101}
102
103struct ServerState {
104 engine: Option<ScopeMatchEngine>,
105 config: Option<PolyfontConfig>,
106 workspace_root: Option<PathBuf>,
107 documents: HashMap<String, DocumentState>,
108}
109
110impl ServerState {
111 fn new() -> Self {
112 Self {
113 engine: None,
114 config: None,
115 workspace_root: None,
116 documents: HashMap::new(),
117 }
118 }
119}
120
121fn build_assignment_entries(
122 engine: &ScopeMatchEngine,
123 tokens: &[TokenInfo],
124) -> Vec<FontAssignmentEntry> {
125 let assignments = engine.resolve_all(tokens);
126 tokens
127 .iter()
128 .zip(assignments)
129 .filter_map(|(token, assignment)| {
130 let assignment = assignment?;
131 let font_info = FontInfo::from(&assignment.font);
132 let range = LspRange {
133 start: LspPosition {
134 line: token.range.start.line,
135 character: token.range.start.column,
136 },
137 end: LspPosition {
138 line: token.range.end.line,
139 character: token.range.end.column,
140 },
141 };
142 Some(FontAssignmentEntry {
143 scope: assignment.scope,
144 range,
145 font: font_info,
146 })
147 })
148 .collect()
149}
150
151impl PolyfontLanguageServer {
152 #[must_use]
153 pub fn new(client: Client) -> Self {
154 Self {
155 client,
156 state: Arc::new(RwLock::new(ServerState::new())),
157 }
158 }
159
160 pub fn build_service() -> (LspService<Self>, ClientSocket) {
161 LspService::build(Self::new)
162 .custom_method(
163 "polyfont/requestFontAssignments",
164 Self::serve_request_font_assignments,
165 )
166 .custom_method("polyfont/suggestFonts", Self::serve_suggest_fonts)
167 .finish()
168 }
169
170 async fn load_config(&self, root: &std::path::Path) {
171 info!(
172 "loading polyfont config from workspace root: {}",
173 root.display()
174 );
175 match ConfigLoader::load_from_dir(root) {
176 Ok(config) => {
177 info!("loaded config with {} rules", config.rules.len());
178 let rules = config.to_rules();
179 let engine = ScopeMatchEngine::from_rules(rules);
180 let mut state = self.state.write().await;
181 state.config = Some(config);
182 state.engine = Some(engine);
183 state.workspace_root = Some(root.to_path_buf());
184 }
185 Err(e) => {
186 warn!("failed to load config: {e}");
187 }
188 }
189 }
190
191 async fn publish_assignments(&self, uri: &str) {
192 let entries = {
193 let state = self.state.read().await;
194 let Some(engine) = &state.engine else {
195 return;
196 };
197 let Some(doc) = state.documents.get(uri) else {
198 return;
199 };
200 let tokens = if doc.cached_tokens.is_empty() {
202 tokenize_document(&doc.text, uri)
203 } else {
204 doc.cached_tokens.clone()
205 };
206 let entries = build_assignment_entries(engine, &tokens);
207 drop(state);
208 entries
209 };
210
211 if entries.is_empty() {
212 return;
213 }
214
215 let notification = FontAssignmentNotification {
216 uri: uri.to_string(),
217 assignments: entries,
218 };
219
220 self.client
221 .send_notification::<PolyfontFontAssignments>(notification)
222 .await;
223 }
224
225 #[allow(dead_code)]
229 async fn tokenize_and_cache(&self, uri: &str) -> Vec<TokenInfo> {
230 let state = self.state.read().await;
231 let Some(doc) = state.documents.get(uri) else {
232 return vec![];
233 };
234 if !doc.cached_tokens.is_empty() {
236 return doc.cached_tokens.clone();
237 }
238 tokenize_document(&doc.text, uri)
239 }
240
241 async fn serve_request_font_assignments(
242 &self,
243 params: FontAssignmentsRequestParams,
244 ) -> LspResult<Option<FontAssignmentNotification>> {
245 let entries = {
246 let state = self.state.read().await;
247 let Some(engine) = &state.engine else {
248 return Ok(None);
249 };
250 let Some(doc) = state.documents.get(¶ms.uri) else {
251 return Ok(None);
252 };
253 let tokens = tokenize_document(&doc.text, ¶ms.uri);
254 let entries = build_assignment_entries(engine, &tokens);
255 drop(state);
256 entries
257 };
258
259 if entries.is_empty() {
260 return Ok(None);
261 }
262
263 Ok(Some(FontAssignmentNotification {
264 uri: params.uri,
265 assignments: entries,
266 }))
267 }
268
269 async fn serve_suggest_fonts(
270 &self,
271 params: SuggestFontsParams,
272 ) -> LspResult<Option<FontSuggestionsResponse>> {
273 let _language = params.language;
274 let suggestions: Vec<FontSuggestion> = FONT_PAIRINGS
275 .iter()
276 .map(|(scope, family, reason, category)| FontSuggestion {
277 scope: (*scope).to_string(),
278 recommended_family: (*family).to_string(),
279 reason: (*reason).to_string(),
280 category: (*category).to_string(),
281 })
282 .collect();
283
284 Ok(Some(FontSuggestionsResponse { suggestions }))
285 }
286}
287
288#[derive(Debug, Deserialize)]
289struct FontAssignmentsRequestParams {
290 uri: String,
291}
292
293#[derive(Debug, Deserialize)]
294struct SuggestFontsParams {
295 language: Option<String>,
297}
298
299#[derive(Debug, Serialize)]
300struct FontSuggestion {
301 scope: String,
302 recommended_family: String,
303 reason: String,
304 category: String,
305}
306
307#[derive(Debug, Serialize)]
308struct FontSuggestionsResponse {
309 suggestions: Vec<FontSuggestion>,
310}
311
312static FONT_PAIRINGS: &[(&str, &str, &str, &str)] = &[
314 (
316 "keyword",
317 "Maple Mono",
318 "Clear geometric mono with heavy weight for keywords",
319 "geometric",
320 ),
321 (
322 "keyword.control",
323 "Fira Code",
324 "Ligature support for control flow operators",
325 "ligature",
326 ),
327 (
328 "comment",
329 "IBM Plex Mono",
330 "Humanist design improves readability for prose comments",
331 "humanist",
332 ),
333 (
334 "comment.doc",
335 "Source Serif Pro",
336 "Serif face signals documentation distinct from code",
337 "serif",
338 ),
339 (
340 "string",
341 "Source Code Pro",
342 "Light weight creates visual contrast for string literals",
343 "light",
344 ),
345 (
346 "string.regexp",
347 "JetBrains Mono",
348 "Dense information density suits regex patterns",
349 "dense",
350 ),
351 (
352 "entity.name.function",
353 "Monaspace Argon",
354 "Distinctive x-height for function identification",
355 "variable",
356 ),
357 (
358 "entity.name.type",
359 "Monaspace Neon",
360 "Wide stance for type names at a glance",
361 "variable",
362 ),
363 (
364 "variable",
365 "JetBrains Mono",
366 "Balanced weight for the most common token type",
367 "balanced",
368 ),
369 (
370 "variable.parameter",
371 "MonoLisa",
372 "Italic-friendly for parameter distinction",
373 "humanist",
374 ),
375 (
376 "constant",
377 "Monaspace Radon",
378 "Heavy weight emphasizes constant values",
379 "variable",
380 ),
381 (
382 "constant.numeric",
383 "Input Mono",
384 "Tabular figures for numeric alignment",
385 "tabular",
386 ),
387 (
388 "support.function",
389 "Monaspace Krypton",
390 "Medium weight for built-in function calls",
391 "variable",
392 ),
393 (
394 "punctuation",
395 "Fira Code",
396 "Ligature support for bracket pairs and arrows",
397 "ligature",
398 ),
399 (
400 "operator",
401 "Operator Mono",
402 "Italic-style operators for visual separation",
403 "stylish",
404 ),
405 (
406 "storage.type",
407 "Maple Mono",
408 "Bold weight for type annotations",
409 "geometric",
410 ),
411];
412
413static TOKEN_PARSER: LazyLock<TokenParser> = LazyLock::new(TokenParser::new);
414
415fn language_id_from_uri(uri: &str) -> &str {
416 let path = uri.split('/').next_back().unwrap_or(uri);
417 match path.split('.').next_back().unwrap_or("") {
418 "rs" => "rust",
419 "ts" => "typescript",
420 "tsx" => "typescript",
421 "js" => "javascript",
422 "jsx" => "javascript",
423 "py" => "python",
424 "go" => "go",
425 "c" => "c",
426 "cpp" | "cc" | "cxx" | "h" | "hpp" => "cpp",
427 "json" => "json",
428 "toml" => "toml",
429 "lua" => "lua",
430 _ => "unknown",
431 }
432}
433
434fn tokenize_document(text: &str, uri: &str) -> Vec<polyfont_core::TokenInfo> {
435 let lang = language_id_from_uri(uri);
436
437 match TOKEN_PARSER.parse_tokens(text, lang, OffsetEncoding::Utf16) {
438 Ok(tokens) if !tokens.is_empty() => {
439 info!(
440 language = lang,
441 method = "tree-sitter",
442 "tokenized document"
443 );
444 tokens
445 }
446 Ok(_) => {
447 info!(
448 language = lang,
449 method = "naive",
450 reason = "tree-sitter returned no tokens",
451 "tokenized document"
452 );
453 tokenize_document_naive(text)
454 }
455 Err(e) => {
456 info!(
457 language = lang,
458 method = "naive",
459 reason = %e,
460 "tokenized document"
461 );
462 tokenize_document_naive(text)
463 }
464 }
465}
466
467#[allow(clippy::cast_possible_truncation)]
468fn tokenize_document_naive(text: &str) -> Vec<polyfont_core::TokenInfo> {
469 let mut tokens = Vec::new();
470 for (line_idx, line) in text.lines().enumerate() {
471 let leading = line.len() - line.trim_start().len();
472 let trimmed = line.trim();
473
474 if trimmed.is_empty() {
475 continue;
476 }
477
478 let scope = classify_line(trimmed);
479 let end_char = (leading + trimmed.len()) as u32;
480
481 tokens.push(polyfont_core::TokenInfo {
482 text: trimmed.to_string(),
483 range: polyfont_core::Range {
484 start: polyfont_core::Position {
485 line: line_idx as u32,
486 column: leading as u32,
487 },
488 end: polyfont_core::Position {
489 line: line_idx as u32,
490 column: end_char,
491 },
492 },
493 scope,
494 modifiers: Vec::new(),
495 });
496 }
497 tokens
498}
499
500fn classify_line(line: &str) -> String {
501 let trimmed = line.trim();
502
503 if trimmed.starts_with("///") || trimmed.starts_with("//") || trimmed.starts_with('#') {
504 return "comment".to_string();
505 }
506 if trimmed.starts_with('"') || trimmed.starts_with('\'') || trimmed.starts_with('`') {
507 return "string".to_string();
508 }
509 if trimmed.starts_with("fn ")
510 || trimmed.starts_with("function ")
511 || trimmed.starts_with("def ")
512 || trimmed.starts_with("pub fn ")
513 || trimmed.starts_with("async fn ")
514 {
515 return "entity.name.function".to_string();
516 }
517 if trimmed.starts_with("let ")
518 || trimmed.starts_with("const ")
519 || trimmed.starts_with("var ")
520 || trimmed.starts_with("mut ")
521 || trimmed.starts_with("let mut ")
522 {
523 return "variable".to_string();
524 }
525 if trimmed.starts_with("struct ")
526 || trimmed.starts_with("enum ")
527 || trimmed.starts_with("class ")
528 || trimmed.starts_with("interface ")
529 || trimmed.starts_with("type ")
530 || trimmed.starts_with("impl ")
531 || trimmed.starts_with("trait ")
532 {
533 return "entity.name.type".to_string();
534 }
535 if trimmed.starts_with("use ")
536 || trimmed.starts_with("import ")
537 || trimmed.starts_with("from ")
538 || trimmed.starts_with("mod ")
539 {
540 return "keyword".to_string();
541 }
542 if trimmed.starts_with("if ")
543 || trimmed.starts_with("else")
544 || trimmed.starts_with("for ")
545 || trimmed.starts_with("while ")
546 || trimmed.starts_with("loop ")
547 || trimmed.starts_with("match ")
548 || trimmed.starts_with("switch ")
549 || trimmed.starts_with("return")
550 || trimmed.starts_with("break")
551 || trimmed.starts_with("continue")
552 {
553 return "keyword.control".to_string();
554 }
555
556 "source".to_string()
557}
558
559#[tower_lsp::async_trait]
560impl LanguageServer for PolyfontLanguageServer {
561 async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
562 info!("initializing polyfont LSP server");
563
564 let workspace_root = params.root_uri.and_then(|uri| uri.to_file_path().ok());
565
566 if let Some(ref root) = workspace_root {
567 let mut state = self.state.write().await;
568 state.workspace_root = Some(root.clone());
569 }
570
571 let capabilities = ServerCapabilities {
572 text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
573 ..Default::default()
574 };
575
576 Ok(InitializeResult {
577 capabilities,
578 server_info: Some(ServerInfo {
579 name: "polyfont-lsp".to_string(),
580 version: Some(env!("CARGO_PKG_VERSION").to_string()),
581 }),
582 })
583 }
584
585 async fn initialized(&self, _params: InitializedParams) {
586 info!("polyfont LSP server initialized");
587
588 let workspace_root = {
589 let state = self.state.read().await;
590 state.workspace_root.clone()
591 };
592
593 if let Some(root) = workspace_root {
594 self.load_config(&root).await;
595
596 let uris: Vec<String> = {
597 let state = self.state.read().await;
598 state.documents.keys().cloned().collect()
599 };
600 for uri in uris {
601 self.publish_assignments(&uri).await;
602 }
603 }
604 }
605
606 async fn shutdown(&self) -> LspResult<()> {
607 info!("shutting down polyfont LSP server");
608 let mut state = self.state.write().await;
609 state.engine = None;
610 state.config = None;
611 state.documents.clear();
612 drop(state);
613 Ok(())
614 }
615
616 async fn did_open(&self, params: DidOpenTextDocumentParams) {
617 let uri = params.text_document.uri.to_string();
618 info!("document opened: {uri}");
619
620 let text = params.text_document.text.clone();
621 let tokens = tokenize_document(&text, &uri);
622
623 {
624 let mut state = self.state.write().await;
625 state.documents.insert(
626 uri.clone(),
627 DocumentState {
628 version: params.text_document.version,
629 text,
630 cached_tokens: tokens,
631 },
632 );
633 }
634
635 self.publish_assignments(&uri).await;
636 }
637
638 async fn did_change(&self, params: DidChangeTextDocumentParams) {
639 let uri = params.text_document.uri.to_string();
640
641 if let Some(change) = params.content_changes.into_iter().last() {
642 let text = change.text.clone();
643 let tokens = tokenize_document(&text, &uri);
644 let mut state = self.state.write().await;
645 if let Some(doc) = state.documents.get_mut(&uri) {
646 doc.text = text;
647 doc.version = params.text_document.version;
648 doc.cached_tokens = tokens;
649 }
650 }
651
652 self.publish_assignments(&uri).await;
653 }
654
655 async fn did_close(&self, params: DidCloseTextDocumentParams) {
656 let uri = params.text_document.uri.to_string();
657 info!("document closed: {uri}");
658 let mut state = self.state.write().await;
659 state.documents.remove(&uri);
660 }
661
662 async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) {
663 info!("configuration changed, reloading");
664
665 let workspace_root = {
666 let state = self.state.read().await;
667 state.workspace_root.clone()
668 };
669
670 if let Some(root) = workspace_root {
671 self.load_config(&root).await;
672
673 let uris: Vec<String> = {
674 let state = self.state.read().await;
675 state.documents.keys().cloned().collect()
676 };
677 for uri in uris {
678 self.publish_assignments(&uri).await;
679 }
680 }
681 }
682}