on master help sign in
Attempt to auto-detect indentation style on document load.

This also moves the primary indentation-style setting into Document.

commit2329512122105ab146395b1889b0eed4f2fb522b authorNathan Vegdahl <cessen@cessen.com> dateSun 13 Jun 2021, 11:26 (5 years ago) parent1bda454
1 file changed+197−6
helix-view/src/document.rs+197−6
helix-view/src/document.rs+197−6
@@ -21,11 +21,18 @@ pub enum Mode {
2121 Insert,
2222}
2323
24+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
25+pub enum IndentStyle {
26+ Tabs,
27+ Spaces(u8),
28+}
29+
2430pub struct Document {
2531 // rope + selection
2632 pub(crate) id: DocumentId,
2733 text: Rope,
2834 pub(crate) selections: HashMap<ViewId, Selection>,
35+ pub(crate) indent_style: IndentStyle,
2936
3037 path: Option<PathBuf>,
3138
@@ -149,6 +156,7 @@ impl Document {
149156 path: None,
150157 text,
151158 selections: HashMap::default(),
159+ indent_style: IndentStyle::Spaces(4),
152160 mode: Mode::Normal,
153161 restore_cursor: false,
154162 syntax: None,
@@ -182,6 +190,7 @@ impl Document {
182190 let mut doc = Self::new(doc);
183191 // set the path and try detecting the language
184192 doc.set_path(&path)?;
193+ doc.detect_indent_style();
185194
186195 Ok(doc)
187196 }
@@ -265,6 +274,176 @@ impl Document {
265274 }
266275 }
267276
277+ fn detect_indent_style(&mut self) {
278+ // Determine whether a character is a line break.
279+ //
280+ // TODO: this is probably a generally useful utility function. Where
281+ // should we put it?
282+ fn char_is_linebreak(c: char) -> bool {
283+ match c {
284+ '\u{000A}' | // LineFeed
285+ '\u{000B}' | // VerticalTab
286+ '\u{000C}' | // FormFeed
287+ '\u{000D}' | // CarriageReturn
288+ '\u{0085}' | // NextLine
289+ '\u{2028}' | // Line Separator
290+ '\u{2029}' // ParagraphSeparator
291+ => true,
292+
293+ _ => false,
294+ }
295+ }
296+
297+ // Determine whether a character qualifies as (non-line-break)
298+ // whitespace.
299+ //
300+ // TODO: this is probably a generally useful utility function. Where
301+ // should we put it?
302+ //
303+ // TODO: this is a naive binary categorization of whitespace
304+ // characters. For display, word wrapping, etc. we'll need a better
305+ // categorization based on e.g. breaking vs non-breaking spaces
306+ // and whether they're zero-width or not.
307+ pub fn char_is_whitespace(c: char) -> bool {
308+ match c {
309+ //'\u{1680}' | // Ogham Space Mark (here for completeness, but usually displayed as a dash, not as whitespace)
310+ '\u{0009}' | // Character Tabulation
311+ '\u{0020}' | // Space
312+ '\u{00A0}' | // No-break Space
313+ '\u{180E}' | // Mongolian Vowel Separator
314+ '\u{202F}' | // Narrow No-break Space
315+ '\u{205F}' | // Medium Mathematical Space
316+ '\u{3000}' | // Ideographic Space
317+ '\u{FEFF}' // Zero Width No-break Space
318+ => true,
319+
320+ // En Quad, Em Quad, En Space, Em Space, Three-per-em Space,
321+ // Four-per-em Space, Six-per-em Space, Figure Space,
322+ // Punctuation Space, Thin Space, Hair Space, Zero Width Space.
323+ c if c >= '\u{2000}' && c <= '\u{200B}' => true,
324+
325+ _ => false,
326+ }
327+ }
328+
329+ // Build a histogram of the indentation *increases* between
330+ // subsequent lines, ignoring lines that are all whitespace.
331+ //
332+ // Index 0 is for tabs, the rest are 1-8 spaces.
333+ let histogram: [usize; 9] = {
334+ let mut histogram = [0; 9];
335+ let mut prev_line_indent = (false, 0usize); // (was_tabs, count)
336+
337+ 'outer: for line in self.text.lines().take(1000) {
338+ let mut c_iter = line.chars();
339+
340+ // Is first character a tab or space?
341+ let is_tabs = match c_iter.next() {
342+ Some('\t') => true,
343+ Some(' ') => false,
344+
345+ // Ignore blank lines.
346+ Some(c) if char_is_linebreak(c) => continue 'outer,
347+
348+ _ => {
349+ prev_line_indent = (false, 0usize);
350+ continue 'outer;
351+ }
352+ };
353+
354+ // Count the total leading tab/space characters.
355+ let mut count = 1;
356+ let mut count_is_done = false;
357+ for c in c_iter {
358+ match c {
359+ '\t' if is_tabs && !count_is_done => count += 1,
360+ ' ' if !is_tabs && !count_is_done => count += 1,
361+
362+ // We stop counting if we hit whitespace that doesn't
363+ // qualify as indent or doesn't match the leading
364+ // whitespace, but we don't exit the loop yet because
365+ // we still want to determine if the line is blank.
366+ c if char_is_whitespace(c) => count_is_done = true,
367+
368+ // Ignore blank lines.
369+ c if char_is_linebreak(c) => continue 'outer,
370+
371+ _ => break,
372+ }
373+
374+ // Bound the worst-case execution time for weird text files.
375+ if count > 256 {
376+ continue 'outer;
377+ }
378+ }
379+
380+ // Update stats.
381+ if (prev_line_indent.0 == is_tabs || prev_line_indent.1 == 0)
382+ && prev_line_indent.1 < count
383+ {
384+ if is_tabs {
385+ histogram[0] += 1;
386+ } else {
387+ let amount = count - prev_line_indent.1;
388+ if amount <= 8 {
389+ histogram[amount] += 1;
390+ }
391+ }
392+ }
393+
394+ // Store data for use with the next line.
395+ prev_line_indent = (is_tabs, count);
396+ }
397+
398+ // Give more weight to tabs, because their presence is a very
399+ // strong indicator.
400+ histogram[0] *= 2;
401+
402+ histogram
403+ };
404+
405+ // Find the most frequent indent, its frequency, and the frequency of
406+ // the next-most frequent indent.
407+ let indent = histogram
408+ .iter()
409+ .enumerate()
410+ .max_by_key(|kv| kv.1)
411+ .unwrap()
412+ .0;
413+ let indent_freq = histogram[indent];
414+ let indent_freq_2 = *histogram
415+ .iter()
416+ .enumerate()
417+ .filter(|kv| kv.0 != indent)
418+ .map(|kv| kv.1)
419+ .max()
420+ .unwrap();
421+
422+ // Use the auto-detected result if we're confident enough in its
423+ // accuracy, based on some heuristics. Otherwise fall back to
424+ // the language-based setting.
425+ if indent_freq >= 1 && (indent_freq_2 as f64 / indent_freq as f64) < 0.66 {
426+ // Use the auto-detected setting.
427+ self.indent_style = match indent {
428+ 0 => IndentStyle::Tabs,
429+ _ => IndentStyle::Spaces(indent as u8),
430+ };
431+ } else {
432+ // Fall back to language-based setting.
433+ let indent = self
434+ .language
435+ .as_ref()
436+ .and_then(|config| config.indent.as_ref())
437+ .map_or(" ", |config| config.unit.as_str()); // fallback to 2 spaces
438+
439+ self.indent_style = if indent.starts_with(" ") {
440+ IndentStyle::Spaces(indent.len() as u8)
441+ } else {
442+ IndentStyle::Tabs
443+ };
444+ }
445+ }
446+
268447 pub fn set_path(&mut self, path: &Path) -> Result<(), std::io::Error> {
269448 let path = canonicalize_path(path)?;
270449
@@ -507,13 +686,25 @@ impl Document {
507686 }
508687
509688 /// Returns a string containing a single level of indentation.
689+ ///
690+ /// TODO: we might not need this function anymore, since the information
691+ /// is conveniently available in `Document::indent_style` now.
510692 pub fn indent_unit(&self) -> &str {
511− self.language
512− .as_ref()
513− .and_then(|config| config.indent.as_ref())
514− .map_or(" ", |config| config.unit.as_str()) // fallback to 2 spaces
515−
516− // " ".repeat(TAB_WIDTH)
693+ match self.indent_style {
694+ IndentStyle::Tabs => "\t",
695+ IndentStyle::Spaces(1) => " ",
696+ IndentStyle::Spaces(2) => " ",
697+ IndentStyle::Spaces(3) => " ",
698+ IndentStyle::Spaces(4) => " ",
699+ IndentStyle::Spaces(5) => " ",
700+ IndentStyle::Spaces(6) => " ",
701+ IndentStyle::Spaces(7) => " ",
702+ IndentStyle::Spaces(8) => " ",
703+
704+ // Unsupported indentation style. This should never happen,
705+ // but just in case fall back to two spaces.
706+ _ => " ",
707+ }
517708 }
518709
519710 #[inline]