block-in-file 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/- +3 -0
  2. package/.beads/README.md +85 -0
  3. package/.beads/config.yaml +67 -0
  4. package/.beads/interactions.jsonl +0 -0
  5. package/.beads/issues.jsonl +23 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.git-blame-ignore-revs +2 -0
  8. package/.gitattributes +3 -0
  9. package/.prettierrc.json +5 -0
  10. package/AGENTS.md +40 -0
  11. package/README.md +122 -0
  12. package/block-in-file.ts +150 -0
  13. package/content +10 -0
  14. package/deno.json +14 -0
  15. package/deno.lock +1084 -0
  16. package/doc/PLAN-envsubst.md +200 -0
  17. package/doc/PLAN-restructure.md +114 -0
  18. package/package.json +44 -0
  19. package/src/attributes.ts +161 -0
  20. package/src/backup.ts +180 -0
  21. package/src/block-parser.ts +170 -0
  22. package/src/block-remover.ts +128 -0
  23. package/src/conflict-detection.ts +179 -0
  24. package/src/defaults.ts +23 -0
  25. package/src/envsubst.ts +59 -0
  26. package/src/file-processor.ts +378 -0
  27. package/src/index.ts +5 -0
  28. package/src/input.ts +69 -0
  29. package/src/mode-handler.ts +39 -0
  30. package/src/output.ts +107 -0
  31. package/src/plugins/.beads/.local_version +1 -0
  32. package/src/plugins/.beads/issues.jsonl +21 -0
  33. package/src/plugins/.beads/metadata.json +4 -0
  34. package/src/plugins/config.ts +282 -0
  35. package/src/plugins/diff.ts +109 -0
  36. package/src/plugins/io.ts +72 -0
  37. package/src/plugins/logger.ts +41 -0
  38. package/src/tags/tag-merger.ts +31 -0
  39. package/src/tags/tag-mode.ts +1 -0
  40. package/src/tags/tag.ts +36 -0
  41. package/src/tags/tags.ts +4 -0
  42. package/src/tags/types.ts +4 -0
  43. package/src/timestamp.ts +39 -0
  44. package/src/types.ts +32 -0
  45. package/src/validation.ts +11 -0
  46. package/test/additive-cli.test.ts +109 -0
  47. package/test/additive.test.ts +233 -0
  48. package/test/attributes-integration.test.ts +161 -0
  49. package/test/attributes.test.ts +100 -0
  50. package/test/backup.test.ts +386 -0
  51. package/test/block-in-file.test.ts +235 -0
  52. package/test/block-parser.test.ts +221 -0
  53. package/test/block-remover.test.ts +209 -0
  54. package/test/cli.test.ts +254 -0
  55. package/test/defaults.test.ts +38 -0
  56. package/test/envsubst-edge-cases.test.ts +116 -0
  57. package/test/envsubst-integration.test.ts +78 -0
  58. package/test/envsubst.test.ts +184 -0
  59. package/test/input.test.ts +86 -0
  60. package/test/mode.test.ts +193 -0
  61. package/test/output.test.ts +44 -0
  62. package/test/tag-merger.test.ts +176 -0
  63. package/test/tags.test.ts +116 -0
  64. package/test/timestamp-integration.test.ts +209 -0
  65. package/test/timestamp.test.ts +76 -0
  66. package/tsconfig.json +16 -0
  67. package/vitest.config.ts +8 -0
