use std::cell::RefCell;
use std::sync::LazyLock;
use anyhow::Result;
use super::{extract_todo, ImportKind, ImportStatement, StaticFileAnalysis};
use crate::analysis::walker::{Language, WalkedFile};
static GO_LANGUAGE: LazyLock<tree_sitter::Language> =
LazyLock::new(|| tree_sitter_go::LANGUAGE.into());
const GO_QUERY_SRC: &str = r#"
(function_declaration name: (identifier) @fn_name)
(method_declaration name: (field_identifier) @method_name)
(import_spec path: (interpreted_string_literal) @import)
(type_declaration (type_spec name: (type_identifier) @type_name))
(if_statement) @branch
(for_statement) @branch
(expression_switch_statement) @branch
(select_statement) @branch
(type_switch_statement) @branch
(comment) @comment
"#;
static GO_QUERY: LazyLock<tree_sitter::Query> = LazyLock::new(|| {
tree_sitter::Query::new(&GO_LANGUAGE, GO_QUERY_SRC).expect("parser/go: invalid query")
});
static GO_CAPTURES: LazyLock<GoCaptures> = LazyLock::new(|| GoCaptures::new(&GO_QUERY));
thread_local! {
static GO_PARSER: RefCell<tree_sitter::Parser> = RefCell::new({
let mut p = tree_sitter::Parser::new();
p.set_language(&GO_LANGUAGE).expect("parser/go: grammar load failed");
p
});
}
struct GoCaptures {
fn_name: u32,
method_name: u32,
import: u32,
type_name: u32,
branch: u32,
comment: u32,
}
impl GoCaptures {
fn new(query: &tree_sitter::Query) -> Self {
let idx = |name: &str| {
query
.capture_index_for_name(name)
.unwrap_or_else(|| panic!("parser/go: query missing @{name}"))
};
Self {
fn_name: idx("fn_name"),
method_name: idx("method_name"),
import: idx("import"),
type_name: idx("type_name"),
branch: idx("branch"),
comment: idx("comment"),
}
}
}
pub(super) fn parse_go(file: &WalkedFile, source: &str) -> Result<StaticFileAnalysis> {
let tree = GO_PARSER.with(|cell| cell.borrow_mut().parse(source.as_bytes(), None));
let tree = match tree {
Some(t) => t,
None => {
tracing::warn!("parser/go: tree-sitter failed on {}", file.rel_path);
return Ok(StaticFileAnalysis::empty(file));
}
};
let query = &*GO_QUERY;
let ci = &*GO_CAPTURES;
let src = source.as_bytes();
let mut out = StaticFileAnalysis {
path: file.rel_path.clone(),
language: Language::Go,
entry_points: Vec::with_capacity(16),
exported_types: Vec::with_capacity(8),
imports: Vec::with_capacity(16),
todos: Vec::new(),
unsafe_count: 0,
unwrap_count: 0,
panic_count: 0,
branch_count: 0,
module_doc: None,
content_hash: None,
line_count: 0,
};
let mut doc_lines: Vec<(usize, String)> = Vec::new();
let mut cursor = tree_sitter::QueryCursor::new();
for m in cursor.matches(query, tree.root_node(), src) {
for capture in m.captures {
let idx = capture.index;
let node = capture.node;
if idx == ci.branch {
out.branch_count += 1;
} else if idx == ci.fn_name || idx == ci.method_name {
if let Ok(name) = node.utf8_text(src) {
if is_exported(name) {
out.entry_points.push(name.to_owned());
}
}
} else if idx == ci.type_name {
if let Ok(name) = node.utf8_text(src) {
if is_exported(name) {
out.exported_types.push(name.to_owned());
}
}
} else if idx == ci.import {
if let Ok(path) = node.utf8_text(src) {
let stripped = path.trim_matches('"');
let line = node.start_position().row as u32 + 1;
let kind = if is_go_stdlib(stripped) {
ImportKind::External
} else {
ImportKind::Normal
};
out.imports.push(ImportStatement::new(stripped, kind, line));
}
} else if idx == ci.comment {
if let Ok(text) = node.utf8_text(src) {
let row = node.start_position().row;
let line = row as u32 + 1;
if let Some(todo) = extract_todo(text, line) {
out.todos.push(todo);
}
if row < 10 {
let stripped = text.trim_start_matches("//").trim().to_string();
if !stripped.is_empty()
&& !stripped.starts_with("go:build")
&& !stripped.starts_with("+build")
{
doc_lines.push((row, stripped));
}
}
}
}
}
}
if !doc_lines.is_empty() {
doc_lines.sort_by_key(|(r, _)| *r);
let start_row = doc_lines[0].0;
let contiguous: Vec<&str> = doc_lines
.iter()
.enumerate()
.take_while(|(i, (r, _))| *r == start_row + i)
.map(|(_, (_, text))| text.as_str())
.collect();
if !contiguous.is_empty() {
out.module_doc = Some(super::normalize_doc(&contiguous.join(" ")));
}
}
Ok(out)
}
fn is_exported(name: &str) -> bool {
name.chars().next().is_some_and(|c| c.is_uppercase())
}
fn is_go_stdlib(import_path: &str) -> bool {
let first_segment = import_path.split('/').next().unwrap_or("");
!first_segment.contains('.')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::record::TodoKind;
use tempfile::TempDir;
fn make_file(dir: &TempDir, rel: &str, content: &str) -> WalkedFile {
let abs = dir.path().join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&abs, content).unwrap();
WalkedFile {
abs_path: abs,
rel_path: rel.to_owned(),
language: Language::Go,
size_bytes: content.len() as u64,
mtime_secs: 0,
}
}
fn parse(dir: &TempDir, source: &str) -> StaticFileAnalysis {
let f = make_file(dir, "test.go", source);
parse_go(&f, source).unwrap()
}
#[test]
fn exported_func_in_entry_points() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\n\nfunc ExportedFunc() {}\n");
assert!(a.entry_points.contains(&"ExportedFunc".to_owned()));
}
#[test]
fn unexported_func_excluded() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\n\nfunc unexportedFunc() {}\n");
assert!(!a.entry_points.contains(&"unexportedFunc".to_owned()));
}
#[test]
fn main_func_excluded() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\n\nfunc main() {}\n");
assert!(!a.entry_points.contains(&"main".to_owned()));
}
#[test]
fn exported_method_in_entry_points() {
let dir = TempDir::new().unwrap();
let src = "package main\n\ntype Foo struct{}\n\nfunc (f Foo) ServeHTTP() {}\n";
let a = parse(&dir, src);
assert!(a.entry_points.contains(&"ServeHTTP".to_owned()));
}
#[test]
fn unexported_method_excluded() {
let dir = TempDir::new().unwrap();
let src = "package main\n\ntype Foo struct{}\n\nfunc (f Foo) helper() {}\n";
let a = parse(&dir, src);
assert!(!a.entry_points.contains(&"helper".to_owned()));
}
#[test]
fn pointer_receiver_method_exported() {
let dir = TempDir::new().unwrap();
let src = "package main\n\ntype Bar struct{}\n\nfunc (b *Bar) Handle() {}\n";
let a = parse(&dir, src);
assert!(a.entry_points.contains(&"Handle".to_owned()));
}
#[test]
fn single_import() {
let dir = TempDir::new().unwrap();
let src = "package main\n\nimport \"fmt\"\n\nfunc main() {}\n";
let a = parse(&dir, src);
assert!(a.imports.iter().any(|i| i.path == "fmt"));
}
#[test]
fn grouped_imports() {
let dir = TempDir::new().unwrap();
let src = r#"package main
import (
"fmt"
"os"
"net/http"
)
func main() {}
"#;
let a = parse(&dir, src);
assert!(a.imports.iter().any(|i| i.path == "fmt"));
assert!(a.imports.iter().any(|i| i.path == "os"));
assert!(a.imports.iter().any(|i| i.path == "net/http"));
}
#[test]
fn import_quotes_stripped() {
let dir = TempDir::new().unwrap();
let src = "package main\n\nimport \"encoding/json\"\n\nfunc F() {}\n";
let a = parse(&dir, src);
assert!(a.imports.iter().all(|i| !i.path.starts_with('"')));
assert!(a.imports.iter().any(|i| i.path == "encoding/json"));
}
#[test]
fn todo_in_line_comment() {
let dir = TempDir::new().unwrap();
let src = "package main\n\n// TODO: fix this\nfunc F() {}\n";
let a = parse(&dir, src);
assert_eq!(a.todos.len(), 1);
assert_eq!(a.todos[0].kind, TodoKind::Todo);
}
#[test]
fn fixme_in_comment() {
let dir = TempDir::new().unwrap();
let src = "package main\n\n// FIXME: broken\nfunc F() {}\n";
let a = parse(&dir, src);
assert_eq!(a.todos[0].kind, TodoKind::Fixme);
}
#[test]
fn hack_in_comment() {
let dir = TempDir::new().unwrap();
let src = "package main\n\n// HACK: workaround\nfunc F() {}\n";
let a = parse(&dir, src);
assert_eq!(a.todos[0].kind, TodoKind::Hack);
}
#[test]
fn plain_comment_not_captured_as_todo() {
let dir = TempDir::new().unwrap();
let src = "package main\n\n// just a comment\nfunc F() {}\n";
let a = parse(&dir, src);
assert!(a.todos.is_empty());
}
#[test]
fn todo_line_number_one_based() {
let dir = TempDir::new().unwrap();
let src = "package main\n\nfunc F() {}\n// TODO: line 4\n";
let a = parse(&dir, src);
assert_eq!(a.todos[0].line, 4);
}
#[test]
fn exported_type_in_exported_types() {
let dir = TempDir::new().unwrap();
let src = "package main\n\ntype MyHandler struct{}\n";
let a = parse(&dir, src);
assert!(a.exported_types.contains(&"MyHandler".to_owned()));
}
#[test]
fn unexported_type_excluded() {
let dir = TempDir::new().unwrap();
let src = "package main\n\ntype internalState struct{}\n";
let a = parse(&dir, src);
assert!(!a.exported_types.contains(&"internalState".to_owned()));
}
#[test]
fn empty_file() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "");
assert!(a.entry_points.is_empty());
assert!(a.imports.is_empty());
assert_eq!(a.branch_count, 0);
}
#[test]
fn path_preserved() {
let dir = TempDir::new().unwrap();
let f = make_file(&dir, "cmd/server/main.go", "package main\nfunc main() {}\n");
let a = parse_go(&f, "package main\nfunc main() {}\n").unwrap();
assert_eq!(a.path, "cmd/server/main.go");
}
#[test]
fn no_rust_specific_fields_set() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\nfunc F() {}\n");
assert_eq!(a.unsafe_count, 0);
assert_eq!(a.unwrap_count, 0);
assert_eq!(a.panic_count, 0);
}
#[test]
fn branch_if() {
let dir = TempDir::new().unwrap();
let src = "package main\nfunc F(x bool) {\n if x {\n }\n}\n";
let a = parse(&dir, src);
assert_eq!(a.branch_count, 1);
}
#[test]
fn branch_for() {
let dir = TempDir::new().unwrap();
let src = "package main\nfunc F() {\n for i := 0; i < 10; i++ {\n }\n}\n";
let a = parse(&dir, src);
assert_eq!(a.branch_count, 1);
}
#[test]
fn fmt_is_external() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\nimport \"fmt\"\n");
assert_eq!(a.imports[0].kind, ImportKind::External);
}
#[test]
fn net_http_is_external() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\nimport \"net/http\"\n");
assert_eq!(a.imports[0].kind, ImportKind::External);
}
#[test]
fn encoding_json_is_external() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\nimport \"encoding/json\"\n");
assert_eq!(a.imports[0].kind, ImportKind::External);
}
#[test]
fn github_com_is_normal() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\nimport \"github.com/acme/foo\"\n");
assert_eq!(a.imports[0].kind, ImportKind::Normal);
}
#[test]
fn gopkg_in_is_normal() {
let dir = TempDir::new().unwrap();
let a = parse(&dir, "package main\nimport \"gopkg.in/yaml.v3\"\n");
assert_eq!(a.imports[0].kind, ImportKind::Normal);
}
}