Skip to main content

weavatrix_parse/
shell.rs

1//! Structural extraction for shell scripts.
2//!
3//! Shell is where a repository keeps the things nothing else records. A CI job
4//! or a deploy script sources its helpers, invokes its siblings, and calls
5//! services by their address - so a script is often the only place an endpoint
6//! is written down at all. No other extractor here can see any of that, and
7//! most tools do not read shell at all.
8//!
9//! Words are rebuilt from the token stream by byte adjacency rather than by
10//! splitting the line, because that is what keeps a `#` inside a string from
11//! ending the line and a quoted URL in one piece.
12
13use crate::facts::{Declaration, DeclarationKind, Facts, Import, Reference, ReferenceKind, Span};
14use crate::syntax::Language;
15use crate::token::{Mode, Token, TokenKind, Tokenizer};
16
17/// Extracts structural facts from one shell script.
18#[must_use]
19pub fn extract(source: &str) -> Facts {
20    let tokens = Tokenizer::new(source, Language::Bash)
21        .mode(Mode::Lite)
22        .collect::<Vec<_>>();
23    let mut state = Extractor {
24        source,
25        tokens: &tokens,
26        facts: Facts::default(),
27        function: None,
28        depth: 0,
29    };
30    state.run();
31    state.facts
32}
33
34/// Commands that address a service, and so name an endpoint.
35const CLIENTS: &[&str] = &[
36    "curl", "wget", "http", "https", "xh", "httpie", "nc", "ncat", "grpcurl", "ab", "hey", "siege",
37    "wrk",
38];
39
40/// Commands whose first argument is another script to run.
41const RUNNERS: &[&str] = &["source", ".", "bash", "sh", "zsh", "ksh"];
42
43/// Words that begin a statement, so the word after one is a command.
44const KEYWORDS: &[&str] = &[
45    "then", "do", "else", "elif", "fi", "done", "if", "while", "until", "for", "case", "esac",
46    "in", "function", "return", "local", "export", "declare", "readonly", "eval", "exec", "time",
47];
48
49struct Extractor<'source, 'tokens> {
50    source: &'source str,
51    tokens: &'tokens [Token],
52    facts: Facts,
53    /// The function whose body the walk is inside.
54    function: Option<(String, i32)>,
55    depth: i32,
56}
57
58impl Extractor<'_, '_> {
59    fn run(&mut self) {
60        let mut index = 0;
61        while index < self.tokens.len() {
62            index = self.step(index);
63        }
64    }
65
66    fn text(&self, index: usize) -> &str {
67        self.tokens
68            .get(index)
69            .map_or("", |token| token.text(self.source))
70    }
71
72    fn kind(&self, index: usize) -> Option<TokenKind> {
73        self.tokens.get(index).map(|token| token.kind)
74    }
75
76    fn punct(&self, index: usize, mark: &str) -> bool {
77        self.kind(index) == Some(TokenKind::Punctuation) && self.text(index) == mark
78    }
79
80    fn span(&self, start: usize, end: usize) -> Span {
81        let last_index = self.tokens.len().saturating_sub(1);
82        let first = &self.tokens[start.min(last_index)];
83        let last = &self.tokens[end.min(last_index)];
84        Span {
85            start: first.start,
86            end: last.end,
87            line: first.line,
88            column: first.column,
89            end_line: last.line,
90            end_column: last.column,
91        }
92    }
93
94    /// One shell word: every token written without a space between them, which
95    /// is how `./lib/common.sh` and `$HOME/bin` are one argument each.
96    fn word(&self, start: usize) -> (String, usize) {
97        let mut text = String::new();
98        let mut cursor = start;
99        if start >= self.tokens.len() {
100            return (text, start);
101        }
102        while cursor < self.tokens.len() {
103            let token = &self.tokens[cursor];
104            if cursor > start && self.tokens[cursor - 1].end != token.start {
105                break;
106            }
107            if token.line != self.tokens[start].line {
108                break;
109            }
110            let raw = token.text(self.source);
111            if token.kind == TokenKind::String {
112                text.push_str(raw.trim_matches(['"', '\'']));
113            } else {
114                text.push_str(raw);
115            }
116            cursor += 1;
117        }
118        (text, cursor)
119    }
120
121    /// Every word of the statement starting at `index`, up to its end.
122    fn words(&self, start: usize) -> Vec<String> {
123        let mut found = Vec::new();
124        let Some(line) = self.tokens.get(start).map(|token| token.line) else {
125            return found;
126        };
127        let mut cursor = start;
128        while cursor < self.tokens.len() && self.tokens[cursor].line == line {
129            // A pipe or a separator ends this command's arguments.
130            if self.punct(cursor, ";") || self.punct(cursor, "|") || self.punct(cursor, "&") {
131                break;
132            }
133            let (word, next) = self.word(cursor);
134            if next == cursor {
135                break;
136            }
137            if !word.is_empty() {
138                found.push(word);
139            }
140            cursor = next;
141        }
142        found
143    }
144
145    fn step(&mut self, index: usize) -> usize {
146        if self.punct(index, "{") {
147            self.depth += 1;
148            return index + 1;
149        }
150        if self.punct(index, "}") {
151            self.depth -= 1;
152            if self
153                .function
154                .as_ref()
155                .is_some_and(|(_, depth)| self.depth < *depth)
156            {
157                self.function = None;
158            }
159            return index + 1;
160        }
161        // `. lib.sh` and `./deploy.sh` are commands whose first character is
162        // punctuation, so a word may begin with one.
163        let opens_a_word = self.kind(index) == Some(TokenKind::Identifier)
164            || self.punct(index, ".")
165            || self.punct(index, "/");
166        if !opens_a_word || !self.starts_a_statement(index) {
167            return index + 1;
168        }
169        if let Some(next) = self.definition(index) {
170            return next;
171        }
172        self.command(index)
173    }
174
175    /// Whether this word is the first of a command rather than an argument.
176    fn starts_a_statement(&self, index: usize) -> bool {
177        if index == 0 {
178            return true;
179        }
180        let previous = &self.tokens[index - 1];
181        if previous.line != self.tokens[index].line {
182            return true;
183        }
184        // A word joined to the one before it is part of it, not a new command.
185        if previous.end == self.tokens[index].start {
186            return false;
187        }
188        matches!(previous.text(self.source), ";" | "|" | "&" | "(" | "{")
189            || KEYWORDS.contains(&previous.text(self.source))
190    }
191
192    /// `function deploy {`, `deploy() {`.
193    fn definition(&mut self, index: usize) -> Option<usize> {
194        let (name_index, after) = if self.text(index) == "function" {
195            (index + 1, index + 2)
196        } else if self.punct(index + 1, "(") && self.punct(index + 2, ")") {
197            (index, index + 3)
198        } else {
199            return None;
200        };
201        if self.kind(name_index) != Some(TokenKind::Identifier) {
202            return None;
203        }
204        // `function name()` writes both forms; either way a brace follows.
205        let mut cursor = after;
206        while cursor < self.tokens.len() && !self.punct(cursor, "{") {
207            if self.tokens[cursor].line != self.tokens[index].line {
208                return None;
209            }
210            cursor += 1;
211        }
212        let name = self.text(name_index).to_owned();
213        self.facts.declarations.push(Declaration {
214            name: name.clone(),
215            kind: DeclarationKind::Function,
216            span: self.span(index, name_index),
217            owner: None,
218            // A shell function is callable by anything that sources the file.
219            exported: true,
220        });
221        self.function = Some((name, self.depth + 1));
222        Some(cursor)
223    }
224
225    /// A command, which may pull in another script or address a service.
226    fn command(&mut self, index: usize) -> usize {
227        let (name, after) = self.word(index);
228        if name.is_empty() {
229            return index + 1;
230        }
231        let arguments = self.words(after);
232        let span = self.span(index, index);
233
234        if RUNNERS.contains(&name.as_str())
235            && let Some(script) = arguments.iter().find(|word| !word.starts_with('-'))
236        {
237            self.facts.imports.push(Import {
238                specifier: script.clone(),
239                span,
240                type_only: false,
241                reexport: false,
242                names: Vec::new(),
243                bindings: Vec::new(),
244            });
245            return after;
246        }
247        // Running a sibling script directly is the same dependency.
248        let extension = name
249            .rsplit_once('.')
250            .map(|(_, tail)| tail.to_ascii_lowercase());
251        if matches!(extension.as_deref(), Some("sh" | "bash" | "zsh")) {
252            self.facts.imports.push(Import {
253                specifier: name,
254                span,
255                type_only: false,
256                reexport: false,
257                names: Vec::new(),
258                bindings: Vec::new(),
259            });
260            return after;
261        }
262
263        let addresses = if CLIENTS.contains(&name.as_str()) {
264            endpoints(&arguments)
265        } else {
266            Vec::new()
267        };
268        self.facts.references.push(Reference {
269            name,
270            kind: ReferenceKind::Call,
271            receiver: None,
272            span,
273            owner: self.function.as_ref().map(|(name, _)| name.clone()),
274            string_arguments: addresses,
275            name_arguments: Vec::new(),
276        });
277        after
278    }
279}
280
281/// The addresses and method a client command was given.
282///
283/// A URL in a shell script is usually written unquoted, so it arrives as one
284/// word rather than as a string literal - which is why arguments are rebuilt
285/// before they are read.
286fn endpoints(arguments: &[String]) -> Vec<String> {
287    let mut found = Vec::new();
288    let mut expecting_method = false;
289    for argument in arguments {
290        if expecting_method {
291            found.push(argument.clone());
292            expecting_method = false;
293            continue;
294        }
295        if argument == "-X" || argument == "--request" {
296            expecting_method = true;
297            continue;
298        }
299        if argument.contains("://") || argument.starts_with("localhost:") {
300            found.push(argument.clone());
301        }
302    }
303    found
304}
305
306#[cfg(test)]
307mod tests {
308    use super::extract;
309
310    #[test]
311    fn a_script_depends_on_what_it_sources_and_what_it_runs() {
312        let source = "#!/usr/bin/env bash\n\
313             source ./lib/common.sh\n\
314             . \"${DIR}/env.sh\"\n\
315             bash scripts/migrate.sh --yes\n\
316             ./scripts/deploy.sh production\n\
317             echo \"source ./ghost.sh\"\n";
318        assert_eq!(
319            extract(source)
320                .imports
321                .into_iter()
322                .map(|import| import.specifier)
323                .collect::<Vec<_>>(),
324            [
325                "./lib/common.sh",
326                "${DIR}/env.sh",
327                "scripts/migrate.sh",
328                "./scripts/deploy.sh",
329            ],
330            "a path inside a string argument is text, not a dependency"
331        );
332    }
333
334    #[test]
335    fn a_client_command_records_the_endpoint_it_addresses() {
336        let source = "curl -sf http://localhost:8080/api/v1/health\n\
337             curl -X POST \"https://api.example.com/v2/jobs\" -d @payload.json\n\
338             wget https://cdn.example.com/artifact.tgz\n\
339             echo https://not-a-request.example.com\n";
340        let addressed = extract(source)
341            .references
342            .into_iter()
343            .filter(|reference| !reference.string_arguments.is_empty())
344            .map(|reference| (reference.name, reference.string_arguments))
345            .collect::<Vec<_>>();
346        assert_eq!(
347            addressed,
348            [
349                (
350                    "curl".to_owned(),
351                    vec!["http://localhost:8080/api/v1/health".to_owned()]
352                ),
353                (
354                    "curl".to_owned(),
355                    vec![
356                        "POST".to_owned(),
357                        "https://api.example.com/v2/jobs".to_owned()
358                    ]
359                ),
360                (
361                    "wget".to_owned(),
362                    vec!["https://cdn.example.com/artifact.tgz".to_owned()]
363                ),
364            ],
365            "echo is not a client, so its argument is not an endpoint"
366        );
367    }
368
369    #[test]
370    fn functions_are_declared_and_own_the_commands_inside_them() {
371        let source = "deploy() {\n\
372             \x20 curl -sf http://svc/ready\n\
373             }\n\
374             \n\
375             function rollback {\n\
376             \x20 kubectl rollout undo\n\
377             }\n\
378             \n\
379             deploy\n";
380        let facts = extract(source);
381        assert_eq!(
382            facts
383                .declarations
384                .iter()
385                .map(|item| item.name.as_str())
386                .collect::<Vec<_>>(),
387            ["deploy", "rollback"],
388            "both spellings of a definition count"
389        );
390        assert!(
391            facts
392                .references
393                .iter()
394                .any(|reference| reference.name == "curl"
395                    && reference.owner.as_deref() == Some("deploy")),
396            "a command belongs to the function it is written in"
397        );
398        assert!(
399            facts
400                .references
401                .iter()
402                .any(|reference| reference.name == "kubectl"
403                    && reference.owner.as_deref() == Some("rollback")),
404            "and the next function owns its own"
405        );
406    }
407
408    #[test]
409    fn a_comment_is_not_a_command_and_a_hash_in_a_string_is_not_a_comment() {
410        let source = "# curl http://ghost/api\n\
411             echo \"a # inside a string\" && curl http://real/api\n";
412        let names = extract(source)
413            .references
414            .into_iter()
415            .map(|reference| reference.name)
416            .collect::<Vec<_>>();
417        assert!(names.contains(&"curl".to_owned()), "got {names:?}");
418        assert_eq!(
419            names.iter().filter(|name| *name == "curl").count(),
420            1,
421            "only the command after the string, got {names:?}"
422        );
423    }
424
425    #[test]
426    fn an_argument_is_not_read_as_a_command_of_its_own() {
427        let source = "docker run --rm -v /tmp:/tmp alpine sh -c 'echo hi'\n";
428        let names = extract(source)
429            .references
430            .into_iter()
431            .map(|reference| reference.name)
432            .collect::<Vec<_>>();
433        assert_eq!(
434            names,
435            ["docker"],
436            "everything after the command word is an argument, got {names:?}"
437        );
438    }
439}