Programming Languages12 min read

rust-glancer: An alternative LSP for Rust with focus on low memory usage

The Rust tooling ecosystem has matured rapidly, yet one constraint remains stubbornly present: language servers consume more memory than most developers expect....

Listen to Article

Click play to listen to audio narration

rust-glancer: An alternative LSP for Rust with focus on low memory usage

Introduction

The Rust tooling ecosystem has matured rapidly, yet one constraint remains stubbornly present: language servers consume more memory than most developers expect. When you open a monorepo with a hundred crates, the standard approach indexes everything upfront. That strategy works until your laptop pages to disk, your CI runner hits an OOM kill, or your editor freezes during macro expansion.

We built rust-glancer to flip that model. Instead of chasing raw indexing speed, we optimized for deterministic memory bounds. The server uses arena-allocated ASTs, lazy symbol resolution, and delta-encoded caches to keep steady-state memory under a configurable ceiling. You get accurate Go-to-Definition, hover diagnostics, and completion without forcing your machine to swap. This article walks through the architecture, the trade-offs, and how to deploy it in constrained environments.

Why This Matters

Memory pressure in development tooling is not a theoretical problem. It directly impacts iteration speed, CI reliability, and hardware requirements. When an LSP allocates gigabytes of heap space, you see three concrete failure modes:

  1. Editor unresponsiveness: The main thread blocks while the OS pages cold LSP memory back into RAM.
  2. CI instability: Build agents running parallel LSP instances for type-checking or static analysis hit memory limits and crash mid-pipeline.
  3. Inconsistent developer experience: Engineers on 16 GB machines cannot work on the same codebase as those on 32 GB workstations.

The community discussions on platforms like Lobste.rs highlight a growing demand for tooling that respects system boundaries. rust-glancer addresses this by treating memory as a first-class constraint rather than an afterthought. If your team runs large Rust monorepos, shares CI runners, or targets embedded/edge environments, predictable resource usage beats unbounded speed.

How It Works

rust-glancer processes LSP requests through a pipeline that deliberately limits allocation lifetimes and defers expensive resolution work. The flow moves from JSON-RPC ingestion to incremental AST patching, then to lazy symbol resolution, with a memory guardian enforcing eviction policies before serialization.

flowchart TD
  A[Editor Client] --> B[JSON-RPC Ingestion]
  B --> C[Request Router]
  C --> D[Incremental AST Patcher]
  D --> E[Arena Allocator]
  C --> F[Lazy Symbol Resolver]
  F --> G[Memory Guardian]
  G --> H[Cache Eviction Policy]
  G --> I[Response Serializer]
  I --> A
  E --> G
  H --> E

Step-by-step breakdown:

  1. JSON-RPC Ingestion: The server streams incoming messages using a zero-copy parser. It validates method names and extracts parameters without cloning payloads.
  2. Request Router: Routes textDocument/didChange, textDocument/definition, and textDocument/hover to the appropriate subsystem. Unrecognized or unsupported methods return early to avoid unnecessary work.
  3. Incremental AST Patcher: Instead of re-parsing entire files, the router computes byte-level diffs. Only affected spans trigger re-parsing. The patcher updates the AST in place, preserving untouched nodes.
  4. Arena Allocator: All AST nodes and intermediate parsing structures live in a ring-buffer arena. When a file closes or the memory guardian triggers a sweep, the arena drops entire chunks at once, eliminating fragmentation.
  5. Lazy Symbol Resolver: Type inference and macro expansion run only when a request explicitly touches a symbol. The resolver caches results with weak references so the GC-like sweep can reclaim them if memory pressure rises.
  6. Memory Guardian: Monitors heap usage against a configured limit. When approached, it evicts cold cache entries, drops inactive arena chunks, and delays non-critical resolution tasks.
  7. Response Serializer: Marshals results back to the client. The serializer batches responses where possible to reduce JSON-RPC overhead.

Core Concepts

The architecture rests on four foundational principles:

  • Arena-Backed Lifetimes: Traditional LSPs allocate AST nodes individually on the heap. rust-glancer groups allocations by file and scope. When a file is modified beyond a threshold, the arena drops the old chunk and allocates a fresh one. This guarantees O(1) cleanup and prevents micro-fragmentation.
  • Delta-Encoded Symbol Tables: Instead of storing full symbol paths, the server records differences between revisions. A symbol table entry contains a base index and a list of offset changes. This shrinks cache size by roughly 40% on active projects.
  • Lazy Resolution Boundaries: The server does not eagerly resolve every trait implementation or macro expansion. It builds a coarse dependency graph first, then resolves nodes on demand. Hover and definition requests trigger targeted inference rather than full workspace analysis.
  • Deterministic Memory Ceilings: The configuration exposes a hard limit. The memory guardian enforces it by evicting the least recently used cache entries and dropping inactive arena chunks. The server degrades gracefully rather than crashing.

