From 8b6c2edbed2a787f3ab12b477d38811f911569f3 Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Sun, 1 Mar 2026 15:54:38 +0800 Subject: [PATCH 01/10] feat: add LSP server for IDE integration Add tower-lsp based Language Server Protocol implementation to provide CODEOWNERS information directly in supported editors. Includes optional 'lsp' feature flag and new `ci lsp` command to start the server. --- Cargo.toml | 3 + ci/Cargo.toml | 2 + ci/src/cli/mod.rs | 21 + codeinput/Cargo.toml | 6 + codeinput/src/core/cache.rs | 38 +- codeinput/src/core/commands/inspect.rs | 2 +- codeinput/src/core/commands/list_files.rs | 2 +- codeinput/src/core/commands/list_owners.rs | 2 +- codeinput/src/core/commands/list_rules.rs | 2 +- codeinput/src/core/commands/list_tags.rs | 2 +- codeinput/src/core/commands/parse.rs | 2 +- codeinput/src/core/mod.rs | 2 +- codeinput/src/core/parse.rs | 12 +- codeinput/src/lib.rs | 4 + codeinput/src/lsp/mod.rs | 8 + codeinput/src/lsp/server.rs | 483 +++++++++++++++++++++ 16 files changed, 563 insertions(+), 28 deletions(-) create mode 100644 codeinput/src/lsp/mod.rs create mode 100644 codeinput/src/lsp/server.rs diff --git a/Cargo.toml b/Cargo.toml index 4b7e20f..3961b5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ tabled = "0.20.0" terminal_size = "0.4.2" clap = { version = "4.5.40", features = ["cargo", "derive"] } chrono = { version = "0.4.41", features = ["serde"] } +tower-lsp = "0.20" +tokio = { version = "1", features = ["full"] } +url = "2.5" # Dev dependencies assert_cmd = "2.0.17" diff --git a/ci/Cargo.toml b/ci/Cargo.toml index 57da6a3..26f0359 100644 --- a/ci/Cargo.toml +++ b/ci/Cargo.toml @@ -21,6 +21,7 @@ default = ["termlog"] termlog = ["codeinput/termlog"] journald = ["codeinput/journald"] syslog = ["codeinput/syslog"] +lsp = ["codeinput/tower-lsp", "codeinput/tokio", "tokio"] [dependencies] codeinput = { version = "0.0.4", path = "../codeinput" } @@ -34,6 +35,7 @@ thiserror = { workspace = true } tabled = { workspace = true } terminal_size = { workspace = true } clap = { workspace = true } +tokio = { workspace = true, optional = true } [dev-dependencies] assert_cmd = { workspace = true } diff --git a/ci/src/cli/mod.rs b/ci/src/cli/mod.rs index 66469fd..f3307e0 100644 --- a/ci/src/cli/mod.rs +++ b/ci/src/cli/mod.rs @@ -16,6 +16,9 @@ use codeinput::utils::app_config::AppConfig; use codeinput::utils::error::Result; use codeinput::utils::types::LogLevel; +#[cfg(feature = "lsp")] +use codeinput::lsp::server::run_lsp_server; + #[derive(Parser, Debug)] #[command( name = "codeinput", @@ -76,6 +79,17 @@ enum Commands { long_about = None, )] Config, + #[cfg(feature = "lsp")] + #[clap( + name = "lsp", + about = "Start LSP server for IDE integration", + long_about = "Starts a Language Server Protocol (LSP) server that provides CODEOWNERS information to supported editors" + )] + Lsp { + /// Use stdio for LSP communication (passed by VS Code) + #[arg(long, hide = true)] + stdio: bool, + }, } #[derive(Subcommand, PartialEq, Debug)] @@ -279,6 +293,13 @@ pub fn cli_match() -> Result<()> { } } Commands::Config => commands::config::run()?, + #[cfg(feature = "lsp")] + Commands::Lsp { stdio: _ } => { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + run_lsp_server().await + })?; + } } Ok(()) diff --git a/codeinput/Cargo.toml b/codeinput/Cargo.toml index 7306f6c..97b5d0e 100644 --- a/codeinput/Cargo.toml +++ b/codeinput/Cargo.toml @@ -57,6 +57,9 @@ full = [ "clap", "chrono", "utoipa", + "tower-lsp", + "tokio", + "url", ] nightly = [] termlog = ["slog-term"] @@ -93,6 +96,9 @@ tabled = { workspace = true, optional = true } terminal_size = { workspace = true, optional = true } clap = { workspace = true, optional = true } chrono = { version = "0.4.41", features = ["serde"], optional = true } +tower-lsp = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +url = { workspace = true, optional = true } [target.'cfg(target_os = "linux")'.dependencies] slog-journald = { version = "2.2.0", optional = true } diff --git a/codeinput/src/core/cache.rs b/codeinput/src/core/cache.rs index b6e663d..f2d1d18 100644 --- a/codeinput/src/core/cache.rs +++ b/codeinput/src/core/cache.rs @@ -18,7 +18,7 @@ use std::{ /// Create a cache from parsed CODEOWNERS entries and files pub fn build_cache( - entries: Vec, files: Vec, hash: [u8; 32], + entries: Vec, files: Vec, hash: [u8; 32], quiet: bool, ) -> Result { let mut owners_map = std::collections::HashMap::new(); let mut tags_map = std::collections::HashMap::new(); @@ -42,18 +42,20 @@ pub fn build_cache( processed_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; // Limit filename display length and clear the line properly - let file_display = file_path.display().to_string(); - let truncated_file = if file_display.len() > 60 { - format!("...{}", &file_display[file_display.len() - 57..]) - } else { - file_display - }; - - print!( - "\r\x1b[K📁 Processing [{}/{}] {}", - current, total_files, truncated_file - ); - std::io::stdout().flush().unwrap(); + if !quiet { + let file_display = file_path.display().to_string(); + let truncated_file = if file_display.len() > 60 { + format!("...{}", &file_display[file_display.len() - 57..]) + } else { + file_display + }; + + print!( + "\r\x1b[K📁 Processing [{}/{}] {}", + current, total_files, truncated_file + ); + std::io::stdout().flush().unwrap(); + } let (owners, tags) = find_owners_and_tags_for_file(file_path, &matched_entries).unwrap(); @@ -70,7 +72,9 @@ pub fn build_cache( .collect(); // Print newline after processing is complete - println!("\r\x1b[K✅ Processed {} files successfully", total_files); + if !quiet { + println!("\r\x1b[K✅ Processed {} files successfully", total_files); + } // Process each owner let owners = collect_owners(&entries); @@ -175,7 +179,7 @@ pub fn load_cache(path: &Path) -> Result { } pub fn sync_cache( - repo: &std::path::Path, cache_file: Option<&std::path::Path>, + repo: &std::path::Path, cache_file: Option<&std::path::Path>, quiet: bool, ) -> Result { let config_cache_file = crate::utils::app_config::AppConfig::fetch()? .cache_file @@ -189,7 +193,7 @@ pub fn sync_cache( // Verify that the cache file exists if !repo.join(cache_file).exists() { // parse the codeowners files and build the cache - return parse_repo(&repo, &cache_file); + return parse_repo(&repo, &cache_file, quiet); } // Load the cache from the specified file @@ -207,7 +211,7 @@ pub fn sync_cache( if cache_hash != current_hash { // parse the codeowners files and build the cache - return parse_repo(&repo, &cache_file); + return parse_repo(&repo, &cache_file, quiet); } else { return Ok(cache); } diff --git a/codeinput/src/core/commands/inspect.rs b/codeinput/src/core/commands/inspect.rs index f70d8a4..7de30a5 100644 --- a/codeinput/src/core/commands/inspect.rs +++ b/codeinput/src/core/commands/inspect.rs @@ -16,7 +16,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file)?; + let cache = sync_cache(repo, cache_file, false)?; // Normalize the file path to be relative to the repo let normalized_file_path = if file_path.is_absolute() { diff --git a/codeinput/src/core/commands/list_files.rs b/codeinput/src/core/commands/list_files.rs index 7844b11..4d8f53e 100644 --- a/codeinput/src/core/commands/list_files.rs +++ b/codeinput/src/core/commands/list_files.rs @@ -28,7 +28,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file)?; + let cache = sync_cache(repo, cache_file, false)?; // Filter files based on criteria let filtered_files = cache diff --git a/codeinput/src/core/commands/list_owners.rs b/codeinput/src/core/commands/list_owners.rs index cc5a0c9..0369ce9 100644 --- a/codeinput/src/core/commands/list_owners.rs +++ b/codeinput/src/core/commands/list_owners.rs @@ -25,7 +25,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file)?; + let cache = sync_cache(repo, cache_file, false)?; // Sort owners by number of files they own (descending) let mut owners_with_counts: Vec<_> = cache.owners_map.iter().collect(); diff --git a/codeinput/src/core/commands/list_rules.rs b/codeinput/src/core/commands/list_rules.rs index 72f1b0a..9ee67f2 100644 --- a/codeinput/src/core/commands/list_rules.rs +++ b/codeinput/src/core/commands/list_rules.rs @@ -22,7 +22,7 @@ struct RuleDisplay { /// Display CODEOWNERS rules from the cache pub fn run(format: &OutputFormat, cache_file: Option<&std::path::Path>) -> Result<()> { // Load the cache - let cache = sync_cache(std::path::Path::new("."), cache_file)?; + let cache = sync_cache(std::path::Path::new("."), cache_file, false)?; // Process the rules from the cache match format { diff --git a/codeinput/src/core/commands/list_tags.rs b/codeinput/src/core/commands/list_tags.rs index 9924d49..c7e74c0 100644 --- a/codeinput/src/core/commands/list_tags.rs +++ b/codeinput/src/core/commands/list_tags.rs @@ -23,7 +23,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file)?; + let cache = sync_cache(repo, cache_file, false)?; // Sort tags by number of files they're associated with (descending) let mut tags_with_counts: Vec<_> = cache.tags_map.iter().collect(); diff --git a/codeinput/src/core/commands/parse.rs b/codeinput/src/core/commands/parse.rs index dec9ef3..7fcab3a 100644 --- a/codeinput/src/core/commands/parse.rs +++ b/codeinput/src/core/commands/parse.rs @@ -41,7 +41,7 @@ pub fn run( // Build the cache from the parsed CODEOWNERS entries and the files let hash = get_repo_hash(path)?; - let cache = build_cache(parsed_codeowners, files, hash)?; + let cache = build_cache(parsed_codeowners, files, hash, false)?; // Store the cache in the specified file store_cache(&cache, &cache_file, encoding)?; diff --git a/codeinput/src/core/mod.rs b/codeinput/src/core/mod.rs index 068b474..a4a0969 100644 --- a/codeinput/src/core/mod.rs +++ b/codeinput/src/core/mod.rs @@ -1,4 +1,4 @@ -pub(crate) mod cache; +pub mod cache; pub mod commands; pub(crate) mod common; pub(crate) mod display; diff --git a/codeinput/src/core/parse.rs b/codeinput/src/core/parse.rs index c2cc032..29f4b55 100644 --- a/codeinput/src/core/parse.rs +++ b/codeinput/src/core/parse.rs @@ -7,8 +7,10 @@ use super::{ types::{CacheEncoding, CodeownersCache, CodeownersEntry}, }; -pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path) -> Result { - println!("Parsing CODEOWNERS files at {}", repo.display()); +pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path, quiet: bool) -> Result { + if !quiet { + println!("Parsing CODEOWNERS files at {}", repo.display()); + } // Collect all CODEOWNERS files in the specified path let codeowners_files = find_codeowners_files(repo)?; @@ -30,12 +32,14 @@ pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path) -> Resul let hash = get_repo_hash(repo)?; // Build the cache from the parsed CODEOWNERS entries and the files - let cache = build_cache(parsed_codeowners, files, hash)?; + let cache = build_cache(parsed_codeowners, files, hash, quiet)?; // Store the cache in the specified file store_cache(&cache, &repo.join(cache_file), CacheEncoding::Bincode)?; - println!("CODEOWNERS parsing completed successfully"); + if !quiet { + println!("CODEOWNERS parsing completed successfully"); + } Ok(cache) } diff --git a/codeinput/src/lib.rs b/codeinput/src/lib.rs index f91fd3e..fe44923 100644 --- a/codeinput/src/lib.rs +++ b/codeinput/src/lib.rs @@ -10,3 +10,7 @@ pub use core::types::*; pub mod core; #[cfg(not(feature = "types"))] pub mod utils; + +// LSP server module (requires full features - not just types) +#[cfg(all(not(feature = "types"), feature = "tokio", feature = "tower-lsp"))] +pub mod lsp; diff --git a/codeinput/src/lsp/mod.rs b/codeinput/src/lsp/mod.rs new file mode 100644 index 0000000..c4ddcb3 --- /dev/null +++ b/codeinput/src/lsp/mod.rs @@ -0,0 +1,8 @@ +//! Language Server Protocol (LSP) implementation for CODEOWNERS integration +//! +//! This module provides an LSP server that integrates with the codeinput CLI +//! to provide real-time CODEOWNERS information in editors like VS Code, Neovim, etc. + +pub mod server; + +pub use server::LspServer; diff --git a/codeinput/src/lsp/server.rs b/codeinput/src/lsp/server.rs new file mode 100644 index 0000000..f5324a1 --- /dev/null +++ b/codeinput/src/lsp/server.rs @@ -0,0 +1,483 @@ +//! LSP Server implementation for CODEOWNERS integration +//! +//! Provides LSP handlers for: +//! - textDocument/hover: Show owners/tags when hovering over file paths +//! - textDocument/codeLens: Display ownership information above files +//! - textDocument/publishDiagnostics: Warn about unowned files + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::RwLock; +use tower_lsp::jsonrpc::Result as LspResult; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer}; +use url::Url; + +use crate::core::cache::sync_cache; +use crate::core::types::{CodeownersCache, Owner, OwnerType, Tag}; +use crate::utils::error::{Error, Result}; + +/// LSP Server state +pub struct LspServer { + client: Client, + /// Map of workspace root URIs to their cached CODEOWNERS data + workspaces: Arc>>, +} + +/// State for a single workspace +#[derive(Debug)] +struct WorkspaceState { + cache: CodeownersCache, + cache_file: Option, +} + +/// Information about a file's ownership +#[derive(Debug, Clone)] +pub struct FileOwnershipInfo { + pub path: PathBuf, + pub owners: Vec, + pub tags: Vec, + pub is_unowned: bool, +} + +impl LspServer { + /// Create a new LSP server instance + pub fn new(client: Client) -> Self { + Self { + client, + workspaces: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Initialize a workspace by loading its CODEOWNERS cache + async fn initialize_workspace( + &self, + root_uri: Url, + cache_file: Option, + ) -> Result<()> { + let root_path = uri_to_path(&root_uri)?; + + // Load or create the cache + let cache = sync_cache(&root_path, cache_file.as_deref(), true)?; + + let state = WorkspaceState { + cache, + cache_file, + }; + + let mut workspaces = self.workspaces.write().await; + workspaces.insert(root_uri, state); + + Ok(()) + } + + /// Get file ownership information for a specific file + async fn get_file_ownership(&self, file_uri: &Url) -> Option { + let file_path = uri_to_path(file_uri).ok()?; + let workspaces = self.workspaces.read().await; + + // Find the workspace that contains this file + for (root_uri, state) in workspaces.iter() { + let root_path = uri_to_path(root_uri).ok()?; + + // Check if file is within this workspace + if let Ok(relative_path) = file_path.strip_prefix(&root_path) { + // Cache stores relative paths like "./main.go" + // Convert our relative path to match cache format + let relative_str = relative_path.to_string_lossy(); + let with_dot_slash = PathBuf::from(".").join(&relative_path); + + if let Some(file_entry) = state + .cache + .files + .iter() + .find(|f| { + // Match against both "./file" and "file" formats + f.path == relative_path || + f.path == with_dot_slash || + f.path.to_string_lossy() == relative_str + }) + { + return Some(FileOwnershipInfo { + path: relative_path.to_path_buf(), + owners: file_entry.owners.clone(), + tags: file_entry.tags.clone(), + is_unowned: file_entry.owners.is_empty() + || file_entry.owners.iter().any(|o| { + matches!(o.owner_type, OwnerType::Unowned) + }), + }); + } + } + } + + None + } + + /// Refresh the cache for a workspace + async fn refresh_workspace_cache(&self, root_uri: &Url) -> Result<()> { + let root_path = uri_to_path(root_uri)?; + let mut workspaces = self.workspaces.write().await; + + if let Some(state) = workspaces.get_mut(root_uri) { + // Reload the cache + state.cache = sync_cache(&root_path, state.cache_file.as_deref(), true)?; + } + + Ok(()) + } + + /// Publish diagnostics for unowned files in all workspaces + async fn publish_unowned_diagnostics(&self) { + let workspaces = self.workspaces.read().await; + + for (root_uri, state) in workspaces.iter() { + let mut diagnostics = Vec::new(); + + for file_entry in &state.cache.files { + let is_unowned = file_entry.owners.is_empty() + || file_entry + .owners + .iter() + .any(|o| matches!(o.owner_type, OwnerType::Unowned)); + + if is_unowned { + // Create a diagnostic for this unowned file + // We use a dummy position since we're reporting on the file itself + let file_path = root_uri.join(&file_entry.path.to_string_lossy().to_string()); + + if let Ok(file_uri) = file_path { + let diagnostic = Diagnostic { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + severity: Some(DiagnosticSeverity::WARNING), + code: Some(NumberOrString::String("unowned-file".to_string())), + source: Some("codeinput".to_string()), + message: "This file has no CODEOWNERS assignment".to_string(), + related_information: None, + tags: None, + code_description: None, + data: None, + }; + + diagnostics.push((file_uri, vec![diagnostic])); + } + } + } + + // Publish diagnostics for this workspace + for (file_uri, file_diagnostics) in diagnostics { + self.client + .publish_diagnostics(file_uri, file_diagnostics, None) + .await; + } + } + } +} + +#[tower_lsp::async_trait] +impl LanguageServer for LspServer { + async fn initialize(&self, params: InitializeParams) -> LspResult { + // Initialize workspaces from workspace folders + let workspace_folders = params.workspace_folders.unwrap_or_default(); + + for folder in workspace_folders { + if let Err(e) = self.initialize_workspace(folder.uri, None).await { + self.client + .log_message( + MessageType::WARNING, + format!("Failed to initialize workspace: {}", e), + ) + .await; + } + } + + // If no workspace folders, try to use root_uri + if let Some(root_uri) = params.root_uri { + if let Err(e) = self.initialize_workspace(root_uri, None).await { + self.client + .log_message( + MessageType::WARNING, + format!("Failed to initialize root workspace: {}", e), + ) + .await; + } + } + + Ok(InitializeResult { + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + hover_provider: Some(HoverProviderCapability::Simple(true)), + code_lens_provider: Some(CodeLensOptions { + resolve_provider: Some(false), + }), + workspace: Some(WorkspaceServerCapabilities { + workspace_folders: Some(WorkspaceFoldersServerCapabilities { + supported: Some(true), + change_notifications: Some(OneOf::Left(true)), + }), + file_operations: None, + }), + ..ServerCapabilities::default() + }, + server_info: Some(ServerInfo { + name: "codeinput-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }) + } + + async fn initialized(&self, _: InitializedParams) { + self.client + .log_message(MessageType::INFO, "codeinput LSP server initialized") + .await; + + // Publish initial diagnostics + self.publish_unowned_diagnostics().await; + } + + async fn shutdown(&self) -> LspResult<()> { + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let file_uri = params.text_document.uri; + + // Check if this is a CODEOWNERS file and refresh cache if so + if is_codeowners_file(&file_uri) { + // Find which workspace this file belongs to + let matching_root = { + let workspaces = self.workspaces.read().await; + workspaces + .keys() + .find(|root_uri| file_uri.as_str().starts_with(root_uri.as_str())) + .cloned() + }; + + if let Some(root_uri) = matching_root { + if let Err(e) = self.refresh_workspace_cache(&root_uri).await { + self.client + .log_message( + MessageType::WARNING, + format!("Failed to refresh cache: {}", e), + ) + .await; + } + // Re-publish diagnostics after cache refresh + self.publish_unowned_diagnostics().await; + } + } + } + + async fn did_change(&self, _params: DidChangeTextDocumentParams) { + // We use full sync, so we don't need to handle incremental changes + } + + async fn did_save(&self, params: DidSaveTextDocumentParams) { + let file_uri = params.text_document.uri; + + // If CODEOWNERS file was saved, refresh the cache + if is_codeowners_file(&file_uri) { + let matching_root = { + let workspaces = self.workspaces.read().await; + workspaces + .keys() + .find(|root_uri| file_uri.as_str().starts_with(root_uri.as_str())) + .cloned() + }; + + if let Some(root_uri) = matching_root { + if let Err(e) = self.refresh_workspace_cache(&root_uri).await { + self.client + .log_message( + MessageType::WARNING, + format!("Failed to refresh cache: {}", e), + ) + .await; + } + self.publish_unowned_diagnostics().await; + } + } + } + + async fn hover(&self, params: HoverParams) -> LspResult> { + let file_uri = params.text_document_position_params.text_document.uri; + + // Get ownership info for this file + if let Some(info) = self.get_file_ownership(&file_uri).await { + let mut contents = vec![]; + + // Add owners section + if info.owners.is_empty() { + contents.push(MarkedString::String("**Owners:** (none)".to_string())); + } else { + let owners_str = info + .owners + .iter() + .map(|o| format!("`{}`", o.identifier)) + .collect::>() + .join(", "); + contents.push(MarkedString::String(format!("**Owners:** {}", owners_str))); + } + + // Add tags section + if !info.tags.is_empty() { + let tags_str = info + .tags + .iter() + .map(|t| format!("`#{}`", t.0)) + .collect::>() + .join(", "); + contents.push(MarkedString::String(format!("**Tags:** {}", tags_str))); + } + + // Add warning if unowned + if info.is_unowned { + contents.push(MarkedString::String( + "⚠️ **Warning:** This file has no CODEOWNERS assignment".to_string(), + )); + } + + return Ok(Some(Hover { + contents: HoverContents::Array(contents), + range: None, + })); + } + + Ok(None) + } + + async fn code_lens(&self, params: CodeLensParams) -> LspResult>> { + let file_uri = params.text_document.uri; + + // Get ownership info for this file + if let Some(info) = self.get_file_ownership(&file_uri).await { + let mut lenses = vec![]; + + // Create a CodeLens showing ownership at the top of the file + if !info.owners.is_empty() { + let owners_str = info + .owners + .iter() + .map(|o| o.identifier.clone()) + .collect::>() + .join(", "); + + lenses.push(CodeLens { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + command: Some(Command { + title: format!("$(organization) {}", owners_str), + command: "codeinput.showOwners".to_string(), + arguments: Some(vec![ + serde_json::to_value(file_uri.to_string()).unwrap(), + serde_json::to_value(info.owners).unwrap(), + ]), + }), + data: None, + }); + } + + // Add tags CodeLens if any + if !info.tags.is_empty() { + let tags_str = info + .tags + .iter() + .map(|t| format!("#{}", t.0)) + .collect::>() + .join(", "); + + lenses.push(CodeLens { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + command: Some(Command { + title: format!("$(tag) {}", tags_str), + command: "codeinput.showTags".to_string(), + arguments: Some(vec![ + serde_json::to_value(file_uri.to_string()).unwrap(), + serde_json::to_value(info.tags).unwrap(), + ]), + }), + data: None, + }); + } + + // Add unowned warning CodeLens + if info.is_unowned { + lenses.push(CodeLens { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + command: Some(Command { + title: "$(warning) Unowned file".to_string(), + command: "codeinput.addOwner".to_string(), + arguments: Some(vec![serde_json::to_value(file_uri.to_string()).unwrap()]), + }), + data: None, + }); + } + + return Ok(Some(lenses)); + } + + Ok(None) + } + + async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) { + // Handle removed workspaces - collect URIs first, then remove them + let removed_uris: Vec = params.event.removed.iter().map(|f| f.uri.clone()).collect(); + { + let mut workspaces = self.workspaces.write().await; + for uri in removed_uris { + workspaces.remove(&uri); + } + } + + // Handle added workspaces + for folder in params.event.added { + if let Err(e) = self.initialize_workspace(folder.uri, None).await { + self.client + .log_message( + MessageType::WARNING, + format!("Failed to initialize workspace: {}", e), + ) + .await; + } + } + } +} + +/// Convert a URL to a file path +fn uri_to_path(uri: &Url) -> Result { + uri.to_file_path() + .map_err(|_| Error::new("Invalid file URI")) +} + +/// Check if a URI points to a CODEOWNERS file +fn is_codeowners_file(uri: &Url) -> bool { + let path = uri.path(); + path.contains("CODEOWNERS") || path.contains("codeowners") +} + +/// Run the LSP server +pub async fn run_lsp_server() -> Result<()> { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = tower_lsp::LspService::new(|client| LspServer::new(client)); + + tower_lsp::Server::new(stdin, stdout, socket).serve(service).await; + + Ok(()) +} From 953a0a4da4668fcd017d4af6a01e5f4e25bb8581 Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Sun, 1 Mar 2026 16:50:59 +0800 Subject: [PATCH 02/10] refactor: extract LSP command handler and make feature opt-in Move LSP server initialization into dedicated command handler module. Remove tower-lsp and tokio from default features to avoid bundling unused code when LSP is not needed. --- ci/src/cli/mod.rs | 9 +-------- codeinput/Cargo.toml | 2 -- codeinput/src/core/commands/lsp.rs | 15 +++++++++++++++ codeinput/src/core/commands/mod.rs | 3 +++ 4 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 codeinput/src/core/commands/lsp.rs diff --git a/ci/src/cli/mod.rs b/ci/src/cli/mod.rs index f3307e0..478aa0e 100644 --- a/ci/src/cli/mod.rs +++ b/ci/src/cli/mod.rs @@ -16,8 +16,6 @@ use codeinput::utils::app_config::AppConfig; use codeinput::utils::error::Result; use codeinput::utils::types::LogLevel; -#[cfg(feature = "lsp")] -use codeinput::lsp::server::run_lsp_server; #[derive(Parser, Debug)] #[command( @@ -294,12 +292,7 @@ pub fn cli_match() -> Result<()> { } Commands::Config => commands::config::run()?, #[cfg(feature = "lsp")] - Commands::Lsp { stdio: _ } => { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { - run_lsp_server().await - })?; - } + Commands::Lsp { stdio: _ } => commands::lsp::run()?, } Ok(()) diff --git a/codeinput/Cargo.toml b/codeinput/Cargo.toml index 97b5d0e..5c5c1c9 100644 --- a/codeinput/Cargo.toml +++ b/codeinput/Cargo.toml @@ -57,8 +57,6 @@ full = [ "clap", "chrono", "utoipa", - "tower-lsp", - "tokio", "url", ] nightly = [] diff --git a/codeinput/src/core/commands/lsp.rs b/codeinput/src/core/commands/lsp.rs new file mode 100644 index 0000000..d5ab0d0 --- /dev/null +++ b/codeinput/src/core/commands/lsp.rs @@ -0,0 +1,15 @@ +//! LSP command handler +//! +//! Starts the Language Server Protocol server for IDE integration. + +use crate::utils::error::Result; + +#[cfg(feature = "tower-lsp")] +use crate::lsp::server::run_lsp_server; + +/// Run the LSP server +#[cfg(feature = "tower-lsp")] +pub fn run() -> Result<()> { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { run_lsp_server().await }) +} diff --git a/codeinput/src/core/commands/mod.rs b/codeinput/src/core/commands/mod.rs index dba0609..60ac098 100644 --- a/codeinput/src/core/commands/mod.rs +++ b/codeinput/src/core/commands/mod.rs @@ -6,3 +6,6 @@ pub mod list_owners; pub mod list_rules; pub mod list_tags; pub mod parse; + +#[cfg(feature = "tower-lsp")] +pub mod lsp; From ba348e83dc26e6ff57b43f4911e3c561dd9b4ad8 Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Sun, 1 Mar 2026 18:36:46 +0800 Subject: [PATCH 03/10] refactor: centralize quiet mode handling in output module Replace scattered quiet parameter passing with a unified output module that checks AppConfig.quiet. Add -q/--quiet CLI flag and automatic quiet mode for LSP server. --- ci/src/cli/mod.rs | 4 ++ codeinput/src/core/cache.rs | 42 ++++++++++----------- codeinput/src/core/commands/inspect.rs | 2 +- codeinput/src/core/commands/list_files.rs | 2 +- codeinput/src/core/commands/list_owners.rs | 2 +- codeinput/src/core/commands/list_rules.rs | 2 +- codeinput/src/core/commands/list_tags.rs | 2 +- codeinput/src/core/commands/parse.rs | 6 +-- codeinput/src/core/parse.rs | 17 ++++----- codeinput/src/lsp/server.rs | 12 ++++-- codeinput/src/resources/default_config.toml | 1 + codeinput/src/utils/app_config.rs | 8 ++++ codeinput/src/utils/logger.rs | 2 + codeinput/src/utils/mod.rs | 1 + codeinput/src/utils/output.rs | 25 ++++++++++++ 15 files changed, 86 insertions(+), 42 deletions(-) create mode 100644 codeinput/src/utils/output.rs diff --git a/ci/src/cli/mod.rs b/ci/src/cli/mod.rs index 478aa0e..7471998 100644 --- a/ci/src/cli/mod.rs +++ b/ci/src/cli/mod.rs @@ -46,6 +46,10 @@ pub struct Cli { )] pub log_level: Option, + /// Suppress progress output + #[arg(short, long)] + pub quiet: bool, + /// Subcommands #[clap(subcommand)] command: Commands, diff --git a/codeinput/src/core/cache.rs b/codeinput/src/core/cache.rs index f2d1d18..16d2695 100644 --- a/codeinput/src/core/cache.rs +++ b/codeinput/src/core/cache.rs @@ -8,7 +8,10 @@ use crate::{ CodeownersEntryMatcher, FileEntry, }, }, - utils::error::{Error, Result}, + utils::{ + error::{Error, Result}, + output, + }, }; use rayon::{iter::ParallelIterator, slice::ParallelSlice}; use std::{ @@ -18,7 +21,7 @@ use std::{ /// Create a cache from parsed CODEOWNERS entries and files pub fn build_cache( - entries: Vec, files: Vec, hash: [u8; 32], quiet: bool, + entries: Vec, files: Vec, hash: [u8; 32], ) -> Result { let mut owners_map = std::collections::HashMap::new(); let mut tags_map = std::collections::HashMap::new(); @@ -42,20 +45,17 @@ pub fn build_cache( processed_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; // Limit filename display length and clear the line properly - if !quiet { - let file_display = file_path.display().to_string(); - let truncated_file = if file_display.len() > 60 { - format!("...{}", &file_display[file_display.len() - 57..]) - } else { - file_display - }; - - print!( - "\r\x1b[K📁 Processing [{}/{}] {}", - current, total_files, truncated_file - ); - std::io::stdout().flush().unwrap(); - } + let file_display = file_path.display().to_string(); + let truncated_file = if file_display.len() > 60 { + format!("...{}", &file_display[file_display.len() - 57..]) + } else { + file_display + }; + + output::print(&format!( + "\r\x1b[K📁 Processing [{}/{}] {}", + current, total_files, truncated_file + )); let (owners, tags) = find_owners_and_tags_for_file(file_path, &matched_entries).unwrap(); @@ -72,9 +72,7 @@ pub fn build_cache( .collect(); // Print newline after processing is complete - if !quiet { - println!("\r\x1b[K✅ Processed {} files successfully", total_files); - } + output::println(&format!("\r\x1b[K✅ Processed {} files successfully", total_files)); // Process each owner let owners = collect_owners(&entries); @@ -179,7 +177,7 @@ pub fn load_cache(path: &Path) -> Result { } pub fn sync_cache( - repo: &std::path::Path, cache_file: Option<&std::path::Path>, quiet: bool, + repo: &std::path::Path, cache_file: Option<&std::path::Path>, ) -> Result { let config_cache_file = crate::utils::app_config::AppConfig::fetch()? .cache_file @@ -193,7 +191,7 @@ pub fn sync_cache( // Verify that the cache file exists if !repo.join(cache_file).exists() { // parse the codeowners files and build the cache - return parse_repo(&repo, &cache_file, quiet); + return parse_repo(&repo, &cache_file); } // Load the cache from the specified file @@ -211,7 +209,7 @@ pub fn sync_cache( if cache_hash != current_hash { // parse the codeowners files and build the cache - return parse_repo(&repo, &cache_file, quiet); + return parse_repo(&repo, &cache_file); } else { return Ok(cache); } diff --git a/codeinput/src/core/commands/inspect.rs b/codeinput/src/core/commands/inspect.rs index 7de30a5..f70d8a4 100644 --- a/codeinput/src/core/commands/inspect.rs +++ b/codeinput/src/core/commands/inspect.rs @@ -16,7 +16,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file, false)?; + let cache = sync_cache(repo, cache_file)?; // Normalize the file path to be relative to the repo let normalized_file_path = if file_path.is_absolute() { diff --git a/codeinput/src/core/commands/list_files.rs b/codeinput/src/core/commands/list_files.rs index 4d8f53e..7844b11 100644 --- a/codeinput/src/core/commands/list_files.rs +++ b/codeinput/src/core/commands/list_files.rs @@ -28,7 +28,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file, false)?; + let cache = sync_cache(repo, cache_file)?; // Filter files based on criteria let filtered_files = cache diff --git a/codeinput/src/core/commands/list_owners.rs b/codeinput/src/core/commands/list_owners.rs index 0369ce9..cc5a0c9 100644 --- a/codeinput/src/core/commands/list_owners.rs +++ b/codeinput/src/core/commands/list_owners.rs @@ -25,7 +25,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file, false)?; + let cache = sync_cache(repo, cache_file)?; // Sort owners by number of files they own (descending) let mut owners_with_counts: Vec<_> = cache.owners_map.iter().collect(); diff --git a/codeinput/src/core/commands/list_rules.rs b/codeinput/src/core/commands/list_rules.rs index 9ee67f2..72f1b0a 100644 --- a/codeinput/src/core/commands/list_rules.rs +++ b/codeinput/src/core/commands/list_rules.rs @@ -22,7 +22,7 @@ struct RuleDisplay { /// Display CODEOWNERS rules from the cache pub fn run(format: &OutputFormat, cache_file: Option<&std::path::Path>) -> Result<()> { // Load the cache - let cache = sync_cache(std::path::Path::new("."), cache_file, false)?; + let cache = sync_cache(std::path::Path::new("."), cache_file)?; // Process the rules from the cache match format { diff --git a/codeinput/src/core/commands/list_tags.rs b/codeinput/src/core/commands/list_tags.rs index c7e74c0..9924d49 100644 --- a/codeinput/src/core/commands/list_tags.rs +++ b/codeinput/src/core/commands/list_tags.rs @@ -23,7 +23,7 @@ pub fn run( let repo = repo.unwrap_or_else(|| std::path::Path::new(".")); // Load the cache - let cache = sync_cache(repo, cache_file, false)?; + let cache = sync_cache(repo, cache_file)?; // Sort tags by number of files they're associated with (descending) let mut tags_with_counts: Vec<_> = cache.tags_map.iter().collect(); diff --git a/codeinput/src/core/commands/parse.rs b/codeinput/src/core/commands/parse.rs index 7fcab3a..3fbcb3d 100644 --- a/codeinput/src/core/commands/parse.rs +++ b/codeinput/src/core/commands/parse.rs @@ -5,14 +5,14 @@ use crate::{ parser::parse_codeowners, types::{CacheEncoding, CodeownersEntry}, }, - utils::{app_config::AppConfig, error::Result}, + utils::{app_config::AppConfig, error::Result, output}, }; /// Preprocess CODEOWNERS files and build ownership map pub fn run( path: &std::path::Path, cache_file: Option<&std::path::Path>, encoding: CacheEncoding, ) -> Result<()> { - println!("Parsing CODEOWNERS files at {}", path.display()); + output::println(&format!("Parsing CODEOWNERS files at {}", path.display())); let cache_file = match cache_file { Some(file) => path.join(file), @@ -41,7 +41,7 @@ pub fn run( // Build the cache from the parsed CODEOWNERS entries and the files let hash = get_repo_hash(path)?; - let cache = build_cache(parsed_codeowners, files, hash, false)?; + let cache = build_cache(parsed_codeowners, files, hash)?; // Store the cache in the specified file store_cache(&cache, &cache_file, encoding)?; diff --git a/codeinput/src/core/parse.rs b/codeinput/src/core/parse.rs index 29f4b55..0426279 100644 --- a/codeinput/src/core/parse.rs +++ b/codeinput/src/core/parse.rs @@ -1,4 +1,7 @@ -use crate::utils::error::Result; +use crate::utils::{ + error::Result, + output, +}; use super::{ cache::{build_cache, store_cache}, @@ -7,10 +10,8 @@ use super::{ types::{CacheEncoding, CodeownersCache, CodeownersEntry}, }; -pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path, quiet: bool) -> Result { - if !quiet { - println!("Parsing CODEOWNERS files at {}", repo.display()); - } +pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path) -> Result { + output::println(&format!("Parsing CODEOWNERS files at {}", repo.display())); // Collect all CODEOWNERS files in the specified path let codeowners_files = find_codeowners_files(repo)?; @@ -32,14 +33,12 @@ pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path, quiet: b let hash = get_repo_hash(repo)?; // Build the cache from the parsed CODEOWNERS entries and the files - let cache = build_cache(parsed_codeowners, files, hash, quiet)?; + let cache = build_cache(parsed_codeowners, files, hash)?; // Store the cache in the specified file store_cache(&cache, &repo.join(cache_file), CacheEncoding::Bincode)?; - if !quiet { - println!("CODEOWNERS parsing completed successfully"); - } + output::println("CODEOWNERS parsing completed successfully"); Ok(cache) } diff --git a/codeinput/src/lsp/server.rs b/codeinput/src/lsp/server.rs index f5324a1..88cef3b 100644 --- a/codeinput/src/lsp/server.rs +++ b/codeinput/src/lsp/server.rs @@ -17,7 +17,10 @@ use url::Url; use crate::core::cache::sync_cache; use crate::core::types::{CodeownersCache, Owner, OwnerType, Tag}; -use crate::utils::error::{Error, Result}; +use crate::utils::{ + app_config::AppConfig, + error::{Error, Result}, +}; /// LSP Server state pub struct LspServer { @@ -60,7 +63,7 @@ impl LspServer { let root_path = uri_to_path(&root_uri)?; // Load or create the cache - let cache = sync_cache(&root_path, cache_file.as_deref(), true)?; + let cache = sync_cache(&root_path, cache_file.as_deref())?; let state = WorkspaceState { cache, @@ -123,7 +126,7 @@ impl LspServer { if let Some(state) = workspaces.get_mut(root_uri) { // Reload the cache - state.cache = sync_cache(&root_path, state.cache_file.as_deref(), true)?; + state.cache = sync_cache(&root_path, state.cache_file.as_deref())?; } Ok(()) @@ -472,6 +475,9 @@ fn is_codeowners_file(uri: &Url) -> bool { /// Run the LSP server pub async fn run_lsp_server() -> Result<()> { + // Set quiet mode for LSP (suppresses progress output) + AppConfig::set("quiet", "true")?; + let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); diff --git a/codeinput/src/resources/default_config.toml b/codeinput/src/resources/default_config.toml index bbc720f..c72fb9c 100644 --- a/codeinput/src/resources/default_config.toml +++ b/codeinput/src/resources/default_config.toml @@ -1,3 +1,4 @@ debug = false +quiet = false log_level = "info" cache_file = ".codeowners.cache" diff --git a/codeinput/src/utils/app_config.rs b/codeinput/src/utils/app_config.rs index fe54b07..fc4d49d 100644 --- a/codeinput/src/utils/app_config.rs +++ b/codeinput/src/utils/app_config.rs @@ -17,6 +17,7 @@ lazy_static! { #[derive(Debug, Serialize, Deserialize)] pub struct AppConfig { pub debug: bool, + pub quiet: bool, pub log_level: LogLevel, pub cache_file: String, } @@ -56,6 +57,12 @@ impl AppConfig { AppConfig::set("debug", &value.to_string())?; } + if args.contains_id("quiet") { + let value: &bool = args.get_one("quiet").unwrap_or(&false); + + AppConfig::set("quiet", &value.to_string())?; + } + if args.contains_id("log_level") { let value: &LogLevel = args.get_one("log_level").unwrap_or(&LogLevel::Info); AppConfig::set("log_level", &value.to_string())?; @@ -119,6 +126,7 @@ impl TryFrom for AppConfig { fn try_from(config: Config) -> Result { Ok(AppConfig { debug: config.get_bool("debug")?, + quiet: config.get_bool("quiet")?, log_level: config.get::("log_level")?, cache_file: config.get::("cache_file")?, }) diff --git a/codeinput/src/utils/logger.rs b/codeinput/src/utils/logger.rs index 85e78ff..ce480fe 100644 --- a/codeinput/src/utils/logger.rs +++ b/codeinput/src/utils/logger.rs @@ -17,6 +17,7 @@ pub fn setup_logging() -> Result { // Set log level for the log crate (used by ignore and other crates) let config = AppConfig::fetch().unwrap_or(AppConfig { debug: false, + quiet: false, log_level: LogLevel::Info, cache_file: ".codeowners.cache".to_string(), }); @@ -37,6 +38,7 @@ pub fn default_root_logger() -> Result { // Get configured log level let config = AppConfig::fetch().unwrap_or(AppConfig { debug: false, + quiet: false, log_level: LogLevel::Info, cache_file: ".codeowners.cache".to_string(), }); diff --git a/codeinput/src/utils/mod.rs b/codeinput/src/utils/mod.rs index 0436beb..09360d5 100644 --- a/codeinput/src/utils/mod.rs +++ b/codeinput/src/utils/mod.rs @@ -3,4 +3,5 @@ pub mod app_config; pub mod error; pub mod logger; +pub mod output; pub mod types; diff --git a/codeinput/src/utils/output.rs b/codeinput/src/utils/output.rs new file mode 100644 index 0000000..ca4c9a3 --- /dev/null +++ b/codeinput/src/utils/output.rs @@ -0,0 +1,25 @@ +//! Output utilities for CLI progress and status messages + +use std::io::Write; + +use super::app_config::AppConfig; + +/// Check if quiet mode is enabled +fn is_quiet() -> bool { + AppConfig::fetch().map(|c| c.quiet).unwrap_or(false) +} + +/// Print a message if not in quiet mode +pub fn print(msg: &str) { + if !is_quiet() { + print!("{}", msg); + std::io::stdout().flush().ok(); + } +} + +/// Print a line if not in quiet mode +pub fn println(msg: &str) { + if !is_quiet() { + println!("{}", msg); + } +} From 1bf4816e5a8cbb7c10d0e277f8dea1f372981577 Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Sun, 1 Mar 2026 19:12:16 +0800 Subject: [PATCH 04/10] refactor: simplify path matching in LSP server Use consistent cache_path format instead of checking multiple path variations. The cache always stores paths with "./" prefix, so we only need to match against that single format. --- codeinput/src/lsp/server.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/codeinput/src/lsp/server.rs b/codeinput/src/lsp/server.rs index 88cef3b..d64fc2d 100644 --- a/codeinput/src/lsp/server.rs +++ b/codeinput/src/lsp/server.rs @@ -88,20 +88,13 @@ impl LspServer { // Check if file is within this workspace if let Ok(relative_path) = file_path.strip_prefix(&root_path) { // Cache stores relative paths like "./main.go" - // Convert our relative path to match cache format - let relative_str = relative_path.to_string_lossy(); - let with_dot_slash = PathBuf::from(".").join(&relative_path); + let cache_path = PathBuf::from(".").join(relative_path); if let Some(file_entry) = state .cache .files .iter() - .find(|f| { - // Match against both "./file" and "file" formats - f.path == relative_path || - f.path == with_dot_slash || - f.path.to_string_lossy() == relative_str - }) + .find(|f| f.path == cache_path) { return Some(FileOwnershipInfo { path: relative_path.to_path_buf(), From bfef743fb5ddc89692d740f512620a74fc96df9f Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Sun, 1 Mar 2026 20:06:58 +0800 Subject: [PATCH 05/10] refactor: redirect all output to stderr for LSP compatibility Move progress/status messages to stderr to keep stdout clean for LSP protocol communication. This eliminates the need to set quiet mode in the LSP server startup. --- codeinput/src/lsp/server.rs | 8 +------- codeinput/src/utils/output.rs | 15 +++++++++------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/codeinput/src/lsp/server.rs b/codeinput/src/lsp/server.rs index d64fc2d..d3e8d27 100644 --- a/codeinput/src/lsp/server.rs +++ b/codeinput/src/lsp/server.rs @@ -17,10 +17,7 @@ use url::Url; use crate::core::cache::sync_cache; use crate::core::types::{CodeownersCache, Owner, OwnerType, Tag}; -use crate::utils::{ - app_config::AppConfig, - error::{Error, Result}, -}; +use crate::utils::error::{Error, Result}; /// LSP Server state pub struct LspServer { @@ -468,9 +465,6 @@ fn is_codeowners_file(uri: &Url) -> bool { /// Run the LSP server pub async fn run_lsp_server() -> Result<()> { - // Set quiet mode for LSP (suppresses progress output) - AppConfig::set("quiet", "true")?; - let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); diff --git a/codeinput/src/utils/output.rs b/codeinput/src/utils/output.rs index ca4c9a3..d778474 100644 --- a/codeinput/src/utils/output.rs +++ b/codeinput/src/utils/output.rs @@ -1,6 +1,8 @@ //! Output utilities for CLI progress and status messages +//! +//! All output goes to stderr to keep stdout clean for data/LSP protocol. -use std::io::Write; +use std::io::{stderr, Write}; use super::app_config::AppConfig; @@ -9,17 +11,18 @@ fn is_quiet() -> bool { AppConfig::fetch().map(|c| c.quiet).unwrap_or(false) } -/// Print a message if not in quiet mode +/// Print a message to stderr if not in quiet mode pub fn print(msg: &str) { if !is_quiet() { - print!("{}", msg); - std::io::stdout().flush().ok(); + let mut stderr = stderr(); + write!(stderr, "{}", msg).ok(); + stderr.flush().ok(); } } -/// Print a line if not in quiet mode +/// Print a line to stderr if not in quiet mode pub fn println(msg: &str) { if !is_quiet() { - println!("{}", msg); + writeln!(stderr(), "{}", msg).ok(); } } From 25ea50012dec63cfbe65a63545b35c0ffd3d41aa Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Mon, 2 Mar 2026 11:50:35 +0800 Subject: [PATCH 06/10] chore: upgrade utoipa to 5.4.0 with schema fixes Add explicit schema type annotations for PathBuf and HashMap fields required by utoipa 5.x's stricter type handling. --- codeinput/Cargo.toml | 2 +- codeinput/src/core/types.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/codeinput/Cargo.toml b/codeinput/Cargo.toml index 5c5c1c9..ca39049 100644 --- a/codeinput/Cargo.toml +++ b/codeinput/Cargo.toml @@ -68,7 +68,7 @@ types = [] [dependencies] # Core dependencies always needed serde = { workspace = true } -utoipa = { version = "4.2.3", optional = true } +utoipa = { version = "5.4.0", optional = true } # Full feature dependencies rayon = { workspace = true, optional = true } diff --git a/codeinput/src/core/types.rs b/codeinput/src/core/types.rs index 6057d15..7e8835e 100644 --- a/codeinput/src/core/types.rs +++ b/codeinput/src/core/types.rs @@ -28,6 +28,7 @@ fn normalize_codeowners_pattern(pattern: &str) -> String { #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct CodeownersEntry { + #[cfg_attr(feature = "utoipa", schema(value_type = String))] pub source_file: PathBuf, pub line_number: usize, pub pattern: String, @@ -163,6 +164,7 @@ impl std::fmt::Display for OutputFormat { #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "utoipa", derive(ToSchema))] pub struct FileEntry { + #[cfg_attr(feature = "utoipa", schema(value_type = String))] pub path: PathBuf, pub owners: Vec, pub tags: Vec, @@ -176,7 +178,9 @@ pub struct CodeownersCache { pub entries: Vec, pub files: Vec, // Derived data for lookups + #[cfg_attr(feature = "utoipa", schema(value_type = std::collections::HashMap>))] pub owners_map: std::collections::HashMap>, + #[cfg_attr(feature = "utoipa", schema(value_type = std::collections::HashMap>))] pub tags_map: std::collections::HashMap>, } From b5c213657fe8d3a4ca8e78cf5bdf2da514411522 Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Mon, 2 Mar 2026 12:15:15 +0800 Subject: [PATCH 07/10] fix(lsp): safely serialize CodeLens arguments Replace unwrap() with ok() and pattern matching to gracefully handle serialization failures instead of panicking. --- codeinput/src/lsp/server.rs | 95 +++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/codeinput/src/lsp/server.rs b/codeinput/src/lsp/server.rs index d3e8d27..69b7186 100644 --- a/codeinput/src/lsp/server.rs +++ b/codeinput/src/lsp/server.rs @@ -362,21 +362,25 @@ impl LanguageServer for LspServer { .collect::>() .join(", "); - lenses.push(CodeLens { - range: Range { - start: Position::new(0, 0), - end: Position::new(0, 0), - }, - command: Some(Command { - title: format!("$(organization) {}", owners_str), - command: "codeinput.showOwners".to_string(), - arguments: Some(vec![ - serde_json::to_value(file_uri.to_string()).unwrap(), - serde_json::to_value(info.owners).unwrap(), - ]), - }), - data: None, - }); + // Safely serialize arguments + let args = ( + serde_json::to_value(file_uri.to_string()).ok(), + serde_json::to_value(&info.owners).ok(), + ); + if let (Some(uri_val), Some(owners_val)) = args { + lenses.push(CodeLens { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + command: Some(Command { + title: format!("$(organization) {}", owners_str), + command: "codeinput.showOwners".to_string(), + arguments: Some(vec![uri_val, owners_val]), + }), + data: None, + }); + } } // Add tags CodeLens if any @@ -388,37 +392,44 @@ impl LanguageServer for LspServer { .collect::>() .join(", "); - lenses.push(CodeLens { - range: Range { - start: Position::new(0, 0), - end: Position::new(0, 0), - }, - command: Some(Command { - title: format!("$(tag) {}", tags_str), - command: "codeinput.showTags".to_string(), - arguments: Some(vec![ - serde_json::to_value(file_uri.to_string()).unwrap(), - serde_json::to_value(info.tags).unwrap(), - ]), - }), - data: None, - }); + // Safely serialize arguments + let args = ( + serde_json::to_value(file_uri.to_string()).ok(), + serde_json::to_value(&info.tags).ok(), + ); + if let (Some(uri_val), Some(tags_val)) = args { + lenses.push(CodeLens { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + command: Some(Command { + title: format!("$(tag) {}", tags_str), + command: "codeinput.showTags".to_string(), + arguments: Some(vec![uri_val, tags_val]), + }), + data: None, + }); + } } // Add unowned warning CodeLens if info.is_unowned { - lenses.push(CodeLens { - range: Range { - start: Position::new(0, 0), - end: Position::new(0, 0), - }, - command: Some(Command { - title: "$(warning) Unowned file".to_string(), - command: "codeinput.addOwner".to_string(), - arguments: Some(vec![serde_json::to_value(file_uri.to_string()).unwrap()]), - }), - data: None, - }); + // Safely serialize arguments + if let Some(uri_val) = serde_json::to_value(file_uri.to_string()).ok() { + lenses.push(CodeLens { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 0), + }, + command: Some(Command { + title: "$(warning) Unowned file".to_string(), + command: "codeinput.addOwner".to_string(), + arguments: Some(vec![uri_val]), + }), + data: None, + }); + } } return Ok(Some(lenses)); From b548a24ab484252c98d990f68afe83347f8f55dc Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Mon, 2 Mar 2026 12:25:26 +0800 Subject: [PATCH 08/10] refactor(cli): remove unused stdio flag from LSP command The hidden --stdio argument was never used, so remove it to simplify the command interface. --- ci/src/cli/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ci/src/cli/mod.rs b/ci/src/cli/mod.rs index 7471998..45a05a9 100644 --- a/ci/src/cli/mod.rs +++ b/ci/src/cli/mod.rs @@ -87,11 +87,7 @@ enum Commands { about = "Start LSP server for IDE integration", long_about = "Starts a Language Server Protocol (LSP) server that provides CODEOWNERS information to supported editors" )] - Lsp { - /// Use stdio for LSP communication (passed by VS Code) - #[arg(long, hide = true)] - stdio: bool, - }, + Lsp, } #[derive(Subcommand, PartialEq, Debug)] @@ -296,7 +292,7 @@ pub fn cli_match() -> Result<()> { } Commands::Config => commands::config::run()?, #[cfg(feature = "lsp")] - Commands::Lsp { stdio: _ } => commands::lsp::run()?, + Commands::Lsp => commands::lsp::run()?, } Ok(()) From 09944f0a6d526c7b9ef8ac3a2dcab32337ad3058 Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Mon, 2 Mar 2026 13:24:22 +0800 Subject: [PATCH 09/10] feat(lsp): add TCP transport support for LSP server Add optional --port flag to start LSP server over TCP instead of stdio, enabling remote editor connections and easier debugging. --- ci/src/cli/mod.rs | 12 ++++++++++-- codeinput/src/core/commands/lsp.rs | 11 ++++++++--- codeinput/src/lsp/server.rs | 20 +++++++++++++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/ci/src/cli/mod.rs b/ci/src/cli/mod.rs index 45a05a9..8877363 100644 --- a/ci/src/cli/mod.rs +++ b/ci/src/cli/mod.rs @@ -87,7 +87,15 @@ enum Commands { about = "Start LSP server for IDE integration", long_about = "Starts a Language Server Protocol (LSP) server that provides CODEOWNERS information to supported editors" )] - Lsp, + Lsp { + /// Use stdio for communication (default, flag exists for client compatibility) + #[arg(long, hide = true)] + stdio: bool, + + /// Port for TCP communication (if not specified, uses stdio) + #[arg(long, value_name = "PORT")] + port: Option, + }, } #[derive(Subcommand, PartialEq, Debug)] @@ -292,7 +300,7 @@ pub fn cli_match() -> Result<()> { } Commands::Config => commands::config::run()?, #[cfg(feature = "lsp")] - Commands::Lsp => commands::lsp::run()?, + Commands::Lsp { port, .. } => commands::lsp::run(*port)?, } Ok(()) diff --git a/codeinput/src/core/commands/lsp.rs b/codeinput/src/core/commands/lsp.rs index d5ab0d0..033a459 100644 --- a/codeinput/src/core/commands/lsp.rs +++ b/codeinput/src/core/commands/lsp.rs @@ -5,11 +5,16 @@ use crate::utils::error::Result; #[cfg(feature = "tower-lsp")] -use crate::lsp::server::run_lsp_server; +use crate::lsp::server::{run_lsp_server, run_lsp_server_tcp}; /// Run the LSP server #[cfg(feature = "tower-lsp")] -pub fn run() -> Result<()> { +pub fn run(port: Option) -> Result<()> { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { run_lsp_server().await }) + rt.block_on(async { + match port { + Some(p) => run_lsp_server_tcp(p).await, + None => run_lsp_server().await, + } + }) } diff --git a/codeinput/src/lsp/server.rs b/codeinput/src/lsp/server.rs index 69b7186..dc4e37a 100644 --- a/codeinput/src/lsp/server.rs +++ b/codeinput/src/lsp/server.rs @@ -474,7 +474,7 @@ fn is_codeowners_file(uri: &Url) -> bool { path.contains("CODEOWNERS") || path.contains("codeowners") } -/// Run the LSP server +/// Run the LSP server over stdio pub async fn run_lsp_server() -> Result<()> { let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); @@ -485,3 +485,21 @@ pub async fn run_lsp_server() -> Result<()> { Ok(()) } + +/// Run the LSP server over TCP +pub async fn run_lsp_server_tcp(port: u16) -> Result<()> { + use tokio::net::TcpListener; + + let addr = format!("127.0.0.1:{}", port); + let listener = TcpListener::bind(&addr).await?; + + eprintln!("LSP server listening on {}", addr); + + loop { + let (stream, _) = listener.accept().await?; + let (read, write) = tokio::io::split(stream); + let (service, socket) = tower_lsp::LspService::new(|client| LspServer::new(client)); + + tokio::spawn(tower_lsp::Server::new(read, write, socket).serve(service)); + } +} From 137ea85d9b764c3a6e4446cd94271af808b8b32b Mon Sep 17 00:00:00 2001 From: Abid Omar Date: Mon, 2 Mar 2026 14:26:41 +0800 Subject: [PATCH 10/10] feat(lsp): add release workflow and upgrade dependencies Add GitHub Actions workflow for building and releasing LSP server binaries for Linux, Windows, and macOS (x64 and arm64). Upgrade workspace dependencies to latest versions including rayon, tokio, clap, and various other crates. --- .github/workflows/release-lsp.yml | 203 ++++++++++++++++++++++++++++++ Cargo.toml | 50 ++++---- codeinput/Cargo.toml | 4 +- 3 files changed, 230 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/release-lsp.yml diff --git a/.github/workflows/release-lsp.yml b/.github/workflows/release-lsp.yml new file mode 100644 index 0000000..e087de1 --- /dev/null +++ b/.github/workflows/release-lsp.yml @@ -0,0 +1,203 @@ +name: Release LSP + +on: + push: + tags: + - '*' + +jobs: + release: + name: Release LSP - ${{ matrix.os }} (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - os: linux + arch: x64 + runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + binary_name: ci + asset_name: ci-lsp-linux-x64 + - os: linux + arch: arm64 + runner: ubuntu-latest + target: aarch64-unknown-linux-gnu + binary_name: ci + asset_name: ci-lsp-linux-arm64 + - os: windows + arch: x64 + runner: windows-latest + target: x86_64-pc-windows-msvc + binary_name: ci.exe + asset_name: ci-lsp-windows-x64.exe + - os: windows + arch: arm64 + runner: windows-latest + target: aarch64-pc-windows-msvc + binary_name: ci.exe + asset_name: ci-lsp-windows-arm64.exe + - os: darwin + arch: x64 + runner: macos-latest + target: x86_64-apple-darwin + binary_name: ci + asset_name: ci-lsp-darwin-x64 + - os: darwin + arch: arm64 + runner: macos-latest + target: aarch64-apple-darwin + binary_name: ci + asset_name: ci-lsp-darwin-arm64 + + steps: + - name: Checkout Source + uses: actions/checkout@v4 + + - name: Set variables + id: vars + shell: bash + run: | + echo "package_name=$(sed -En 's/name[[:space:]]*=[[:space:]]*"([^"]+)"/\1/p' Cargo.toml | head -1)" >> $GITHUB_OUTPUT + echo "package_version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Configure cross-compilation (Linux ARM64) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + echo "[target.aarch64-unknown-linux-gnu]" >> ~/.cargo/config.toml + echo "linker = \"aarch64-linux-gnu-gcc\"" >> ~/.cargo/config.toml + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-lsp-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.target }}-cargo-lsp- + + - name: Build release binary with LSP feature + run: cargo build --release --features lsp --target ${{ matrix.target }} + + - name: Upload binary as artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset_name }} + path: target/${{ matrix.target }}/release/${{ matrix.binary_name }} + + create-release: + name: Create LSP Release + runs-on: ubuntu-latest + needs: release + steps: + - name: Checkout Source + uses: actions/checkout@v4 + + - name: Set variables + id: vars + run: | + echo "package_name=$(sed -En 's/name[[:space:]]*=[[:space:]]*"([^"]+)"/\1/p' Cargo.toml | head -1)" >> $GITHUB_OUTPUT + echo "package_version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Remove Same Release + uses: omarabid-forks/action-rollback@stable + continue-on-error: true + with: + tag: ${{ steps.vars.outputs.package_version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Release + id: create-release + uses: actions/create-release@latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.vars.outputs.package_version }} + release_name: LSP Server ${{ steps.vars.outputs.package_version }} + body: ${{ steps.vars.outputs.package_name }} LSP Server - ${{ steps.vars.outputs.package_version }} + draft: false + prerelease: false + + - name: Upload Linux x64 binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create-release.outputs.upload_url }} + asset_path: artifacts/ci-lsp-linux-x64/ci + asset_name: ci-lsp-linux-x64 + asset_content_type: application/octet-stream + + - name: Upload Linux arm64 binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create-release.outputs.upload_url }} + asset_path: artifacts/ci-lsp-linux-arm64/ci + asset_name: ci-lsp-linux-arm64 + asset_content_type: application/octet-stream + + - name: Upload Windows x64 binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create-release.outputs.upload_url }} + asset_path: artifacts/ci-lsp-windows-x64.exe/ci.exe + asset_name: ci-lsp-windows-x64.exe + asset_content_type: application/octet-stream + + - name: Upload Windows arm64 binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create-release.outputs.upload_url }} + asset_path: artifacts/ci-lsp-windows-arm64.exe/ci.exe + asset_name: ci-lsp-windows-arm64.exe + asset_content_type: application/octet-stream + + - name: Upload macOS x64 binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create-release.outputs.upload_url }} + asset_path: artifacts/ci-lsp-darwin-x64/ci + asset_name: ci-lsp-darwin-x64 + asset_content_type: application/octet-stream + + - name: Upload macOS arm64 binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create-release.outputs.upload_url }} + asset_path: artifacts/ci-lsp-darwin-arm64/ci + asset_name: ci-lsp-darwin-arm64 + asset_content_type: application/octet-stream + + - name: Purge artifacts + uses: omarabid-forks/purge-artifacts@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + expire-in: 0 diff --git a/Cargo.toml b/Cargo.toml index 3961b5d..2d8a428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,42 +4,42 @@ resolver = "2" [workspace.dependencies] # Shared dependencies -rand = { version = "0.9.1", default-features = false } -rayon = "1.10.0" -human-panic = "2.0.2" +rand = { version = "0.10.0", default-features = false } +rayon = "1.11.0" +human-panic = "2.0.6" better-panic = "0.3.0" -log = "0.4.27" -clap_complete = "4.5.54" -ignore = "0.4.23" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" +log = "0.4.29" +clap_complete = "4.5.66" +ignore = "0.4.25" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" bincode = { version = "2.0.1", features = ["serde"] } -git2 = { version = "0.20.2", default-features = false } +git2 = { version = "0.20.4", default-features = false } sha2 = { version = "0.10.9" } -thiserror = "2.0.12" -backtrace = "0.3.75" -color-backtrace = "0.7.0" -config = "0.15.11" +thiserror = "2.0.18" +backtrace = "0.3.76" +color-backtrace = "0.7.2" +config = "0.15.19" lazy_static = "1.5.0" -slog = "2.7.0" +slog = "2.8.2" slog-syslog = "0.13.0" -slog-term = "2.9.1" -slog-scope = "4.4.0" +slog-term = "2.9.2" +slog-scope = "4.4.1" slog-async = "2.8.0" slog-stdlog = "4.1.1" tabled = "0.20.0" -terminal_size = "0.4.2" -clap = { version = "4.5.40", features = ["cargo", "derive"] } -chrono = { version = "0.4.41", features = ["serde"] } +terminal_size = "0.4.3" +clap = { version = "4.5.60", features = ["cargo", "derive"] } +chrono = { version = "0.4.44", features = ["serde"] } tower-lsp = "0.20" -tokio = { version = "1", features = ["full"] } -url = "2.5" +tokio = { version = "1.49.0", features = ["full"] } +url = "2.5.8" # Dev dependencies -assert_cmd = "2.0.17" -predicates = "3.1.3" -tempfile = "3.20" -criterion = { version = "0.6.0", features = ["html_reports"] } +assert_cmd = "2.1.2" +predicates = "3.1.4" +tempfile = "3.26.0" +criterion = { version = "0.8.2", features = ["html_reports"] } [profile.dev] opt-level = 0 diff --git a/codeinput/Cargo.toml b/codeinput/Cargo.toml index ca39049..cdcad1f 100644 --- a/codeinput/Cargo.toml +++ b/codeinput/Cargo.toml @@ -86,14 +86,14 @@ config = { workspace = true, optional = true } lazy_static = { workspace = true, optional = true } slog = { workspace = true, optional = true } slog-syslog = { version = "0.13.0", optional = true } -slog-term = { version = "2.9.1", optional = true } +slog-term = { version = "2.9.2", optional = true } slog-scope = { workspace = true, optional = true } slog-async = { workspace = true, optional = true } slog-stdlog = { workspace = true, optional = true } tabled = { workspace = true, optional = true } terminal_size = { workspace = true, optional = true } clap = { workspace = true, optional = true } -chrono = { version = "0.4.41", features = ["serde"], optional = true } +chrono = { version = "0.4.44", features = ["serde"], optional = true } tower-lsp = { workspace = true, optional = true } tokio = { workspace = true, optional = true } url = { workspace = true, optional = true }