Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions src/explore.zig
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub const Language = enum(u8) {
markdown,
json,
yaml,
hcl,
unknown,
};

Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Comment on lines +1105 to +1107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enter module context when block brace is on next line

in_module is only enabled when the module declaration line already contains {, so valid Terraform formatted as module "x" on one line and { on the next never enters module-tracking mode. In that case, subsequent source = "..." inside the module is not captured in outline.imports, which makes dependency graph results incomplete for a supported HCL style.

Useful? React with 👍 / 👎.

Comment on lines +1105 to +1107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enter module scope when opening brace is on next line

Set in_module when a module "..." block starts even if { is placed on the following line. As written, module context is only enabled when { is on the same line, so common formatting like module "x" then newline { causes all nested source = "..." values to be skipped, leaving dependency imports incomplete.

Useful? React with 👍 / 👎.

}
Comment on lines +1105 to +1108
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enter module scope even when { is on next line

The parser only sets in_module when the module declaration line already contains {, so formatted Terraform like module "vpc" followed by { on the next line never enters module scope. In that case source = "..." inside the module is skipped, so dependency imports are silently missing for a common HCL style.

Useful? React with 👍 / 👎.

}
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;
Comment on lines +1217 to +1219
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore braces inside strings when tracking module depth

Module depth is updated by counting every { and } character in the raw line, including braces inside quoted values, so a line like description = "}" can prematurely close module context (or "{" can keep it open). This causes source extraction to be missed inside real modules or to leak into following non-module blocks, producing incorrect dependency edges.

Useful? React with 👍 / 👎.

Comment on lines +1217 to +1219
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore quoted braces when updating module brace depth

The module brace counter currently increments/decrements on every {/} byte, including braces inside string literals. In module blocks, values like description = "}" can prematurely drop brace_depth to zero and exit module scope, so a later source = "..." is missed (or scope may drift and capture non-module source lines). Brace tracking needs to skip quoted content.

Useful? React with 👍 / 👎.

Comment on lines +1217 to +1219
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore braces inside strings when tracking module depth

Brace depth is updated by scanning raw characters, which counts {/} inside quoted values (for example description = "}"). That can prematurely drop brace_depth to 0 and exit module scope before the real closing brace, causing later source = "..." lines in the same module to be missed.

Useful? React with 👍 / 👎.

}
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);
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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;
Expand Down
Loading