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 4b7e20f..2d8a428 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,39 +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.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/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..8877363 100644 --- a/ci/src/cli/mod.rs +++ b/ci/src/cli/mod.rs @@ -16,6 +16,7 @@ use codeinput::utils::app_config::AppConfig; use codeinput::utils::error::Result; use codeinput::utils::types::LogLevel; + #[derive(Parser, Debug)] #[command( name = "codeinput", @@ -45,6 +46,10 @@ pub struct Cli { )] pub log_level: Option, + /// Suppress progress output + #[arg(short, long)] + pub quiet: bool, + /// Subcommands #[clap(subcommand)] command: Commands, @@ -76,6 +81,21 @@ 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 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)] @@ -279,6 +299,8 @@ pub fn cli_match() -> Result<()> { } } Commands::Config => commands::config::run()?, + #[cfg(feature = "lsp")] + Commands::Lsp { port, .. } => commands::lsp::run(*port)?, } Ok(()) diff --git a/codeinput/Cargo.toml b/codeinput/Cargo.toml index 7306f6c..cdcad1f 100644 --- a/codeinput/Cargo.toml +++ b/codeinput/Cargo.toml @@ -57,6 +57,7 @@ full = [ "clap", "chrono", "utoipa", + "url", ] nightly = [] termlog = ["slog-term"] @@ -67,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 } @@ -85,14 +86,17 @@ 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 } [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..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::{ @@ -49,11 +52,10 @@ pub fn build_cache( file_display }; - print!( + output::print(&format!( "\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,7 @@ pub fn build_cache( .collect(); // Print newline after processing is complete - 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); diff --git a/codeinput/src/core/commands/lsp.rs b/codeinput/src/core/commands/lsp.rs new file mode 100644 index 0000000..033a459 --- /dev/null +++ b/codeinput/src/core/commands/lsp.rs @@ -0,0 +1,20 @@ +//! 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_lsp_server_tcp}; + +/// Run the LSP server +#[cfg(feature = "tower-lsp")] +pub fn run(port: Option) -> Result<()> { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + match port { + Some(p) => run_lsp_server_tcp(p).await, + None => 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; diff --git a/codeinput/src/core/commands/parse.rs b/codeinput/src/core/commands/parse.rs index dec9ef3..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), 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..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}, @@ -8,7 +11,7 @@ use super::{ }; pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path) -> Result { - println!("Parsing CODEOWNERS files at {}", repo.display()); + output::println(&format!("Parsing CODEOWNERS files at {}", repo.display())); // Collect all CODEOWNERS files in the specified path let codeowners_files = find_codeowners_files(repo)?; @@ -35,7 +38,7 @@ pub fn parse_repo(repo: &std::path::Path, cache_file: &std::path::Path) -> Resul // Store the cache in the specified file store_cache(&cache, &repo.join(cache_file), CacheEncoding::Bincode)?; - println!("CODEOWNERS parsing completed successfully"); + output::println("CODEOWNERS parsing completed successfully"); Ok(cache) } 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>, } 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..dc4e37a --- /dev/null +++ b/codeinput/src/lsp/server.rs @@ -0,0 +1,505 @@ +//! 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())?; + + 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" + let cache_path = PathBuf::from(".").join(relative_path); + + if let Some(file_entry) = state + .cache + .files + .iter() + .find(|f| f.path == cache_path) + { + 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())?; + } + + 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(", "); + + // 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 + if !info.tags.is_empty() { + let tags_str = info + .tags + .iter() + .map(|t| format!("#{}", t.0)) + .collect::>() + .join(", "); + + // 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 { + // 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)); + } + + 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 over stdio +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(()) +} + +/// 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)); + } +} 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..d778474 --- /dev/null +++ b/codeinput/src/utils/output.rs @@ -0,0 +1,28 @@ +//! Output utilities for CLI progress and status messages +//! +//! All output goes to stderr to keep stdout clean for data/LSP protocol. + +use std::io::{stderr, 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 to stderr if not in quiet mode +pub fn print(msg: &str) { + if !is_quiet() { + let mut stderr = stderr(); + write!(stderr, "{}", msg).ok(); + stderr.flush().ok(); + } +} + +/// Print a line to stderr if not in quiet mode +pub fn println(msg: &str) { + if !is_quiet() { + writeln!(stderr(), "{}", msg).ok(); + } +}