Файли

407 рядки
16 KiB
Rust

//! `.editorconfig` support (<https://spec.editorconfig.org>).
//!
//! Files are looked up from the edited file's directory upwards until one
//! declares `root = true`. Sections of files closer to the edited file win,
//! and within a file later sections win. Parsed files are cached and
//! re-read when their modification time or size changes.
use globset::{GlobBuilder, GlobMatcher};
use ide_text::LineEnding;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
pub const FILE_NAME: &str = ".editorconfig";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum IndentStyle {
Space,
Tab,
}
/// Effective EditorConfig properties of one file. `None` = not specified
/// (or `unset`), so the IDE setting applies.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct EditorConfigProps {
pub indent_style: Option<IndentStyle>,
/// Columns per indentation level (`indent_size = tab` resolves to `tab_width`).
pub indent_size: Option<u32>,
pub tab_width: Option<u32>,
pub end_of_line: Option<LineEnding>,
/// `utf-8`, `utf-8-bom`, `utf-16le`, `utf-16be` or `latin1`.
pub charset: Option<String>,
pub trim_trailing_whitespace: Option<bool>,
pub insert_final_newline: Option<bool>,
pub max_line_length: Option<u32>,
}
impl EditorConfigProps {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
/// WHATWG encoding label and BOM flag for `charset`.
pub fn encoding(&self) -> Option<(&'static str, bool)> {
Some(match self.charset.as_deref()? {
"utf-8" => ("utf-8", false),
"utf-8-bom" => ("utf-8", true),
"utf-16le" => ("utf-16le", true),
"utf-16be" => ("utf-16be", true),
"latin1" => ("windows-1252", false),
_ => return None,
})
}
}
/// Raw `key = value` pairs (keys lower-cased) in application order.
type Pairs = Vec<(String, String)>;
struct Section {
matcher: Option<GlobMatcher>,
pairs: Pairs,
}
struct ParsedFile {
root: bool,
sections: Vec<Section>,
}
/// Parses `.editorconfig` text. Section globs containing `/` are relative to
/// the directory of the file.
fn parse(text: &str) -> ParsedFile {
let mut file = ParsedFile { root: false, sections: Vec::new() };
let mut current: Option<Section> = None;
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') && line.len() >= 2 {
if let Some(s) = current.take() {
file.sections.push(s);
}
let glob = &line[1..line.len() - 1];
current = Some(Section { matcher: compile_glob(glob), pairs: Vec::new() });
continue;
}
let Some((key, value)) = line.split_once(['=', ':']) else { continue };
let key = key.trim().to_ascii_lowercase();
let value = value.trim().to_owned();
match &mut current {
Some(section) => section.pairs.push((key, value)),
None => {
if key == "root" {
file.root = value.eq_ignore_ascii_case("true");
}
}
}
}
if let Some(s) = current.take() {
file.sections.push(s);
}
file
}
/// Expands `{n..m}` numeric ranges into `{n,n+1,...,m}` (globset has no ranges).
fn expand_ranges(glob: &str) -> String {
let mut out = String::with_capacity(glob.len());
let mut rest = glob;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
out.push_str(&rest[open..]);
return out;
};
let inner = &after[..close];
let range = inner.split_once("..").and_then(|(a, b)| Some((a.parse::<i64>().ok()?, b.parse::<i64>().ok()?)));
match range {
Some((a, b)) if (b - a).abs() <= 1000 => {
let (lo, hi) = (a.min(b), a.max(b));
let items: Vec<String> = (lo..=hi).map(|n| n.to_string()).collect();
out.push('{');
out.push_str(&items.join(","));
out.push('}');
}
_ => {
out.push('{');
out.push_str(inner);
out.push('}');
}
}
rest = &after[close + 1..];
}
out.push_str(rest);
out
}
fn compile_glob(glob: &str) -> Option<GlobMatcher> {
let glob = expand_ranges(glob.trim());
// Without a `/` the pattern matches the file name at any depth.
let pattern = if !glob.contains('/') {
format!("**/{glob}")
} else if let Some(stripped) = glob.strip_prefix('/') {
stripped.to_owned()
} else {
// `a/b.txt` is anchored to the .editorconfig directory as well.
glob.clone()
};
match GlobBuilder::new(&pattern).literal_separator(true).backslash_escape(true).build() {
Ok(g) => Some(g.compile_matcher()),
Err(e) => {
tracing::debug!("editorconfig: invalid section [{glob}]: {e}");
None
}
}
}
fn apply_pairs(acc: &mut HashMap<String, String>, pairs: &Pairs) {
for (k, v) in pairs {
acc.insert(k.clone(), v.clone());
}
}
fn props_from(map: &HashMap<String, String>) -> EditorConfigProps {
let get = |k: &str| map.get(k).map(|v| v.to_ascii_lowercase()).filter(|v| v != "unset");
let num = |k: &str| get(k).and_then(|v| v.parse::<u32>().ok()).filter(|n| *n > 0 && *n <= 256);
let flag = |k: &str| match get(k).as_deref() {
Some("true") => Some(true),
Some("false") => Some(false),
_ => None,
};
let indent_style = match get("indent_style").as_deref() {
Some("space") => Some(IndentStyle::Space),
Some("tab") => Some(IndentStyle::Tab),
_ => None,
};
let mut tab_width = num("tab_width");
let mut indent_size = match get("indent_size").as_deref() {
Some("tab") => tab_width,
_ => num("indent_size"),
};
// Spec defaults: tab_width follows a numeric indent_size; indent_size = tab
// (or indent_style = tab without indent_size) follows tab_width.
if tab_width.is_none() && get("indent_size").as_deref() != Some("tab") {
tab_width = indent_size;
}
if indent_size.is_none() && (indent_style == Some(IndentStyle::Tab) || get("indent_size").as_deref() == Some("tab"))
{
indent_size = tab_width;
}
let end_of_line = match get("end_of_line").as_deref() {
Some("lf") => Some(LineEnding::Lf),
Some("crlf") => Some(LineEnding::CrLf),
Some("cr") => Some(LineEnding::Cr),
_ => None,
};
let charset =
get("charset").filter(|c| matches!(c.as_str(), "utf-8" | "utf-8-bom" | "utf-16le" | "utf-16be" | "latin1"));
let max_line_length = get("max_line_length").and_then(|v| v.parse::<u32>().ok());
EditorConfigProps {
indent_style,
indent_size,
tab_width,
end_of_line,
charset,
trim_trailing_whitespace: flag("trim_trailing_whitespace"),
insert_final_newline: flag("insert_final_newline"),
max_line_length,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct Stamp {
mtime: Option<SystemTime>,
len: u64,
}
/// A parsed `.editorconfig` with the stamp it was read at; `None` = no such file.
type CachedFile = Option<(Stamp, Arc<ParsedFile>)>;
/// Resolves EditorConfig properties, caching parsed files.
#[derive(Default)]
pub struct EditorConfig {
files: Mutex<HashMap<PathBuf, CachedFile>>,
}
impl EditorConfig {
pub fn new() -> Self {
Self::default()
}
/// Drops every cached file (e.g. after "Clear caches").
pub fn clear(&self) {
self.files.lock().clear();
}
fn load(&self, path: &Path) -> Option<Arc<ParsedFile>> {
let meta = std::fs::metadata(path).ok().filter(|m| m.is_file());
let stamp = meta.as_ref().map(|m| Stamp { mtime: m.modified().ok(), len: m.len() });
let mut files = self.files.lock();
match (files.get(path), stamp) {
(Some(None), None) => return None,
(Some(Some((s, parsed))), Some(stamp)) if *s == stamp => return Some(parsed.clone()),
_ => {}
}
let parsed = stamp.and_then(|stamp| {
let text = std::fs::read_to_string(path).ok()?;
Some((stamp, Arc::new(parse(&text))))
});
let result = parsed.as_ref().map(|(_, p)| p.clone());
files.insert(path.to_path_buf(), parsed);
result
}
/// Effective properties for `file` (absolute); `None` when no section applies.
pub fn resolve(&self, file: &Path) -> Option<EditorConfigProps> {
let mut chain: Vec<(PathBuf, Arc<ParsedFile>)> = Vec::new();
for dir in file.ancestors().skip(1) {
if let Some(parsed) = self.load(&dir.join(FILE_NAME)) {
let root = parsed.root;
chain.push((dir.to_path_buf(), parsed));
if root {
break;
}
}
}
if chain.is_empty() {
return None;
}
let mut map: HashMap<String, String> = HashMap::new();
let mut matched = false;
// Outermost first, so closer files override.
for (dir, parsed) in chain.iter().rev() {
let Ok(rel) = file.strip_prefix(dir) else { continue };
let rel = rel.to_string_lossy().replace('\\', "/");
for section in &parsed.sections {
if section.matcher.as_ref().is_some_and(|m| m.is_match(&rel)) {
matched = true;
apply_pairs(&mut map, &section.pairs);
}
}
}
if !matched {
return None;
}
let props = props_from(&map);
(!props.is_empty()).then_some(props)
}
}
/// Bytes for a new, empty file honouring `charset` (a BOM for `utf-8-bom` and UTF-16).
pub fn new_file_bytes(props: Option<&EditorConfigProps>) -> Vec<u8> {
match props.and_then(EditorConfigProps::encoding) {
Some((enc, true)) => ide_vfs::encode("", enc, true).unwrap_or_default(),
_ => Vec::new(),
}
}
/// Creates an empty file like `ide_vfs::create_file`, writing the BOM that
/// the file's EditorConfig `charset` asks for.
pub fn create_file(config: &EditorConfig, path: &Path) -> ide_core::IdeResult<()> {
ide_vfs::create_file(path)?;
let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir()?.join(path) };
let bytes = new_file_bytes(config.resolve(&abs).as_ref());
if !bytes.is_empty() {
std::fs::write(path, bytes)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
#[test]
fn ranges_and_globs() {
assert_eq!(expand_ranges("file{1..3}.txt"), "file{1,2,3}.txt");
assert_eq!(expand_ranges("{a,b}.{x..y}"), "{a,b}.{x..y}");
assert_eq!(expand_ranges("x{"), "x{");
let m = |g: &str, p: &str| compile_glob(g).unwrap().is_match(p);
assert!(m("*.rs", "src/deep/a.rs") && m("*.rs", "a.rs"));
assert!(m("*", "Makefile") && m("*", "a/b/c"));
assert!(m("src/*.rs", "src/a.rs") && !m("src/*.rs", "src/x/a.rs") && !m("src/*.rs", "lib/src/a.rs"));
assert!(m("/src/**/*.rs", "src/x/a.rs") && !m("/src/**/*.rs", "lib/src/a.rs"));
assert!(m("{package.json,.travis.yml}", "package.json") && m("{package.json,.travis.yml}", "x/.travis.yml"));
assert!(m("*.{js,ts}", "a.ts") && !m("*.{js,ts}", "a.tsx"));
assert!(m("f[ab].txt", "fa.txt") && !m("f[!ab].txt", "fa.txt") && m("f[!ab].txt", "fc.txt"));
assert!(m("v{1..12}.md", "v10.md") && !m("v{1..12}.md", "v13.md"));
assert!(m("?.c", "a.c") && !m("?.c", "ab.c"));
}
#[test]
fn hierarchy_root_and_precedence() {
let tmp = tempfile::tempdir().unwrap();
let r = tmp.path().canonicalize().unwrap();
write(
&r.join(".editorconfig"),
"# top\nroot = true\n\n[*]\nindent_style = space\nindent_size = 4\nend_of_line = lf\ncharset = utf-8\n\
trim_trailing_whitespace = true\ninsert_final_newline = true\n\n[*.md]\ntrim_trailing_whitespace = false\n\n\
[Makefile]\nindent_style = tab\n\n[*.go]\nindent_style = tab\ntab_width = 8\nindent_size = tab\n",
);
write(&r.join("sub/.editorconfig"), "[*.rs]\nindent_size = 2\n; comment\n[*.bat]\nend_of_line = CRLF\n");
write(&r.join("sub/leaf/.editorconfig"), "root=true\n[*.rs]\nindent_size=unset\n");
let ec = EditorConfig::new();
let p = ec.resolve(&r.join("src/main.rs")).unwrap();
assert_eq!(p.indent_style, Some(IndentStyle::Space));
assert_eq!((p.indent_size, p.tab_width), (Some(4), Some(4)));
assert_eq!(p.end_of_line, Some(LineEnding::Lf));
assert_eq!(p.charset.as_deref(), Some("utf-8"));
assert_eq!((p.trim_trailing_whitespace, p.insert_final_newline), (Some(true), Some(true)));
assert_eq!(ec.resolve(&r.join("README.md")).unwrap().trim_trailing_whitespace, Some(false));
let mk = ec.resolve(&r.join("Makefile")).unwrap();
assert_eq!((mk.indent_style, mk.indent_size), (Some(IndentStyle::Tab), Some(4)));
let go = ec.resolve(&r.join("cmd/x.go")).unwrap();
assert_eq!((go.indent_size, go.tab_width), (Some(8), Some(8)));
// Closer file overrides; outer sections still apply.
let sub = ec.resolve(&r.join("sub/a.rs")).unwrap();
assert_eq!((sub.indent_size, sub.tab_width, sub.indent_style), (Some(2), Some(2), Some(IndentStyle::Space)));
assert_eq!(ec.resolve(&r.join("sub/x.bat")).unwrap().end_of_line, Some(LineEnding::CrLf));
// `root = true` in leaf stops the search; `unset` clears.
assert_eq!(ec.resolve(&r.join("sub/leaf/a.rs")), None);
assert_eq!(ec.resolve(&r.join("sub/leaf/a.txt")), None);
// Edits are picked up (cache keyed on mtime + size).
write(&r.join("sub/.editorconfig"), "[*.rs]\nindent_size = 3\nmax_line_length = 100\n");
let sub = ec.resolve(&r.join("sub/a.rs")).unwrap();
assert_eq!((sub.indent_size, sub.max_line_length), (Some(3), Some(100)));
}
#[test]
fn charset_and_new_files() {
let tmp = tempfile::tempdir().unwrap();
let r = tmp.path().canonicalize().unwrap();
write(
&r.join(".editorconfig"),
"root = true\n[*.txt]\ncharset = utf-8-bom\n[*.u16]\ncharset = utf-16le\n[*.l1]\ncharset = latin1\n[*.bad]\ncharset = ebcdic\n",
);
let ec = EditorConfig::new();
assert_eq!(ec.resolve(&r.join("a.txt")).unwrap().encoding(), Some(("utf-8", true)));
assert_eq!(ec.resolve(&r.join("a.l1")).unwrap().encoding(), Some(("windows-1252", false)));
assert_eq!(ec.resolve(&r.join("a.bad")), None);
create_file(&ec, &r.join("new.txt")).unwrap();
assert_eq!(std::fs::read(r.join("new.txt")).unwrap(), b"\xEF\xBB\xBF");
create_file(&ec, &r.join("new.u16")).unwrap();
assert_eq!(std::fs::read(r.join("new.u16")).unwrap(), [0xFF, 0xFE]);
create_file(&ec, &r.join("new.l1")).unwrap();
assert!(std::fs::read(r.join("new.l1")).unwrap().is_empty());
assert!(create_file(&ec, &r.join("new.txt")).is_err(), "never truncates");
}
}