Skip to content

Commit 7d72065

Browse files
authored
Extract Jinja scanner (#46)
1 parent 95f2fef commit 7d72065

3 files changed

Lines changed: 322 additions & 239 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ Exit criteria:
569569

570570
- M0 is complete as the Zig `0.16.0` runtime scaffold.
571571
- M1 has started on stacked branches with native Zig artifact-first parser slices.
572-
- M1A has started with behavior-preserving `src/project/types.zig`, `src/project/selector.zig`, `src/project/manifest.zig`, `src/project/util.zig`, `src/project/config.zig`, `src/project/fs.zig`, `src/project/jinja.zig`, `src/project/resolve.zig`, and `src/project/parse.zig` extractions. `src/project/manifest.zig` owns selected-resource JSON and partial `manifest.json` writing with native tests for selected JSON shape, JSON escaping, exposure dependency ordering, disabled-resource filtering, and graph-map output. `src/project/util.zig` owns shared display, membership, append-dedup, string sorting, and narrow YAML scalar/list helpers. `src/project/config.zig` owns `dbt_project.yml` loading, project path/docs config parsing, and applying parsed project path/docs configs to graph nodes. `src/project/fs.zig` owns deterministic resource file discovery, Linux directory traversal helpers, and resource path/name helpers. `src/project/jinja.zig` owns lexical Jinja call, parenthesis, quoted string, and literal argument helpers while graph-aware scanning remains in `src/project.zig`. `src/project/resolve.zig` owns graph lookup/count helpers, canonical graph resource ordering, duplicate resource validation, macro unique-id package extraction, low-level ref/source resolution helpers, and dependency-map mutation for refs, sources, and known macro dependencies. `src/project/parse.zig` owns narrow parser scalar helpers for YAML booleans, JSON-compatible scalar classification, generic-test YAML item names, generic-test definition construction/cloning, generic-test relationship target ref parsing, and generic-test identity/name/hash helpers with native tests. `src/project.zig` remains the public parser/list facade and still owns most loader, resource parser, scanner, and resolver orchestration code until follow-up extractions move those pieces behind focused internal modules.
572+
- M1A has started with behavior-preserving `src/project/types.zig`, `src/project/selector.zig`, `src/project/manifest.zig`, `src/project/util.zig`, `src/project/config.zig`, `src/project/fs.zig`, `src/project/jinja.zig`, `src/project/resolve.zig`, and `src/project/parse.zig` extractions. `src/project/manifest.zig` owns selected-resource JSON and partial `manifest.json` writing with native tests for selected JSON shape, JSON escaping, exposure dependency ordering, disabled-resource filtering, and graph-map output. `src/project/util.zig` owns shared display, membership, append-dedup, string sorting, and narrow YAML scalar/list helpers. `src/project/config.zig` owns `dbt_project.yml` loading, project path/docs config parsing, and applying parsed project path/docs configs to graph nodes. `src/project/fs.zig` owns deterministic resource file discovery, Linux directory traversal helpers, and resource path/name helpers. `src/project/jinja.zig` owns lexical Jinja call, parenthesis, quoted string, literal argument helpers, supported model SQL scanning, inline config/tag parsing, and known macro-call scanning with native tests. `src/project/resolve.zig` owns graph lookup/count helpers, canonical graph resource ordering, duplicate resource validation, macro unique-id package extraction, low-level ref/source resolution helpers, and dependency-map mutation for refs, sources, and known macro dependencies. `src/project/parse.zig` owns narrow parser scalar helpers for YAML booleans, JSON-compatible scalar classification, generic-test YAML item names, generic-test definition construction/cloning, generic-test relationship target ref parsing, and generic-test identity/name/hash helpers with native tests. `src/project.zig` remains the public parser/list facade and still owns most loader, resource parser, and resolver orchestration code until follow-up extractions move those pieces behind focused internal modules.
573573
- `dxt parse` now targets the supported Tier 0 subset: project name/model paths/seed paths/macro paths, project and package model path configs for literal `+materialized`, `+tags`, and model/seed `+docs.node_color`, root-project model config overrides for installed packages, SQL model discovery, CSV seed discovery, installed package SQL model and CSV seed discovery from `dbt_packages`, source discovery, installed package source discovery, exposure discovery, installed package exposure discovery, project macro discovery, installed package macro discovery from `dbt_packages`, macro property YAML for project macro descriptions and arguments, project and package docs block discovery, literal `ref` to models or seeds, two-argument package refs, package-local refs in installed package models and exposures, unique installed-package fallback for unqualified refs, literal `source`, package-local sources in installed package models, unique installed-package fallback for unqualified sources, literal `doc` in project and package descriptions, inline `config(materialized=..., tags=...)`, known project/package-qualified/package-local macro call dependencies, narrow project and package YAML model properties for scalar descriptions, simple columns, tags, materialization, disabled SQL models, dbt-shaped `unique`, `not_null`, `accepted_values`, and `relationships` generic test nodes, model/test `refs` and `sources` artifact fields, dependency maps, and deterministic partial `manifest.json`. The manifest includes the v12 top-level maps needed by the M1 artifact shape and is covered by a pinned local dbt Manifest v12 schema slice. YAML generic test arguments are currently supported for scalar values plus inline and block lists required by public Jaffle Shop DuckDB-style tests.
574574
- `dxt ls` now lists dbt-selectable resources from the same parser graph with stable text/JSON output and basic name/FQN wildcards, tag wildcards, slash-aware `path:` wildcards, exact `package:`/`package:this`, `source:` wildcards including package-qualified source selectors, `exposure:` wildcards, `resource_type:`, `test_type:generic`, config materialization, comma intersection, whitespace union, multi-argument selector lists, repeated selector flags, leading/trailing `+` graph expansion, and exact exclude filters; macros are emitted in artifacts but not exposed as `ls` resources.
575575
- Synthetic fixtures cover one model, model refs, seed refs, source refs, exposure refs to models and sources, combined source/model YAML, inline config/tag selection, config materialization selection, comma-intersection selection, YAML model properties and columns, emitted `unique`, `not_null`, `accepted_values`, and `relationships` generic test nodes, project macro artifacts and macro properties, configured `macro-paths` replacing the default macro directory, installed package macros with package-qualified calls and package-local macro calls, installed package models, seeds, sources, docs, exposures, package YAML model properties, root package config overrides, and package-qualified/package-local refs/sources, macro calls recorded in model and macro `depends_on.macros`, docs blocks with literal `doc` descriptions, disabled models, disabled ref diagnostics, unmatched model-property warnings, duplicate model and docs diagnostics, unsupported dynamic ref/doc diagnostics, missing doc diagnostics, malformed docs block diagnostics, unresolved package macro diagnostics, and unsupported unknown macro-call diagnostics.

src/project.zig

Lines changed: 2 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -58,27 +58,18 @@ const parseJsonScalar = project_parse.parseJsonScalar;
5858
const refDepFromValue = project_parse.refDepFromValue;
5959
const synthesizeGenericTestNames = project_parse.synthesizeGenericTestNames;
6060
const testNameFromYamlItem = project_parse.testNameFromYamlItem;
61-
const findKeyword = project_jinja.findKeyword;
6261
const findMatchingParen = project_jinja.findMatchingParen;
63-
const findValueStart = project_jinja.findValueStart;
6462
const isIdentChar = project_jinja.isIdentChar;
6563
const isIdentStart = project_jinja.isIdentStart;
6664
const parseLiteralArgs = project_jinja.parseLiteralArgs;
67-
const parseQuoted = project_jinja.parseQuoted;
68-
const readJinjaCall = project_jinja.readJinjaCall;
69-
const skipQuotedSpan = project_jinja.skipQuotedSpan;
7065
const skipWs = project_jinja.skipWs;
7166
const appendUnique = util.appendUnique;
7267
const countActiveExposures = project_resolve.countActiveExposures;
7368
const countActiveNodes = project_resolve.countActiveNodes;
7469
const countActiveSeeds = project_resolve.countActiveSeeds;
7570
const findDoc = project_resolve.findDoc;
76-
const findMacroIdByPackageAndName = project_resolve.findMacroIdByPackageAndName;
77-
const findMacroIdForUnqualifiedCall = project_resolve.findMacroIdForUnqualifiedCall;
7871
const findMacroIndexByPackageAndName = project_resolve.findMacroIndexByPackageAndName;
7972
const findModelIndexByName = project_resolve.findModelIndexByName;
80-
const hasMacroPackage = project_resolve.hasMacroPackage;
81-
const packageNameFromMacroUniqueId = project_resolve.packageNameFromMacroUniqueId;
8273
const rejectDuplicateDocs = project_resolve.rejectDuplicateDocs;
8374
const rejectDuplicateExposures = project_resolve.rejectDuplicateExposures;
8475
const rejectDuplicateMacroProperties = project_resolve.rejectDuplicateMacroProperties;
@@ -982,7 +973,7 @@ fn parseModel(runtime: Runtime, project_dir: []const u8, model_root: []const u8,
982973
errdefer {
983974
deinitNode(runtime.allocator, &node);
984975
}
985-
try scanSql(runtime.allocator, sql, &node, graph);
976+
try project_jinja.scanSql(runtime.allocator, sql, &node, graph);
986977
try graph.nodes.append(runtime.allocator, node);
987978
}
988979

@@ -1155,7 +1146,7 @@ fn appendColumnClone(graph: *Graph, package_name: []const u8, columns: *std.Arra
11551146

11561147
fn resolveMacroDependencies(graph: *Graph) !void {
11571148
for (graph.macros.items) |*macro| {
1158-
try scanMacroSqlForKnownMacroCalls(graph.allocator, macro.macro_sql, graph, macro.unique_id, &macro.macro_depends_on);
1149+
try project_jinja.scanMacroSqlForKnownMacroCalls(graph.allocator, macro.macro_sql, graph, macro.unique_id, &macro.macro_depends_on);
11591150
sortStrings(macro.macro_depends_on.items);
11601151
}
11611152
}
@@ -1182,195 +1173,6 @@ fn resolveDocDescription(graph: *Graph, package_name: []const u8, description: [
11821173
return doc.block_contents;
11831174
}
11841175

1185-
fn scanSql(allocator: std.mem.Allocator, sql: []const u8, node: *Node, graph: ?*const Graph) !void {
1186-
var index: usize = 0;
1187-
while (index + 1 < sql.len) {
1188-
if (sql[index] != '{') {
1189-
index += 1;
1190-
continue;
1191-
}
1192-
if (sql[index + 1] == '#') {
1193-
const end = std.mem.indexOfPos(u8, sql, index + 2, "#}") orelse return error.UnsupportedJinja;
1194-
index = end + 2;
1195-
continue;
1196-
}
1197-
const close = if (sql[index + 1] == '{')
1198-
std.mem.indexOfPos(u8, sql, index + 2, "}}")
1199-
else if (sql[index + 1] == '%')
1200-
std.mem.indexOfPos(u8, sql, index + 2, "%}")
1201-
else
1202-
null;
1203-
if (close) |end| {
1204-
try scanJinjaSpan(allocator, sql[index + 2 .. end], node, graph);
1205-
index = end + 2;
1206-
continue;
1207-
}
1208-
index += 1;
1209-
}
1210-
}
1211-
1212-
fn scanJinjaSpan(allocator: std.mem.Allocator, span: []const u8, node: *Node, graph: ?*const Graph) !void {
1213-
var i: usize = 0;
1214-
while (i < span.len) {
1215-
if (span[i] == '"' or span[i] == '\'') {
1216-
i = skipQuotedSpan(span, i) orelse return error.UnsupportedJinja;
1217-
continue;
1218-
}
1219-
if (!isIdentStart(span[i])) {
1220-
i += 1;
1221-
continue;
1222-
}
1223-
const start = i;
1224-
i += 1;
1225-
while (i < span.len and isIdentChar(span[i])) i += 1;
1226-
const ident = span[start..i];
1227-
const call = (try readJinjaCall(span, ident, i)) orelse continue;
1228-
const args = span[call.open + 1 .. call.close];
1229-
1230-
if (call.package_name) |package_name| {
1231-
if (graph) |known_graph| {
1232-
if (findMacroIdByPackageAndName(known_graph, package_name, call.name)) |macro_id| {
1233-
try appendUnique(allocator, &node.macro_depends_on, macro_id);
1234-
i = call.close + 1;
1235-
continue;
1236-
}
1237-
if (hasMacroPackage(known_graph, package_name)) return error.UnresolvedMacro;
1238-
}
1239-
return error.UnsupportedJinja;
1240-
} else if (std.mem.eql(u8, call.name, "ref")) {
1241-
var strings = try parseLiteralArgs(allocator, args, error.UnsupportedDynamicRef);
1242-
defer strings.deinit(allocator);
1243-
if (!(strings.items.len == 1 or strings.items.len == 2)) return error.UnsupportedDynamicRef;
1244-
try node.refs.append(allocator, .{
1245-
.package = if (strings.items.len == 2) strings.items[0] else null,
1246-
.name = if (strings.items.len == 2) strings.items[1] else strings.items[0],
1247-
});
1248-
} else if (std.mem.eql(u8, call.name, "source")) {
1249-
var strings = try parseLiteralArgs(allocator, args, error.UnsupportedDynamicSource);
1250-
defer strings.deinit(allocator);
1251-
if (strings.items.len != 2) return error.UnsupportedDynamicSource;
1252-
try node.source_refs.append(allocator, .{
1253-
.source_name = strings.items[0],
1254-
.table_name = strings.items[1],
1255-
});
1256-
} else if (std.mem.eql(u8, call.name, "config")) {
1257-
try parseConfig(allocator, args, node);
1258-
} else {
1259-
if (graph) |known_graph| {
1260-
if (findMacroIdForUnqualifiedCall(known_graph, node.package_name, call.name)) |macro_id| {
1261-
try appendUnique(allocator, &node.macro_depends_on, macro_id);
1262-
i = call.close + 1;
1263-
continue;
1264-
}
1265-
}
1266-
return error.UnsupportedJinja;
1267-
}
1268-
i = call.close + 1;
1269-
}
1270-
}
1271-
1272-
fn scanMacroSqlForKnownMacroCalls(allocator: std.mem.Allocator, sql: []const u8, graph: *const Graph, current_macro_id: []const u8, macro_depends_on: *std.ArrayList([]const u8)) !void {
1273-
var index: usize = 0;
1274-
while (index + 1 < sql.len) {
1275-
if (sql[index] != '{') {
1276-
index += 1;
1277-
continue;
1278-
}
1279-
if (sql[index + 1] == '#') {
1280-
const end = std.mem.indexOfPos(u8, sql, index + 2, "#}") orelse break;
1281-
index = end + 2;
1282-
continue;
1283-
}
1284-
const close = if (sql[index + 1] == '{')
1285-
std.mem.indexOfPos(u8, sql, index + 2, "}}")
1286-
else if (sql[index + 1] == '%')
1287-
std.mem.indexOfPos(u8, sql, index + 2, "%}")
1288-
else
1289-
null;
1290-
if (close) |end| {
1291-
try scanMacroSpanForKnownMacroCalls(allocator, sql[index + 2 .. end], graph, current_macro_id, macro_depends_on);
1292-
index = end + 2;
1293-
continue;
1294-
}
1295-
index += 1;
1296-
}
1297-
}
1298-
1299-
fn scanMacroSpanForKnownMacroCalls(allocator: std.mem.Allocator, span: []const u8, graph: *const Graph, current_macro_id: []const u8, macro_depends_on: *std.ArrayList([]const u8)) !void {
1300-
var i: usize = 0;
1301-
const current_package = packageNameFromMacroUniqueId(current_macro_id) orelse graph.project_name;
1302-
while (i < span.len) {
1303-
if (span[i] == '"' or span[i] == '\'') {
1304-
i = skipQuotedSpan(span, i) orelse break;
1305-
continue;
1306-
}
1307-
if (!isIdentStart(span[i])) {
1308-
i += 1;
1309-
continue;
1310-
}
1311-
const start = i;
1312-
i += 1;
1313-
while (i < span.len and isIdentChar(span[i])) i += 1;
1314-
const ident = span[start..i];
1315-
const call = (readJinjaCall(span, ident, i) catch break) orelse continue;
1316-
const macro_id = if (call.package_name) |package_name| blk: {
1317-
const resolved = findMacroIdByPackageAndName(graph, package_name, call.name);
1318-
if (resolved == null and hasMacroPackage(graph, package_name)) return error.UnresolvedMacro;
1319-
break :blk resolved;
1320-
} else findMacroIdForUnqualifiedCall(graph, current_package, call.name);
1321-
if (macro_id) |resolved_macro_id| {
1322-
if (std.mem.eql(u8, resolved_macro_id, current_macro_id)) {
1323-
i = call.close + 1;
1324-
continue;
1325-
}
1326-
try appendUnique(allocator, macro_depends_on, resolved_macro_id);
1327-
}
1328-
i = call.close + 1;
1329-
}
1330-
}
1331-
1332-
fn parseConfig(allocator: std.mem.Allocator, args: []const u8, node: *Node) !void {
1333-
if (findKeyword(args, "materialized")) |pos| {
1334-
if (findValueStart(args, pos + "materialized".len)) |value_pos| {
1335-
if (args[value_pos] != '"' and args[value_pos] != '\'') return error.UnsupportedJinja;
1336-
const parsed = try parseQuoted(allocator, args, value_pos);
1337-
node.materialized = parsed.value;
1338-
node.inline_materialized = true;
1339-
}
1340-
}
1341-
if (findKeyword(args, "tags")) |pos| {
1342-
if (findValueStart(args, pos + "tags".len)) |value_pos| {
1343-
try parseTagList(allocator, args[value_pos..], &node.tags);
1344-
node.inline_tags = true;
1345-
sortStrings(node.tags.items);
1346-
}
1347-
}
1348-
}
1349-
1350-
fn parseTagList(allocator: std.mem.Allocator, text: []const u8, tags: *std.ArrayList([]const u8)) !void {
1351-
var i = skipWs(text, 0);
1352-
if (i >= text.len) return;
1353-
if (text[i] == '"' or text[i] == '\'') {
1354-
const parsed = try parseQuoted(allocator, text, i);
1355-
try appendUnique(allocator, tags, parsed.value);
1356-
return;
1357-
}
1358-
if (text[i] != '[') return error.UnsupportedJinja;
1359-
i += 1;
1360-
while (i < text.len) {
1361-
i = skipWs(text, i);
1362-
if (i >= text.len or text[i] == ']') break;
1363-
if (text[i] == ',') {
1364-
i += 1;
1365-
continue;
1366-
}
1367-
if (text[i] != '"' and text[i] != '\'') return error.UnsupportedJinja;
1368-
const parsed = try parseQuoted(allocator, text, i);
1369-
try appendUnique(allocator, tags, parsed.value);
1370-
i = parsed.next;
1371-
}
1372-
}
1373-
13741176
fn appendMacroArgumentClone(graph: *Graph, arguments: *std.ArrayList(MacroArgument), source: MacroArgument) !void {
13751177
for (arguments.items) |*existing| {
13761178
if (std.mem.eql(u8, existing.name, source.name)) {
@@ -1425,41 +1227,3 @@ fn sortColumns(columns: []ColumnDef) void {
14251227
}
14261228
}.lessThan);
14271229
}
1428-
1429-
test "sql scanner extracts refs sources and config tags from jinja spans" {
1430-
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1431-
defer arena.deinit();
1432-
const allocator = arena.allocator();
1433-
1434-
var node = Node{
1435-
.package_name = "demo",
1436-
.unique_id = "model.demo.customers",
1437-
.name = "customers",
1438-
.path = "customers.sql",
1439-
.original_file_path = "models/customers.sql",
1440-
.raw_code = "",
1441-
};
1442-
defer {
1443-
node.tags.deinit(allocator);
1444-
node.refs.deinit(allocator);
1445-
node.source_refs.deinit(allocator);
1446-
node.depends_on.deinit(allocator);
1447-
node.macro_depends_on.deinit(allocator);
1448-
}
1449-
1450-
try scanSql(allocator,
1451-
\\{{ config(materialized="table", tags=["nightly", 'core']) }}
1452-
\\select * from {{ ref("stg_customers") }}
1453-
\\union all select * from {{ source('raw', "customers") }}
1454-
\\select {{ "ref('not_a_dependency')" }} as literal_ref
1455-
\\{# {{ ref("ignored") }} #}
1456-
, &node, null);
1457-
1458-
try std.testing.expectEqual(@as(usize, 1), node.refs.items.len);
1459-
try std.testing.expectEqualStrings("stg_customers", node.refs.items[0].name);
1460-
try std.testing.expectEqual(@as(usize, 1), node.source_refs.items.len);
1461-
try std.testing.expectEqualStrings("raw", node.source_refs.items[0].source_name);
1462-
try std.testing.expectEqualStrings("customers", node.source_refs.items[0].table_name);
1463-
try std.testing.expectEqualStrings("table", node.materialized);
1464-
try std.testing.expectEqual(@as(usize, 2), node.tags.items.len);
1465-
}

0 commit comments

Comments
 (0)