on master help sign in
Capture word parts while calculating shellwords (#4632)
c6b8336Michael Davis
helix-core/src/shellwords.rs+171−160
@@ -27,181 +27,172 @@ enum State {
27 DquoteEscaped,
27 DquoteEscaped,
28}
28}
29
29
30−/// Get the vec of escaped / quoted / doublequoted filenames from the input str
30+pub struct Shellwords<'a> {
31−pub fn shellwords(input: &str) -> Vec<Cow<'_, str>> {
32− use State::*;
31+ state: State,
32+ /// Shellwords where whitespace and escapes has been resolved.
33+ words: Vec<Cow<'a, str>>,
34+ /// The parts of the input that are divided into shellwords. This can be
35+ /// used to retrieve the original text for a given word by looking up the
36+ /// same index in the Vec as the word in `words`.
37+ parts: Vec<&'a str>,
38+}
33
39
34− let mut state = Unquoted;
40+impl<'a> From<&'a str> for Shellwords<'a> {
35− let mut args: Vec<Cow<str>> = Vec::new();
41+ fn from(input: &'a str) -> Self {
36− let mut escaped = String::with_capacity(input.len());
42+ use State::*;
37
43
38− let mut start = 0;
44+ let mut state = Unquoted;
39− let mut end = 0;
45+ let mut words = Vec::new();
46+ let mut parts = Vec::new();
47+ let mut escaped = String::with_capacity(input.len());
40
48
49+ let mut part_start = 0;
50+ let mut unescaped_start = 0;
51+ let mut end = 0;
52+
41− for (i, c) in input.char_indices() {
53+ for (i, c) in input.char_indices() {
42− state = match state {
54+ state = match state {
43− OnWhitespace => match c {
55+ OnWhitespace => match c {
44− '"' => {
56+ '"' => {
45− end = i;
57+ end = i;
46− Dquoted
58+ Dquoted
47− }
59+ }
48− '\'' => {
60+ '\'' => {
49− end = i;
61+ end = i;
50− Quoted
62+ Quoted
51− }
63+ }
52− '\\' => {
64+ '\\' => {
53− if cfg!(unix) {
65+ if cfg!(unix) {
54− escaped.push_str(&input[start..i]);
66+ escaped.push_str(&input[unescaped_start..i]);
55− start = i + 1;
67+ unescaped_start = i + 1;
56− UnquotedEscaped
68+ UnquotedEscaped
57− } else {
69+ } else {
70+ OnWhitespace
71+ }
72+ }
73+ c if c.is_ascii_whitespace() => {
74+ end = i;
58 OnWhitespace
75 OnWhitespace
59 }
76 }
60− }
61− c if c.is_ascii_whitespace() => {
62− end = i;
63− OnWhitespace
64− }
65− _ => Unquoted,
77+ _ => Unquoted,
66− },
78+ },
67− Unquoted => match c {
79+ Unquoted => match c {
68− '\\' => {
80+ '\\' => {
69− if cfg!(unix) {
81+ if cfg!(unix) {
70− escaped.push_str(&input[start..i]);
82+ escaped.push_str(&input[unescaped_start..i]);
71− start = i + 1;
83+ unescaped_start = i + 1;
72− UnquotedEscaped
84+ UnquotedEscaped
73− } else {
85+ } else {
74− Unquoted
86+ Unquoted
87+ }
75 }
88 }
76− }
77− c if c.is_ascii_whitespace() => {
89+ c if c.is_ascii_whitespace() => {
78− end = i;
90+ end = i;
79− OnWhitespace
91+ OnWhitespace
80− }
81− _ => Unquoted,
82− },
83− UnquotedEscaped => Unquoted,
84− Quoted => match c {
85− '\\' => {
86− if cfg!(unix) {
87− escaped.push_str(&input[start..i]);
88− start = i + 1;
89− QuoteEscaped
90− } else {
91− Quoted
92 }
92 }
93− }
93+ _ => Unquoted,
94− '\'' => {
95− end = i;
96− OnWhitespace
97− }
98− _ => Quoted,
99− },
94+ },
100− QuoteEscaped => Quoted,
95+ UnquotedEscaped => Unquoted,
101− Dquoted => match c {
96+ Quoted => match c {
102− '\\' => {
97+ '\\' => {
103− if cfg!(unix) {
98+ if cfg!(unix) {
104− escaped.push_str(&input[start..i]);
99+ escaped.push_str(&input[unescaped_start..i]);
105− start = i + 1;
100+ unescaped_start = i + 1;
106− DquoteEscaped
101+ QuoteEscaped
107− } else {
102+ } else {
108− Dquoted
103+ Quoted
104+ }
109 }
105 }
106+ '\'' => {
107+ end = i;
108+ OnWhitespace
109+ }
110+ _ => Quoted,
110− }
111+ },
112+ QuoteEscaped => Quoted,
113+ Dquoted => match c {
114+ '\\' => {
115+ if cfg!(unix) {
116+ escaped.push_str(&input[unescaped_start..i]);
117+ unescaped_start = i + 1;
118+ DquoteEscaped
119+ } else {
120+ Dquoted
121+ }
122+ }
111− '"' => {
123+ '"' => {
112− end = i;
124+ end = i;
113− OnWhitespace
125+ OnWhitespace
114− }
126+ }
115− _ => Dquoted,
127+ _ => Dquoted,
116− },
128+ },
117− DquoteEscaped => Dquoted,
129+ DquoteEscaped => Dquoted,
118− };
130+ };
119
131
120− if i >= input.len() - 1 && end == 0 {
132+ if i >= input.len() - 1 && end == 0 {
121− end = i + 1;
133+ end = i + 1;
122− }
134+ }
123
135
124− if end > 0 {
136+ if end > 0 {
125− let esc_trim = escaped.trim();
137+ let esc_trim = escaped.trim();
126− let inp = &input[start..end];
138+ let inp = &input[unescaped_start..end];
127
139
128− if !(esc_trim.is_empty() && inp.trim().is_empty()) {
140+ if !(esc_trim.is_empty() && inp.trim().is_empty()) {
129− if esc_trim.is_empty() {
141+ if esc_trim.is_empty() {
130− args.push(inp.into());
142+ words.push(inp.into());
143+ parts.push(inp);
131− } else {
144+ } else {
132− args.push([escaped, inp.into()].concat().into());
145+ words.push([escaped, inp.into()].concat().into());
146+ parts.push(&input[part_start..end]);
133− escaped = "".to_string();
147+ escaped = "".to_string();
148+ }
134 }
149 }
150+ unescaped_start = i + 1;
151+ part_start = i + 1;
152+ end = 0;
135 }
153 }
136− start = i + 1;
137− end = 0;
138 }
154 }
139− }
140− args
141−}
142
155
143−/// Checks that the input ends with an ascii whitespace character which is
156+ debug_assert!(words.len() == parts.len());
144−/// not escaped.
145−///
146−/// # Examples
147−///
148−/// ```rust
149−/// use helix_core::shellwords::ends_with_whitespace;
150−/// assert_eq!(ends_with_whitespace(" "), true);
151−/// assert_eq!(ends_with_whitespace(":open "), true);
152−/// assert_eq!(ends_with_whitespace(":open foo.txt "), true);
153−/// assert_eq!(ends_with_whitespace(":open"), false);
154−/// #[cfg(unix)]
155−/// assert_eq!(ends_with_whitespace(":open a\\ "), false);
156−/// #[cfg(unix)]
157−/// assert_eq!(ends_with_whitespace(":open a\\ b.txt"), false);
158−/// ```
159−pub fn ends_with_whitespace(input: &str) -> bool {
160− use State::*;
161
157
162− // Fast-lane: the input must end with a whitespace character
158+ Self {
163− // regardless of quoting.
159+ state,
164− if !input.ends_with(|c: char| c.is_ascii_whitespace()) {
160+ words,
165− return false;
161+ parts,
162+ }
166 }
163 }
164+}
167
165
168− let mut state = Unquoted;
166+impl<'a> Shellwords<'a> {
167+ /// Checks that the input ends with a whitespace character which is not escaped.
168+ ///
169+ /// # Examples
170+ ///
171+ /// ```rust
172+ /// use helix_core::shellwords::Shellwords;
173+ /// assert_eq!(Shellwords::from(" ").ends_with_whitespace(), true);
174+ /// assert_eq!(Shellwords::from(":open ").ends_with_whitespace(), true);
175+ /// assert_eq!(Shellwords::from(":open foo.txt ").ends_with_whitespace(), true);
176+ /// assert_eq!(Shellwords::from(":open").ends_with_whitespace(), false);
177+ /// #[cfg(unix)]
178+ /// assert_eq!(Shellwords::from(":open a\\ ").ends_with_whitespace(), false);
179+ /// #[cfg(unix)]
180+ /// assert_eq!(Shellwords::from(":open a\\ b.txt").ends_with_whitespace(), false);
181+ /// ```
182+ pub fn ends_with_whitespace(&self) -> bool {
183+ matches!(self.state, State::OnWhitespace)
184+ }
169
185
170− for c in input.chars() {
186+ /// Returns the list of shellwords calculated from the input string.
171− state = match state {
187+ pub fn words(&self) -> &[Cow<'a, str>] {
172− OnWhitespace => match c {
188+ &self.words
173− '"' => Dquoted,
174− '\'' => Quoted,
175− '\\' if cfg!(unix) => UnquotedEscaped,
176− '\\' => OnWhitespace,
177− c if c.is_ascii_whitespace() => OnWhitespace,
178− _ => Unquoted,
179− },
180− Unquoted => match c {
181− '\\' if cfg!(unix) => UnquotedEscaped,
182− '\\' => Unquoted,
183− c if c.is_ascii_whitespace() => OnWhitespace,
184− _ => Unquoted,
185− },
186− UnquotedEscaped => Unquoted,
187− Quoted => match c {
188− '\\' if cfg!(unix) => QuoteEscaped,
189− '\\' => Quoted,
190− '\'' => OnWhitespace,
191− _ => Quoted,
192− },
193− QuoteEscaped => Quoted,
194− Dquoted => match c {
195− '\\' if cfg!(unix) => DquoteEscaped,
196− '\\' => Dquoted,
197− '"' => OnWhitespace,
198− _ => Dquoted,
199− },
200− DquoteEscaped => Dquoted,
201− }
202 }
189 }
203
190
204− matches!(state, OnWhitespace)
191+ /// Returns a list of strings which correspond to [`Self::words`] but represent the original
192+ /// text in the input string - including escape characters - without separating whitespace.
193+ pub fn parts(&self) -> &[&'a str] {
194+ &self.parts
195+ }
205}
196}
206
197
207#[cfg(test)]
198#[cfg(test)]
@@ -212,7 +203,8 @@ mod test {
212 #[cfg(windows)]
203 #[cfg(windows)]
213 fn test_normal() {
204 fn test_normal() {
214 let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
205 let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
215− let result = shellwords(input);
206+ let shellwords = Shellwords::from(input);
207+ let result = shellwords.words().to_vec();
216 let expected = vec![
208 let expected = vec![
217 Cow::from(":o"),
209 Cow::from(":o"),
218 Cow::from("single_word"),
210 Cow::from("single_word"),
@@ -230,7 +222,8 @@ mod test {
230 #[cfg(unix)]
222 #[cfg(unix)]
231 fn test_normal() {
223 fn test_normal() {
232 let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
224 let input = r#":o single_word twó wörds \three\ \"with\ escaping\\"#;
233− let result = shellwords(input);
225+ let shellwords = Shellwords::from(input);
226+ let result = shellwords.words().to_vec();
234 let expected = vec![
227 let expected = vec![
235 Cow::from(":o"),
228 Cow::from(":o"),
236 Cow::from("single_word"),
229 Cow::from("single_word"),
@@ -247,7 +240,8 @@ mod test {
247 fn test_quoted() {
240 fn test_quoted() {
248 let quoted =
241 let quoted =
249 r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#;
242 r#":o 'single_word' 'twó wörds' '' ' ''\three\' \"with\ escaping\\' 'quote incomplete"#;
250− let result = shellwords(quoted);
243+ let shellwords = Shellwords::from(quoted);
244+ let result = shellwords.words().to_vec();
251 let expected = vec![
245 let expected = vec![
252 Cow::from(":o"),
246 Cow::from(":o"),
253 Cow::from("single_word"),
247 Cow::from("single_word"),
@@ -262,7 +256,8 @@ mod test {
262 #[cfg(unix)]
256 #[cfg(unix)]
263 fn test_dquoted() {
257 fn test_dquoted() {
264 let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#;
258 let dquoted = r#":o "single_word" "twó wörds" "" " ""\three\' \"with\ escaping\\" "dquote incomplete"#;
265− let result = shellwords(dquoted);
259+ let shellwords = Shellwords::from(dquoted);
260+ let result = shellwords.words().to_vec();
266 let expected = vec![
261 let expected = vec![
267 Cow::from(":o"),
262 Cow::from(":o"),
268 Cow::from("single_word"),
263 Cow::from("single_word"),
@@ -277,7 +272,8 @@ mod test {
277 #[cfg(unix)]
272 #[cfg(unix)]
278 fn test_mixed() {
273 fn test_mixed() {
279 let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#;
274 let dquoted = r#":o single_word 'twó wörds' "\three\' \"with\ escaping\\""no space before"'and after' $#%^@ "%^&(%^" ')(*&^%''a\\\\\b' '"#;
280− let result = shellwords(dquoted);
275+ let shellwords = Shellwords::from(dquoted);
276+ let result = shellwords.words().to_vec();
281 let expected = vec![
277 let expected = vec![
282 Cow::from(":o"),
278 Cow::from(":o"),
283 Cow::from("single_word"),
279 Cow::from("single_word"),
@@ -298,7 +294,8 @@ mod test {
298 fn test_lists() {
294 fn test_lists() {
299 let input =
295 let input =
300 r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#;
296 r#":set statusline.center ["file-type","file-encoding"] '["list", "in", "qoutes"]'"#;
301− let result = shellwords(input);
297+ let shellwords = Shellwords::from(input);
298+ let result = shellwords.words().to_vec();
302 let expected = vec![
299 let expected = vec![
303 Cow::from(":set"),
300 Cow::from(":set"),
304 Cow::from("statusline.center"),
301 Cow::from("statusline.center"),
@@ -322,4 +319,18 @@ mod test {
322 assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
319 assert_eq!(escape("foobar".into()), Cow::Borrowed("foobar"));
323 assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\""));
320 assert_eq!(escape("foo bar".into()), Cow::Borrowed("\"foo bar\""));
324 }
321 }
322+
323+ #[test]
324+ #[cfg(unix)]
325+ fn test_parts() {
326+ assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
327+ assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\ "]);
328+ }
329+
330+ #[test]
331+ #[cfg(windows)]
332+ fn test_parts() {
333+ assert_eq!(Shellwords::from(":o a").parts(), &[":o", "a"]);
334+ assert_eq!(Shellwords::from(":o a\\ ").parts(), &[":o", "a\\"]);
335+ }
325}
336}
helix-term/src/commands/typed.rs+15−10
@@ -2253,7 +2253,10 @@ pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableComma
2253 .collect()
2253 .collect()
2254 });
2254 });
2255
2255
2256+#[allow(clippy::unnecessary_unwrap)]
2256pub(super) fn command_mode(cx: &mut Context) {
2257pub(super) fn command_mode(cx: &mut Context) {
2258+ use shellwords::Shellwords;
2259+
2257 let mut prompt = Prompt::new(
2260 let mut prompt = Prompt::new(
2258 ":".into(),
2261 ":".into(),
2259 Some(':'),
2262 Some(':'),
@@ -2261,10 +2264,10 @@ pub(super) fn command_mode(cx: &mut Context) {
2261 static FUZZY_MATCHER: Lazy<fuzzy_matcher::skim::SkimMatcherV2> =
2264 static FUZZY_MATCHER: Lazy<fuzzy_matcher::skim::SkimMatcherV2> =
2262 Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default);
2265 Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default);
2263
2266
2264− let parts = shellwords::shellwords(input);
2267+ let shellwords = Shellwords::from(input);
2265− let ends_with_whitespace = shellwords::ends_with_whitespace(input);
2268+ let words = shellwords.words();
2266
2269
2267− if parts.is_empty() || (parts.len() == 1 && !ends_with_whitespace) {
2270+ if words.is_empty() || (words.len() == 1 && !shellwords.ends_with_whitespace()) {
2268 // If the command has not been finished yet, complete commands.
2271 // If the command has not been finished yet, complete commands.
2269 let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST
2272 let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST
2270 .iter()
2273 .iter()
@@ -2283,19 +2286,20 @@ pub(super) fn command_mode(cx: &mut Context) {
2283 } else {
2286 } else {
2284 // Otherwise, use the command's completer and the last shellword
2287 // Otherwise, use the command's completer and the last shellword
2285 // as completion input.
2288 // as completion input.
2286− let part = if parts.len() == 1 {
2289+ let (part, part_len) = if words.len() == 1 || shellwords.ends_with_whitespace() {
2287− &Cow::Borrowed("")
2290+ (&Cow::Borrowed(""), 0)
2288 } else {
2291 } else {
2292+ (
2293+ words.last().unwrap(),
2289− parts.last().unwrap()
2294+ shellwords.parts().last().unwrap().len(),
2295+ )
2290 };
2296 };
2291
2297
2292 if let Some(typed::TypableCommand {
2298 if let Some(typed::TypableCommand {
2293 completer: Some(completer),
2299 completer: Some(completer),
2294 ..
2300 ..
2295− }) = typed::TYPABLE_COMMAND_MAP.get(&parts[0] as &str)
2301+ }) = typed::TYPABLE_COMMAND_MAP.get(&words[0] as &str)
2296 {
2302 {
2297− let part_len = shellwords::escape(part.clone()).len();
2298−
2299 completer(editor, part)
2303 completer(editor, part)
2300 .into_iter()
2304 .into_iter()
2301 .map(|(range, file)| {
2305 .map(|(range, file)| {
@@ -2328,7 +2332,8 @@ pub(super) fn command_mode(cx: &mut Context) {
2328
2332
2329 // Handle typable commands
2333 // Handle typable commands
2330 if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) {
2334 if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) {
2331− let args = shellwords::shellwords(input);
2335+ let shellwords = Shellwords::from(input);
2336+ let args = shellwords.words();
2332
2337
2333 if let Err(e) = (cmd.fun)(cx, &args[1..], event) {
2338 if let Err(e) = (cmd.fun)(cx, &args[1..], event) {
2334 cx.editor.set_error(format!("{}", e));
2339 cx.editor.set_error(format!("{}", e));