Skip to content

Commit 1fcd947

Browse files
doyomentic
authored andcommitted
Add basic modeline support
ref: helix-editor/helix#729 ref: helix-editor/helix#7788
1 parent fa3e03f commit 1fcd947

File tree

10 files changed

+427
-45
lines changed

10 files changed

+427
-45
lines changed

helix-core/src/indent.rs

+13
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,19 @@ impl IndentStyle {
4141
}
4242
}
4343

44+
#[inline]
45+
pub fn from_option_str(indent: &str) -> Option<Self> {
46+
match indent {
47+
arg if "tabs".starts_with(&arg.to_lowercase()) => Some(IndentStyle::Tabs),
48+
"0" => Some(IndentStyle::Tabs),
49+
arg => arg
50+
.parse::<u8>()
51+
.ok()
52+
.filter(|n| (1..=MAX_INDENT).contains(n))
53+
.map(IndentStyle::Spaces),
54+
}
55+
}
56+
4457
#[inline]
4558
pub fn as_str(&self) -> &'static str {
4659
match *self {

helix-core/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod indent;
1717
pub mod line_ending;
1818
pub mod macros;
1919
pub mod match_brackets;
20+
pub mod modeline;
2021
pub mod movement;
2122
pub mod object;
2223
mod position;

helix-core/src/line_ending.rs

+15
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,21 @@ impl LineEnding {
100100
}
101101
}
102102