@@ -0,0 +1,170 @@
1
+ import type { ParseResult } from "./types.ts";
2
+ import { stripTagsForMatching } from "./tags/tags.ts";
3
+
4
+ export interface ParseOptions {
5
+ opener: string;
6
+ closer: string;
7
+ inputBlock: string;
8
+ before?: RegExp | boolean;
9
+ after?: RegExp | boolean;
10
+ appendNewline?: boolean;
11
+ additive?: boolean;
12
+ additiveBefore?: RegExp | "EOB" | "EOF" | "BOF";
13
+ additiveAfter?: RegExp | "EOB" | "EOF" | "BOF";
14
+ actualOpener?: string;
15
+ actualCloser?: string;
16
+ }
17
+
18
+ export function parseAndInsertBlock(fileContent: string, opts: ParseOptions): ParseResult {
19
+ const {
20
+ opener,
21
+ closer,
22
+ inputBlock,
23
+ before,
24
+ after,
25
+ appendNewline,
26
+ additive,
27
+ additiveBefore,
28
+ additiveAfter,
29
+ actualOpener,
30
+ actualCloser,
31
+ } = opts;
32
+ const match = before || after;
33
+ const outputs: string[] = [];
34
+ const lines = fileContent.split("\n");
35
+
36
+ let done = false;
37
+ let opened: number | undefined;
38
+ let matched = -1;
39
+ let i = -1;
40
+ let blockContentLines: string[] = [];
41
+ let blockStartIndex = -1;
42
+ let blockEndIndex = -1;
43
+
44
+ const inputLines = inputBlock.split("\n");
45
+ const outputOpener = actualOpener || opener;
46
+ const outputCloser = actualCloser || closer;
47
+
48
+ const isOpener = (line: string) => {
49
+ return stripTagsForMatching(line.trim()) === opener;
50
+ };
51
+
52
+ const isCloser = (line: string) => {
53
+ return stripTagsForMatching(line.trim()) === closer;
54
+ };
55
+
56
+ if (before === true) {
57
+ outputs.push(outputOpener, ...inputLines, outputCloser);
58
+ if (appendNewline) {
59
+ outputs.push("");
60
+ }
61
+ done = true;
62
+ }
63
+
64
+ for (const line of lines) {
65
+ const isOpen = opened !== undefined;
66
+ i++;
67
+
68
+ if (!isOpen && isOpener(line)) {
69
+ opened = outputs.length;
70
+ blockStartIndex = outputs.length;
71
+ } else if (isOpen) {
72
+ if (!isCloser(line)) {
73
+ if (additive) {
74
+ blockContentLines.push(line);
75
+ }
76
+ continue;
77
+ }
78
+
79
+ opened = undefined;
80
+ blockEndIndex = outputs.length;
81
+
82
+ if (done) {
83
+ continue;
84
+ }
85
+
86
+ if (additive) {
87
+ const missingLines = inputLines.filter((line) => !blockContentLines.includes(line));
88
+
89
+ if (missingLines.length > 0 || blockContentLines.length === 0) {
90
+ let newContentLines: string[];
91
+
92
+ if (blockContentLines.length === 0) {
93
+ newContentLines = inputLines;
94
+ } else if (
95
+ additiveAfter === "EOB" ||
96
+ additiveAfter === "EOF" ||
97
+ (!additiveBefore && !additiveAfter)
98
+ ) {
99
+ newContentLines = [...blockContentLines, ...missingLines];
100
+ } else if (additiveBefore === "BOF") {
101
+ newContentLines = [...missingLines, ...blockContentLines];
102
+ } else if (
103
+ additiveAfter &&
104
+ typeof additiveAfter === "object" &&
105
+ "test" in additiveAfter
106
+ ) {
107
+ let insertIndex = blockContentLines.findIndex((l) => additiveAfter.test(l));
108
+ if (insertIndex === -1) insertIndex = blockContentLines.length;
109
+ newContentLines = [
110
+ ...blockContentLines.slice(0, insertIndex + 1),
111
+ ...missingLines,
112
+ ...blockContentLines.slice(insertIndex + 1),
113
+ ];
114
+ } else if (
115
+ additiveBefore &&
116
+ typeof additiveBefore === "object" &&
117
+ "test" in additiveBefore
118
+ ) {
119
+ let insertIndex = blockContentLines.findIndex((l) => additiveBefore.test(l));
120
+ if (insertIndex === -1) insertIndex = 0;
121
+ newContentLines = [
122
+ ...blockContentLines.slice(0, insertIndex),
123
+ ...missingLines,
124
+ ...blockContentLines.slice(insertIndex),
125
+ ];
126
+ } else {
127
+ newContentLines = [...blockContentLines, ...missingLines];
128
+ }
129
+
130
+ outputs.push(outputOpener, ...newContentLines, outputCloser);
131
+ } else {
132
+ outputs.push(outputOpener, ...blockContentLines, outputCloser);
133
+ }
134
+ } else {
135
+ outputs.push(outputOpener, ...inputLines, outputCloser);
136
+ }
137
+
138
+ if (appendNewline) {
139
+ outputs.push("");
140
+ }
141
+ done = true;
142
+ } else {
143
+ outputs.push(line);
144
+
145
+ if (!done && matched === -1 && typeof match === "object" && match?.test?.(line)) {
146
+ matched = i;
147
+ }
148
+ }
149
+ }
150
+
151
+ if (opened !== undefined) {
152
+ outputs.push(outputOpener, ...inputLines, outputCloser);
153
+ if (appendNewline) {
154
+ outputs.push("");
155
+ }
156
+ done = true;
157
+ }
158
+
159
+ if (!done) {
160
+ if (matched === -1) {
161
+ matched = i;
162
+ }
163
+ outputs.splice(matched + (after ? 1 : 0), 0, outputOpener, ...inputLines, outputCloser);
164
+ if (appendNewline) {
165
+ outputs.splice(matched + (after ? 1 : 0) + inputLines.length + 2, 0, "");
166
+ }
167
+ }
168
+
169
+ return { outputs, matched, opened };
170
+ }
@@ -0,0 +1,128 @@
1
+ import type { LoggerExtension } from "./plugins/logger.ts";
2
+ import { stripTagsForMatching } from "./tags/tags.ts";
3
+
4
+ export interface RemovedBlock {
5
+ blockName: string;
6
+ startLine: number;
7
+ endLine: number;
8
+ content: string;
9
+ }
10
+
11
+ export interface RemovalStats {
12
+ removed: number;
13
+ orphans: number;
14
+ totalLinesRemoved: number;
15
+ blocks: RemovedBlock[];
16
+ }
17
+
18
+ export interface BlockRemoverOptions {
19
+ fileContent: string;
20
+ blockNames: string[];
21
+ comment: string;
22
+ markerStart: string;
23
+ markerEnd: string;
24
+ removeOrphans: boolean;
25
+ debug: boolean;
26
+ logger: LoggerExtension;
27
+ }
28
+
29
+ export function escapeRegex(str: string): string {
30
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
+ }
32
+
33
+ export function removeBlocks(opts: BlockRemoverOptions): { content: string; stats: RemovalStats } {
34
+ const { fileContent, blockNames, comment, markerStart, markerEnd, removeOrphans, debug, logger } =
35
+ opts;
36
+
37
+ const lines = fileContent.split("\n");
38
+ const stats: RemovalStats = {
39
+ removed: 0,
40
+ orphans: 0,
41
+ totalLinesRemoved: 0,
42
+ blocks: [],
43
+ };
44
+
45
+ let newLines: string[] = [];
46
+ let inBlock = false;
47
+ let blockStartLine = 0;
48
+ let blockContent: string[] = [];
49
+ let currentBlockName: string | null = null;
50
+
51
+ const openerRegex = new RegExp(
52
+ `^${escapeRegex(comment)}\\s+(\\S+)\\s+${escapeRegex(markerStart)}(\\s+.*)?$`,
53
+ );
54
+
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const line = lines[i];
57
+
58
+ if (inBlock) {
59
+ const isCloser =
60
+ line.trim() === `${comment} ${currentBlockName || ""} ${markerEnd}` ||
61
+ stripTagsForMatching(line.trim()) === `${comment} ${currentBlockName || ""} ${markerEnd}`;
62
+
63
+ if (isCloser) {
64
+ const isTargetBlock = currentBlockName && blockNames.includes(currentBlockName);
65
+ const contentStr = blockContent.join("\n");
66
+ const isOrphan = contentStr.trim() === "" || contentStr.trim().length === 0;
67
+
68
+ if (isTargetBlock || (isOrphan && removeOrphans)) {
69
+ stats.removed++;
70
+ stats.totalLinesRemoved += i - blockStartLine + 1;
71
+ stats.blocks.push({
72
+ blockName: currentBlockName || "orphan",
73
+ startLine: blockStartLine + 1,
74
+ endLine: i + 1,
75
+ content: contentStr,
76
+ });
77
+
78
+ if (isOrphan && removeOrphans) {
79
+ stats.orphans++;
80
+ }
81
+
82
+ if (debug) {
83
+ logger.debug(
84
+ `Removed block at lines ${blockStartLine + 1}-${i + 1}: ${currentBlockName || "orphan"}`,
85
+ );
86
+ }
87
+
88
+ inBlock = false;
89
+ currentBlockName = null;
90
+ blockContent = [];
91
+ continue;
92
+ }
93
+
94
+ newLines.push(...blockContent);
95
+ newLines.push(line);
96
+ inBlock = false;
97
+ currentBlockName = null;
98
+ blockContent = [];
99
+ } else {
100
+ blockContent.push(line);
101
+ }
102
+ } else {
103
+ const openerMatch = line.match(openerRegex);
104
+ if (
105
+ openerMatch &&
106
+ (line.trim() === `${comment} ${openerMatch[1] || ""} ${markerStart}` ||
107
+ stripTagsForMatching(line.trim()) === `${comment} ${openerMatch[1] || ""} ${markerStart}`)
108
+ ) {
109
+ inBlock = true;
110
+ blockStartLine = i;
111
+ currentBlockName = openerMatch[1] || "";
112
+ blockContent = [];
113
+ } else {
114
+ newLines.push(line);
115
+ }
116
+ }
117
+ }
118
+
119
+ if (inBlock) {
120
+ newLines.push(...blockContent);
121
+ if (debug) {
122
+ logger.warn(`Warning: Unclosed block starting at line ${blockStartLine + 1}`);
123
+ }
124
+ }
125
+
126
+ const content = newLines.join("\n");
127
+ return { content, stats };
128
+ }
@@ -0,0 +1,179 @@
1
+ import type { LoggerExtension } from "./plugins/logger.ts";
2
+ import { stripTagsForMatching } from "./tags/tags.ts";
3
+
4
+ export interface Conflict {
5
+ type: "duplicate" | "nested" | "mismatched";
6
+ line: number;
7
+ message: string;
8
+ }
9
+
10
+ export interface ConflictDetectionResult {
11
+ hasConflicts: boolean;
12
+ conflicts: Conflict[];
13
+ }
14
+
15
+ export function detectConflicts(
16
+ fileContent: string,
17
+ opener: string,
18
+ closer: string,
19
+ logger?: LoggerExtension,
20
+ ): ConflictDetectionResult {
21
+ const lines = fileContent.split("\n");
22
+ const conflicts: Conflict[] = [];
23
+ const openerPositions: number[] = [];
24
+ const closerPositions: number[] = [];
25
+
26
+ for (let i = 0; i < lines.length; i++) {
27
+ const line = stripTagsForMatching(lines[i].trim());
28
+ if (line === opener) {
29
+ openerPositions.push(i);
30
+ } else if (line === closer) {
31
+ closerPositions.push(i);
32
+ }
33
+ }
34
+
35
+ if (openerPositions.length > 1) {
36
+ for (const pos of openerPositions) {
37
+ conflicts.push({
38
+ type: "duplicate",
39
+ line: pos + 1,
40
+ message: `Duplicate block opener found at line ${pos + 1}`,
41
+ });
42
+ }
43
+ }
44
+
45
+ if (closerPositions.length > 1) {
46
+ for (const pos of closerPositions) {
47
+ conflicts.push({
48
+ type: "duplicate",
49
+ line: pos + 1,
50
+ message: `Duplicate block closer found at line ${pos + 1}`,
51
+ });
52
+ }
53
+ }
54
+
55
+ let depth = 0;
56
+ for (let i = 0; i < lines.length; i++) {
57
+ const line = stripTagsForMatching(lines[i].trim());
58
+ if (line === opener) {
59
+ depth++;
60
+ if (depth > 1) {
61
+ conflicts.push({
62
+ type: "nested",
63
+ line: i + 1,
64
+ message: `Nested block detected at line ${i + 1}`,
65
+ });
66
+ }
67
+ } else if (line === closer) {
68
+ depth--;
69
+ }
70
+ }
71
+
72
+ if (depth !== 0) {
73
+ const lastOpener = openerPositions[openerPositions.length - 1];
74
+ if (lastOpener !== undefined) {
75
+ conflicts.push({
76
+ type: "mismatched",
77
+ line: lastOpener + 1,
78
+ message: `Unmatched block opener at line ${lastOpener + 1} (missing closer)`,
79
+ });
80
+ }
81
+ }
82
+
83
+ const result: ConflictDetectionResult = {
84
+ hasConflicts: conflicts.length > 0,
85
+ conflicts,
86
+ };
87
+
88
+ if (result.hasConflicts && logger) {
89
+ for (const conflict of conflicts) {
90
+ logger.log(`Conflict: ${conflict.message}`);
91
+ }
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ export function detectConflictsWithPattern(
98
+ fileContent: string,
99
+ opener: string,
100
+ closer: string,
101
+ openerPattern: RegExp,
102
+ closerPattern: RegExp,
103
+ logger?: LoggerExtension,
104
+ ): ConflictDetectionResult {
105
+ const lines = fileContent.split("\n");
106
+ const conflicts: Conflict[] = [];
107
+ const openerPositions: number[] = [];
108
+ const closerPositions: number[] = [];
109
+
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = stripTagsForMatching(lines[i].trim());
112
+ if (openerPattern.test(line)) {
113
+ openerPositions.push(i);
114
+ } else if (closerPattern.test(line)) {
115
+ closerPositions.push(i);
116
+ }
117
+ }
118
+
119
+ if (openerPositions.length > 1) {
120
+ for (const pos of openerPositions) {
121
+ conflicts.push({
122
+ type: "duplicate",
123
+ line: pos + 1,
124
+ message: `Duplicate block opener found at line ${pos + 1}`,
125
+ });
126
+ }
127
+ }
128
+
129
+ if (closerPositions.length > 1) {
130
+ for (const pos of closerPositions) {
131
+ conflicts.push({
132
+ type: "duplicate",
133
+ line: pos + 1,
134
+ message: `Duplicate block closer found at line ${pos + 1}`,
135
+ });
136
+ }
137
+ }
138
+
139
+ let depth = 0;
140
+ for (let i = 0; i < lines.length; i++) {
141
+ const line = stripTagsForMatching(lines[i].trim());
142
+ if (openerPattern.test(line)) {
143
+ depth++;
144
+ if (depth > 1) {
145
+ conflicts.push({
146
+ type: "nested",
147
+ line: i + 1,
148
+ message: `Nested block detected at line ${i + 1}`,
149
+ });
150
+ }
151
+ } else if (closerPattern.test(line)) {
152
+ depth--;
153
+ }
154
+ }
155
+
156
+ if (depth !== 0) {
157
+ const lastOpener = openerPositions[openerPositions.length - 1];
158
+ if (lastOpener !== undefined) {
159
+ conflicts.push({
160
+ type: "mismatched",
161
+ line: lastOpener + 1,
162
+ message: `Unmatched block opener at line ${lastOpener + 1} (missing closer)`,
163
+ });
164
+ }
165
+ }
166
+
167
+ const result: ConflictDetectionResult = {
168
+ hasConflicts: conflicts.length > 0,
169
+ conflicts,
170
+ };
171
+
172
+ if (result.hasConflicts && logger) {
173
+ for (const conflict of conflicts) {
174
+ logger.log(`Conflict: ${conflict.message}`);
175
+ }
176
+ }
177
+
178
+ return result;
179
+ }
@@ -0,0 +1,23 @@
1
+ import type { BlockInFileOptions } from "./types.ts";
2
+
3
+ const defaultOptions: Readonly<BlockInFileOptions> = Object.freeze({
4
+ after: undefined,
5
+ before: undefined,
6
+ comment: "#",
7
+ create: false,
8
+ debug: false,
9
+ diff: undefined,
10
+ dos: false,
11
+ envsubst: false,
12
+ input: undefined,
13
+ markerStart: "start",
14
+ markerEnd: "end",
15
+ name: "blockinfile",
16
+ output: undefined,
17
+ });
18
+
19
+ export function getDefaultOptions(): BlockInFileOptions {
20
+ return { ...defaultOptions };
21
+ }
22
+
23
+ export { defaultOptions };
@@ -0,0 +1,59 @@
1
+ const MAX_ITERATIONS = 100;
2
+ const VAR_PATTERN_BRACES = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
3
+ const VAR_PATTERN_SIMPLE = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
4
+
5
+ export type EnvsubstMode = "recursive" | "non-recursive" | false;
6
+
7
+ export interface EnvsubstOptions {
8
+ mode: EnvsubstMode;
9
+ }
10
+
11
+ export function substitute(text: string, options: EnvsubstOptions): string {
12
+ const { mode } = options;
13
+
14
+ if (mode === false) {
15
+ return text;
16
+ }
17
+
18
+ if (mode === "non-recursive") {
19
+ return substituteOnce(text);
20
+ }
21
+
22
+ return substituteUntilStable(text, MAX_ITERATIONS);
23
+ }
24
+
25
+ function substituteUntilStable(text: string, maxIterations: number): string {
26
+ let current = text;
27
+ let previous = "";
28
+ let iterations = 0;
29
+
30
+ while (current !== previous && iterations < maxIterations) {
31
+ previous = current;
32
+ current = substituteOnce(current);
33
+ iterations++;
34
+ }
35
+
36
+ return current;
37
+ }
38
+
39
+ function substituteOnce(text: string): string {
40
+ let result = text;
41
+
42
+ result = result.replace(VAR_PATTERN_BRACES, (match, varName) => {
43
+ const value = process.env[varName];
44
+ if (value === undefined) {
45
+ return "";
46
+ }
47
+ return value;
48
+ });
49
+
50
+ result = result.replace(VAR_PATTERN_SIMPLE, (match, varName) => {
51
+ const value = process.env[varName];
52
+ if (value === undefined) {
53
+ return "";
54
+ }
55
+ return value;
56
+ });
57
+
58
+ return result;
59
+ }