use std::path::Path;
use std::sync::LazyLock;
use tree_sitter::{Query, QueryCursor};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CiProvider {
GitHub,
GitLab,
Bitbucket,
CircleCI,
Unknown,
}
impl CiProvider {
pub fn from_path(path: &Path) -> Self {
let path_str = path.to_string_lossy();
if path_str.contains(".github/workflows") || path_str.contains(".gitea/workflows") {
CiProvider::GitHub
} else if path_str.contains(".gitlab-ci") {
CiProvider::GitLab
} else if path_str.contains("bitbucket-pipelines") {
CiProvider::Bitbucket
} else if path_str.contains(".circleci") {
CiProvider::CircleCI
} else {
CiProvider::Unknown
}
}
pub fn supports_key(&self, key: &str) -> bool {
match self {
CiProvider::GitHub => matches!(key, "uses" | "image"),
CiProvider::GitLab => matches!(key, "include" | "image" | "ref"),
CiProvider::Bitbucket => matches!(key, "pipe" | "image"),
CiProvider::CircleCI => matches!(key, "orbs" | "image"),
CiProvider::Unknown => true,
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct DependencyNode {
pub start: usize,
pub end: usize,
pub value: String,
pub comment: Option<String>,
pub key: String,
}
static USES_QUERY: LazyLock<Query> = LazyLock::new(|| {
Query::new(
tree_sitter_yaml::language(),
r#"
(block_mapping_pair
key: [
(flow_node (plain_scalar (string_scalar) @key))
(plain_scalar (string_scalar) @key)
]
value: (_) @value
(#match? @key "^(uses|pipe|image|include|ref|orbs)$"))
(comment) @comment
"#,
)
.expect("Failed to create tree-sitter query")
});
fn unquote(s: &str) -> String {
let s = s.trim();
if ((s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')))
&& s.len() >= 2
{
return s[1..s.len() - 1].to_string();
}
s.to_string()
}
fn resolve_gitlab_project(v_node: tree_sitter::Node, content: &[u8]) -> Option<String> {
let parent_pair = v_node.parent()?;
let mapping = parent_pair.parent()?;
let mut cursor = mapping.walk();
for child in mapping.children(&mut cursor) {
if child.kind() == "block_mapping_pair" {
if let Some(k_node) = child.child_by_field_name("key") {
if k_node.utf8_text(content).unwrap_or("") == "project" {
if let Some(v_node) = child.child_by_field_name("value") {
return Some(unquote(v_node.utf8_text(content).unwrap_or("")));
}
}
}
}
}
None
}
pub fn find_uses_nodes(
node: tree_sitter::Node,
content: &[u8],
provider: CiProvider,
) -> Vec<DependencyNode> {
let mut results = Vec::new();
let mut cursor = QueryCursor::new();
let matches = cursor.matches(&USES_QUERY, node, content);
let key_idx = USES_QUERY
.capture_index_for_name("key")
.expect("key capture missing");
let value_idx = USES_QUERY
.capture_index_for_name("value")
.expect("value capture missing");
let comment_idx = USES_QUERY.capture_index_for_name("comment");
let mut last_value: Option<(usize, usize, String, usize, String)> = None;
for m in matches {
let mut current_key = String::new();
for cap in m.captures {
if cap.index == key_idx {
current_key = cap.node.utf8_text(content).unwrap_or("").to_string();
} else if cap.index == value_idx {
if !provider.supports_key(¤t_key) {
continue;
}
if let Some((start, end, value, _, key)) = last_value.take() {
results.push(DependencyNode {
start,
end,
value,
comment: None,
key,
});
}
let v_node = cap.node;
let mut val = unquote(v_node.utf8_text(content).unwrap_or(""));
if current_key == "ref" {
if let Some(project) = resolve_gitlab_project(v_node, content) {
val = format!("{}@{}", project, val);
}
}
last_value = Some((
v_node.start_byte(),
v_node.end_byte(),
val,
v_node.start_position().row,
current_key.clone(),
));
} else if Some(cap.index) == comment_idx {
if let Some((start, end, value, row, key)) = last_value.take() {
let comment_node = cap.node;
if comment_node.start_position().row == row {
let comment_text =
comment_node.utf8_text(content).unwrap_or("").to_string();
results.push(DependencyNode {
start,
end,
value,
comment: Some(comment_text),
key,
});
} else {
results.push(DependencyNode {
start,
end,
value,
comment: None,
key,
});
}
}
}
}
}
if let Some((start, end, value, _, key)) = last_value {
results.push(DependencyNode {
start,
end,
value,
comment: None,
key,
});
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use tree_sitter::Parser as TSParser;
fn parse_yaml(content: &str) -> (tree_sitter::Tree, Vec<u8>) {
let mut parser = TSParser::new();
parser
.set_language(tree_sitter_yaml::language())
.expect("Error loading YAML grammar");
let tree = parser.parse(content, None).expect("Error parsing YAML");
(tree, content.as_bytes().to_vec())
}
#[test]
fn test_find_uses_nodes() {
let yaml = "uses: actions/checkout@v3";
let (tree, content) = parse_yaml(yaml);
let results = find_uses_nodes(tree.root_node(), &content, CiProvider::GitHub);
assert_eq!(results.len(), 1);
assert_eq!(results[0].value, "actions/checkout@v3");
assert_eq!(results[0].key, "uses");
}
#[test]
fn test_find_uses_with_quotes() {
let yaml = "uses: \"actions/checkout@v3\"";
let (tree, content) = parse_yaml(yaml);
let results = find_uses_nodes(tree.root_node(), &content, CiProvider::GitHub);
assert_eq!(results.len(), 1);
assert_eq!(results[0].value, "actions/checkout@v3");
}
#[test]
fn test_find_uses_with_comment() {
let yaml = "uses: actions/checkout@hash # v3";
let (tree, content) = parse_yaml(yaml);
let results = find_uses_nodes(tree.root_node(), &content, CiProvider::GitHub);
assert_eq!(results.len(), 1);
assert_eq!(results[0].value, "actions/checkout@hash");
assert_eq!(results[0].comment, Some("# v3".to_string()));
}
#[test]
fn test_find_other_keys() {
let yaml = r#"
image: alpine:latest
pipe: sonarsource/sonarcloud-scan:1.4.0
include: other-template.yml
orbs:
node: circleci/node@5.0.0
"#;
let (tree, content) = parse_yaml(yaml);
let results = find_uses_nodes(tree.root_node(), &content, CiProvider::Unknown);
let keys: Vec<String> = results.iter().map(|r| r.key.clone()).collect();
assert!(keys.contains(&"image".to_string()));
assert!(keys.contains(&"pipe".to_string()));
assert!(keys.contains(&"include".to_string()));
assert!(keys.contains(&"orbs".to_string()));
}
#[test]
fn test_gitlab_ref_project() {
let yaml = r#"
include:
- project: 'my-group/my-project'
ref: 'v1.0.0'
file: '/templates/.gitlab-ci.yml'
"#;
let (tree, content) = parse_yaml(yaml);
let results = find_uses_nodes(tree.root_node(), &content, CiProvider::GitLab);
let ref_node = results.iter().find(|r| r.key == "ref").unwrap();
assert_eq!(ref_node.value, "my-group/my-project@v1.0.0");
}
#[test]
fn test_github_ignore_include() {
let yaml = r#"
strategy:
matrix:
include:
- os: ubuntu-latest
"#;
let (tree, content) = parse_yaml(yaml);
let results = find_uses_nodes(tree.root_node(), &content, CiProvider::GitHub);
assert!(results.is_empty());
}
#[test]
fn test_unquote_exhaustive() {
assert_eq!(unquote("'v1'"), "v1");
assert_eq!(unquote("\"v2\""), "v2");
assert_eq!(unquote("v3"), "v3");
assert_eq!(unquote("'"), "'");
assert_eq!(unquote("\""), "\"");
assert_eq!(unquote("''"), "");
assert_eq!(unquote(" 'v4' "), "v4");
}
#[test]
fn test_comment_on_different_line() {
let yaml = r#"
uses: actions/checkout@v3
# unrelated comment
"#;
let (tree, content) = parse_yaml(yaml);
let results = find_uses_nodes(tree.root_node(), &content, CiProvider::GitHub);
assert_eq!(results.len(), 1);
assert_eq!(results[0].comment, None);
}
#[test]
fn test_ci_provider_from_path() {
assert_eq!(
CiProvider::from_path(Path::new(".github/workflows/ci.yml")),
CiProvider::GitHub
);
assert_eq!(
CiProvider::from_path(Path::new(".gitea/workflows/deploy.yaml")),
CiProvider::GitHub
);
assert_eq!(
CiProvider::from_path(Path::new(".gitlab-ci.yml")),
CiProvider::GitLab
);
assert_eq!(
CiProvider::from_path(Path::new("bitbucket-pipelines.yml")),
CiProvider::Bitbucket
);
assert_eq!(
CiProvider::from_path(Path::new(".circleci/config.yml")),
CiProvider::CircleCI
);
assert_eq!(
CiProvider::from_path(Path::new("docker-compose.yml")),
CiProvider::Unknown
);
}
#[test]
fn test_ci_provider_supports_key() {
let github = CiProvider::GitHub;
assert!(github.supports_key("uses"));
assert!(github.supports_key("image"));
assert!(!github.supports_key("pipe"));
let gitlab = CiProvider::GitLab;
assert!(gitlab.supports_key("include"));
assert!(gitlab.supports_key("ref"));
assert!(!gitlab.supports_key("uses"));
let bitbucket = CiProvider::Bitbucket;
assert!(bitbucket.supports_key("pipe"));
assert!(bitbucket.supports_key("image"));
assert!(!bitbucket.supports_key("orbs"));
let circle = CiProvider::CircleCI;
assert!(circle.supports_key("orbs"));
assert!(circle.supports_key("image"));
assert!(!circle.supports_key("include"));
let unknown = CiProvider::Unknown;
assert!(unknown.supports_key("any_key"));
}
}