1use crate::facts::{Declaration, DeclarationKind, Facts, Import, Reference, ReferenceKind, Span};
14use crate::syntax::Language;
15use crate::token::{Mode, Token, TokenKind, Tokenizer};
16
17#[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
34const CLIENTS: &[&str] = &[
36 "curl", "wget", "http", "https", "xh", "httpie", "nc", "ncat", "grpcurl", "ab", "hey", "siege",
37 "wrk",
38];
39
40const RUNNERS: &[&str] = &["source", ".", "bash", "sh", "zsh", "ksh"];
42
43const 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 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 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 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 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 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 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 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 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 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 exported: true,
220 });
221 self.function = Some((name, self.depth + 1));
222 Some(cursor)
223 }
224
225 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 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
281fn 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}