Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: EmbarkStudios/cargo-deny-action@v2

coverage:
name: Coverage
Expand Down
2 changes: 1 addition & 1 deletion crates/mcpls-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub struct Args {
/// Path to configuration file
///
/// If not specified, searches for mcpls.toml in:
/// 1. $MCPLS_CONFIG environment variable
/// 1. `$MCPLS_CONFIG` environment variable
/// 2. Current directory
/// 3. ~/.config/mcpls/mcpls.toml
#[arg(short, long, value_name = "FILE", env = "MCPLS_CONFIG")]
Expand Down
6 changes: 3 additions & 3 deletions crates/mcpls-cli/src/logging.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
//! Logging initialization and configuration.

use anyhow::{Context, Result};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};

/// Initialize the logging subsystem.
///
/// # Errors
///
/// Returns an error if the log level is invalid or initialization fails.
pub fn init(level: String) -> Result<()> {
let filter = EnvFilter::try_new(&level)
pub fn init(level: &str) -> Result<()> {
let filter = EnvFilter::try_new(level)
.or_else(|_| EnvFilter::try_new("info"))
.context("failed to parse log level")?;

Expand Down
14 changes: 4 additions & 10 deletions crates/mcpls-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,16 @@ async fn main() -> Result<()> {
let args = Args::parse();

// Initialize logging
logging::init(args.log_level)?;
logging::init(&args.log_level)?;

tracing::info!(
version = env!("CARGO_PKG_VERSION"),
"starting mcpls"
);
tracing::info!(version = env!("CARGO_PKG_VERSION"), "starting mcpls");

// Load configuration
let config = if let Some(config_path) = &args.config {
mcpls_core::ServerConfig::load_from(config_path)
.with_context(|| format!("failed to load config from {}", config_path.display()))?
} else {
mcpls_core::ServerConfig::load()
.context("failed to load configuration")?
mcpls_core::ServerConfig::load().context("failed to load configuration")?
};

tracing::debug!(
Expand All @@ -38,9 +34,7 @@ async fn main() -> Result<()> {
);

// Start the server
mcpls_core::serve(config)
.await
.context("server error")?;
mcpls_core::serve(config).await.context("server error")?;

tracing::info!("mcpls shutdown complete");
Ok(())
Expand Down
200 changes: 194 additions & 6 deletions crates/mcpls-core/src/bridge/encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ impl PositionEncoding {

/// Convert to LSP position encoding kind string.
#[must_use]
pub fn to_lsp(&self) -> &'static str {
pub const fn to_lsp(&self) -> &'static str {
match self {
Self::Utf8 => "utf-8",
Self::Utf16 => "utf-16",
Expand All @@ -45,7 +45,7 @@ impl PositionEncoding {
/// MCP tools use 1-based line and column numbers for human readability.
/// LSP uses 0-based positions internally.
#[must_use]
pub fn mcp_to_lsp_position(line: u32, character: u32) -> Position {
pub const fn mcp_to_lsp_position(line: u32, character: u32) -> Position {
Position {
line: line.saturating_sub(1),
character: character.saturating_sub(1),
Expand All @@ -54,11 +54,120 @@ pub fn mcp_to_lsp_position(line: u32, character: u32) -> Position {

/// Convert LSP position (0-based) to MCP position (1-based).
#[must_use]
pub fn lsp_to_mcp_position(pos: Position) -> (u32, u32) {
pub const fn lsp_to_mcp_position(pos: Position) -> (u32, u32) {
(pos.line + 1, pos.character + 1)
}

/// Position encoding converter for handling UTF-8/UTF-16/UTF-32 conversions.
///
/// Different LSP servers may use different character encodings. This converter
/// handles the conversion between byte offsets and character offsets based on
/// the negotiated encoding.
#[derive(Debug, Clone)]
pub struct EncodingConverter {
encoding: PositionEncoding,
}

#[allow(dead_code)] // Will be used when LSP client integration is complete
impl EncodingConverter {
/// Create a new encoding converter with the specified encoding.
#[must_use]
pub const fn new(encoding: PositionEncoding) -> Self {
Self { encoding }
}

/// Convert byte offset to character offset in the configured encoding.
///
/// # Errors
///
/// Returns an error if:
/// - The byte offset is not on a character boundary
/// - The encoding is unsupported
#[allow(clippy::cast_possible_truncation)] // LSP positions use u32, truncation acceptable
pub fn byte_offset_to_character(&self, text: &str, byte_offset: usize) -> Result<u32, String> {
if byte_offset > text.len() {
let text_len = text.len();
return Err(format!(
"Byte offset {byte_offset} exceeds text length {text_len}"
));
}

match self.encoding {
PositionEncoding::Utf8 => Ok(byte_offset as u32),
PositionEncoding::Utf16 => {
let utf16_units = text[..byte_offset].encode_utf16().count();
Ok(utf16_units as u32)
}
PositionEncoding::Utf32 => {
let code_points = text[..byte_offset].chars().count();
Ok(code_points as u32)
}
}
}

/// Convert character offset to byte offset in the configured encoding.
///
/// # Errors
///
/// Returns an error if:
/// - The character offset is out of bounds
/// - The encoding is unsupported
#[allow(clippy::cast_possible_truncation)] // LSP positions use u32, truncation acceptable
pub fn character_to_byte_offset(
&self,
text: &str,
character_offset: u32,
) -> Result<usize, String> {
match self.encoding {
PositionEncoding::Utf8 => {
let byte_offset = character_offset as usize;
if byte_offset > text.len() {
let text_len = text.len();
return Err(format!(
"Character offset {character_offset} exceeds text length {text_len}"
));
}
Ok(byte_offset)
}
PositionEncoding::Utf16 => {
let mut utf16_count = 0u32;
for (byte_idx, ch) in text.char_indices() {
if utf16_count >= character_offset {
return Ok(byte_idx);
}
utf16_count += ch.len_utf16() as u32;
}
if utf16_count == character_offset {
Ok(text.len())
} else {
Err(format!(
"Character offset {character_offset} out of bounds (max UTF-16 units: {utf16_count})"
))
}
}
PositionEncoding::Utf32 => text
.char_indices()
.nth(character_offset as usize)
.map(|(byte_idx, _)| byte_idx)
.or_else(|| {
if character_offset == text.chars().count() as u32 {
Some(text.len())
} else {
None
}
})
.ok_or_else(|| {
let max_code_points = text.chars().count();
format!(
"Character offset {character_offset} out of bounds (max code points: {max_code_points})"
)
}),
}
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;

Expand Down Expand Up @@ -112,9 +221,88 @@ mod tests {

#[test]
fn test_position_encoding_parsing() {
assert_eq!(PositionEncoding::from_lsp("utf-8"), Some(PositionEncoding::Utf8));
assert_eq!(PositionEncoding::from_lsp("utf-16"), Some(PositionEncoding::Utf16));
assert_eq!(PositionEncoding::from_lsp("utf-32"), Some(PositionEncoding::Utf32));
assert_eq!(
PositionEncoding::from_lsp("utf-8"),
Some(PositionEncoding::Utf8)
);
assert_eq!(
PositionEncoding::from_lsp("utf-16"),
Some(PositionEncoding::Utf16)
);
assert_eq!(
PositionEncoding::from_lsp("utf-32"),
Some(PositionEncoding::Utf32)
);
assert_eq!(PositionEncoding::from_lsp("invalid"), None);
}

#[test]
fn test_utf8_encoding() {
let converter = EncodingConverter::new(PositionEncoding::Utf8);
let text = "Hello, world!";

let char_offset = converter.byte_offset_to_character(text, 7).unwrap();
assert_eq!(char_offset, 7);

let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
assert_eq!(byte_offset, 7);
}

#[test]
fn test_utf16_encoding_with_emoji() {
let converter = EncodingConverter::new(PositionEncoding::Utf16);
let text = "Hello 😀 world";

let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
assert_eq!(char_offset, 6);

let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
assert_eq!(char_offset, 8);

let byte_offset = converter.character_to_byte_offset(text, 6).unwrap();
assert_eq!(byte_offset, 6);

let byte_offset = converter.character_to_byte_offset(text, 8).unwrap();
assert_eq!(byte_offset, 10);
}

#[test]
fn test_utf16_encoding_roundtrip() {
let converter = EncodingConverter::new(PositionEncoding::Utf16);
let text = "Hello 🌍 world!";

for byte_idx in [0, 6, 10, 11] {
let char_offset = converter.byte_offset_to_character(text, byte_idx).unwrap();
let back_to_byte = converter
.character_to_byte_offset(text, char_offset)
.unwrap();
assert_eq!(byte_idx, back_to_byte);
}
}

#[test]
fn test_utf32_encoding() {
let converter = EncodingConverter::new(PositionEncoding::Utf32);
let text = "Hello 😀 world";

let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
assert_eq!(char_offset, 6);

let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
assert_eq!(char_offset, 7);

let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
assert_eq!(byte_offset, 10);
}

#[test]
fn test_encoding_edge_cases() {
let converter = EncodingConverter::new(PositionEncoding::Utf8);

assert!(converter.byte_offset_to_character("test", 100).is_err());
assert!(converter.character_to_byte_offset("test", 100).is_err());

let end_offset = converter.byte_offset_to_character("test", 4).unwrap();
assert_eq!(end_offset, 4);
}
}
2 changes: 1 addition & 1 deletion crates/mcpls-core/src/bridge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ mod encoding;
mod state;
mod translator;

pub use encoding::{lsp_to_mcp_position, mcp_to_lsp_position, PositionEncoding};
pub use encoding::{PositionEncoding, lsp_to_mcp_position, mcp_to_lsp_position};
pub use state::{DocumentState, DocumentTracker};
pub use translator::Translator;
8 changes: 4 additions & 4 deletions crates/mcpls-core/src/bridge/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,15 @@ pub fn path_to_uri(path: &Path) -> Uri {
} else {
format!("file://{}", path.display())
};
// Path-to-URI conversion should always succeed for valid paths
#[allow(clippy::expect_used)]
uri_string.parse().expect("failed to create URI from path")
}

/// Detect the language ID from a file path.
#[must_use]
pub fn detect_language(path: &Path) -> String {
let extension = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");

match extension {
"rs" => "rust",
Expand Down Expand Up @@ -155,6 +154,7 @@ pub fn detect_language(path: &Path) -> String {
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;

Expand Down
34 changes: 5 additions & 29 deletions crates/mcpls-core/src/bridge/translator.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
//! MCP to LSP translation layer.

use crate::error::Result;
use crate::lsp::LspClient;
use std::collections::HashMap;

Expand Down Expand Up @@ -32,42 +31,19 @@ impl Translator {

/// Get the document tracker.
#[must_use]
pub fn document_tracker(&self) -> &DocumentTracker {
pub const fn document_tracker(&self) -> &DocumentTracker {
&self.document_tracker
}

/// Get a mutable reference to the document tracker.
pub fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
pub const fn document_tracker_mut(&mut self) -> &mut DocumentTracker {
&mut self.document_tracker
}

/// Initialize all registered LSP clients.
///
/// # Errors
///
/// Returns an error if any client fails to initialize.
pub async fn initialize_all(&mut self) -> Result<()> {
for client in self.lsp_clients.values_mut() {
client.initialize().await?;
}
Ok(())
}

/// Shutdown all registered LSP clients.
///
/// # Errors
///
/// Returns an error if any client fails to shutdown.
pub async fn shutdown_all(&mut self) -> Result<()> {
// Close all tracked documents
let _closed = self.document_tracker.close_all();
// TODO: These methods will be implemented in Phase 3-5
// Initialize and shutdown are now handled by LspServer in lifecycle.rs

// Shutdown all clients
for client in self.lsp_clients.values_mut() {
client.shutdown().await?;
}
Ok(())
}
// Future implementation will use LspServer instead of LspClient directly
}

impl Default for Translator {
Expand Down
Loading