use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use super::dto::{Position2D, Range};
use crate::bridge::encoding::{PositionEncoding, lsp_to_mcp_position, mcp_to_lsp_position};
use crate::bridge::state::{DEFAULT_MAX_FILE_SIZE, uri_to_path};
use crate::bridge::{DocumentTracker, lock_std};
const MAX_LINE_READ_BYTES_PER_RESPONSE: u64 = 4 * DEFAULT_MAX_FILE_SIZE;
#[derive(Debug)]
pub(super) struct LineCacheState {
pub(super) entries: HashMap<(PathBuf, u32), Option<String>>,
bytes_remaining: u64,
budget_exhausted_logged: bool,
positions_degraded: bool,
}
impl LineCacheState {
fn new() -> Self {
Self {
entries: HashMap::new(),
bytes_remaining: MAX_LINE_READ_BYTES_PER_RESPONSE,
budget_exhausted_logged: false,
positions_degraded: false,
}
}
}
type LineCache = Arc<StdMutex<LineCacheState>>;
pub(super) fn new_line_cache() -> LineCache {
Arc::new(StdMutex::new(LineCacheState::new()))
}
#[derive(Debug, Clone)]
pub(super) struct EncodingCtx {
pub(super) encoding: PositionEncoding,
pub(super) tracker: Arc<DocumentTracker>,
pub(super) workspace_roots: Arc<Vec<PathBuf>>,
pub(super) line_cache: LineCache,
}
async fn read_line_text(uri: &lsp_types::Uri, line: u32, ctx: &EncodingCtx) -> Option<String> {
let path = uri_to_path(uri)?;
let key = (path.clone(), line);
if let Some(cached) = lock_std(&ctx.line_cache).entries.get(&key) {
return cached.clone();
}
let text = if let Some(text) = ctx.tracker.line_text(&path, line) {
Some(text)
} else {
disk_read_line_budgeted(&path, line, ctx).await
};
lock_std(&ctx.line_cache).entries.insert(key, text.clone());
text
}
async fn disk_read_line_budgeted(path: &Path, line: u32, ctx: &EncodingCtx) -> Option<String> {
let budget = {
let mut state = lock_std(&ctx.line_cache);
if state.bytes_remaining != 0 {
state.bytes_remaining
} else {
let already_logged = state.budget_exhausted_logged;
state.budget_exhausted_logged = true;
drop(state);
if !already_logged {
tracing::warn!(
path = %path.display(),
budget_bytes = MAX_LINE_READ_BYTES_PER_RESPONSE,
"per-response disk-read budget exhausted; further position conversions \
requiring a disk read in this response will pass columns through \
unconverted"
);
}
return None;
}
};
if let Ok(read) = ctx.tracker.read_line_checked(path, line, budget).await {
let mut state = lock_std(&ctx.line_cache);
state.bytes_remaining = state.bytes_remaining.saturating_sub(read.bytes_read);
drop(state);
read.text
} else {
let mut state = lock_std(&ctx.line_cache);
state.bytes_remaining = state.bytes_remaining.saturating_sub(budget);
drop(state);
None
}
}
impl EncodingCtx {
pub(super) fn is_out_of_workspace(&self, uri: &lsp_types::Uri) -> bool {
!crate::bridge::uri_in_workspace_roots(uri, &self.workspace_roots)
}
pub(super) fn positions_degraded(&self) -> bool {
lock_std(&self.line_cache).positions_degraded
}
pub(super) async fn to_lsp(
&self,
uri: &lsp_types::Uri,
line: u32,
character: u32,
) -> lsp_types::Position {
let line_text = if self.encoding == PositionEncoding::Utf16 {
None
} else {
let text = read_line_text(uri, line.saturating_sub(1), self).await;
if text.is_none() {
lock_std(&self.line_cache).positions_degraded = true;
tracing::warn!(
uri = uri.as_ref(),
line,
encoding = self.encoding.to_lsp(),
"could not resolve line text for position conversion; passing MCP column \
through unconverted, which is wrong for a non-UTF-16 server"
);
}
text
};
mcp_to_lsp_position(line, character, line_text.as_deref(), self.encoding)
}
pub(super) async fn to_mcp(
&self,
uri: &lsp_types::Uri,
pos: lsp_types::Position,
) -> Position2D {
let line_text = if self.encoding == PositionEncoding::Utf16 {
None
} else {
let text = read_line_text(uri, pos.line, self).await;
if text.is_none() {
lock_std(&self.line_cache).positions_degraded = true;
tracing::warn!(
uri = uri.as_ref(),
line = pos.line,
encoding = self.encoding.to_lsp(),
"could not resolve line text for position conversion; passing server \
column through unconverted, which is wrong for a non-UTF-16 server"
);
}
text
};
let (line, character) = lsp_to_mcp_position(pos, line_text.as_deref(), self.encoding);
Position2D { line, character }
}
pub(super) async fn normalize_range(
&self,
uri: &lsp_types::Uri,
range: lsp_types::Range,
) -> Range {
Range {
start: self.to_mcp(uri, range.start).await,
end: self.to_mcp(uri, range.end).await,
}
}
pub(super) async fn denormalize_range(
&self,
uri: &lsp_types::Uri,
range: &Range,
) -> lsp_types::Range {
lsp_types::Range {
start: self
.to_lsp(uri, range.start.line, range.start.character)
.await,
end: self.to_lsp(uri, range.end.line, range.end.character).await,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::bridge::path_to_uri;
use crate::bridge::state::ResourceLimits;
use crate::bridge::translator::testing::*;
#[test]
fn test_is_out_of_workspace_false_when_uri_inside_configured_root() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with_roots(PositionEncoding::Utf16, vec![dir.path().to_path_buf()]);
assert!(!ctx.is_out_of_workspace(&uri));
}
#[test]
fn test_is_out_of_workspace_true_when_uri_outside_configured_roots() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let uri = path_to_uri(&path).unwrap();
let other_dir = TempDir::new().unwrap();
let ctx = test_ctx_with_roots(
PositionEncoding::Utf16,
vec![other_dir.path().to_path_buf()],
);
assert!(ctx.is_out_of_workspace(&uri));
}
#[test]
fn test_is_out_of_workspace_true_when_no_roots_configured() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with_roots(PositionEncoding::Utf16, Vec::new());
assert!(ctx.is_out_of_workspace(&uri));
}
#[tokio::test]
async fn test_normalize_range() {
let lsp_range = lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 2,
character: 5,
},
};
let mcp_range = test_ctx().normalize_range(&test_uri(), lsp_range).await;
assert_eq!(mcp_range.start.line, 1);
assert_eq!(mcp_range.start.character, 1);
assert_eq!(mcp_range.end.line, 3);
assert_eq!(mcp_range.end.character, 6);
}
#[tokio::test]
async fn test_encoding_ctx_utf8_reads_disk_line_text_for_untracked_document() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("multibyte.rs");
fs::write(&path, "héllo").unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
let lsp_pos = ctx.to_lsp(&uri, 1, 3).await;
assert_eq!(lsp_pos.character, 3);
}
#[tokio::test]
async fn test_read_line_text_enforces_max_file_size_for_untracked_document() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("big.rs");
fs::write(&path, "a".repeat(200)).unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = EncodingCtx {
encoding: PositionEncoding::Utf8,
tracker: Arc::new(DocumentTracker::new(
ResourceLimits {
max_documents: 100,
max_file_size: 50,
},
HashMap::new(),
)),
workspace_roots: Arc::new(Vec::new()),
line_cache: new_line_cache(),
};
assert!(
read_line_text(&uri, 0, &ctx).await.is_none(),
"must refuse to return content from a file over max_file_size"
);
}
#[tokio::test]
async fn test_encoding_ctx_utf8_prefers_tracked_content_over_stale_disk() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("tracked.rs");
fs::write(&path, "hello").unwrap();
let tracker = Arc::new(DocumentTracker::new(
ResourceLimits::default(),
HashMap::new(),
));
let uri = tracker.open(path.clone(), "héllo".to_string()).unwrap();
let ctx = EncodingCtx {
encoding: PositionEncoding::Utf8,
tracker,
workspace_roots: Arc::new(Vec::new()),
line_cache: new_line_cache(),
};
let lsp_pos = ctx.to_lsp(&uri, 1, 3).await;
assert_eq!(
lsp_pos.character, 3,
"must convert against the tracker's live content (\"héllo\" -> byte 3), not disk's \
stale content (\"hello\" -> byte 2)"
);
}
#[tokio::test]
async fn test_normalize_range_multi_file_converts_each_location_against_its_own_uri() {
let dir = TempDir::new().unwrap();
let path_a = dir.path().join("a.rs");
fs::write(&path_a, "héllo").unwrap();
let uri_a = path_to_uri(&path_a).unwrap();
let path_b = dir.path().join("b.rs");
fs::write(&path_b, "hello").unwrap();
let uri_b = path_to_uri(&path_b).unwrap();
let lsp_range = lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 3,
},
};
let ctx = test_ctx_with(PositionEncoding::Utf8);
let range_a = ctx.normalize_range(&uri_a, lsp_range).await;
let range_b = ctx.normalize_range(&uri_b, lsp_range).await;
assert_eq!(
range_a.end.character, 3,
"must convert against a.rs's own content"
);
assert_eq!(
range_b.end.character, 4,
"must convert against b.rs's own content"
);
}
#[tokio::test]
async fn test_read_line_text_caches_disk_read_per_path_line() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("cached.rs");
fs::write(&path, "hello").unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
assert_eq!(
read_line_text(&uri, 0, &ctx).await.as_deref(),
Some("hello")
);
fs::write(&path, "héllo").unwrap();
assert_eq!(
read_line_text(&uri, 0, &ctx).await.as_deref(),
Some("hello"),
"must reuse the first lookup's cached result instead of re-reading disk"
);
}
#[tokio::test]
async fn test_read_line_text_stops_disk_reads_once_budget_exhausted() {
let dir = TempDir::new().unwrap();
let path_a = dir.path().join("a.rs");
fs::write(&path_a, "hello\n").unwrap();
let uri_a = path_to_uri(&path_a).unwrap();
let path_b = dir.path().join("b.rs");
fs::write(&path_b, "world\n").unwrap();
let uri_b = path_to_uri(&path_b).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
lock_std(&ctx.line_cache).bytes_remaining = 6;
assert_eq!(
read_line_text(&uri_a, 0, &ctx).await.as_deref(),
Some("hello")
);
assert_eq!(lock_std(&ctx.line_cache).bytes_remaining, 0);
assert_eq!(read_line_text(&uri_b, 0, &ctx).await, None);
}
#[tokio::test]
async fn test_read_line_text_bounds_read_by_remaining_budget() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("long_line.rs");
fs::write(&path, "a".repeat(1000)).unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
lock_std(&ctx.line_cache).bytes_remaining = 10;
assert_eq!(
read_line_text(&uri, 0, &ctx).await,
None,
"a line far longer than the remaining budget must not be returned"
);
assert_eq!(
lock_std(&ctx.line_cache).bytes_remaining,
0,
"the physically-capped read must charge (at most one byte over) the budget it was \
given, not overshoot to max_file_size"
);
}
#[tokio::test]
async fn test_positions_degraded_true_on_first_lookup_with_insufficient_remaining_budget() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("long_line.rs");
fs::write(&path, "a".repeat(1000)).unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
lock_std(&ctx.line_cache).bytes_remaining = 10;
assert!(!ctx.positions_degraded(), "no lookup has happened yet");
ctx.to_lsp(&uri, 1, 3).await;
assert!(
ctx.positions_degraded(),
"a line too long for the remaining budget must mark positions_degraded, even on \
the very first such lookup"
);
}
#[tokio::test]
async fn test_positions_degraded_true_for_nonexistent_path() {
let dir = TempDir::new().unwrap();
let uri = path_to_uri(&dir.path().join("does_not_exist.rs")).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
assert!(!ctx.positions_degraded());
ctx.to_mcp(
&uri,
lsp_types::Position {
line: 0,
character: 0,
},
)
.await;
assert!(ctx.positions_degraded());
}
#[tokio::test]
async fn test_read_line_text_charges_budget_even_when_line_is_invalid_utf8() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("invalid_utf8.rs");
let mut content = vec![0xFFu8, 0xFE, 0xFD];
content.push(b'\n');
fs::write(&path, &content).unwrap();
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
assert_eq!(read_line_text(&uri, 0, &ctx).await, None);
assert_eq!(
lock_std(&ctx.line_cache).bytes_remaining,
MAX_LINE_READ_BYTES_PER_RESPONSE - content.len() as u64,
"the budget must be charged for the bytes scanned even though the line was not \
valid UTF-8"
);
}
#[tokio::test]
async fn test_read_line_text_charges_nominal_amount_for_nonexistent_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("rustc_stdlib_without_rust_src.rs");
let uri = path_to_uri(&path).unwrap();
let ctx = test_ctx_with(PositionEncoding::Utf8);
assert_eq!(read_line_text(&uri, 0, &ctx).await, None);
assert_eq!(
lock_std(&ctx.line_cache).bytes_remaining,
MAX_LINE_READ_BYTES_PER_RESPONSE - crate::bridge::state::OPEN_FAILURE_CHARGE_BYTES,
"a nonexistent path must charge only the small nominal amount, not the whole budget"
);
}
}