use crate::facts::{Declaration, DeclarationKind, Facts, Import, Reference, ReferenceKind, Span};
use crate::syntax::Language;
use crate::token::{Mode, Token, TokenKind, Tokenizer};
#[must_use]
pub fn extract(source: &str) -> Facts {
let tokens = Tokenizer::new(source, Language::Terraform)
.mode(Mode::Lite)
.collect::<Vec<_>>();
let mut state = Extractor {
source,
tokens: &tokens,
facts: Facts::default(),
block: Vec::new(),
depth: 0,
};
state.run();
state.facts
}
const DECLARING: &[(&str, DeclarationKind)] = &[
("resource", DeclarationKind::Resource),
("data", DeclarationKind::Resource),
("module", DeclarationKind::Module),
("variable", DeclarationKind::Variable),
("output", DeclarationKind::Resource),
("provider", DeclarationKind::Module),
("locals", DeclarationKind::Constant),
];
const REFERENCE_ROOTS: &[&str] = &["var", "module", "data", "local", "each"];
struct Extractor<'source, 'tokens> {
source: &'source str,
tokens: &'tokens [Token],
facts: Facts,
block: Vec<String>,
depth: i32,
}
impl Extractor<'_, '_> {
fn run(&mut self) {
let mut index = 0;
while index < self.tokens.len() {
index = self.step(index);
}
}
fn text(&self, index: usize) -> &str {
self.tokens
.get(index)
.map_or("", |token| token.text(self.source))
}
fn kind(&self, index: usize) -> Option<TokenKind> {
self.tokens.get(index).map(|token| token.kind)
}
fn punct(&self, index: usize, mark: &str) -> bool {
self.kind(index) == Some(TokenKind::Punctuation) && self.text(index) == mark
}
fn string(&self, index: usize) -> Option<String> {
(self.kind(index) == Some(TokenKind::String))
.then(|| self.text(index).trim_matches('"').to_owned())
}
fn span(&self, start: usize, end: usize) -> Span {
let last_index = self.tokens.len().saturating_sub(1);
let first = &self.tokens[start.min(last_index)];
let last = &self.tokens[end.min(last_index)];
Span {
start: first.start,
end: last.end,
line: first.line,
column: first.column,
end_line: last.line,
end_column: last.column,
}
}
fn step(&mut self, index: usize) -> usize {
if self.punct(index, "{") {
self.depth += 1;
return index + 1;
}
if self.punct(index, "}") {
self.depth -= 1;
self.block.pop();
return index + 1;
}
if self.kind(index) != Some(TokenKind::Identifier) {
return index + 1;
}
if let Some(next) = self.block_header(index) {
return next;
}
if let Some(next) = self.source_attribute(index) {
return next;
}
if let Some(next) = self.reference(index) {
return next;
}
index + 1
}
fn block_header(&mut self, index: usize) -> Option<usize> {
let word = self.text(index);
let kind = DECLARING
.iter()
.find(|(name, _)| *name == word)
.map(|(_, kind)| *kind);
let mut cursor = index + 1;
let mut labels = Vec::new();
while let Some(label) = self.string(cursor) {
labels.push(label);
cursor += 1;
}
if !self.punct(cursor, "{") {
return None;
}
let name = labels.join(".");
if let Some(kind) = kind
&& !name.is_empty()
{
let qualified = if word == "data" {
format!("data.{name}")
} else if word == "module" || word == "variable" {
format!("{word}.{name}")
} else {
name
};
self.facts.declarations.push(Declaration {
name: qualified.clone(),
kind,
span: self.span(index, cursor.saturating_sub(1)),
owner: None,
exported: true,
});
self.block.push(qualified);
} else {
self.block.push(word.to_owned());
}
Some(cursor)
}
fn source_attribute(&mut self, index: usize) -> Option<usize> {
if self.text(index) != "source" || !self.punct(index + 1, "=") {
return None;
}
let specifier = self.string(index + 2)?;
self.facts.imports.push(Import {
specifier,
span: self.span(index, index + 2),
type_only: false,
reexport: false,
names: Vec::new(),
bindings: Vec::new(),
});
Some(index + 3)
}
fn reference(&mut self, index: usize) -> Option<usize> {
if !self.punct(index + 1, ".") || self.kind(index + 2) != Some(TokenKind::Identifier) {
return None;
}
let root = self.text(index);
let rooted = REFERENCE_ROOTS.contains(&root);
if !rooted && !root.contains('_') {
return None;
}
let parts = if root == "var" || root == "local" || root == "each" {
2
} else if root == "data" {
3
} else {
2
};
let mut name = String::from(root);
let mut cursor = index + 1;
let mut taken = 1;
while taken < parts && self.punct(cursor, ".") {
if self.kind(cursor + 1) != Some(TokenKind::Identifier) {
break;
}
name.push('.');
name.push_str(self.text(cursor + 1));
cursor += 2;
taken += 1;
}
if taken < parts {
return None;
}
self.facts.references.push(Reference {
name,
kind: ReferenceKind::Uses,
receiver: None,
span: self.span(index, cursor.saturating_sub(1)),
owner: self.block.last().cloned(),
string_arguments: Vec::new(),
name_arguments: Vec::new(),
});
Some(cursor)
}
}
#[cfg(test)]
mod tests {
use super::extract;
use crate::facts::DeclarationKind;
#[test]
fn blocks_declare_the_objects_the_rest_of_the_file_addresses() {
let source = "resource \"aws_s3_bucket\" \"logs\" {\n\
\x20 bucket = \"my-logs\"\n\
}\n\
variable \"region\" { default = \"eu-west-1\" }\n\
data \"aws_ami\" \"ubuntu\" { most_recent = true }\n\
output \"bucket_arn\" { value = \"x\" }\n";
let declared = extract(source)
.declarations
.into_iter()
.map(|item| (item.name, item.kind))
.collect::<Vec<_>>();
assert_eq!(
declared,
[
("aws_s3_bucket.logs".to_owned(), DeclarationKind::Resource),
("variable.region".to_owned(), DeclarationKind::Variable),
("data.aws_ami.ubuntu".to_owned(), DeclarationKind::Resource),
("bucket_arn".to_owned(), DeclarationKind::Resource),
],
"a resource is addressed by type and name together"
);
}
#[test]
fn a_module_names_the_configuration_it_pulls_in() {
let source = "module \"vpc\" {\n\
\x20 source = \"./modules/vpc\"\n\
\x20 version = \"1.2.0\"\n\
}\n\
terraform {\n\
\x20 required_providers {\n\
\x20 aws = { source = \"hashicorp/aws\" }\n\
\x20 }\n\
}\n";
let facts = extract(source);
assert_eq!(
facts
.imports
.iter()
.map(|import| import.specifier.as_str())
.collect::<Vec<_>>(),
["./modules/vpc", "hashicorp/aws"],
"a local module and a registry provider are both dependencies"
);
assert!(
facts
.declarations
.iter()
.any(|item| item.name == "module.vpc")
);
}
#[test]
fn interpolations_reference_the_objects_they_name() {
let source = "resource \"aws_instance\" \"web\" {\n\
\x20 ami = data.aws_ami.ubuntu.id\n\
\x20 subnet_id = module.vpc.public_subnet\n\
\x20 instance_type = var.instance_type\n\
\x20 bucket = aws_s3_bucket.logs.arn\n\
}\n";
let used = extract(source)
.references
.into_iter()
.map(|reference| (reference.name, reference.owner))
.collect::<Vec<_>>();
assert_eq!(
used,
[
(
"data.aws_ami.ubuntu".to_owned(),
Some("aws_instance.web".to_owned())
),
("module.vpc".to_owned(), Some("aws_instance.web".to_owned())),
(
"var.instance_type".to_owned(),
Some("aws_instance.web".to_owned())
),
(
"aws_s3_bucket.logs".to_owned(),
Some("aws_instance.web".to_owned())
),
],
"every reference belongs to the resource that makes it"
);
}
#[test]
fn a_comment_declares_nothing() {
let source = "# resource \"aws_s3_bucket\" \"ghost\" {}\n\
// module \"ghost\" { source = \"./nowhere\" }\n\
/* variable \"ghost\" {} */\n\
resource \"aws_vpc\" \"real\" {}\n";
let facts = extract(source);
assert_eq!(facts.declarations.len(), 1);
assert!(facts.imports.is_empty());
}
}