103+
#[inline]
104+
pub fn from_option_str(arg: &str) -> Option<LineEnding> {
105+
match arg {
106+
arg if arg.starts_with("crlf") => Some(LineEnding::Crlf),
107+
arg if arg.starts_with("lf") => Some(LineEnding::LF),
108+
#[cfg(feature = "unicode-lines")]
109+
arg if arg.starts_with("cr") => Some(LineEnding::CR),
110+
#[cfg(feature = "unicode-lines")]
111+
arg if arg.starts_with("ff") => Some(LineEnding::FF),
112+
#[cfg(feature = "unicode-lines")]
113+
arg if arg.starts_with("nel") => Some(LineEnding::Nel),
114+
_ => None,
115+
}
116+
}
117+
103118
#[inline]
104119
pub fn from_rope_slice(g: &RopeSlice) -> Option<LineEnding> {
105120
if let Some(text) = g.as_str() {

helix-core/src/modeline.rs

+333
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
use std::borrow::Cow;
2+
3+
use helix_stdx::rope::{self, RopeSliceExt};
4+
use once_cell::sync::Lazy;
5+
6+
use crate::indent::IndentStyle;
7+
use crate::{LineEnding, RopeSlice};
8+
9+
// 5 is the vim default
10+
const LINES_TO_CHECK: usize = 5;
11+
12+
static HELIX_MODELINE_REGEX: Lazy<rope::Regex> =
13+
Lazy::new(|| rope::Regex::new(r"^(.{0,100}\s{1,100})?helix:").unwrap());
14+
static HELIX_MODELINE_OPTION_REGEX: Lazy<rope::Regex> =
15+
Lazy::new(|| rope::Regex::new(r"[a-zA-Z0-9_-]{1,100}(?:=[a-zA-Z0-9_-]{1,100})").unwrap());
16+
static VIM_MODELINE_REGEX: Lazy<rope::Regex> = Lazy::new(|| {
17+
rope::Regex::new(
18+
r"^(.{0,100}\s{1,100})?(vi|[vV]im[<=>]?\d{0,100}|ex):\s{0,100}(set?\s{1,100})?",
19+
)
20+
.unwrap()
21+
});
22+
static VIM_MODELINE_OPTION_REGEX: Lazy<rope::Regex> =
23+
Lazy::new(|| rope::Regex::new(r"[a-zA-Z0-9_-]{1,100}(?:=(?:\\:|[^:\s]){0,1000})?").unwrap());
24+
25+
#[derive(Default, Debug, Eq, PartialEq)]
26+
pub struct Modeline {
27+
language: Option<String>,
28+
indent_style: Option<IndentStyle>,
29+
line_ending: Option<LineEnding>,
30+
}
31+
32+
impl Modeline {
33+
pub fn parse(text: RopeSlice) -> Self {
34+
let mut modeline = Self::default();
35+
36+
for line in text.lines().take(LINES_TO_CHECK).chain(
37+
text.lines_at(text.len_lines())
38+
.reversed()
39+
.take(LINES_TO_CHECK),
40+
) {
41+
modeline.parse_from_line(line);
42+
}
43+
44+
modeline
45+
}
46+
47+
pub fn language(&self) -> Option<&str> {
48+
self.language.as_deref()
49+
}
50+
51+
pub fn indent_style(&self) -> Option<IndentStyle> {
52+
self.indent_style
53+
}
54+
55+
pub fn line_ending(&self) -> Option<LineEnding> {
56+
self.line_ending
57+
}
58+
59+
fn parse_from_line(&mut self, mut line: RopeSlice) {
60+
if let Some(pos) = HELIX_MODELINE_REGEX.find(line.regex_input()) {
61+
line = line.slice(line.byte_to_char(pos.end())..);
62+
while let Some(opt_pos) = HELIX_MODELINE_OPTION_REGEX.find(line.regex_input()) {
63+
let option =
64+
Cow::from(line.slice(
65+
line.byte_to_char(opt_pos.start())..line.byte_to_char(opt_pos.end()),
66+
));
67+
let mut parts = option.as_ref().split('=');
68+
match parts.next().unwrap() {
69+
"set-language" | "lang" => {
70+
if let Some(val) = parts.next() {
71+
self.language = Some(val.to_string());
72+
}
73+
}
74+
"indent-style" => {
75+
if let Some(val) = parts.next() {
76+
if let Some(indent_style) = IndentStyle::from_option_str(val) {
77+
self.indent_style = Some(indent_style);
78+
}
79+
}
80+
}
81+
"line-ending" => {
82+
if let Some(val) = parts.next() {
83+
if let Some(line_ending) = LineEnding::from_option_str(val) {
84+
self.line_ending = Some(line_ending);
85+
}
86+
}
87+
}
88+
_ => {}
89+
}
90+
line = line.slice(line.byte_to_char(opt_pos.end())..);
91+
let whitespace = line.chars().take_while(|c| char::is_whitespace(*c)).count();
92+
line = line.slice(whitespace..);
93+
}
94+
} else if let Some(pos) = VIM_MODELINE_REGEX.find(line.regex_input()) {
95+
line = line.slice(line.byte_to_char(pos.end())..);
96+
while let Some(opt_pos) = VIM_MODELINE_OPTION_REGEX.find(line.regex_input()) {
97+
let option =
98+
Cow::from(line.slice(
99+
line.byte_to_char(opt_pos.start())..line.byte_to_char(opt_pos.end()),
100+
));
101+
let mut parts = option.as_ref().splitn(2, '=');
102+
match parts.next().unwrap() {
103+
"ft" | "filetype" => {
104+
if let Some(val) = parts.next() {
105+
self.language = Some(val.to_string());
106+
}
107+
}
108+
"sw" | "shiftwidth" => {
109+
if let Some(val) = parts.next().and_then(|val| val.parse().ok()) {
110+
if self.indent_style != Some(IndentStyle::Tabs) {
111+
self.indent_style = Some(IndentStyle::Spaces(val));
112+
}
113+
}
114+
}
115+
"ff" | "fileformat" => {
116+
if let Some(val) = parts.next() {
117+
self.line_ending = vim_ff_to_helix_line_ending(val);
118+
}
119+
}
120+
"noet" | "noexpandtab" => {
121+
self.indent_style = Some(IndentStyle::Tabs);
122+
}
123+
"et" | "expandtab" => {
124+
if !matches!(self.indent_style, Some(IndentStyle::Spaces(_))) {
125+
self.indent_style = Some(IndentStyle::Spaces(0));
126+
}
127+
}
128+
_ => {}
129+
}
130+
line = line.slice(line.byte_to_char(opt_pos.end())..);
131+
let whitespace = line
132+
.chars()
133+
.take_while(|c| char::is_whitespace(*c) || *c == ':')
134+
.count();
135+
line = line.slice(whitespace..);
136+
}
137+
}
138+
}
139+
}
140+
141+
fn vim_ff_to_helix_line_ending(val: &str) -> Option<LineEnding> {
142+
match val {
143+
"dos" => Some(LineEnding::Crlf),
144+
"unix" => Some(LineEnding::LF),
145+
#[cfg(feature = "unicode-lines")]
146+
"mac" => Some(LineEnding::CR),
147+
_ => None,
148+
}
149+
}
150+
151+
#[cfg(test)]
152+
mod test {
153+
use super::*;
154+
155+
#[test]
156+
fn test_modeline_parsing() {
157+
let tests = [
158+
(
159+
"# helix: set-language=perl",
160+
Modeline {
161+
language: Some("perl".to_string()),
162+
..Default::default()
163+
},
164+
),
165+
(
166+
"# helix: lang=perl",
167+
Modeline {
168+
language: Some("perl".to_string()),
169+
..Default::default()
170+
},
171+
),
172+
(
173+
"# helix: indent-style=3",
174+
Modeline {
175+
indent_style: Some(IndentStyle::Spaces(3)),
176+
..Default::default()
177+
},
178+
),
179+
(
180+
"# helix: indent-style=t",
181+
Modeline {
182+
indent_style: Some(IndentStyle::Tabs),
183+
..Default::default()
184+
},
185+
),
186+
(
187+
"# helix: line-ending=crlf",
188+
Modeline {
189+
line_ending: Some(LineEnding::Crlf),
190+
..Default::default()
191+
},
192+
),
193+
(
194+
"# helix: lang=perl indent-style=t line-ending=crlf",
195+
Modeline {
196+
language: Some("perl".to_string()),
197+
indent_style: Some(IndentStyle::Tabs),
198+
line_ending: Some(LineEnding::Crlf),
199+
},
200+
),
201+
(
202+
"#//-- helix: lang=perl indent-style=t line-ending=crlf",
203+
Modeline {
204+
language: Some("perl".to_string()),
205+
indent_style: Some(IndentStyle::Tabs),
206+
line_ending: Some(LineEnding::Crlf),
207+
},
208+
),
209+
(
210+
"vi:noai:sw=3 ts=6",
211+
Modeline {
212+
indent_style: Some(IndentStyle::Spaces(3)),
213+
..Default::default()
214+
},
215+
),
216+
(
217+
"vim: tw=77",
218+
Modeline {
219+
..Default::default()
220+
},
221+
),
222+
(
223+
"/* vim: set ai sw=5: */",
224+
Modeline {
225+
indent_style: Some(IndentStyle::Spaces(5)),
226+
..Default::default()
227+
},
228+
),
229+
(
230+
"# vim: set noexpandtab:",
231+
Modeline {
232+
indent_style: Some(IndentStyle::Tabs),
233+
..Default::default()
234+
},
235+
),
236+
(
237+
"# vim: set expandtab:",
238+
Modeline {
239+
indent_style: Some(IndentStyle::Spaces(0)),
240+
..Default::default()
241+
},
242+
),
243+
(
244+
"// vim: noai:ts=4:sw=4",
245+
Modeline {
246+
indent_style: Some(IndentStyle::Spaces(4)),
247+
..Default::default()
248+
},
249+
),
250+
(
251+
"/* vim: set noai ts=4 sw=4: */",
252+
Modeline {
253+
indent_style: Some(IndentStyle::Spaces(4)),
254+
..Default::default()
255+
},
256+
),
257+
(
258+
"/* vim: set fdm=expr ft=c fde=getline(v\\:lnum)=~'{'?'>1'\\:'1' sw=4: */",
259+
Modeline {
260+
language: Some("c".to_string()),
261+
indent_style: Some(IndentStyle::Spaces(4)),
262+
..Default::default()
263+
},
264+
),
265+
(
266+
"/* vim: set ts=8 sw=4 tw=0 noet : */",
267+
Modeline {
268+
indent_style: Some(IndentStyle::Tabs),
269+
..Default::default()
270+
},
271+
),
272+
(
273+
"vim:ff=unix ts=4 sw=4",
274+
Modeline {
275+
indent_style: Some(IndentStyle::Spaces(4)),
276+
line_ending: Some(LineEnding::LF),
277+
..Default::default()
278+
},
279+
),
280+
(
281+
"vim:tw=78:sw=2:ts=2:ft=help:norl:nowrap:",
282+
Modeline {
283+
language: Some("help".to_string()),
284+
indent_style: Some(IndentStyle::Spaces(2)),
285+
..Default::default()
286+
},
287+
),
288+
(
289+
"# vim: ft=zsh sw=2 ts=2 et",
290+
Modeline {
291+
language: Some("zsh".to_string()),
292+
indent_style: Some(IndentStyle::Spaces(2)),
293+
..Default::default()
294+
},
295+
),
296+
(
297+
"# vim:ft=sh:",
298+
Modeline {
299+
language: Some("sh".to_string()),
300+
..Default::default()
301+
},
302+
),
303+
(
304+
"\" vim:ts=8:sts=4:sw=4:expandtab:ft=vim",
305+
Modeline {
306+
language: Some("vim".to_string()),
307+
indent_style: Some(IndentStyle::Spaces(4)),
308+
..Default::default()
309+
},
310+
),
311+
(
312+
"\" vim: ts=8 noet tw=100 sw=8 sts=0 ft=vim isk+=-",
313+
Modeline {
314+
language: Some("vim".to_string()),
315+
indent_style: Some(IndentStyle::Tabs),
316+
..Default::default()
317+
},
318+
),
319+
(
320+
"; vim:ft=gitconfig:",
321+
Modeline {
322+
language: Some("gitconfig".to_string()),
323+
..Default::default()
324+
},
325+
),
326+
];
327+
for (line, expected) in tests {
328+
let mut got = Modeline::default();
329+
got.parse_from_line(line.into());
330+
assert_eq!(got, expected, "{line}");
331+
}
332+
}
333+
}

0 commit comments

Comments
 (0)