Skip to content

Commit 8bc60bc

Browse files
committed
add basic modeline support
currently only supports setting language, indent style, and line endings
1 parent 8b076e3 commit 8bc60bc

File tree

3 files changed

+276
-9
lines changed

3 files changed

+276
-9
lines changed

helix-core/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod indent;
1515
pub mod line_ending;
1616
pub mod macros;
1717
pub mod match_brackets;
18+
pub mod modeline;
1819
pub mod movement;
1920
pub mod object;
2021
pub mod path;

helix-core/src/modeline.rs

+247
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
use std::borrow::Cow;
2+
3+
use once_cell::sync::Lazy;
4+
5+
use crate::indent::IndentStyle;
6+
use crate::regex::Regex;
7+
use crate::{LineEnding, RopeSlice};
8+
9+
// 5 is the vim default
10+
const LINES_TO_CHECK: usize = 5;
11+
const LENGTH_TO_CHECK: usize = 256;
12+
13+
static MODELINE_REGEX: Lazy<Regex> =
14+
Lazy::new(|| Regex::new(r"^(\S*\s+)?(vi|[vV]im[<=>]?\d*|ex):\s*(set?\s+)?").unwrap());
15+
16+
#[derive(Default, Debug, Eq, PartialEq)]
17+
pub struct Modeline {
18+
language: Option<String>,
19+
indent_style: Option<IndentStyle>,
20+
line_ending: Option<LineEnding>,
21+
}
22+
23+
impl Modeline {
24+
pub fn parse(text: RopeSlice) -> Self {
25+
let mut modeline = Self::default();
26+
27+
for line in text.lines().take(LINES_TO_CHECK).chain(
28+
text.lines_at(text.len_lines())
29+
.reversed()
30+
.take(LINES_TO_CHECK),
31+
) {
32+
// can't guarantee no extra copies, since we need to regex and
33+
// regexes can't operate over chunks yet, but we can at least
34+
// limit how much we potentially need to copy because modelines
35+
// are typically quite short.
36+
if line.len_chars() > LENGTH_TO_CHECK {
37+
continue;
38+
}
39+
let line = Cow::<str>::from(line);
40+
modeline.parse_from_line(&line);
41+
}
42+
43+
modeline
44+
}
45+
46+
pub fn language(&self) -> Option<&str> {
47+
self.language.as_deref()
48+
}
49+
50+
pub fn indent_style(&self) -> Option<IndentStyle> {
51+
self.indent_style
52+
}
53+
54+
pub fn line_ending(&self) -> Option<LineEnding> {
55+
self.line_ending
56+
}
57+
58+
fn parse_from_line(&mut self, line: &str) {
59+
let mut saw_backslash = false;
60+
let split_modeline = move |c| {
61+
saw_backslash = match c {
62+
':' if !saw_backslash => return true,
63+
'\\' => true,
64+
_ => false,
65+
};
66+
c == ' ' || c == '\t'
67+
};
68+
if let Some(pos) = MODELINE_REGEX.find(line) {
69+
for option in line[pos.end()..].split(split_modeline) {
70+
let parts: Vec<_> = option.split('=').collect();
71+
match parts[0] {
72+
"ft" | "filetype" => {
73+
if let Some(val) = parts.get(1) {
74+
self.language = Some(vim_ft_to_helix_language(val));
75+
}
76+
}
77+
"sw" | "shiftwidth" => {
78+
if let Some(val) = parts.get(1).and_then(|val| val.parse().ok()) {
79+
if self.indent_style != Some(IndentStyle::Tabs) {
80+
self.indent_style = Some(IndentStyle::Spaces(val));
81+
}
82+
}
83+
}
84+
"ff" | "fileformat" => {
85+
if let Some(val) = parts.get(1) {
86+
self.line_ending = vim_ff_to_helix_line_ending(val);
87+
}
88+
}
89+
"noet" | "noexpandtab" => {
90+
self.indent_style = Some(IndentStyle::Tabs);
91+
}
92+
_ => {}
93+
}
94+
}
95+
}
96+
}
97+
}
98+
99+
fn vim_ff_to_helix_line_ending(val: &str) -> Option<LineEnding> {
100+
match val {
101+
"dos" => Some(LineEnding::Crlf),
102+
"unix" => Some(LineEnding::LF),
103+
#[cfg(feature = "unicode-lines")]
104+
"mac" => Some(LineEnding::CR),
105+
_ => None,
106+
}
107+
}
108+
109+
// if vim and helix use different names for a language, put that mapping here
110+
fn vim_ft_to_helix_language(val: &str) -> String {
111+
match val {
112+
"sh" | "zsh" => "bash",
113+
"gitattributes" => "git-attributes",
114+
"gitcommit" => "git-commit",
115+
"gitconfig" => "git-config",
116+
"gitignore" => "git-ignore",
117+
"gitrebase" => "git-rebase",
118+
_ => val,
119+
}
120+
.to_string()
121+
}
122+
123+
#[cfg(test)]
124+
mod test {
125+
use super::*;
126+
127+
#[test]
128+
fn test_modeline_parsing() {
129+
let tests = [
130+
(
131+
"vi:noai:sw=3 ts=6",
132+
Modeline {
133+
indent_style: Some(IndentStyle::Spaces(3)),
134+
..Default::default()
135+
},
136+
),
137+
(
138+
"vim: tw=77",
139+
Modeline {
140+
..Default::default()
141+
},
142+
),
143+
(
144+
"/* vim: set ai sw=5: */",
145+
Modeline {
146+
indent_style: Some(IndentStyle::Spaces(5)),
147+
..Default::default()
148+
},
149+
),
150+
(
151+
"# vim: set noexpandtab:",
152+
Modeline {
153+
indent_style: Some(IndentStyle::Tabs),
154+
..Default::default()
155+
},
156+
),
157+
(
158+
"// vim: noai:ts=4:sw=4",
159+
Modeline {
160+
indent_style: Some(IndentStyle::Spaces(4)),
161+
..Default::default()
162+
},
163+
),
164+
(
165+
"/* vim: set noai ts=4 sw=4: */",
166+
Modeline {
167+
indent_style: Some(IndentStyle::Spaces(4)),
168+
..Default::default()
169+
},
170+
),
171+
(
172+
"/* vim: set fdm=expr ft=c fde=getline(v\\:lnum)=~'{'?'>1'\\:'1' sw=4: */",
173+
Modeline {
174+
language: Some("c".to_string()),
175+
indent_style: Some(IndentStyle::Spaces(4)),
176+
..Default::default()
177+
},
178+
),
179+
(
180+
"/* vim: set ts=8 sw=4 tw=0 noet : */",
181+
Modeline {
182+
indent_style: Some(IndentStyle::Tabs),
183+
..Default::default()
184+
},
185+
),
186+
(
187+
"vim:ff=unix ts=4 sw=4",
188+
Modeline {
189+
indent_style: Some(IndentStyle::Spaces(4)),
190+
line_ending: Some(LineEnding::LF),
191+
..Default::default()
192+
},
193+
),
194+
(
195+
"vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap:",
196+
Modeline {
197+
language: Some("help".to_string()),
198+
indent_style: Some(IndentStyle::Spaces(2)),
199+
..Default::default()
200+
},
201+
),
202+
(
203+
"# vim: ft=zsh sw=2 ts=2 et",
204+
Modeline {
205+
language: Some("bash".to_string()),
206+
indent_style: Some(IndentStyle::Spaces(2)),
207+
..Default::default()
208+
},
209+
),
210+
(
211+
"# vim:ft=sh:",
212+
Modeline {
213+
language: Some("bash".to_string()),
214+
..Default::default()
215+
},
216+
),
217+
(
218+
"\" vim:ts=8:sts=4:sw=4:expandtab:ft=vim",
219+
Modeline {
220+
language: Some("vim".to_string()),
221+
indent_style: Some(IndentStyle::Spaces(4)),
222+
..Default::default()
223+
},
224+
),
225+
(
226+
"\" vim: ts=8 noet tw=100 sw=8 sts=0 ft=vim isk+=-",
227+
Modeline {
228+
language: Some("vim".to_string()),
229+
indent_style: Some(IndentStyle::Tabs),
230+
..Default::default()
231+
},
232+
),
233+
(
234+
"; vim:ft=gitconfig:",
235+
Modeline {
236+
language: Some("git-config".to_string()),
237+
..Default::default()
238+
},
239+
),
240+
];
241+
for (line, expected) in tests {
242+
let mut got = Modeline::default();
243+
got.parse_from_line(line);
244+
assert_eq!(got, expected);
245+
}
246+
}
247+
}

