Skip to main content

polyfont_lsp/
lib.rs

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 tokenization result to enable incremental re-parsing.
88    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            // Use cached tokens if available, otherwise re-tokenize.
201            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    /// Tokenize and cache results for a document, returning the token list.
226    /// On subsequent calls for the same URI, performs incremental re-parsing
227    /// by only re-tokenizing if the document has changed.
228    #[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 we have cached tokens and the text hasn't changed, reuse them.
235        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(&params.uri) else {
251                return Ok(None);
252            };
253            let tokens = tokenize_document(&doc.text, &params.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    /// Optional language ID to tailor suggestions.
296    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
312/// Curated font pairing database indexed by scope category.
313static FONT_PAIRINGS: &[(&str, &str, &str, &str)] = &[
314    // (scope_prefix, family, reason, category)
315    (
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}