use std::collections::HashMap;
use std::num::NonZeroU64;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use super::dto::{Position, Position2D, PositionDegradation, PositionRange, Range};
use crate::bridge::encoding::{
ColumnFidelity, PositionEncoding, lsp_to_mcp_position, mcp_to_lsp_position,
};
use crate::bridge::state::{ResourceLimits, uri_to_path};
use crate::bridge::{DocumentTracker, WorkspaceRoots};
use crate::config::SizeLimit;
use crate::util::lock_std;
const LINE_READ_BUDGET_FILE_MULTIPLE: u64 = 4;
const LINE_READ_BUDGET_CEILING: u64 = 256 * 1024 * 1024;
const DEFAULT_FILE_SIZE: NonZeroU64 = match SizeLimit::DEFAULT.get() {
Some(default) => default,
None => panic!("SizeLimit::DEFAULT must be bounded"),
};
const fn line_read_budget(limits: ResourceLimits) -> u64 {
let basis = match limits.max_file_size.get() {
Some(max) => max.get(),
None => DEFAULT_FILE_SIZE.get(),
};
let budget = basis.saturating_mul(LINE_READ_BUDGET_FILE_MULTIPLE);
if budget > LINE_READ_BUDGET_CEILING {
LINE_READ_BUDGET_CEILING
} else {
budget
}
}
#[derive(Debug)]
pub(super) struct LineCacheState {
pub(super) entries: HashMap<(PathBuf, u32), Option<String>>,
bytes_remaining: u64,
budget_exhausted_logged: bool,
positions_degraded: Option<PositionDegradation>,
}
impl LineCacheState {
fn new(budget: u64) -> Self {
Self {
entries: HashMap::new(),
bytes_remaining: budget,
budget_exhausted_logged: false,
positions_degraded: None,
}
}
}
type LineCache = Arc<StdMutex<LineCacheState>>;
fn new_line_cache(budget: u64) -> LineCache {
Arc::new(StdMutex::new(LineCacheState::new(budget)))
}
#[derive(Debug, Clone)]
pub(super) struct EncodingCtx {
pub(super) encoding: PositionEncoding,
pub(super) tracker: Arc<DocumentTracker>,
pub(super) workspace_roots: WorkspaceRoots,
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 = line_read_budget(ctx.tracker.limits()),
"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 new(
encoding: PositionEncoding,
tracker: Arc<DocumentTracker>,
workspace_roots: WorkspaceRoots,
) -> Self {
let line_cache = new_line_cache(line_read_budget(tracker.limits()));
Self {
encoding,
tracker,
workspace_roots,
line_cache,
}
}
pub(super) fn is_out_of_workspace(&self, uri: &lsp_types::Uri) -> bool {
!self.workspace_roots.admits_uri(uri)
}
pub(super) fn positions_degraded(&self) -> Option<PositionDegradation> {
lock_std(&self.line_cache).positions_degraded
}
fn record_degradation(&self, degradation: PositionDegradation) -> bool {
let mut state = lock_std(&self.line_cache);
let raised = state.positions_degraded < Some(degradation);
state.positions_degraded = state.positions_degraded.max(Some(degradation));
raised
}
pub(super) async fn to_lsp(
&self,
uri: &lsp_types::Uri,
position: Position,
) -> lsp_types::Position {
let line_text =
if self.encoding == PositionEncoding::Utf16 || position.character().get() <= 1 {
None
} else {
read_line_text(uri, position.lsp_line(), self).await
};
let converted = mcp_to_lsp_position(position, line_text.as_deref(), self.encoding);
if converted.fidelity == ColumnFidelity::PassedThrough
&& self.record_degradation(PositionDegradation::Request)
{
tracing::warn!(
uri = uri.as_ref(),
line = position.line().get(),
encoding = self.encoding.to_lsp(),
"could not convert MCP column exactly; passing it through unconverted, which \
is wrong for a non-UTF-16 server (logged once per response)"
);
}
converted.value
}
pub(super) async fn to_mcp(
&self,
uri: &lsp_types::Uri,
pos: lsp_types::Position,
) -> Position2D {
let needs_text = self.encoding != PositionEncoding::Utf16
&& pos.character != 0
&& pos.character != u32::MAX;
let line_text = if needs_text {
read_line_text(uri, pos.line, self).await
} else {
None
};
let converted = lsp_to_mcp_position(pos, line_text.as_deref(), self.encoding);
if converted.fidelity == ColumnFidelity::PassedThrough
&& self.record_degradation(PositionDegradation::Response)
{
tracing::warn!(
uri = uri.as_ref(),
line = pos.line,
encoding = self.encoding.to_lsp(),
"could not convert server column exactly; passing it through unconverted, \
which is wrong for a non-UTF-16 server (logged once per response)"
);
}
converted.value
}
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: PositionRange,
) -> lsp_types::Range {
lsp_types::Range {
start: self.to_lsp(uri, range.start()).await,
end: self.to_lsp(uri, range.end()).await,
}
}
}
#[cfg(test)]
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::*;
use crate::config::{DocumentLimit, SizeLimit};
#[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,
WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap(),
);
assert!(!ctx.is_out_of_workspace(&uri));
}
#[cfg(unix)]
#[test]
fn test_is_out_of_workspace_false_under_alias_spelling() {
let dir = TempDir::new().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
let real = base.join("real");
fs::create_dir(&real).unwrap();
let link = base.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&link)).unwrap();
let ctx = test_ctx_with_roots(PositionEncoding::Utf16, roots);
assert!(!ctx.is_out_of_workspace(&path_to_uri(&link.join("main.rs")).unwrap()));
assert!(!ctx.is_out_of_workspace(&path_to_uri(&real.join("main.rs")).unwrap()));
assert!(ctx.is_out_of_workspace(&path_to_uri(&base.join("other/main.rs")).unwrap()));
}
#[cfg(target_os = "macos")]
#[test]
fn test_is_out_of_workspace_false_under_system_symlink_alias() {
let dir = TempDir::new_in("/tmp").unwrap();
let canonical = dunce::canonicalize(dir.path()).unwrap();
assert!(canonical.starts_with("/private/tmp"));
let roots = WorkspaceRoots::from_paths(&[canonical]).unwrap();
let ctx = test_ctx_with_roots(PositionEncoding::Utf16, roots);
let alias_spelling = dir.path().join("a.rs");
assert!(alias_spelling.starts_with("/tmp"));
assert!(!ctx.is_out_of_workspace(&path_to_uri(&alias_spelling).unwrap()));
}
#[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,
WorkspaceRoots::from_paths(&[other_dir.path().to_path_buf()]).unwrap(),
);
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, WorkspaceRoots::default());
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, Position::at(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::new(
PositionEncoding::Utf8,
Arc::new(DocumentTracker::new(
ResourceLimits {
max_documents: DocumentLimit::new(100),
max_file_size: SizeLimit::from_static(50),
},
HashMap::new(),
)),
WorkspaceRoots::default(),
);
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::new(PositionEncoding::Utf8, tracker, WorkspaceRoots::default());
let lsp_pos = ctx.to_lsp(&uri, Position::at(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_eq!(ctx.positions_degraded(), None, "no lookup has happened yet");
ctx.to_lsp(&uri, Position::at(1, 3)).await;
assert_eq!(
ctx.positions_degraded(),
Some(PositionDegradation::Request),
"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_eq!(ctx.positions_degraded(), None);
ctx.to_mcp(
&uri,
lsp_types::Position {
line: 0,
character: 1,
},
)
.await;
assert_eq!(
ctx.positions_degraded(),
Some(PositionDegradation::Response)
);
}
fn lsp_position(line: u32, character: u32) -> lsp_types::Position {
lsp_types::Position { line, character }
}
#[tokio::test]
async fn test_column_zero_on_unresolvable_line_is_not_degraded_and_skips_io() {
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);
let before = lock_std(&ctx.line_cache).bytes_remaining;
assert_eq!(ctx.to_lsp(&uri, pos(3, 1)).await.character, 0);
assert_eq!(ctx.to_mcp(&uri, lsp_position(2, 0)).await.character, 1);
assert_eq!(ctx.positions_degraded(), None);
assert_eq!(lock_std(&ctx.line_cache).bytes_remaining, before);
assert!(lock_std(&ctx.line_cache).entries.is_empty());
}
#[tokio::test]
async fn test_to_lsp_nonzero_column_on_unresolvable_line_is_request() {
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);
let _ = ctx.to_lsp(&uri, pos(1, 2)).await;
assert_eq!(ctx.positions_degraded(), Some(PositionDegradation::Request));
}
#[tokio::test]
async fn test_request_degradation_subsumes_response_in_either_order() {
let dir = TempDir::new().unwrap();
let uri = path_to_uri(&dir.path().join("does_not_exist.rs")).unwrap();
let response_first = test_ctx_with(PositionEncoding::Utf8);
let _ = response_first.to_mcp(&uri, lsp_position(0, 3)).await;
assert_eq!(
response_first.positions_degraded(),
Some(PositionDegradation::Response)
);
let _ = response_first.to_lsp(&uri, pos(1, 3)).await;
assert_eq!(
response_first.positions_degraded(),
Some(PositionDegradation::Request)
);
let request_first = test_ctx_with(PositionEncoding::Utf8);
let _ = request_first.to_lsp(&uri, pos(1, 3)).await;
let _ = request_first.to_mcp(&uri, lsp_position(0, 3)).await;
assert_eq!(
request_first.positions_degraded(),
Some(PositionDegradation::Request)
);
}
fn tracked_ctx(content: &str) -> (EncodingCtx, lsp_types::Uri, TempDir) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("tracked.rs");
let tracker = Arc::new(DocumentTracker::new(
ResourceLimits::default(),
HashMap::new(),
));
let uri = tracker.open(path, content.to_string()).unwrap();
let ctx = EncodingCtx::new(PositionEncoding::Utf8, tracker, WorkspaceRoots::default());
(ctx, uri, dir)
}
#[tokio::test]
async fn test_to_lsp_past_end_of_tracked_line_clamps_without_degradation() {
let (ctx, uri, _dir) = tracked_ctx("éééé");
assert_eq!(ctx.to_lsp(&uri, pos(1, 6)).await.character, 8);
assert_eq!(ctx.positions_degraded(), None);
}
#[tokio::test]
async fn test_to_lsp_on_trailing_empty_line_is_exact() {
let (ctx, uri, _dir) = tracked_ctx("fn main() {}\n");
assert_eq!(ctx.to_lsp(&uri, pos(2, 1)).await.character, 0);
assert_eq!(ctx.to_lsp(&uri, pos(2, 4)).await.character, 0);
assert_eq!(ctx.positions_degraded(), None);
}
#[tokio::test]
async fn test_to_lsp_mid_surrogate_column_is_request() {
let (ctx, uri, _dir) = tracked_ctx("𝄞x");
let _ = ctx.to_lsp(&uri, pos(1, 2)).await;
assert_eq!(ctx.positions_degraded(), Some(PositionDegradation::Request));
}
#[tokio::test]
async fn test_to_mcp_end_of_line_sentinel_is_not_degraded_and_skips_io() {
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_eq!(
ctx.to_mcp(&uri, lsp_position(0, u32::MAX)).await.character,
u32::MAX
);
assert_eq!(ctx.positions_degraded(), None);
assert!(lock_std(&ctx.line_cache).entries.is_empty());
}
#[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,
line_read_budget(ResourceLimits::default()) - 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,
line_read_budget(ResourceLimits::default())
- crate::bridge::state::OPEN_FAILURE_CHARGE_BYTES,
"a nonexistent path must charge only the small nominal amount, not the whole budget"
);
}
#[test]
fn test_line_read_budget_scales_with_max_file_size() {
let budget = |max_file_size| {
line_read_budget(ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::from_static(max_file_size),
})
};
assert_eq!(budget(1000), 4000);
assert_eq!(budget(1000 * 1024), 4 * 1000 * 1024);
}
#[test]
fn test_line_read_budget_is_capped_at_ceiling() {
let budget = |max_file_size| {
line_read_budget(ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::from_static(max_file_size),
})
};
assert_eq!(
budget(crate::config::MAX_FILE_SIZE_LIMIT),
LINE_READ_BUDGET_CEILING
);
assert_eq!(
budget(LINE_READ_BUDGET_CEILING / 4),
LINE_READ_BUDGET_CEILING
);
}
#[test]
fn test_line_read_budget_zero_limit_falls_back_to_default() {
let budget = line_read_budget(ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
});
assert_eq!(budget, 4 * 10 * 1024 * 1024);
}
#[test]
fn test_encoding_ctx_new_derives_budget_from_tracker_limits() {
let tracker = Arc::new(DocumentTracker::new(
ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::from_static(4096),
},
HashMap::new(),
));
let ctx = EncodingCtx::new(PositionEncoding::Utf8, tracker, WorkspaceRoots::default());
assert_eq!(lock_std(&ctx.line_cache).bytes_remaining, 4 * 4096);
}
}