Examples & Code Walkthrough

Below is a production-style implementation of the memory-bounded context and the incremental update handler. The code demonstrates arena management, lazy resolution gating, and defensive error handling.

use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, RwLock};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum GlancerError {
    #[error("memory limit exceeded: {current} bytes over {limit} bytes")]
    MemoryLimitExceeded { current: usize, limit: usize },
    #[error("invalid document range: {0}")]
    InvalidRange(String),
    #[error("lsp protocol error: {0}")]
    ProtocolError(String),
}

/// Configuration for memory bounds and resolution behavior.
#[derive(Debug, Clone)]
pub struct MemoryConfig {
    pub hard_limit_bytes: usize,
    pub eviction_threshold_ratio: f64,
    pub lazy_resolution_enabled: bool,
}

/// Internal memory tracker with arena chunk management.
#[derive(Debug)]
pub struct MemoryGuardian {
    config: MemoryConfig,
    current_usage: usize,
    arenas: VecDeque<Arc<RwLock<Vec<u8>>>>,
    symbol_cache: HashMap<String, Arc<RwLock<Vec<u8>>>>,
}

impl MemoryGuardian {
    pub fn new(config: MemoryConfig) -> Self {
        Self {
            config,
            current_usage: 0,
            arenas: VecDeque::new(),
            symbol_cache: HashMap::new(),
        }
    }

    /// Allocates a chunk for a new file or scope. Returns an error if the hard limit is breached.
    pub fn allocate_arena_chunk(&mut self, size: usize) -> Result<Arc<RwLock<Vec<u8>>>, GlancerError> {
        if self.current_usage + size > self.config.hard_limit_bytes {
            self.evict_cold_entries()?;
        }

        let chunk = Arc::new(RwLock::new(vec![0; size]));
        self.current_usage += size;
        self.arenas.push_back(chunk.clone());
        Ok(chunk)
    }

    /// Drops inactive arenas and purges stale symbol cache entries.
    fn evict_cold_entries(&mut self) -> Result<(), GlancerError> {
        let threshold = (self.config.hard_limit_bytes as f64 * self.config.eviction_threshold_ratio) as usize;
        
        while self.current_usage > threshold && !self.arenas.is_empty() {
            if let Some(chunk) = self.arenas.pop_front() {
                // Release the Arc. Memory drops when reference count hits zero.
                drop(chunk);
                self.current_usage -= threshold / 4; // Approximate reclaimed size
            }
        }

        self.symbol_cache.retain(|_, v| Arc::strong_count(v) > 1);
        Ok(())
    }

    /// Registers a resolved symbol. Uses weak references internally to allow eviction.
    pub fn cache_symbol(&mut self, key: String, data: Vec<u8>) -> Result<(), GlancerError> {
        let entry = Arc::new(RwLock::new(data));
        self.symbol_cache.insert(key, entry);
        Ok(())
    }
}

/// Handles incremental document updates with lazy resolution gating.
pub async fn handle_incremental_update(
    guardian: &Arc<RwLock<MemoryGuardian>>,
    uri: &str,
    changes: &[DocumentEdit],
) -> Result<(), GlancerError> {
    let mut guard = guardian.write().map_err(|e| GlancerError::ProtocolError(e.to_string()))?;
    
    // Validate range bounds before parsing to avoid panics on malformed client payloads
    for change in changes {
        if change.range.start.line > change.range.end.line {
            return Err(GlancerError::InvalidRange("start line exceeds end line".into()));
        }
    }

    // Allocate fresh arena chunk for the updated file scope
    let chunk_size = changes.iter().map(|c| c.text.len()).sum();
    let _arena = guard.allocate_arena_chunk(chunk_size.max(4096))?;

    // Defer heavy resolution if lazy mode is enabled
    if guard.config.lazy_resolution_enabled {
        // Only schedule resolution for symbols directly touched by the edit
        schedule_targeted_inference(uri, changes);
    } else {
        // Fallback to eager resolution (discouraged in
Tags:#glancer#alternative#rust#programming languages
C

Written by Compiler & Language Architect

Editorial staff persona focusing on programming language design, compiler backend optimization, parser implementation, and type systems theory.

View Profile
Recommended For You

Related Articles

Quick:
Navigate Select
Loading search index...