diff --git a/Cargo.lock b/Cargo.lock index b7ed2c9c8182..1027c82579ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,6 +327,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bd4b30a6560bbd9b4620f4de34c3f14f60848e58a9b7216801afcb4c7b31c3c" +[[package]] +name = "ec4rs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db540f6c584d6e2960617a2b550632715bbe0a50a4d79c0f682a13da100b34eb" + [[package]] name = "either" version = "1.8.1" @@ -1244,6 +1250,7 @@ dependencies = [ "chardetng", "clipboard-win", "crossterm", + "ec4rs", "futures-util", "helix-core", "helix-dap", diff --git a/helix-view/Cargo.toml b/helix-view/Cargo.toml index 4f7b08edd191..778f0d882b27 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -46,6 +46,8 @@ which = "4.4" parking_lot = "0.12.1" +ec4rs = "1.0.1" + [target.'cfg(windows)'.dependencies] clipboard-win = { version = "4.5", features = ["std"] } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index eca6002653f5..46f7b38fedd6 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -135,6 +135,8 @@ pub struct Document { /// Current indent style. pub indent_style: IndentStyle, + /// Current tab width in columns. + pub tab_width: usize, /// The document's default line ending. pub line_ending: LineEnding, @@ -457,6 +459,106 @@ where *mut_ref = f(mem::take(mut_ref)); } +/// Trait representing ways of configuring the document as it's being opened. +trait ConfigureDocument { + type Config; + /// Loads document configuration for a file at `path`. + fn load(&self, path: &Path) -> Result; + /// Retrieves the encoding from a `Config`. + fn encoding(config: &Self::Config) -> Option<&'static encoding::Encoding>; + /// Retrieves the line ending from a `Config`. + fn line_ending(config: &Self::Config) -> Option; + /// Applies any document configuration not handled by one of the other methods. + fn configure_document(doc: &mut Document, config: Self::Config); +} + +/// Document configuration strategy that uses fallback auto-detection as a first resort. +#[derive(Clone, Copy, Debug, Default)] +struct Autodetect; +/// Document configuration strategy that loads configuration from `.editorconfig` files. +#[derive(Clone, Copy, Debug, Default)] +struct EditorConfig; + +impl ConfigureDocument for Autodetect { + type Config = (); + + fn load(&self, _: &Path) -> Result { + Ok(()) + } + + fn encoding(_: &Self::Config) -> Option<&'static encoding::Encoding> { + None + } + + fn line_ending(_: &Self::Config) -> Option { + None + } + + fn configure_document(doc: &mut Document, _: Self::Config) { + doc.detect_indent(); + } +} + +impl ConfigureDocument for EditorConfig { + type Config = ec4rs::Properties; + + fn load(&self, path: &Path) -> Result { + let mut config = ec4rs::properties_of(path)?; + config.use_fallbacks(); + Ok(config) + } + + fn encoding(config: &Self::Config) -> Option<&'static encoding::Encoding> { + use ec4rs::property::Charset; + use encoding::Encoding; + config + .get_raw::() + .filter_unset() + .into_result() + .ok() + .and_then(|string| Encoding::for_label(string.to_lowercase().as_bytes())) + } + + fn line_ending(config: &Self::Config) -> Option { + use ec4rs::property::EndOfLine; + match config.get::() { + Ok(EndOfLine::Lf) => Some(LineEnding::LF), + Ok(EndOfLine::CrLf) => Some(LineEnding::Crlf), + #[cfg(feature = "unicode-lines")] + Ok(EndOfLine::Cr) => Some(LineEnding::CR), + _ => None, + } + } + + fn configure_document(doc: &mut Document, settings: Self::Config) { + use ec4rs::property::{IndentSize, IndentStyle as IndentStyleEc, TabWidth}; + match settings.get::() { + Ok(IndentStyleEc::Tabs) => doc.indent_style = IndentStyle::Tabs, + Ok(IndentStyleEc::Spaces) => { + let spaces = if let Ok(IndentSize::Value(cols)) = settings.get::() { + cols + } else { + doc.tab_width + }; + // Constrain spaces to only supported values for IndentStyle::Spaces. + let spaces_u8 = if spaces > 8 { + 8u8 + } else if spaces > 0 { + // Shouldn't panic. Overflow cases are covered by the above branch. + spaces as u8 + } else { + 4u8 + }; + doc.indent_style = IndentStyle::Spaces(spaces_u8); + } + _ => doc.detect_indent(), + } + if let Ok(TabWidth::Value(width)) = settings.get::() { + doc.tab_width = width; + } + } +} + use helix_lsp::lsp; use url::Url; @@ -479,6 +581,7 @@ impl Document { inlay_hints: HashMap::default(), inlay_hints_oudated: false, indent_style: DEFAULT_INDENT, + tab_width: 4, line_ending: DEFAULT_LINE_ENDING, restore_cursor: false, syntax: None, @@ -511,17 +614,53 @@ impl Document { config_loader: Option>, config: Arc>, ) -> Result { - // Open the file if it exists, otherwise assume it is a new file (and thus empty). - let (rope, encoding) = if path.exists() { - let mut file = - std::fs::File::open(path).context(format!("unable to open {:?}", path))?; - from_reader(&mut file, encoding)? + if config.load().editorconfig { + Document::open_with_cfg(&EditorConfig, path, encoding, config_loader, config) } else { - let encoding = encoding.unwrap_or(encoding::UTF_8); - (Rope::from(DEFAULT_LINE_ENDING.as_str()), encoding) + Document::open_with_cfg(&Autodetect, path, encoding, config_loader, config) + } + } + // TODO: async fn? + fn open_with_cfg( + doc_config_loader: &C, + path: &Path, + encoding: Option<&'static encoding::Encoding>, + config_loader: Option>, + config: Arc>, + ) -> Result { + let (rope, doc_config, encoding, line_ending) = match std::fs::File::open(path) { + // Match errors that we should NOT ignore. + Err(e) if !matches!(e.kind(), std::io::ErrorKind::NotFound) => { + return Err(e).context(format!("unable to open {:?}", path)); + } + result => { + // Load doc_config for the file at this path. + let doc_config = doc_config_loader + .load(path) + .map_err(|e| log::warn!("unable to load document config for {:?}: {}", path, e)) + .ok(); + // Override the doc_config encoding. + let encoding = encoding.or_else(|| doc_config.as_ref().and_then(C::encoding)); + if let Ok(mut file) = result { + let (rope, encoding) = from_reader(&mut file, encoding)?; + (rope, doc_config, Some(encoding), None) + } else { + // If we're here, the error can be recovered from. + // Treat this as a new file. + let line_ending = doc_config + .as_ref() + .and_then(C::line_ending) + .unwrap_or(DEFAULT_LINE_ENDING); + ( + Rope::from(line_ending.as_str()), + doc_config, + encoding, + Some(line_ending), + ) + } + } }; - - let mut doc = Self::from(rope, Some(encoding), config); + let mut doc = Self::from(rope, encoding, config); // set the path and try detecting the language doc.set_path(Some(path))?; @@ -529,7 +668,26 @@ impl Document { doc.detect_language(loader); } - doc.detect_indent_and_line_ending(); + // Set the tab witdh from language config, allowing it to be overridden later. + // Default of 4 is set in Document::from. + if let Some(indent) = doc + .language_config() + .and_then(|config| config.indent.as_ref()) + { + doc.tab_width = indent.tab_width + } + + if let Some(doc_config) = doc_config { + C::configure_document(&mut doc, doc_config); + } else { + Autodetect::configure_document(&mut doc, ()); + } + + if let Some(line_ending) = line_ending { + doc.line_ending = line_ending; + } else { + doc.detect_line_ending() + } Ok(doc) } @@ -754,17 +912,28 @@ impl Document { } /// Detect the indentation used in the file, or otherwise defaults to the language indentation - /// configured in `languages.toml`, with a fallback to tabs if it isn't specified. Line ending - /// is likewise auto-detected, and will fallback to the default OS line ending. - pub fn detect_indent_and_line_ending(&mut self) { + /// configured in `languages.toml`, with a fallback to tabs if it isn't specified. + pub fn detect_indent(&mut self) { self.indent_style = auto_detect_indent_style(&self.text).unwrap_or_else(|| { self.language_config() .and_then(|config| config.indent.as_ref()) .map_or(DEFAULT_INDENT, |config| IndentStyle::from_str(&config.unit)) }); + } + + /// Detect the line endings used in the file, with a fallback to the default OS line ending. + pub fn detect_line_ending(&mut self) { self.line_ending = auto_detect_line_ending(&self.text).unwrap_or(DEFAULT_LINE_ENDING); } + /// Detect the indentation used in the file, or otherwise defaults to the language indentation + /// configured in `languages.toml`, with a fallback to tabs if it isn't specified. Line ending + /// is likewise auto-detected, and will fallback to the default OS line ending. + pub fn detect_indent_and_line_ending(&mut self) { + self.detect_indent(); + self.detect_line_ending(); + } + /// Reload the document from its path. pub fn reload( &mut self, @@ -1301,9 +1470,7 @@ impl Document { /// The width that the tab character is rendered at pub fn tab_width(&self) -> usize { - self.language_config() - .and_then(|config| config.indent.as_ref()) - .map_or(4, |config| config.tab_width) // fallback to 4 columns + self.tab_width } // The width (in spaces) of a level of indentation. diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 34c59b9b4b75..079b9110218b 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -282,6 +282,9 @@ pub struct Config { /// Whether to color modes with different colors. Defaults to `false`. pub color_modes: bool, pub soft_wrap: SoftWrap, + /// Whether to import settings from [EditorConfig](https://editorconfig.org/). + /// Defaults to `true`. + pub editorconfig: bool, /// Workspace specific lsp ceiling dirs pub workspace_lsp_roots: Vec, } @@ -752,6 +755,7 @@ impl Default for Config { soft_wrap: SoftWrap::default(), text_width: 80, completion_replace: false, + editorconfig: true, workspace_lsp_roots: Vec::new(), } }