diff --git a/.claude/skills/drift/SKILL.md b/.claude/skills/drift/SKILL.md index 26499d0..baff949 100644 --- a/.claude/skills/drift/SKILL.md +++ b/.claude/skills/drift/SKILL.md @@ -3,10 +3,10 @@ name: drift description: Drift spec-to-code anchor conventions. Use when editing code that is bound by drift specs, updating specs, working with drift frontmatter, or when drift check reports stale anchors. drift: files: - - src/main.zig@d7d189a - - src/frontmatter.zig@d7d189a - - src/scanner.zig@d7d189a - - src/vcs.zig@d7d189a + - src/main.zig@a7ffa398 + - src/frontmatter.zig@a7ffa398 + - src/scanner.zig@a7ffa398 + - src/vcs.zig@a7ffa398 --- # Drift diff --git a/README.md b/README.md index 0561cd4..ec35be0 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,9 @@ If you don't want frontmatter visible on GitHub, use an HTML comment instead: ``` drift check Check all specs for staleness (exits 1 if stale) -drift status Show all specs and their anchors +drift status Show all spec anchors, including inline @./ refs drift link Add an anchor to a spec (auto-appends provenance) -drift unlink Remove an anchor from a spec +drift unlink Remove an anchor from frontmatter or drift comments ``` `drift lint` is an alias for `drift check`. diff --git a/docs/CLI.md b/docs/CLI.md index 1c8b23a..d477199 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,12 +1,12 @@ --- drift: files: - - src/main.zig@d7d189a + - src/main.zig@a7ffa398 --- # CLI Reference -All commands support `--format json` for tool integration. +`drift status` supports `--format json` for tool integration. Usage and command errors exit non-zero. ## drift lint @@ -39,7 +39,7 @@ docs/project.md ## drift status -Show all specs and their anchors without checking staleness. +Show all specs and their anchors without checking staleness. This includes explicit frontmatter anchors, `` comment anchors, and inline `@./path` references from the spec body. ``` drift status [--format json] @@ -87,7 +87,7 @@ If the spec file doesn't have `drift:` frontmatter yet, it's added. If the file ## drift unlink -Remove an anchor from a spec's frontmatter. +Remove an anchor from a spec's YAML frontmatter or `` comment block. ``` drift unlink diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index db89517..b19d492 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,9 +1,9 @@ --- drift: files: - - src/main.zig@d7d189a - - src/symbols.zig@d7d189a - - src/vcs.zig@d7d189a + - src/main.zig@a7ffa398 + - src/symbols.zig@a7ffa398 + - src/vcs.zig@a7ffa398 --- # Decisions diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d747020..d7cb540 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,11 +1,11 @@ --- drift: files: - - src/main.zig@d7d189a - - src/frontmatter.zig@d7d189a - - src/scanner.zig@d7d189a - - src/symbols.zig@d7d189a - - src/vcs.zig@d7d189a + - src/main.zig@a7ffa398 + - src/frontmatter.zig@a7ffa398 + - src/scanner.zig@a7ffa398 + - src/symbols.zig@a7ffa398 + - src/vcs.zig@a7ffa398 --- # Design diff --git a/src/frontmatter.zig b/src/frontmatter.zig index d81c447..b2936e6 100644 --- a/src/frontmatter.zig +++ b/src/frontmatter.zig @@ -435,7 +435,42 @@ pub fn linkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: []c const frontmatter = after_open[0..close_offset]; // text between the two --- const body_start = 4 + close_offset + 5; // skip opening "---\n" + frontmatter + "\n---\n" - // Process the frontmatter lines + var frontmatter_has_drift = false; + var frontmatter_lines = std.mem.splitScalar(u8, frontmatter, '\n'); + while (frontmatter_lines.next()) |line| { + if (std.mem.eql(u8, line, "drift:") or std.mem.startsWith(u8, line, "drift:")) { + frontmatter_has_drift = true; + break; + } + } + + if (!frontmatter_has_drift) { + if (hasCommentAnchors(content)) { + return try linkCommentAnchor(allocator, content, anchor); + } + + var output: std.ArrayList(u8) = .{}; + defer output.deinit(allocator); + const writer = output.writer(allocator); + + try writer.writeAll("---\n"); + try writer.writeAll(frontmatter); + if (frontmatter.len > 0) { + try writer.writeByte('\n'); + } + try writer.writeAll("drift:\n"); + try writer.writeAll(" files:\n"); + try writer.print(" - {s}\n", .{anchor}); + try writer.writeAll("---\n"); + + if (body_start <= content.len) { + try writer.writeAll(content[body_start..]); + } + + return try allocator.dupe(u8, output.items); + } + + // Process the existing drift frontmatter lines var output: std.ArrayList(u8) = .{}; defer output.deinit(allocator); const writer = output.writer(allocator); @@ -443,12 +478,34 @@ pub fn linkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: []c try writer.writeAll("---\n"); var found_existing = false; - var in_files_section = false; var wrote_anchor = false; + var in_drift_section = false; + var in_files_section = false; + var saw_files_section = false; var lines_iter = std.mem.splitScalar(u8, frontmatter, '\n'); while (lines_iter.next()) |line| { - if (std.mem.startsWith(u8, line, " files:")) { + const is_top_level = line.len > 0 and !std.mem.startsWith(u8, line, " "); + + if (in_drift_section and !in_files_section and is_top_level) { + if (!saw_files_section) { + try writer.writeAll(" files:\n"); + try writer.print(" - {s}\n", .{anchor}); + wrote_anchor = true; + saw_files_section = true; + } + in_drift_section = false; + } + + if (std.mem.eql(u8, line, "drift:") or std.mem.startsWith(u8, line, "drift:")) { + in_drift_section = true; + try writer.writeAll(line); + try writer.writeByte('\n'); + continue; + } + + if (in_drift_section and std.mem.startsWith(u8, line, " files:")) { + saw_files_section = true; in_files_section = true; try writer.writeAll(line); try writer.writeByte('\n'); @@ -460,21 +517,18 @@ pub fn linkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: []c const existing_identity = anchorFileIdentity(existing_anchor); if (std.mem.eql(u8, existing_identity, new_identity)) { - // Replace this line with the new anchor try writer.print(" - {s}\n", .{anchor}); found_existing = true; wrote_anchor = true; continue; } - // Keep the existing line + try writer.writeAll(line); try writer.writeByte('\n'); continue; } - // If we were in files section and hit a non-list line, we left it if (in_files_section and !std.mem.startsWith(u8, line, " - ")) { - // Before leaving files section, append new anchor if not found if (!found_existing and !wrote_anchor) { try writer.print(" - {s}\n", .{anchor}); wrote_anchor = true; @@ -486,14 +540,17 @@ pub fn linkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: []c try writer.writeByte('\n'); } - // If we were still in files section at end of frontmatter, append if (!wrote_anchor) { - try writer.print(" - {s}\n", .{anchor}); + if (saw_files_section) { + try writer.print(" - {s}\n", .{anchor}); + } else { + try writer.writeAll(" files:\n"); + try writer.print(" - {s}\n", .{anchor}); + } } try writer.writeAll("---\n"); - // Append the body if (body_start <= content.len) { try writer.writeAll(content[body_start..]); } @@ -599,12 +656,11 @@ pub const UnlinkResult = struct { removed: bool, }; -/// Core logic: given file content and an anchor, produce new file content with the anchor removed. -/// Matches on file identity (stripping @provenance from both the existing anchor and the argument). -pub fn unlinkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: []const u8) !UnlinkResult { - const target_identity = anchorFileIdentity(anchor); - - // Must have YAML frontmatter to contain anchors +fn unlinkFrontmatterAnchor( + allocator: std.mem.Allocator, + content: []const u8, + target_identity: []const u8, +) !UnlinkResult { if (!std.mem.startsWith(u8, content, "---\n")) { return .{ .content = try allocator.dupe(u8, content), .removed = false }; } @@ -615,7 +671,20 @@ pub fn unlinkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: [ }; const frontmatter = after_open[0..close_offset]; - const body_start = 4 + close_offset + 5; // skip opening "---\n" + frontmatter + "\n---\n" + const body_start = 4 + close_offset + 5; + + var frontmatter_has_drift = false; + var frontmatter_lines = std.mem.splitScalar(u8, frontmatter, '\n'); + while (frontmatter_lines.next()) |line| { + if (std.mem.eql(u8, line, "drift:") or std.mem.startsWith(u8, line, "drift:")) { + frontmatter_has_drift = true; + break; + } + } + + if (!frontmatter_has_drift) { + return .{ .content = try allocator.dupe(u8, content), .removed = false }; + } var output: std.ArrayList(u8) = .{}; defer output.deinit(allocator); @@ -624,11 +693,25 @@ pub fn unlinkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: [ try writer.writeAll("---\n"); var removed = false; + var in_drift_section = false; var in_files_section = false; var lines_iter = std.mem.splitScalar(u8, frontmatter, '\n'); while (lines_iter.next()) |line| { - if (std.mem.startsWith(u8, line, " files:")) { + const is_top_level = line.len > 0 and !std.mem.startsWith(u8, line, " "); + + if (in_drift_section and !in_files_section and is_top_level) { + in_drift_section = false; + } + + if (std.mem.eql(u8, line, "drift:") or std.mem.startsWith(u8, line, "drift:")) { + in_drift_section = true; + try writer.writeAll(line); + try writer.writeByte('\n'); + continue; + } + + if (in_drift_section and std.mem.startsWith(u8, line, " files:")) { in_files_section = true; try writer.writeAll(line); try writer.writeByte('\n'); @@ -640,13 +723,11 @@ pub fn unlinkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: [ const existing_identity = anchorFileIdentity(existing_anchor); if (std.mem.eql(u8, existing_identity, target_identity)) { - // Skip this line (remove the anchor) removed = true; continue; } } - // Non-list-item line ends the files section if (in_files_section and !std.mem.startsWith(u8, line, " - ")) { in_files_section = false; } @@ -657,7 +738,6 @@ pub fn unlinkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: [ try writer.writeAll("---\n"); - // Append the body if (body_start <= content.len) { try writer.writeAll(content[body_start..]); } @@ -665,6 +745,96 @@ pub fn unlinkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: [ return .{ .content = try allocator.dupe(u8, output.items), .removed = removed }; } +fn unlinkCommentAnchor( + allocator: std.mem.Allocator, + content: []const u8, + target_identity: []const u8, +) !UnlinkResult { + const marker = "") orelse { + try writer.writeAll(content[pos..]); + break; + }; + const block_content = content[block_start .. block_start + close_offset]; + const block_end = block_start + close_offset; + + try writer.writeAll(content[pos..block_start]); + + var in_files_section = false; + var lines_iter = std.mem.splitScalar(u8, block_content, '\n'); + while (lines_iter.next()) |line| { + const trimmed = std.mem.trimLeft(u8, line, " \t"); + + if (std.mem.startsWith(u8, trimmed, "files:")) { + in_files_section = true; + try writer.writeAll(line); + try writer.writeByte('\n'); + continue; + } + + if (in_files_section and std.mem.startsWith(u8, trimmed, "- ")) { + const existing_anchor = trimmed["- ".len..]; + if (std.mem.eql(u8, anchorFileIdentity(existing_anchor), target_identity)) { + removed = true; + continue; + } + } + + if (in_files_section and trimmed.len > 0 and !std.mem.startsWith(u8, trimmed, "- ")) { + in_files_section = false; + } + + try writer.writeAll(line); + try writer.writeByte('\n'); + } + + pos = block_end; + } + + return .{ .content = try allocator.dupe(u8, output.items), .removed = removed }; +} + +/// Core logic: given file content and an anchor, produce new file content with the anchor removed. +/// Matches on file identity (stripping @provenance from both the existing anchor and the argument). +pub fn unlinkAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: []const u8) !UnlinkResult { + const target_identity = anchorFileIdentity(anchor); + + var result = try unlinkFrontmatterAnchor(allocator, content, target_identity); + errdefer allocator.free(result.content); + + if (hasCommentAnchors(result.content)) { + const comment_result = try unlinkCommentAnchor(allocator, result.content, target_identity); + allocator.free(result.content); + result = .{ + .content = comment_result.content, + .removed = result.removed or comment_result.removed, + }; + } + + return result; +} + // --- unit tests for unlinkAnchor --- test "unlinkAnchor removes matching anchor" { @@ -704,6 +874,41 @@ test "unlinkAnchor removes symbol anchor" { try std.testing.expect(std.mem.indexOf(u8, result.content, "src/lib.ts#Foo") == null); } +test "unlinkAnchor removes comment-based anchor" { + const allocator = std.testing.allocator; + const content = + "# Doc\n\n" ++ + "\n"; + const result = try unlinkAnchor(allocator, content, "src/a.ts"); + defer allocator.free(result.content); + + try std.testing.expect(result.removed); + try std.testing.expect(std.mem.indexOf(u8, result.content, "src/a.ts") == null); + try std.testing.expect(std.mem.indexOf(u8, result.content, "src/b.ts") != null); +} + +test "unlinkAnchor removes comment-based anchor with unrelated frontmatter" { + const allocator = std.testing.allocator; + const content = + "---\n" ++ + "title: My Doc\n" ++ + "---\n\n" ++ + "\n"; + const result = try unlinkAnchor(allocator, content, "src/a.ts"); + defer allocator.free(result.content); + + try std.testing.expect(result.removed); + try std.testing.expect(std.mem.indexOf(u8, result.content, "title: My Doc") != null); + try std.testing.expect(std.mem.indexOf(u8, result.content, "src/a.ts") == null); +} + // --- unit tests for linkAnchor --- test "linkAnchor adds anchor to empty files list" { @@ -735,12 +940,66 @@ test "linkAnchor adds frontmatter to plain markdown" { try std.testing.expect(std.mem.indexOf(u8, result, "# Just a plain markdown file") != null); } +test "linkAnchor preserves existing non-drift frontmatter" { + const allocator = std.testing.allocator; + const content = + "---\n" ++ + "title: My Doc\n" ++ + "tags:\n" ++ + " - docs\n" ++ + "---\n" ++ + "# Spec\n"; + const result = try linkAnchor(allocator, content, "src/target.ts"); + defer allocator.free(result); + + try std.testing.expect(std.mem.indexOf(u8, result, "title: My Doc") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "tags:") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "drift:") != null); + try std.testing.expect(std.mem.indexOf(u8, result, " files:") != null); + try std.testing.expect(std.mem.indexOf(u8, result, " - src/target.ts") != null); + + var anchors = parseDriftSpec(allocator, result) orelse return error.TestUnexpectedResult; + defer { + for (anchors.items) |b| allocator.free(b); + anchors.deinit(allocator); + } + try std.testing.expectEqual(@as(usize, 1), anchors.items.len); + try std.testing.expectEqualStrings("src/target.ts", anchors.items[0]); +} + +test "linkAnchor adds files section when drift exists without files" { + const allocator = std.testing.allocator; + const content = + "---\n" ++ + "drift:\n" ++ + " owner: docs\n" ++ + "title: My Doc\n" ++ + "---\n" ++ + "# Spec\n"; + const result = try linkAnchor(allocator, content, "src/target.ts"); + defer allocator.free(result); + + try std.testing.expect(std.mem.indexOf(u8, result, " owner: docs") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "title: My Doc") != null); + try std.testing.expect(std.mem.indexOf(u8, result, " files:") != null); + try std.testing.expect(std.mem.indexOf(u8, result, " - src/target.ts") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "title: My Doc\n files:") == null); + + var anchors = parseDriftSpec(allocator, result) orelse return error.TestUnexpectedResult; + defer { + for (anchors.items) |b| allocator.free(b); + anchors.deinit(allocator); + } + try std.testing.expectEqual(@as(usize, 1), anchors.items.len); + try std.testing.expectEqualStrings("src/target.ts", anchors.items[0]); +} + // --- unit tests for comment-based anchors --- test "parseDriftSpec parses comment-based anchors" { const allocator = std.testing.allocator; const content = "# My Doc\n\n\n\nSome content.\n"; - const anchors = parseDriftSpec(allocator, content) orelse { + var anchors = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { @@ -755,7 +1014,7 @@ test "parseDriftSpec parses comment-based anchors" { test "parseDriftSpec merges frontmatter and comment anchors" { const allocator = std.testing.allocator; const content = "---\ndrift:\n files:\n - src/a.ts\n---\n\n\n"; - const anchors = parseDriftSpec(allocator, content) orelse { + var anchors = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { @@ -768,7 +1027,7 @@ test "parseDriftSpec merges frontmatter and comment anchors" { test "parseDriftSpec parses comment with provenance" { const allocator = std.testing.allocator; const content = "\n"; - const anchors = parseDriftSpec(allocator, content) orelse { + var anchors = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { @@ -797,6 +1056,26 @@ test "linkAnchor adds to comment-based anchor" { try std.testing.expect(std.mem.indexOf(u8, result, "src/new.ts@abc") != null); } +test "linkAnchor preserves comment-based drift when unrelated frontmatter exists" { + const allocator = std.testing.allocator; + const content = + "---\n" ++ + "title: My Doc\n" ++ + "---\n\n" ++ + "\n\n" ++ + "Body.\n"; + const result = try linkAnchor(allocator, content, "src/new.ts@abc"); + defer allocator.free(result); + + try std.testing.expect(std.mem.indexOf(u8, result, "title: My Doc") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "src/existing.ts") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "src/new.ts@abc") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "---\n\n\n\nBody.\n"; @@ -824,7 +1103,7 @@ test "parseCommentAnchors skips markers inside fenced code blocks" { \\--> \\``` ; - const anchors = parseDriftSpec(allocator, content) orelse { + var anchors = parseDriftSpec(allocator, content) orelse { return error.TestUnexpectedResult; }; defer { diff --git a/src/main.zig b/src/main.zig index 60a642c..914a92f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -75,16 +75,16 @@ pub fn main() !void { switch (command) { .check, .lint => runLint(allocator, &stdout_w.interface, &stderr_w.interface) catch |err| { - stderr_w.interface.print("lint error: {s}\n", .{@errorName(err)}) catch {}; + exitWithCommandError(&stderr_w.interface, "lint", err); }, .status => runStatus(allocator, &stdout_w.interface, &stderr_w.interface) catch |err| { - stderr_w.interface.print("status error: {s}\n", .{@errorName(err)}) catch {}; + exitWithCommandError(&stderr_w.interface, "status", err); }, .link => runLink(allocator, &stdout_w.interface, &stderr_w.interface) catch |err| { - stderr_w.interface.print("link error: {s}\n", .{@errorName(err)}) catch {}; + exitWithCommandError(&stderr_w.interface, "link", err); }, .unlink => runUnlink(allocator, &stdout_w.interface, &stderr_w.interface) catch |err| { - stderr_w.interface.print("unlink error: {s}\n", .{@errorName(err)}) catch {}; + exitWithCommandError(&stderr_w.interface, "unlink", err); }, .help => printUsage(&stdout_w.interface), } @@ -109,48 +109,58 @@ fn printUsage(w: *std.io.Writer) void { , .{}) catch {}; } -fn runLint(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *std.io.Writer) !void { - var specs: std.ArrayList(Spec) = .{}; - defer { - for (specs.items) |*s| s.deinit(allocator); - specs.deinit(allocator); - } +fn exitWithCommandError(stderr_w: *std.io.Writer, command: []const u8, err: anyerror) noreturn { + stderr_w.print("{s} error: {s}\n", .{ command, @errorName(err) }) catch {}; + stderr_w.flush() catch {}; + std.process.exit(1); +} - try scanner.findSpecs(allocator, &specs); +fn loadSpecsWithInlineAnchors(allocator: std.mem.Allocator, specs: *std.ArrayList(Spec)) !void { + try scanner.findSpecs(allocator, specs); - // Parse inline anchors from body content of each spec for (specs.items) |*spec| { - const content = std.fs.cwd().readFileAlloc(allocator, spec.path, 1024 * 1024) catch continue; + const content = try std.fs.cwd().readFileAlloc(allocator, spec.path, 1024 * 1024); defer allocator.free(content); var inline_anchors = scanner.parseInlineAnchors(allocator, content); - for (inline_anchors.items) |ib| { - // Avoid duplicates + defer inline_anchors.deinit(allocator); + + for (inline_anchors.items) |anchor| { var already_bound = false; for (spec.anchors.items) |existing| { - if (std.mem.eql(u8, existing, ib)) { + if (std.mem.eql(u8, existing, anchor)) { already_bound = true; break; } } - if (!already_bound) { - spec.anchors.append(allocator, ib) catch { - allocator.free(ib); - continue; - }; - } else { - allocator.free(ib); + + if (already_bound) { + allocator.free(anchor); + continue; } + + spec.anchors.append(allocator, anchor) catch |err| { + allocator.free(anchor); + return err; + }; } - inline_anchors.deinit(allocator); } - // Sort specs by path for deterministic output std.mem.sort(Spec, specs.items, {}, struct { fn lessThan(_: void, a: Spec, b: Spec) bool { return std.mem.order(u8, a.path, b.path) == .lt; } }.lessThan); +} + +fn runLint(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *std.io.Writer) !void { + var specs: std.ArrayList(Spec) = .{}; + defer { + for (specs.items) |*s| s.deinit(allocator); + specs.deinit(allocator); + } + + try loadSpecsWithInlineAnchors(allocator, &specs); // Get absolute cwd for VCS commands const cwd_path = try std.fs.cwd().realpathAlloc(allocator, "."); @@ -170,7 +180,7 @@ fn runLint(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *st // Get last commit/change that touched the spec file const spec_commit = vcs.getLastCommit(allocator, cwd_path, spec.path, detected_vcs) catch |err| { stderr_w.print("vcs error for {s}: {s}\n", .{ spec.path, @errorName(err) }) catch {}; - continue; + return error.LintCheckFailed; }; defer if (spec_commit) |c| allocator.free(c); @@ -179,7 +189,7 @@ fn runLint(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *st for (spec.anchors.items) |anchor| { const status = checkAnchor(allocator, cwd_path, anchor, spec_commit, detected_vcs) catch |err| { stderr_w.print("error checking {s}: {s}\n", .{ anchor, @errorName(err) }) catch {}; - continue; + return error.LintCheckFailed; }; defer status.deinit(allocator); @@ -452,14 +462,7 @@ fn runStatus(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: * specs.deinit(allocator); } - try scanner.findSpecs(allocator, &specs); - - // Sort specs by path for deterministic output - std.mem.sort(Spec, specs.items, {}, struct { - fn lessThan(_: void, a: Spec, b: Spec) bool { - return std.mem.order(u8, a.path, b.path) == .lt; - } - }.lessThan); + try loadSpecsWithInlineAnchors(allocator, &specs); if (format_json) { writeSpecsJson(stdout_w, specs.items); @@ -494,17 +497,17 @@ fn writeSpecsText(w: *std.io.Writer, specs: []const Spec) void { } fn writeSpecsJson(w: *std.io.Writer, specs: []const Spec) void { - w.print("[", .{}) catch {}; - for (specs, 0..) |spec, idx| { - if (idx > 0) w.print(",", .{}) catch {}; - w.print("{{\"spec\":\"{s}\",\"files\":[", .{spec.path}) catch {}; - for (spec.anchors.items, 0..) |anchor, bidx| { - if (bidx > 0) w.print(",", .{}) catch {}; - w.print("\"{s}\"", .{anchor}) catch {}; - } - w.print("]}}", .{}) catch {}; + var json_w: std.json.Stringify = .{ .writer = w, .options = .{} }; + + json_w.beginArray() catch return; + for (specs) |spec| { + json_w.write(.{ + .spec = spec.path, + .files = spec.anchors.items, + }) catch return; } - w.print("]\n", .{}) catch {}; + json_w.endArray() catch return; + w.writeByte('\n') catch {}; } fn runUnlink(allocator: std.mem.Allocator, stdout_w: *std.io.Writer, stderr_w: *std.io.Writer) !void { diff --git a/src/scanner.zig b/src/scanner.zig index 73c0c58..0f019be 100644 --- a/src/scanner.zig +++ b/src/scanner.zig @@ -207,5 +207,43 @@ pub fn isPathTerminator(c: u8) bool { } pub fn isTrailingPunctuation(c: u8) bool { - return c == '.' or c == ',' or c == ';' or c == ':' or c == ')' or c == ']' or c == '}' or c == '!' or c == '?'; + return c == '.' or c == ',' or c == ';' or c == ':' or c == ')' or c == ']' or c == '}' or c == '!' or c == '?' or c == '"' or c == '\'' or c == '>'; +} + +// --- unit tests --- + +test "parseInlineAnchors strips surrounding quote punctuation" { + const allocator = std.testing.allocator; + const content = + \\# Spec + \\ + \\See "@./src/main.ts" and '@./src/lib.ts#Foo'. + \\ + ; + + var anchors = parseInlineAnchors(allocator, content); + defer { + for (anchors.items) |anchor| allocator.free(anchor); + anchors.deinit(allocator); + } + + try std.testing.expectEqual(@as(usize, 2), anchors.items.len); + try std.testing.expectEqualStrings("src/main.ts", anchors.items[0]); + try std.testing.expectEqualStrings("src/lib.ts#Foo", anchors.items[1]); +} + +test "updateInlineAnchors preserves surrounding quote punctuation" { + const allocator = std.testing.allocator; + const content = + \\# Spec + \\ + \\See "@./src/main.ts" and '@./src/lib.ts#Foo'. + \\ + ; + + const result = try updateInlineAnchors(allocator, content, null, "abc123"); + defer allocator.free(result); + + try std.testing.expect(std.mem.indexOf(u8, result, "\"@./src/main.ts@abc123\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "'@./src/lib.ts#Foo@abc123'") != null); } diff --git a/test/integration/link_test.zig b/test/integration/link_test.zig index 48c6a6a..888bd9e 100644 --- a/test/integration/link_test.zig +++ b/test/integration/link_test.zig @@ -1,6 +1,18 @@ const std = @import("std"); const helpers = @import("helpers"); +test "link exits non-zero when required arguments are missing" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + const result = try repo.runDrift(&.{"link"}); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 1); + try helpers.expectContains(result.stderr, "usage: drift link [anchor]"); +} + test "link adds new file anchor to spec" { const allocator = std.testing.allocator; var repo = try helpers.TempRepo.init(allocator); diff --git a/test/integration/status_test.zig b/test/integration/status_test.zig index 11904f9..9b55c69 100644 --- a/test/integration/status_test.zig +++ b/test/integration/status_test.zig @@ -51,20 +51,51 @@ test "status shows no specs when none exist" { try helpers.expectExitCode(result.term, 0); } -test "status format json outputs valid json" { +test "status includes inline anchors from spec body" { const allocator = std.testing.allocator; var repo = try helpers.TempRepo.init(allocator); defer repo.cleanup(); - try repo.writeSpec("docs/spec.md", &.{"src/main.ts"}, "# Spec\n"); - try repo.writeFile("src/main.ts", "export function main() {}\n"); - try repo.commit("add spec and source"); + const body = + \\# Spec + \\ + \\References @./src/helper.ts in the body. + \\ + ; + try repo.writeSpec("docs/spec.md", &.{}, body); + try repo.writeFile("src/helper.ts", "export function help() {}\n"); + try repo.commit("add spec with inline anchor"); - const result = try repo.runDrift(&.{ "status", "--format", "json" }); + const result = try repo.runDrift(&.{"status"}); defer result.deinit(allocator); - // The output should contain JSON structural characters and the spec path - try helpers.expectContains(result.stdout, "{"); - try helpers.expectContains(result.stdout, "}"); try helpers.expectContains(result.stdout, "docs/spec.md"); + try helpers.expectContains(result.stdout, "src/helper.ts"); + try helpers.expectContains(result.stdout, "1 anchor"); +} + +test "status format json outputs valid escaped json" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeSpec("docs/spec\"name.md", &.{"src/main\"file.ts"}, "# Spec\n"); + try repo.writeFile("src/main\"file.ts", "export function main() {}\n"); + try repo.commit("add spec and source with quoted paths"); + + const result = try repo.runDrift(&.{ "status", "--format", "json" }); + defer result.deinit(allocator); + + const StatusEntry = struct { + spec: []const u8, + files: []const []const u8, + }; + + var parsed = try std.json.parseFromSlice([]StatusEntry, allocator, result.stdout, .{}); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(usize, 1), parsed.value.len); + try std.testing.expectEqualStrings("docs/spec\"name.md", parsed.value[0].spec); + try std.testing.expectEqual(@as(usize, 1), parsed.value[0].files.len); + try std.testing.expectEqualStrings("src/main\"file.ts", parsed.value[0].files[0]); } diff --git a/test/integration/unlink_test.zig b/test/integration/unlink_test.zig index b0a88fe..8209ed4 100644 --- a/test/integration/unlink_test.zig +++ b/test/integration/unlink_test.zig @@ -75,3 +75,47 @@ test "unlink removes symbol anchor" { defer allocator.free(content); try helpers.expectNotContains(content, "src/lib.ts#Foo"); } + +test "unlink removes comment-based anchor" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile( + "docs/spec.md", + "# Spec\n\n\n", + ); + try repo.commit("add spec with comment-based anchors"); + + const result = try repo.runDrift(&.{ "unlink", "docs/spec.md", "src/a.ts" }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + + const content = try repo.readFile("docs/spec.md"); + defer allocator.free(content); + try helpers.expectNotContains(content, "src/a.ts"); + try helpers.expectContains(content, "src/b.ts"); +} + +test "unlink removes comment-based anchor when unrelated frontmatter exists" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile( + "docs/spec.md", + "---\ntitle: My Doc\n---\n\n\n", + ); + try repo.commit("add spec with unrelated frontmatter and comment anchors"); + + const result = try repo.runDrift(&.{ "unlink", "docs/spec.md", "src/a.ts" }); + defer result.deinit(allocator); + + try helpers.expectExitCode(result.term, 0); + + const content = try repo.readFile("docs/spec.md"); + defer allocator.free(content); + try helpers.expectContains(content, "title: My Doc"); + try helpers.expectNotContains(content, "src/a.ts"); +}