diff --git a/README.md b/README.md index f29e16b..f2a9db0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Super fast incremental analysis! Scans `all-packages.nix` in less than 0.1s and See [release notes][releases] for change log between tagged unstable versions. -See [`doc/features.md`](doc/features.md) for an incomplete list of notable features currently +See [`docs/features.md`](docs/features.md) for an incomplete list of notable features currently implemented or planned. [releases]: https://github.com/oxalica/nil/releases diff --git a/crates/nil/src/capabilities.rs b/crates/nil/src/capabilities.rs index 0a72fd4..f011918 100644 --- a/crates/nil/src/capabilities.rs +++ b/crates/nil/src/capabilities.rs @@ -42,6 +42,7 @@ pub(crate) fn server_capabilities() -> ServerCapabilities { )), hover_provider: Some(HoverProviderCapability::Simple(true)), document_symbol_provider: Some(OneOf::Left(true)), + document_formatting_provider: Some(OneOf::Left(true)), ..Default::default() } } diff --git a/crates/nil/src/handler.rs b/crates/nil/src/handler.rs index b8c3735..783e05a 100644 --- a/crates/nil/src/handler.rs +++ b/crates/nil/src/handler.rs @@ -1,12 +1,15 @@ use crate::{convert, Result, StateSnapshot}; use ide::FileRange; use lsp_types::{ - CompletionParams, CompletionResponse, Diagnostic, DocumentSymbolParams, DocumentSymbolResponse, - GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams, Location, - PrepareRenameResponse, ReferenceParams, RenameParams, SelectionRange, SelectionRangeParams, - SemanticTokens, SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensRangeResult, - SemanticTokensResult, TextDocumentPositionParams, Url, WorkspaceEdit, + CompletionParams, CompletionResponse, Diagnostic, DocumentFormattingParams, + DocumentSymbolParams, DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse, + Hover, HoverParams, Location, Position, PrepareRenameResponse, Range, ReferenceParams, + RenameParams, SelectionRange, SelectionRangeParams, SemanticTokens, SemanticTokensParams, + SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult, + TextDocumentPositionParams, TextEdit, Url, WorkspaceEdit, }; +use std::process; +use std::sync::Arc; use text_size::TextRange; const MAX_DIAGNOSTICS_CNT: usize = 128; @@ -174,3 +177,70 @@ pub(crate) fn document_symbol( let syms = convert::to_document_symbols(&line_map, syms); Ok(Some(DocumentSymbolResponse::Nested(syms))) } + +// FIXME: This is sync now. +pub(crate) fn formatting( + snap: StateSnapshot, + params: DocumentFormattingParams, +) -> Result>> { + fn run_with_stdin( + cmd: &[String], + stdin_data: impl AsRef<[u8]> + Send + 'static, + ) -> Result { + let mut child = process::Command::new(&cmd[0]) + .args(&cmd[1..]) + .stdin(process::Stdio::piped()) + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::piped()) + .spawn()?; + let mut stdin = child.stdin.take().unwrap(); + std::thread::spawn(move || { + let _ = std::io::copy(&mut stdin_data.as_ref(), &mut stdin); + }); + let output = child.wait_with_output()?; + if !output.status.success() { + return Err(format!( + "Process exited with {}.\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + let stdout = String::from_utf8(output.stdout)?; + Ok(stdout) + } + + let cmd = match &snap.config.formatting_command { + Some(cmd) => cmd, + None => return Ok(None), + }; + + let (file_content, line_map) = { + let vfs = snap.vfs(); + let file = convert::from_file(&vfs, ¶ms.text_document)?; + (vfs.content_for_file(file), vfs.line_map_for_file(file)) + }; + + let new_content = run_with_stdin(cmd, >::from(file_content.clone())) + .map_err(|err| format!("Failed to run formatter: {err}"))?; + + if new_content == *file_content { + return Ok(None); + } + + // Replace the whole file. + let last_line = line_map.last_line(); + Ok(Some(vec![TextEdit { + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: last_line, + character: line_map.end_col_for_line(last_line), + }, + }, + new_text: new_content, + }])) +} diff --git a/crates/nil/src/state.rs b/crates/nil/src/state.rs index 59ef1c3..68e9cd5 100644 --- a/crates/nil/src/state.rs +++ b/crates/nil/src/state.rs @@ -21,6 +21,7 @@ const CONFIG_KEY: &str = "nil"; #[derive(Debug, Clone, Default, Deserialize)] pub struct Config { pub(crate) diagnostics_ignored: HashSet, + pub(crate) formatting_command: Option>, } type ReqHandler = fn(&mut State, Response); @@ -124,6 +125,7 @@ impl State { .on::(handler::semantic_token_range) .on::(handler::hover) .on::(handler::document_symbol) + .on::(handler::formatting) .finish(); } @@ -222,6 +224,7 @@ impl State { fn update_config(&mut self, mut v: serde_json::Value) { let mut updated_diagnostics = false; let mut config = Config::clone(&self.config); + let mut errors = Vec::new(); if let Some(v) = v.pointer_mut("/diagnostics/ignored") { match serde_json::from_value(v.take()) { Ok(v) => { @@ -229,15 +232,33 @@ impl State { updated_diagnostics = true; } Err(e) => { - self.show_message( - MessageType::ERROR, - format!("Invalid value of setting `diagnostics.ignored`: {e}"), - ); + errors.push(format!("Invalid value of `diagnostics.ignored`: {e}")); } } } + if let Some(v) = v.pointer_mut("/formatting/command") { + match serde_json::from_value::>>(v.take()) { + Ok(Some(v)) if v.is_empty() => { + errors.push("`formatting.command` must not be an empty list".into()); + } + Ok(v) => { + config.formatting_command = v; + } + Err(e) => { + errors.push(format!("Invalid value of `formatting.command`: {e}")); + } + } + } + tracing::debug!("Updated config, errors: {errors:?}, config: {config:?}"); self.config = Arc::new(config); - tracing::debug!("Updated config: {:?}", self.config); + + if !errors.is_empty() { + let msg = ["Failed to apply some settings:"] + .into_iter() + .chain(errors.iter().flat_map(|s| ["\n- ", s])) + .collect::(); + self.show_message(MessageType::ERROR, msg); + } // Refresh all diagnostics since the filter may be changed. if updated_diagnostics { diff --git a/crates/nil/src/vfs.rs b/crates/nil/src/vfs.rs index e86d80b..2dc6bf0 100644 --- a/crates/nil/src/vfs.rs +++ b/crates/nil/src/vfs.rs @@ -141,8 +141,12 @@ impl Vfs { change } - pub fn line_map_for_file(&self, file_id: FileId) -> Arc { - self.files[file_id.0 as usize].1.clone() + pub fn content_for_file(&self, file: FileId) -> Arc { + self.files[file.0 as usize].0.clone() + } + + pub fn line_map_for_file(&self, file: FileId) -> Arc { + self.files[file.0 as usize].1.clone() } } diff --git a/dev/vim-coc.nix b/dev/vim-coc.nix index 94ce7b6..d530ac4 100644 --- a/dev/vim-coc.nix +++ b/dev/vim-coc.nix @@ -61,12 +61,14 @@ let ''; cocSetting = { + "coc.preferences.formatOnSaveFiletypes" = [ "nix" ]; languageserver.nix = { command = "nil"; filetypes = [ "nix" ]; rootPatterns = [ "flake.nix" ]; settings.nil = { testSetting = 42; + formatting.command = [ "nixpkgs-fmt" ]; }; }; semanticTokens.filetypes = [ "nix" ]; diff --git a/doc/features.md b/docs/features.md similarity index 73% rename from doc/features.md rename to docs/features.md index 3ee83f5..104420e 100644 --- a/doc/features.md +++ b/docs/features.md @@ -46,18 +46,41 @@ This incomplete list tracks noteble features currently implemented or planned. [`coc.nvim`] doesn't enable semantic highlighting by default. You need to manually enable it in settings. - ```json + ```jsonc + // coc-settings.json { "semanticTokens": { "filetypes": ["nix"] } } ``` - [`coc.nvim`]: https://github.com/neoclide/coc.nvim - - - [x] Hover text. `textDocument/hover`. - [x] Show kind of names. - [x] Documentation for builtin names. - [x] File symbols with hierarchy (aka. outline). `textDocument/documentSymbol` + +- [x] File formatting. + - [x] Whole file formatting. + - [ ] Range formatting. + - [ ] On-type formatting. + - [x] External formatter. + - Currently, an external formatter must be configured via LSP setting + `formatting.command` to enable this functionality. + It accepts `null` for disabled, or an non-empty array for the formatting command, + eg. `["nixpkgs-fmt"]` for [nixpkgs-fmt]. + The command must read Nix code from stdin and print the formatted code to stdout. + + [nixpkgs-fmt]: https://github.com/nix-community/nixpkgs-fmt + + You might need to set other editor settings to enable format-on-save. + Like, for [`coc.nvim`], + ```jsonc + // coc-settings.json + { + "coc.preferences.formatOnSaveFiletypes": ["nix"] + } + ``` + - [ ] Cross-file analysis. - [ ] Multi-threaded. + +[`coc.nvim`]: https://github.com/neoclide/coc.nvim diff --git a/flake.nix b/flake.nix index 3b8004c..5d5dfab 100644 --- a/flake.nix +++ b/flake.nix @@ -70,6 +70,7 @@ gdb jq pre-commit + nixpkgs-fmt (import ./dev/neovim-lsp.nix { inherit pkgs; }) (import ./dev/vim-coc.nix { inherit pkgs; }) ] ++ lib.optionals (lib.meta.availableOn stdenv.hostPlatform vscodium) [