-
Notifications
You must be signed in to change notification settings - Fork 37
feat: Add HCL/Terraform language support #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Comment on lines
+1105
to
+1107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Set Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
+1105
to
+1108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The parser only sets 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Module depth is updated by counting every Useful? React with 👍 / 👎.
Comment on lines
+1217
to
+1219
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The module brace counter currently increments/decrements on every Useful? React with 👍 / 👎.
Comment on lines
+1217
to
+1219
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Brace depth is updated by scanning raw characters, which counts 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); | ||
|
|
@@ -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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in_moduleis only enabled when themoduledeclaration line already contains{, so valid Terraform formatted asmodule "x"on one line and{on the next never enters module-tracking mode. In that case, subsequentsource = "..."inside the module is not captured inoutline.imports, which makes dependency graph results incomplete for a supported HCL style.Useful? React with 👍 / 👎.