helix-view/src/document.rs

+28-9
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use helix_core::{
2929
history::{History, State, UndoKind},
3030
indent::{auto_detect_indent_style, IndentStyle},
3131
line_ending::auto_detect_line_ending,
32+
modeline::Modeline,
3233
syntax::{self, LanguageConfiguration},
3334
ChangeSet, Diagnostic, LineEnding, Range, Rope, RopeBuilder, Selection, Syntax, Transaction,
3435
};
@@ -187,6 +188,8 @@ pub struct Document {
187188
pub focused_at: std::time::Instant,
188189

189190
pub readonly: bool,
191+
192+
modeline: Modeline,
190193
}
191194

192195
/// Inlay hints for a single `(Document, View)` combo.
@@ -646,6 +649,7 @@ impl Document {
646649
let line_ending = config.load().default_line_ending.into();
647650
let changes = ChangeSet::new(text.slice(..));
648651
let old_state = None;
652+
let modeline = Modeline::parse(text.slice(..));
649653

650654
Self {
651655
id: DocumentId::default(),
@@ -676,6 +680,7 @@ impl Document {
676680
version_control_head: None,
677681
focused_at: std::time::Instant::now(),
678682
readonly: false,
683+
modeline,
679684
}
680685
}
681686

@@ -939,21 +944,35 @@ impl Document {
939944
&self,
940945
config_loader: &syntax::Loader,
941946
) -> Option<Arc<helix_core::syntax::LanguageConfiguration>> {
942-
config_loader
943-
.language_config_for_file_name(self.path.as_ref()?)
944-
.or_else(|| config_loader.language_config_for_shebang(self.text().slice(..)))
947+
self.modeline
948+
.language()
949+
.and_then(|language| config_loader.language_config_for_language_id(language))
950+
.or_else(|| {
951+
config_loader
952+
.language_config_for_file_name(self.path.as_ref()?)
953+
.or_else(|| config_loader.language_config_for_shebang(self.text().slice(..)))
954+
})
945955
}
946956

947957
/// Detect the indentation used in the file, or otherwise defaults to the language indentation
948958
/// configured in `languages.toml`, with a fallback to tabs if it isn't specified. Line ending
949959
/// is likewise auto-detected, and will remain unchanged if no line endings were detected.
950960
pub fn detect_indent_and_line_ending(&mut self) {
951-
self.indent_style = auto_detect_indent_style(&self.text).unwrap_or_else(|| {
952-
self.language_config()
953-
.and_then(|config| config.indent.as_ref())
954-
.map_or(DEFAULT_INDENT, |config| IndentStyle::from_str(&config.unit))
955-
});
956-
if let Some(line_ending) = auto_detect_line_ending(&self.text) {
961+
self.indent_style = self
962+
.modeline
963+
.indent_style()
964+
.or_else(|| auto_detect_indent_style(&self.text))
965+
.unwrap_or_else(|| {
966+
self.language_config()
967+
.and_then(|config| config.indent.as_ref())
968+
.map_or(DEFAULT_INDENT, |config| IndentStyle::from_str(&config.unit))
969+
});
970+
971+
if let Some(line_ending) = self
972+
.modeline
973+
.line_ending()
974+
.or_else(|| auto_detect_line_ending(&self.text))
975+
{
957976
self.line_ending = line_ending;
958977
}
959978
}

0 commit comments

Comments
 (0)