Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-core/src/editor_config.rs')
| -rw-r--r-- | helix-core/src/editor_config.rs | 333 |
1 files changed, 333 insertions, 0 deletions
diff --git a/helix-core/src/editor_config.rs b/helix-core/src/editor_config.rs new file mode 100644 index 00000000..714f577c --- /dev/null +++ b/helix-core/src/editor_config.rs @@ -0,0 +1,333 @@ +//! Support for [EditorConfig](https://EditorConfig.org) configuration loading. +//! +//! EditorConfig is an editor-agnostic format for specifying configuration in an INI-like, human +//! friendly syntax in `.editorconfig` files (which are intended to be checked into VCS). This +//! module provides functions to search for all `.editorconfig` files that apply to a given path +//! and returns an `EditorConfig` type containing any specified configuration options. +//! +//! At time of writing, this module follows the [spec](https://spec.editorconfig.org/) at +//! version 0.17.2. + +use std::{ + collections::HashMap, + fs, + num::{NonZeroU16, NonZeroU8}, + path::Path, + str::FromStr, +}; + +use encoding_rs::Encoding; +use globset::{GlobBuilder, GlobMatcher}; + +use crate::{ + indent::{IndentStyle, MAX_INDENT}, + LineEnding, +}; + +/// Configuration declared for a path in `.editorconfig` files. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct EditorConfig { + pub indent_style: Option<IndentStyle>, + pub tab_width: Option<NonZeroU8>, + pub line_ending: Option<LineEnding>, + pub encoding: Option<&'static Encoding>, + // pub spelling_language: Option<SpellingLanguage>, + pub trim_trailing_whitespace: Option<bool>, + pub insert_final_newline: Option<bool>, + pub max_line_length: Option<NonZeroU16>, +} + +impl EditorConfig { + /// Finds any configuration in `.editorconfig` files which applies to the given path. + /// + /// If no configuration applies then `EditorConfig::default()` is returned. + pub fn find(path: &Path) -> Self { + let mut configs = Vec::new(); + // <https://spec.editorconfig.org/#file-processing> + for ancestor in path.ancestors() { + let editor_config_file = ancestor.join(".editorconfig"); + let Ok(contents) = fs::read_to_string(&editor_config_file) else { + continue; + }; + let ini = match contents.parse::<Ini>() { + Ok(ini) => ini, + Err(err) => { + log::warn!("Ignoring EditorConfig file at '{editor_config_file:?}' because a glob failed to compile: {err}"); + continue; + } + }; + let is_root = ini.pairs.get("root").map(AsRef::as_ref) == Some("true"); + configs.push((ini, ancestor)); + // > The search shall stop if an EditorConfig file is found with the `root` key set to + // > `true` in the preamble or when reaching the root filesystem directory. + if is_root { + break; + } + } + + let mut pairs = Pairs::new(); + // Reverse the configuration stack so that the `.editorconfig` files closest to `path` + // are applied last and overwrite settings in files closer to the search ceiling. + // + // > If multiple EditorConfig files have matching sections, the pairs from the closer + // > EditorConfig file are read last, so pairs in closer files take precedence. + for (config, dir) in configs.into_iter().rev() { + let relative_path = path.strip_prefix(dir).expect("dir is an ancestor of path"); + + for section in config.sections { + if section.glob.is_match(relative_path) { + log::info!( + "applying EditorConfig from section '{}' in file {:?}", + section.glob.glob(), + dir.join(".editorconfig") + ); + pairs.extend(section.pairs); + } + } + } + + Self::from_pairs(pairs) + } + + fn from_pairs(pairs: Pairs) -> Self { + enum IndentSize { + Tab, + Spaces(NonZeroU8), + } + + // <https://spec.editorconfig.org/#supported-pairs> + let indent_size = pairs.get("indent_size").and_then(|value| { + if value.as_ref() == "tab" { + Some(IndentSize::Tab) + } else if let Ok(spaces) = value.parse::<NonZeroU8>() { + Some(IndentSize::Spaces(spaces)) + } else { + None + } + }); + let tab_width = pairs + .get("tab_width") + .and_then(|value| value.parse::<NonZeroU8>().ok()) + .or(match indent_size { + Some(IndentSize::Spaces(spaces)) => Some(spaces), + _ => None, + }); + let indent_style = pairs + .get("indent_style") + .and_then(|value| match value.as_ref() { + "tab" => Some(IndentStyle::Tabs), + "space" => { + let spaces = match indent_size { + Some(IndentSize::Spaces(spaces)) => spaces.get(), + Some(IndentSize::Tab) => tab_width.map(|n| n.get()).unwrap_or(4), + None => 4, + }; + Some(IndentStyle::Spaces(spaces.clamp(1, MAX_INDENT))) + } + _ => None, + }); + let line_ending = pairs + .get("end_of_line") + .and_then(|value| match value.as_ref() { + "lf" => Some(LineEnding::LF), + "crlf" => Some(LineEnding::Crlf), + #[cfg(feature = "unicode-lines")] + "cr" => Some(LineEnding::CR), + _ => None, + }); + let encoding = pairs.get("charset").and_then(|value| match value.as_ref() { + "latin1" => Some(encoding_rs::WINDOWS_1252), + "utf-8" => Some(encoding_rs::UTF_8), + // `utf-8-bom` is intentionally ignored. + // > `utf-8-bom` is discouraged. + "utf-16le" => Some(encoding_rs::UTF_16LE), + "utf-16be" => Some(encoding_rs::UTF_16BE), + _ => None, + }); + let trim_trailing_whitespace = + pairs + .get("trim_trailing_whitespace") + .and_then(|value| match value.as_ref() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }); + let insert_final_newline = pairs + .get("insert_final_newline") + .and_then(|value| match value.as_ref() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }); + // This option is not in the spec but is supported by some editors. + // <https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#max_line_length> + let max_line_length = pairs + .get("max_line_length") + .and_then(|value| value.parse::<NonZeroU16>().ok()); + + Self { + indent_style, + tab_width, + line_ending, + encoding, + trim_trailing_whitespace, + insert_final_newline, + max_line_length, + } + } +} + +type Pairs = HashMap<Box<str>, Box<str>>; + +#[derive(Debug)] +struct Section { + glob: GlobMatcher, + pairs: Pairs, +} + +#[derive(Debug, Default)] +struct Ini { + pairs: Pairs, + sections: Vec<Section>, +} + +impl FromStr for Ini { + type Err = globset::Error; + + fn from_str(source: &str) -> Result<Self, Self::Err> { + // <https://spec.editorconfig.org/#file-format> + let mut ini = Ini::default(); + // > EditorConfig files are in an INI-like file format. To read an EditorConfig file, take + // > one line at a time, from beginning to end. For each line: + for full_line in source.lines() { + // > 1. Remove all leading and trailing whitespace. + let line = full_line.trim(); + // > 2. Process the remaining text as specified for its type below. + // > The types of lines are: + // > * Blank: contains nothing. Blank lines are ignored. + if line.is_empty() { + continue; + } + // > * Comment: starts with a ';' or '#'. Comment lines are ignored. + if line.starts_with([';', '#']) { + continue; + } + if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + // > * Section Header: starts with a `[` and ends with a `]`. These lines define + // > globs... + + // <https://spec.editorconfig.org/#glob-expressions> + // We need to modify the glob string slightly since EditorConfig's glob flavor + // doesn't match `globset`'s exactly. `globset` only allows '**' at the beginning + // or end of a glob or between two '/'s. (This replacement is not very fancy but + // should cover most practical cases.) + let mut glob_str = section.replace("**.", "**/*."); + if !is_glob_relative(section) { + glob_str.insert_str(0, "**/"); + } + let glob = GlobBuilder::new(&glob_str) + .literal_separator(true) + .backslash_escape(true) + .build()?; + ini.sections.push(Section { + glob: glob.compile_matcher(), + pairs: Pairs::new(), + }); + } else if let Some((key, value)) = line.split_once('=') { + // > * Key-Value Pair (or Pair): contains a key and a value, separated by an `=`. + // > * Key: The part before the first `=` on the line. + // > * Value: The part, if any, after the first `=` on the line. + // > * Keys and values are trimmed of leading and trailing whitespace, but + // > include any whitespace that is between non-whitespace characters. + // > * If a value is not provided, then the value is an empty string. + let key = key.trim().to_lowercase().into_boxed_str(); + let value = value.trim().to_lowercase().into_boxed_str(); + if let Some(section) = ini.sections.last_mut() { + section.pairs.insert(key, value); + } else { + ini.pairs.insert(key, value); + } + } + } + Ok(ini) + } +} + +/// Determines whether a glob is relative to the directory of the config file. +fn is_glob_relative(source: &str) -> bool { + // > If the glob contains a path separator (a `/` not inside square brackets), then the + // > glob is relative to the directory level of the particular `.editorconfig` file itself. + let mut idx = 0; + while let Some(open) = source[idx..].find('[').map(|open| idx + open) { + if source[..open].contains('/') { + return true; + } + idx = source[open..] + .find(']') + .map_or(source.len(), |close| idx + close); + } + source[idx..].contains('/') +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn is_glob_relative_test() { + assert!(is_glob_relative("subdir/*.c")); + assert!(!is_glob_relative("*.txt")); + assert!(!is_glob_relative("[a/b].c")); + } + + fn editor_config(path: impl AsRef<Path>, source: &str) -> EditorConfig { + let path = path.as_ref(); + let ini = source.parse::<Ini>().unwrap(); + let pairs = ini + .sections + .into_iter() + .filter(|section| section.glob.is_match(path)) + .fold(Pairs::new(), |mut acc, section| { + acc.extend(section.pairs); + acc + }); + EditorConfig::from_pairs(pairs) + } + + #[test] + fn parse_test() { + let source = r#" + [*] + indent_style = space + + [Makefile] + indent_style = tab + + [docs/**.txt] + insert_final_newline = true + "#; + + assert_eq!( + editor_config("a.txt", source), + EditorConfig { + indent_style: Some(IndentStyle::Spaces(4)), + ..Default::default() + } + ); + assert_eq!( + editor_config("pkg/Makefile", source), + EditorConfig { + indent_style: Some(IndentStyle::Tabs), + ..Default::default() + } + ); + assert_eq!( + editor_config("docs/config/editor.txt", source), + EditorConfig { + indent_style: Some(IndentStyle::Spaces(4)), + insert_final_newline: Some(true), + ..Default::default() + } + ); + } +} |