Skip to content

Commit 4f2ae58

Browse files
committed
add basic modeline support
currently only supports setting language, indent style, and line endings
1 parent 13e7eda commit 4f2ae58

File tree

3 files changed

+287
-9
lines changed

3 files changed

+287
-9
lines changed

helix-view/src/document.rs

+28-9
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use helix_core::{
3434
};
3535

3636
use crate::editor::{Config, RedrawHandle};
37+
use crate::modeline::Modeline;
3738
use crate::{DocumentId, Editor, Theme, View, ViewId};
3839

3940
/// 8kB of buffer space for encoding and decoding `Rope`s.
@@ -185,6 +186,8 @@ pub struct Document {
185186

186187
// when document was used for most-recent-used buffer picker
187188
pub focused_at: std::time::Instant,
189+
190+
modeline: Modeline,
188191
}
189192

190193
/// Inlay hints for a single `(Document, View)` combo.
@@ -644,6 +647,7 @@ impl Document {
644647
let line_ending = config.load().default_line_ending.into();
645648
let changes = ChangeSet::new(text.slice(..));
646649
let old_state = None;
650+
let modeline = Modeline::parse(&text);
647651

648652
Self {
649653
id: DocumentId::default(),
@@ -673,6 +677,7 @@ impl Document {
673677
config,
674678
version_control_head: None,
675679
focused_at: std::time::Instant::now(),
680+
modeline,
676681
}
677682
}
678683

@@ -936,21 +941,35 @@ impl Document {
936941
&self,
937942
config_loader: &syntax::Loader,
938943
) -> Option<Arc<helix_core::syntax::LanguageConfiguration>> {
939-
config_loader
940-
.language_config_for_file_name(self.path.as_ref()?)
941-
.or_else(|| config_loader.language_config_for_shebang(self.text().slice(..)))
944+
self.modeline
945+
.language()
946+
.and_then(|language| config_loader.language_config_for_language_id(language))
947+
.or_else(|| {
948+
config_loader
949+
.language_config_for_file_name(self.path.as_ref()?)
950+
.or_else(|| config_loader.language_config_for_shebang(self.text().slice(..)))
951+
})
942952
}
943953

944954
/// Detect the indentation used in the file, or otherwise defaults to the language indentation
945955
/// configured in `languages.toml`, with a fallback to tabs if it isn't specified. Line ending
946956
/// is likewise auto-detected, and will remain unchanged if no line endings were detected.
947957
pub fn detect_indent_and_line_ending(&mut self) {
948-
self.indent_style = auto_detect_indent_style(&self.text).unwrap_or_else(|| {
949-
self.language_config()
950-
.and_then(|config| config.indent.as_ref())
951-
.map_or(DEFAULT_INDENT, |config| IndentStyle::from_str(&config.unit))
952-
});
953-
if let Some(line_ending) = auto_detect_line_ending(&self.text) {
958+
self.indent_style = self
959+
.modeline
960+
.indent_style()
961+
.or_else(|| auto_detect_indent_style(&self.text))
962+
.unwrap_or_else(|| {
963+
self.language_config()
964+
.and_then(|config| config.indent.as_ref())
965+
.map_or(DEFAULT_INDENT, |config| IndentStyle::from_str(&config.unit))
966+
});
967+
968+
if let Some(line_ending) = self
969+
.modeline
970+
.line_ending()
971+
.or_else(|| auto_detect_line_ending(&self.text))
972+
{
954973
self.line_ending = line_ending;
955974
}
956975
}

helix-view/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod base64;
1515
pub mod info;
1616
pub mod input;
1717
pub mod keyboard;
18+
pub mod modeline;
1819
pub mod theme;
1920
pub mod tree;
2021
pub mod view;

helix-view/src/modeline.rs

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

0 commit comments

Comments
 (0)