diff --git a/src/explore.zig b/src/explore.zig index fd09c9b..a8960ed 100644 --- a/src/explore.zig +++ b/src/explore.zig @@ -74,6 +74,7 @@ pub const Language = enum(u8) { markdown, json, yaml, + hcl, unknown, }; @@ -89,6 +90,7 @@ pub fn detectLanguage(path: []const u8) Language { if (std.mem.endsWith(u8, path, ".md")) return .markdown; if (std.mem.endsWith(u8, path, ".json")) return .json; if (std.mem.endsWith(u8, path, ".yaml") or std.mem.endsWith(u8, path, ".yml")) return .yaml; + if (std.mem.endsWith(u8, path, ".tf") or std.mem.endsWith(u8, path, ".tfvars") or std.mem.endsWith(u8, path, ".hcl")) return .hcl; return .unknown; } @@ -175,6 +177,10 @@ fn indexFileInner(self: *Explorer, path: []const u8, content: []const u8, full_i var line_num: u32 = 0; var prev_line_trimmed: []const u8 = ""; + // HCL parser state tracked across lines + var hcl_in_block_comment: bool = false; + var hcl_in_module: bool = false; + var hcl_brace_depth: i32 = 0; var lines = std.mem.splitScalar(u8, content, '\n'); while (lines.next()) |line| { line_num += 1; @@ -188,6 +194,8 @@ fn indexFileInner(self: *Explorer, path: []const u8, content: []const u8, full_i try self.parseTsLine(trimmed, line_num, &outline); } else if (outline.language == .rust) { try self.parseRustLine(trimmed, line_num, &outline, prev_line_trimmed); + } else if (outline.language == .hcl) { + try self.parseHclLine(trimmed, line_num, &outline, &hcl_in_block_comment, &hcl_in_module, &hcl_brace_depth); } prev_line_trimmed = trimmed; @@ -993,6 +1001,244 @@ pub fn getHotFiles(self: *Explorer, store: *Store, allocator: std.mem.Allocator, } } + // ── HCL / Terraform parser ──────────────────────────────── + // + // Recognises top-level block types used in Terraform: + // resource "type" "name" { → struct_def "type.name" + // data "type" "name" { → struct_def "data.type.name" + // module "name" { → import "name" + // variable "name" { → variable "name" + // output "name" { → constant "name" + // locals { → variable "locals" + // provider "name" { → import "name" + // terraform { → struct_def "terraform" + // moved { → struct_def "moved" + // + // Also records `source = "..."` inside module blocks as imports + // for the dependency graph. + + fn parseHclLine(self: *Explorer, line: []const u8, line_num: u32, outline: *FileOutline, in_block_comment: *bool, in_module: *bool, brace_depth: *i32) !void { + const a = self.allocator; + + // Handle multiline /* ... */ comments + if (in_block_comment.*) { + if (std.mem.indexOf(u8, line, "*/") != null) { + in_block_comment.* = false; + } + return; + } + + // Skip empty lines and single-line comments + if (line.len == 0 or line[0] == '#') return; + if (startsWith(line, "//")) return; + + // Check for block comment start + if (startsWith(line, "/*")) { + if (std.mem.indexOf(u8, line, "*/") == null) { + in_block_comment.* = true; + } + return; + } + + // resource "aws_instance" "web" { + if (startsWith(line, "resource ")) { + if (extractTwoQuotedStrings(line["resource ".len..])) |pair| { + // Build "type.name" symbol + const name_len = pair.first.len + 1 + pair.second.len; + const name = try a.alloc(u8, name_len); + errdefer a.free(name); + @memcpy(name[0..pair.first.len], pair.first); + name[pair.first.len] = '.'; + @memcpy(name[pair.first.len + 1 ..], pair.second); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .struct_def, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + } + return; + } + + // data "aws_ami" "ubuntu" { + if (startsWith(line, "data ")) { + if (extractTwoQuotedStrings(line["data ".len..])) |pair| { + const prefix = "data."; + const name_len = prefix.len + pair.first.len + 1 + pair.second.len; + const name = try a.alloc(u8, name_len); + errdefer a.free(name); + @memcpy(name[0..prefix.len], prefix); + @memcpy(name[prefix.len .. prefix.len + pair.first.len], pair.first); + name[prefix.len + pair.first.len] = '.'; + @memcpy(name[prefix.len + pair.first.len + 1 ..], pair.second); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .struct_def, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + } + return; + } + + // module "vpc" { + if (startsWith(line, "module ")) { + if (extractQuotedString(line["module ".len..])) |name_str| { + const name = try a.dupe(u8, name_str); + errdefer a.free(name); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .import, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + // Track that we entered a module block for source extraction + if (std.mem.indexOf(u8, line, "{") != null) { + in_module.* = true; + brace_depth.* = 1; + } + } + return; + } + + // variable "region" { + if (startsWith(line, "variable ")) { + if (extractQuotedString(line["variable ".len..])) |name_str| { + const name = try a.dupe(u8, name_str); + errdefer a.free(name); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .variable, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + } + return; + } + + // output "endpoint" { + if (startsWith(line, "output ")) { + if (extractQuotedString(line["output ".len..])) |name_str| { + const name = try a.dupe(u8, name_str); + errdefer a.free(name); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .constant, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + } + return; + } + + // provider "aws" { + if (startsWith(line, "provider ")) { + if (extractQuotedString(line["provider ".len..])) |name_str| { + const name = try a.dupe(u8, name_str); + errdefer a.free(name); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .import, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + } + return; + } + + // locals { + if (startsWith(line, "locals ") or startsWith(line, "locals{") or std.mem.eql(u8, line, "locals")) { + const name = try a.dupe(u8, "locals"); + errdefer a.free(name); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .variable, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + return; + } + + // terraform { + if (startsWith(line, "terraform ") or startsWith(line, "terraform{") or std.mem.eql(u8, line, "terraform")) { + const name = try a.dupe(u8, "terraform"); + errdefer a.free(name); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .struct_def, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + return; + } + + // moved { + if (startsWith(line, "moved ") or startsWith(line, "moved{") or std.mem.eql(u8, line, "moved")) { + const name = try a.dupe(u8, "moved"); + errdefer a.free(name); + const detail = try a.dupe(u8, line); + errdefer a.free(detail); + try outline.symbols.append(a, .{ + .name = name, + .kind = .struct_def, + .line_start = line_num, + .line_end = line_num, + .detail = detail, + }); + return; + } + + // Track brace depth for module context + if (in_module.*) { + for (line) |ch| { + if (ch == '{') brace_depth.* += 1; + if (ch == '}') brace_depth.* -= 1; + } + if (brace_depth.* <= 0) { + in_module.* = false; + brace_depth.* = 0; + } + + // source = "..." only inside module blocks → import for dep graph + const stripped = std.mem.trim(u8, line, " \t"); + if (startsWith(stripped, "source")) { + const after_key = std.mem.trim(u8, stripped["source".len..], " \t"); + if (after_key.len > 0 and after_key[0] == '=') { + const val = std.mem.trim(u8, after_key[1..], " \t"); + if (extractQuotedString(val)) |src| { + const import_copy = try a.dupe(u8, src); + errdefer a.free(import_copy); + try outline.imports.append(a, import_copy); + } + } + } + } + } + fn rebuildDepsFor(self: *Explorer, path: []const u8, outline: *FileOutline) !void { var deps: std.ArrayList([]const u8) = .{}; errdefer deps.deinit(self.allocator); @@ -1190,6 +1436,7 @@ pub fn isCommentOrBlank(line: []const u8, language: Language) bool { return switch (language) { .zig, .rust, .go_lang => std.mem.startsWith(u8, trimmed, "//"), .python => std.mem.startsWith(u8, trimmed, "#"), + .hcl => std.mem.startsWith(u8, trimmed, "#") or std.mem.startsWith(u8, trimmed, "//") or std.mem.startsWith(u8, trimmed, "/*") or std.mem.startsWith(u8, trimmed, "*"), .javascript, .typescript, .c, .cpp => std.mem.startsWith(u8, trimmed, "//") or std.mem.startsWith(u8, trimmed, "/*") or std.mem.startsWith(u8, trimmed, "*"), else => false, }; @@ -1563,6 +1810,34 @@ fn extractStringLiteral(s: []const u8) ?[]const u8 { return null; } +/// Extract the first double-quoted string from s. +/// e.g. `"aws_instance" "web"` → `aws_instance` +fn extractQuotedString(s: []const u8) ?[]const u8 { + if (std.mem.indexOfScalar(u8, s, '"')) |start| { + if (std.mem.indexOfScalarPos(u8, s, start + 1, '"')) |end| { + if (end > start + 1) return s[start + 1 .. end]; + } + } + return null; +} + +/// Extract two consecutive double-quoted strings. +/// e.g. `"aws_instance" "web" {` → { .first = "aws_instance", .second = "web" } +fn extractTwoQuotedStrings(s: []const u8) ?struct { first: []const u8, second: []const u8 } { + if (std.mem.indexOfScalar(u8, s, '"')) |s1| { + if (std.mem.indexOfScalarPos(u8, s, s1 + 1, '"')) |e1| { + if (std.mem.indexOfScalarPos(u8, s, e1 + 1, '"')) |s2| { + if (std.mem.indexOfScalarPos(u8, s, s2 + 1, '"')) |e2| { + if (e1 > s1 + 1 and e2 > s2 + 1) { + return .{ .first = s[s1 + 1 .. e1], .second = s[s2 + 1 .. e2] }; + } + } + } + } + } + return null; +} + fn containsAny(s: []const u8, needles: []const []const u8) bool { for (needles) |needle| { if (std.mem.indexOf(u8, s, needle) != null) return true; diff --git a/src/tests.zig b/src/tests.zig index d4c8fce..40547c5 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -847,6 +847,311 @@ test "explorer: typescript parser" { try testing.expect(outline.symbols.items.len >= 3); } +// ── HCL / Terraform parser tests ──────────────────────────── + +test "explorer: hcl parser — resource block" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("main.tf", + \\resource "aws_instance" "web" { + \\ ami = "ami-12345" + \\ instance_type = "t3.micro" + \\} + ); + + var outline = (try explorer.getOutline("main.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.language == .hcl); + try testing.expect(outline.symbols.items.len == 1); + try testing.expectEqualStrings("aws_instance.web", outline.symbols.items[0].name); + try testing.expect(outline.symbols.items[0].kind == .struct_def); +} + +test "explorer: hcl parser — data block" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("data.tf", + \\data "aws_ami" "ubuntu" { + \\ most_recent = true + \\} + ); + + var outline = (try explorer.getOutline("data.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.symbols.items.len == 1); + try testing.expectEqualStrings("data.aws_ami.ubuntu", outline.symbols.items[0].name); + try testing.expect(outline.symbols.items[0].kind == .struct_def); +} + +test "explorer: hcl parser — variable and output" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("variables.tf", + \\variable "region" { + \\ type = string + \\ default = "us-east-1" + \\} + \\ + \\output "endpoint" { + \\ value = aws_instance.web.public_ip + \\} + ); + + var outline = (try explorer.getOutline("variables.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.symbols.items.len == 2); + try testing.expectEqualStrings("region", outline.symbols.items[0].name); + try testing.expect(outline.symbols.items[0].kind == .variable); + try testing.expectEqualStrings("endpoint", outline.symbols.items[1].name); + try testing.expect(outline.symbols.items[1].kind == .constant); +} + +test "explorer: hcl parser — module with source import" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("modules.tf", + \\module "vpc" { + \\ source = "terraform-aws-modules/vpc/aws" + \\ cidr = "10.0.0.0/16" + \\} + ); + + var outline = (try explorer.getOutline("modules.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.symbols.items.len == 1); + try testing.expectEqualStrings("vpc", outline.symbols.items[0].name); + try testing.expect(outline.symbols.items[0].kind == .import); + // source should be recorded as an import for dep graph + try testing.expect(outline.imports.items.len == 1); + try testing.expectEqualStrings("terraform-aws-modules/vpc/aws", outline.imports.items[0]); +} + +test "explorer: hcl parser — provider block" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("providers.tf", + \\provider "aws" { + \\ region = "us-east-1" + \\} + ); + + var outline = (try explorer.getOutline("providers.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.symbols.items.len == 1); + try testing.expectEqualStrings("aws", outline.symbols.items[0].name); + try testing.expect(outline.symbols.items[0].kind == .import); +} + +test "explorer: hcl parser — locals and terraform blocks" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("config.tf", + \\terraform { + \\ required_version = ">= 1.0" + \\} + \\ + \\locals { + \\ env = "production" + \\} + ); + + var outline = (try explorer.getOutline("config.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.symbols.items.len == 2); + try testing.expectEqualStrings("terraform", outline.symbols.items[0].name); + try testing.expect(outline.symbols.items[0].kind == .struct_def); + try testing.expectEqualStrings("locals", outline.symbols.items[1].name); + try testing.expect(outline.symbols.items[1].kind == .variable); +} + +test "explorer: hcl parser — full terraform file" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("main.tf", + \\# Main infrastructure + \\terraform { + \\ required_version = ">= 1.0" + \\} + \\ + \\provider "aws" { + \\ region = var.region + \\} + \\ + \\variable "region" { + \\ type = string + \\ default = "us-east-1" + \\} + \\ + \\locals { + \\ tags = { env = "prod" } + \\} + \\ + \\resource "aws_vpc" "main" { + \\ cidr_block = "10.0.0.0/16" + \\} + \\ + \\data "aws_ami" "latest" { + \\ most_recent = true + \\} + \\ + \\module "eks" { + \\ source = "terraform-aws-modules/eks/aws" + \\ version = "19.0" + \\} + \\ + \\output "vpc_id" { + \\ value = aws_vpc.main.id + \\} + ); + + var outline = (try explorer.getOutline("main.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + // terraform, provider, variable, locals, resource, data, module, output = 8 + try testing.expect(outline.symbols.items.len == 8); + try testing.expectEqualStrings("terraform", outline.symbols.items[0].name); + try testing.expectEqualStrings("aws", outline.symbols.items[1].name); + try testing.expectEqualStrings("region", outline.symbols.items[2].name); + try testing.expectEqualStrings("locals", outline.symbols.items[3].name); + try testing.expectEqualStrings("aws_vpc.main", outline.symbols.items[4].name); + try testing.expectEqualStrings("data.aws_ami.latest", outline.symbols.items[5].name); + try testing.expectEqualStrings("eks", outline.symbols.items[6].name); + try testing.expectEqualStrings("vpc_id", outline.symbols.items[7].name); + // Module source recorded as import + try testing.expect(outline.imports.items.len == 1); +} + +test "explorer: hcl parser — comments are skipped" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("commented.tf", + \\# resource "aws_instance" "old" { + \\// resource "aws_instance" "also_old" { + \\resource "aws_instance" "active" { + \\ ami = "ami-123" + \\} + ); + + var outline = (try explorer.getOutline("commented.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.symbols.items.len == 1); + try testing.expectEqualStrings("aws_instance.active", outline.symbols.items[0].name); +} + +test "explorer: hcl parser — tfvars detected as hcl" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("prod.tfvars", + \\region = "us-east-1" + \\instance_type = "t3.large" + ); + + var outline = (try explorer.getOutline("prod.tfvars", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + try testing.expect(outline.language == .hcl); +} + +test "explorer: hcl parser — findSymbol works for terraform resources" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("main.tf", + \\resource "aws_s3_bucket" "logs" { + \\ bucket = "my-logs" + \\} + ); + + const result = try explorer.findSymbol("aws_s3_bucket.logs", testing.allocator); + try testing.expect(result != null); + const sym = result.?; + defer testing.allocator.free(sym.path); + defer testing.allocator.free(sym.symbol.name); + if (sym.symbol.detail) |d| testing.allocator.free(d); + try testing.expectEqualStrings("aws_s3_bucket.logs", sym.symbol.name); + try testing.expect(sym.symbol.kind == .struct_def); +} + +test "explorer: hcl parser — multiline block comments are skipped" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("commented.tf", + \\resource "aws_instance" "real" { + \\ ami = "ami-123" + \\} + \\ + \\/* + \\resource "aws_instance" "fake" { + \\ ami = "ami-456" + \\} + \\*/ + \\ + \\variable "region" { + \\ default = "us-east-1" + \\} + ); + + var outline = (try explorer.getOutline("commented.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + // Only "real" resource and "region" variable — "fake" inside /* */ is skipped + try testing.expect(outline.symbols.items.len == 2); + try testing.expectEqualStrings("aws_instance.real", outline.symbols.items[0].name); + try testing.expectEqualStrings("region", outline.symbols.items[1].name); +} + +test "explorer: hcl parser — source only extracted inside module blocks" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var explorer = Explorer.init(arena.allocator()); + + try explorer.indexFile("mixed.tf", + \\module "vpc" { + \\ source = "terraform-aws-modules/vpc/aws" + \\} + \\ + \\resource "null_resource" "provision" { + \\ provisioner "file" { + \\ source = "/local/path/script.sh" + \\ destination = "/tmp/script.sh" + \\ } + \\} + ); + + var outline = (try explorer.getOutline("mixed.tf", testing.allocator)) orelse return error.TestUnexpectedResult; + defer outline.deinit(); + // Only the module source should be recorded as import, not the provisioner source + try testing.expect(outline.imports.items.len == 1); + try testing.expectEqualStrings("terraform-aws-modules/vpc/aws", outline.imports.items[0]); +} + +test "explorer: hcl isCommentOrBlank" { + try testing.expect(isCommentOrBlank("# this is a comment", .hcl)); + try testing.expect(isCommentOrBlank("// also a comment", .hcl)); + try testing.expect(isCommentOrBlank("/* block comment */", .hcl)); + try testing.expect(isCommentOrBlank(" ", .hcl)); + try testing.expect(isCommentOrBlank("", .hcl)); + try testing.expect(!isCommentOrBlank("resource \"aws_instance\" \"web\" {", .hcl)); +} + // ── Version tests ─────────────────────────────────────────── test "file versions: append and latest" { diff --git a/src/watcher.zig b/src/watcher.zig index c534d9d..5bbcf19 100644 --- a/src/watcher.zig +++ b/src/watcher.zig @@ -113,6 +113,8 @@ const skip_dirs = [_][]const u8{ ".tmp", ".temp", ".DS_Store", + ".terraform", // terraform provider/module cache + ".terragrunt-cache", }; fn shouldSkip(path: []const u8) bool {