@effected/templates 0.1.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.
- package/CommentStyle.js +104 -0
- package/LICENSE +21 -0
- package/ManagedSection.js +211 -0
- package/Section.js +126 -0
- package/SectionDialect.js +164 -0
- package/SectionDocument.js +197 -0
- package/SectionOutcome.js +20 -0
- package/index.d.ts +812 -0
- package/index.js +8 -0
- package/internal/reconcile.js +113 -0
- package/internal/scan.js +135 -0
- package/package.json +45 -0
- package/tsdoc-metadata.json +11 -0
package/CommentStyle.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/CommentStyle.ts
|
|
4
|
+
/**
|
|
5
|
+
* A comment delimiter: non-empty, and free of control characters.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Every constraint is load-bearing rather than decorative. An empty delimiter
|
|
9
|
+
* would make the marker scanner match every line in a document. A delimiter
|
|
10
|
+
* containing a line break would let a caller inject arbitrary lines into a
|
|
11
|
+
* rendered marker. And a delimiter containing NUL would collide in
|
|
12
|
+
* {@link CommentStyle.id}, where NUL is the field separator — `{ prefix: "a\0b" }`
|
|
13
|
+
* and `{ prefix: "a", suffix: "b" }` would key the same lookup entry. All of
|
|
14
|
+
* them fail at construction.
|
|
15
|
+
*
|
|
16
|
+
* The pattern is a negated character class with a single quantifier, so it
|
|
17
|
+
* cannot backtrack on hostile input.
|
|
18
|
+
*/
|
|
19
|
+
const Delimiter = Schema.String.check(Schema.isPattern(/^\P{Cc}+$/u));
|
|
20
|
+
/**
|
|
21
|
+
* How a managed section's markers are commented out in a given file format.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* A **line** style carries only a `prefix` (`#`, `//`); a **wrapped** style
|
|
25
|
+
* carries a `suffix` as well (`<!--` … `-->`). The wrapped form is what makes
|
|
26
|
+
* managed sections representable in Markdown, HTML and XML, which the two-way
|
|
27
|
+
* literal union this replaces could not express.
|
|
28
|
+
*
|
|
29
|
+
* The preset set is a convenience, not a closed world: a format nobody
|
|
30
|
+
* anticipated is one `CommentStyle.make({ prefix: "%" })` away.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { CommentStyle } from "@effected/templates";
|
|
35
|
+
*
|
|
36
|
+
* CommentStyle.hash; // # …
|
|
37
|
+
* CommentStyle.html; // <!-- … -->
|
|
38
|
+
* CommentStyle.make({ prefix: "(*", suffix: "*)" }); // ML, no preset needed
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
var CommentStyle = class CommentStyle extends Schema.Class("CommentStyle")({
|
|
44
|
+
/** Opens the comment. Non-empty, single-line. */
|
|
45
|
+
prefix: Delimiter,
|
|
46
|
+
/** Closes the comment, for wrapped styles. Omitted for line styles. */
|
|
47
|
+
suffix: Schema.optionalKey(Delimiter)
|
|
48
|
+
}) {
|
|
49
|
+
/** Shell, YAML, TOML, Python, Dockerfile, `.env`. */
|
|
50
|
+
static hash = CommentStyle.make({ prefix: "#" });
|
|
51
|
+
/** JavaScript, TypeScript, C, Go, Rust, JSONC. */
|
|
52
|
+
static slash = CommentStyle.make({ prefix: "//" });
|
|
53
|
+
/** INI, Lisp, assembly. */
|
|
54
|
+
static semicolon = CommentStyle.make({ prefix: ";" });
|
|
55
|
+
/** SQL, Lua, Haskell. */
|
|
56
|
+
static dash = CommentStyle.make({ prefix: "--" });
|
|
57
|
+
/** Markdown, HTML, XML — the wrapped style v3 could not represent. */
|
|
58
|
+
static html = CommentStyle.make({
|
|
59
|
+
prefix: "<!--",
|
|
60
|
+
suffix: "-->"
|
|
61
|
+
});
|
|
62
|
+
/** CSS, and the block form of every C-family language. */
|
|
63
|
+
static block = CommentStyle.make({
|
|
64
|
+
prefix: "/*",
|
|
65
|
+
suffix: "*/"
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* Every preset, in the order a reader would expect: line styles first,
|
|
69
|
+
* then wrapped.
|
|
70
|
+
*
|
|
71
|
+
* @remarks
|
|
72
|
+
* A grouped static over variants of one concept that live in one module
|
|
73
|
+
* and reach nothing heavier than each other — nothing sits behind a preset
|
|
74
|
+
* but a two-field object, so this is not the namespace-object hazard.
|
|
75
|
+
*/
|
|
76
|
+
static presets = [
|
|
77
|
+
CommentStyle.hash,
|
|
78
|
+
CommentStyle.slash,
|
|
79
|
+
CommentStyle.semicolon,
|
|
80
|
+
CommentStyle.dash,
|
|
81
|
+
CommentStyle.html,
|
|
82
|
+
CommentStyle.block
|
|
83
|
+
];
|
|
84
|
+
/**
|
|
85
|
+
* A stable string identity, for keying a plain `Map`.
|
|
86
|
+
*
|
|
87
|
+
* @remarks
|
|
88
|
+
* The `NUL` separator is not cosmetic: without it `{ prefix: "ab" }`
|
|
89
|
+
* and `{ prefix: "a", suffix: "b" }` would produce the same id and two
|
|
90
|
+
* genuinely different styles would collide in a lookup table. `NUL`
|
|
91
|
+
* cannot occur in a delimiter, which the single-line check guarantees is
|
|
92
|
+
* printable-ish, so the encoding is unambiguous.
|
|
93
|
+
*/
|
|
94
|
+
get id() {
|
|
95
|
+
return `${this.prefix}\u0000${this.suffix ?? ""}`;
|
|
96
|
+
}
|
|
97
|
+
/** True when the style wraps its content, i.e. it carries a suffix. */
|
|
98
|
+
get isWrapped() {
|
|
99
|
+
return this.suffix !== void 0;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
//#endregion
|
|
104
|
+
export { CommentStyle };
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { SectionDialect } from "./SectionDialect.js";
|
|
2
|
+
import { SectionDocument, SectionParseError } from "./SectionDocument.js";
|
|
3
|
+
import { Context, Effect, FileSystem, Layer, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/ManagedSection.ts
|
|
6
|
+
/**
|
|
7
|
+
* Raised when the file behind a managed-section operation could not be read or
|
|
8
|
+
* written.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* The underlying `PlatformError` is preserved structurally in `cause` rather
|
|
12
|
+
* than stringified. `operation` is what a bare platform error cannot tell a
|
|
13
|
+
* caller: these operations are read-modify-write, so "it failed" is ambiguous
|
|
14
|
+
* about whether the document was even seen.
|
|
15
|
+
*
|
|
16
|
+
* A **missing** file is not an error — it reads as a document with no
|
|
17
|
+
* sections, and a sync creates it.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
var SectionFileError = class extends Schema.TaggedErrorClass()("SectionFileError", {
|
|
22
|
+
/** The file the operation was against. */
|
|
23
|
+
path: Schema.String,
|
|
24
|
+
/** Which half of the read-modify-write failed. */
|
|
25
|
+
operation: Schema.Literals(["read", "write"]),
|
|
26
|
+
/** The underlying failure, preserved structurally. */
|
|
27
|
+
cause: Schema.Defect()
|
|
28
|
+
}) {
|
|
29
|
+
get message() {
|
|
30
|
+
return `Failed to ${this.operation} managed sections in "${this.path}"`;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const notFound = (error) => error.reason._tag === "NotFound";
|
|
34
|
+
/**
|
|
35
|
+
* Decodes file bytes **without** consuming a byte-order mark.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* `FileSystem.readFileString` decodes through a default `TextDecoder`, which
|
|
39
|
+
* strips a leading BOM — verified against `@effect/platform-node@4.0.0-beta.101`.
|
|
40
|
+
* Reading through it would make the first sync of a BOM-carrying file silently
|
|
41
|
+
* delete the BOM, which violates this package's central promise that every byte
|
|
42
|
+
* outside a managed span survives. Reading bytes and decoding with
|
|
43
|
+
* `ignoreBOM: true` keeps the mark in the leading text span, where it is
|
|
44
|
+
* preserved like any other content.
|
|
45
|
+
*/
|
|
46
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
47
|
+
const make = (options = {}) => Effect.gen(function* () {
|
|
48
|
+
const fs = yield* FileSystem.FileSystem;
|
|
49
|
+
const dialect = options.dialect ?? SectionDialect.default;
|
|
50
|
+
/**
|
|
51
|
+
* Read and parse, degrading a missing file to an empty document.
|
|
52
|
+
*
|
|
53
|
+
* One read, not an `exists` probe followed by a read: two syscalls with a
|
|
54
|
+
* race between them, where the file can appear or vanish in the gap.
|
|
55
|
+
*/
|
|
56
|
+
const load = (path) => fs.readFile(path).pipe(Effect.map((bytes) => decoder.decode(bytes)), Effect.catchTag("PlatformError", (error) => notFound(error) ? Effect.succeed("") : Effect.fail(new SectionFileError({
|
|
57
|
+
path,
|
|
58
|
+
operation: "read",
|
|
59
|
+
cause: error
|
|
60
|
+
}))), Effect.flatMap((text) => Effect.fromResult(SectionDocument.parseResult(text, dialect)).pipe(Effect.mapError((error) => SectionParseError.at(path, error)))));
|
|
61
|
+
const store = (path, text) => fs.writeFileString(path, text).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new SectionFileError({
|
|
62
|
+
path,
|
|
63
|
+
operation: "write",
|
|
64
|
+
cause: error
|
|
65
|
+
}))));
|
|
66
|
+
const syncAll = Effect.fn("ManagedSection.syncAll")(function* (path, sections) {
|
|
67
|
+
yield* Effect.annotateCurrentSpan({
|
|
68
|
+
path,
|
|
69
|
+
sections: sections.length
|
|
70
|
+
});
|
|
71
|
+
const document = yield* load(path);
|
|
72
|
+
const reconciled = yield* Effect.fromResult(document.reconcile(sections));
|
|
73
|
+
if (reconciled.changed) yield* store(path, reconciled.text);
|
|
74
|
+
return reconciled.outcomes;
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
read: Effect.fn("ManagedSection.read")(function* (path, id) {
|
|
78
|
+
yield* Effect.annotateCurrentSpan({
|
|
79
|
+
path,
|
|
80
|
+
key: id.key
|
|
81
|
+
});
|
|
82
|
+
return (yield* load(path)).read(id);
|
|
83
|
+
}),
|
|
84
|
+
readAll: Effect.fn("ManagedSection.readAll")(function* (path) {
|
|
85
|
+
yield* Effect.annotateCurrentSpan({ path });
|
|
86
|
+
return (yield* load(path)).sections.map((placed) => placed.section);
|
|
87
|
+
}),
|
|
88
|
+
isManaged: Effect.fn("ManagedSection.isManaged")(function* (path, id) {
|
|
89
|
+
yield* Effect.annotateCurrentSpan({
|
|
90
|
+
path,
|
|
91
|
+
key: id.key
|
|
92
|
+
});
|
|
93
|
+
return (yield* load(path)).has(id);
|
|
94
|
+
}),
|
|
95
|
+
sync: Effect.fn("ManagedSection.sync")(function* (path, section) {
|
|
96
|
+
yield* Effect.annotateCurrentSpan({
|
|
97
|
+
path,
|
|
98
|
+
key: section.key
|
|
99
|
+
});
|
|
100
|
+
return (yield* syncAll(path, [section]))[0];
|
|
101
|
+
}),
|
|
102
|
+
syncAll,
|
|
103
|
+
check: Effect.fn("ManagedSection.check")(function* (path, section) {
|
|
104
|
+
yield* Effect.annotateCurrentSpan({
|
|
105
|
+
path,
|
|
106
|
+
key: section.key
|
|
107
|
+
});
|
|
108
|
+
return (yield* load(path)).check(section);
|
|
109
|
+
}),
|
|
110
|
+
checkAll: Effect.fn("ManagedSection.checkAll")(function* (path, sections) {
|
|
111
|
+
yield* Effect.annotateCurrentSpan({
|
|
112
|
+
path,
|
|
113
|
+
sections: sections.length
|
|
114
|
+
});
|
|
115
|
+
const document = yield* load(path);
|
|
116
|
+
return sections.map((section) => document.check(section));
|
|
117
|
+
}),
|
|
118
|
+
remove: Effect.fn("ManagedSection.remove")(function* (path, id) {
|
|
119
|
+
yield* Effect.annotateCurrentSpan({
|
|
120
|
+
path,
|
|
121
|
+
key: id.key
|
|
122
|
+
});
|
|
123
|
+
const next = (yield* load(path)).remove(id);
|
|
124
|
+
if (next._tag === "None") return false;
|
|
125
|
+
yield* store(path, next.value);
|
|
126
|
+
return true;
|
|
127
|
+
})
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
const unimplemented = (member) => {
|
|
131
|
+
throw new Error(`ManagedSection.makeTest: ${member}() was called but not stubbed — pass a \`${member}\` override.`);
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Managed sections in files: read, compare, sync and remove delimited blocks
|
|
135
|
+
* whose surrounding content belongs to the user.
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* A thin shell over {@link SectionDocument}: every member reads the file, runs
|
|
139
|
+
* the pure core, and writes back only when the text actually changed. All the
|
|
140
|
+
* interesting behavior — and all the interesting tests — live in the pure core.
|
|
141
|
+
*
|
|
142
|
+
* `FileSystem` is required in `R` and discharged by the consumer's platform
|
|
143
|
+
* layer at the edge. No `Path` is required: paths are handed to `FileSystem`
|
|
144
|
+
* untouched.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* import { CommentStyle, ManagedSection, SectionId } from "@effected/templates";
|
|
149
|
+
* import { Effect } from "effect";
|
|
150
|
+
*
|
|
151
|
+
* const Base = SectionId.make({ key: "base", commentStyle: CommentStyle.hash });
|
|
152
|
+
* const Tool = SectionId.make({ key: "tool", commentStyle: CommentStyle.hash });
|
|
153
|
+
*
|
|
154
|
+
* const program = Effect.gen(function* () {
|
|
155
|
+
* const sections = yield* ManagedSection;
|
|
156
|
+
* // Declared order is written into the file, so `base` precedes `tool`.
|
|
157
|
+
* return yield* sections.syncAll(".husky/pre-commit", [
|
|
158
|
+
* Base.section("preamble"),
|
|
159
|
+
* Tool.section("run-the-tool"),
|
|
160
|
+
* ]);
|
|
161
|
+
* });
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* @public
|
|
165
|
+
*/
|
|
166
|
+
var ManagedSection = class ManagedSection extends Context.Service()("@effected/templates/ManagedSection") {
|
|
167
|
+
/**
|
|
168
|
+
* The default layer, reading and writing {@link SectionDialect.default}
|
|
169
|
+
* markers.
|
|
170
|
+
*
|
|
171
|
+
* @remarks
|
|
172
|
+
* A bound `const`, not a function, so it memoizes by reference.
|
|
173
|
+
*/
|
|
174
|
+
static layer = Layer.effect(this, make());
|
|
175
|
+
/**
|
|
176
|
+
* A layer with a custom marker dialect.
|
|
177
|
+
*
|
|
178
|
+
* @remarks
|
|
179
|
+
* A parameterized layer factory mints a **fresh reference per call**, and
|
|
180
|
+
* layers memoize by reference — bind the result to a `const` and reuse it
|
|
181
|
+
* rather than calling `layerWith(...)` at each composition site.
|
|
182
|
+
*/
|
|
183
|
+
static layerWith = (options) => Layer.effect(ManagedSection, make(options));
|
|
184
|
+
/**
|
|
185
|
+
* A test double. Members a suite does not stub **die when called**, rather
|
|
186
|
+
* than returning a plausible-looking answer that makes a wrong test pass.
|
|
187
|
+
*/
|
|
188
|
+
static makeTest = (overrides = {}) => ({
|
|
189
|
+
read: () => Effect.sync(() => unimplemented("read")),
|
|
190
|
+
readAll: () => Effect.sync(() => unimplemented("readAll")),
|
|
191
|
+
isManaged: () => Effect.sync(() => unimplemented("isManaged")),
|
|
192
|
+
sync: () => Effect.sync(() => unimplemented("sync")),
|
|
193
|
+
syncAll: () => Effect.sync(() => unimplemented("syncAll")),
|
|
194
|
+
check: () => Effect.sync(() => unimplemented("check")),
|
|
195
|
+
checkAll: () => Effect.sync(() => unimplemented("checkAll")),
|
|
196
|
+
remove: () => Effect.sync(() => unimplemented("remove")),
|
|
197
|
+
...overrides
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* The test layer: {@link ManagedSection.makeTest} behind `Layer.succeed`, so
|
|
201
|
+
* a suite provides only the members it exercises.
|
|
202
|
+
*
|
|
203
|
+
* @remarks
|
|
204
|
+
* A parameterized layer factory mints a fresh reference per call — bind it
|
|
205
|
+
* to a `const` rather than calling it at each composition site.
|
|
206
|
+
*/
|
|
207
|
+
static layerTest = (overrides = {}) => Layer.succeed(ManagedSection, ManagedSection.makeTest(overrides));
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
//#endregion
|
|
211
|
+
export { ManagedSection, SectionFileError };
|
package/Section.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CommentStyle } from "./CommentStyle.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/Section.ts
|
|
5
|
+
/**
|
|
6
|
+
* The name identifying a managed section, as it literally appears in the
|
|
7
|
+
* file's markers.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Keys are **case-sensitive** and rendered verbatim: the key a consumer
|
|
11
|
+
* declares is the key the marker carries. A file already containing
|
|
12
|
+
* `SAVVY-LINT` markers is managed by declaring the key `"SAVVY-LINT"`.
|
|
13
|
+
*
|
|
14
|
+
* The character set is bounded so a key can always be read back out of a
|
|
15
|
+
* marker unambiguously — whitespace in particular would make the scanner's
|
|
16
|
+
* key capture ambiguous against the marker phrase that follows it.
|
|
17
|
+
*
|
|
18
|
+
* This is a checked string rather than a branded one **deliberately**: a
|
|
19
|
+
* brand would force every call site through a decode step to spell
|
|
20
|
+
* `SectionId.make({ key: "example-tool", … })`, and the validation a brand
|
|
21
|
+
* would carry is already enforced by the check at construction.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
const SectionKey = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/));
|
|
26
|
+
/**
|
|
27
|
+
* What identifies a managed section inside a document: its key and the
|
|
28
|
+
* comment style its markers are written in.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* `commentStyle` is **required, with no default**. A defaulted style is how a
|
|
32
|
+
* caller who forgets the argument writes `#` markers into a TypeScript file —
|
|
33
|
+
* a syntax error in the user's own source, produced silently by an omission.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { CommentStyle, SectionId } from "@effected/templates";
|
|
38
|
+
*
|
|
39
|
+
* const ToolSection = SectionId.make({
|
|
40
|
+
* key: "example-tool",
|
|
41
|
+
* commentStyle: CommentStyle.hash,
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* const block = ToolSection.section("echo hello");
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
var SectionId = class extends Schema.Class("SectionId")({
|
|
50
|
+
/** The section's name, exactly as it appears in the markers. */
|
|
51
|
+
key: SectionKey,
|
|
52
|
+
/** How this section's markers are commented out. */
|
|
53
|
+
commentStyle: CommentStyle
|
|
54
|
+
}) {
|
|
55
|
+
/** Pair this identity with the content a tool wants inside it. */
|
|
56
|
+
section(content) {
|
|
57
|
+
return Section.make({
|
|
58
|
+
key: this.key,
|
|
59
|
+
commentStyle: this.commentStyle,
|
|
60
|
+
content
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* A managed section: an identity plus the content its owner wants between the
|
|
66
|
+
* markers.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* Equality is **structural and whitespace-significant**. The v3 model this
|
|
70
|
+
* replaces compared trimmed, whitespace-collapsed content, which silently
|
|
71
|
+
* swallowed any template change that altered only indentation — the change
|
|
72
|
+
* compared equal, reported `Unchanged`, and never reached the file. The one
|
|
73
|
+
* normalization this package applies is to line endings, and it happens at
|
|
74
|
+
* parse time rather than inside equality, so `Equal.equals` stays honest for
|
|
75
|
+
* a consumer comparing two sections directly.
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
var Section = class Section extends Schema.Class("Section")({
|
|
80
|
+
/** The section's name, exactly as it appears in the markers. */
|
|
81
|
+
key: SectionKey,
|
|
82
|
+
/** How this section's markers are commented out. */
|
|
83
|
+
commentStyle: CommentStyle,
|
|
84
|
+
/** Everything between the markers, exclusive of the boundary line breaks. */
|
|
85
|
+
content: Schema.String
|
|
86
|
+
}) {
|
|
87
|
+
/** This section's identity, without its content. */
|
|
88
|
+
get id() {
|
|
89
|
+
return SectionId.make({
|
|
90
|
+
key: this.key,
|
|
91
|
+
commentStyle: this.commentStyle
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** The same section carrying different content. */
|
|
95
|
+
withContent(content) {
|
|
96
|
+
return Section.make({
|
|
97
|
+
key: this.key,
|
|
98
|
+
commentStyle: this.commentStyle,
|
|
99
|
+
content
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* A managed section as found in a document, carrying the span it occupies.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* `start` and `end` bound the **whole** block, from the first character of
|
|
108
|
+
* the begin marker to one past the last character of the end marker, so
|
|
109
|
+
* `text.slice(start, end)` is exactly the block as written. `line` is 1-based
|
|
110
|
+
* and points at the begin marker, which is what a diagnostic needs.
|
|
111
|
+
*
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
var PlacedSection = class extends Schema.Class("PlacedSection")({
|
|
115
|
+
/** The section, with line endings already normalized to `\n`. */
|
|
116
|
+
section: Section,
|
|
117
|
+
/** Offset of the begin marker's first character. */
|
|
118
|
+
start: Schema.Number,
|
|
119
|
+
/** Offset one past the end marker's last character. */
|
|
120
|
+
end: Schema.Number,
|
|
121
|
+
/** 1-based line of the begin marker. */
|
|
122
|
+
line: Schema.Number
|
|
123
|
+
}) {};
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
export { PlacedSection, Section, SectionId, SectionKey };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { CommentStyle } from "./CommentStyle.js";
|
|
2
|
+
import { Equal, Result, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/SectionDialect.ts
|
|
5
|
+
/**
|
|
6
|
+
* Raised when a section cannot be turned into marker-delimited text.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Both reasons describe a document this package would be unable to read back
|
|
10
|
+
* correctly, so rendering refuses rather than writing something it cannot
|
|
11
|
+
* re-parse.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
var SectionRenderError = class extends Schema.TaggedErrorClass()("SectionRenderError", {
|
|
16
|
+
/**
|
|
17
|
+
* `markerInContent` — the content carries a line the scanner would read as
|
|
18
|
+
* a marker, which would move the block boundary and let the next sync
|
|
19
|
+
* consume user text. `unknownCommentStyle` — the section's comment style
|
|
20
|
+
* is not in the dialect's set, so the block would be written into a
|
|
21
|
+
* document where the scanner could never find it again, growing a
|
|
22
|
+
* duplicate on every run. `duplicateDeclaration` — the same identity was
|
|
23
|
+
* declared twice in one call, so the caller stated two intentions for one
|
|
24
|
+
* block and any choice between them would be a guess; this is the
|
|
25
|
+
* caller-side twin of the document-side `duplicateSection`.
|
|
26
|
+
*/
|
|
27
|
+
reason: Schema.Literals([
|
|
28
|
+
"markerInContent",
|
|
29
|
+
"unknownCommentStyle",
|
|
30
|
+
"duplicateDeclaration"
|
|
31
|
+
]),
|
|
32
|
+
/** The key of the section that could not be rendered. */
|
|
33
|
+
key: Schema.String
|
|
34
|
+
}) {
|
|
35
|
+
get message() {
|
|
36
|
+
switch (this.reason) {
|
|
37
|
+
case "markerInContent": return `Section "${this.key}" has content containing a managed-section marker`;
|
|
38
|
+
case "unknownCommentStyle": return `Section "${this.key}" uses a comment style this dialect does not recognize`;
|
|
39
|
+
default: return `Section "${this.key}" was declared twice in one call`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/** The key grammar, mirrored from `SectionKey` for the scanner's capture group. */
|
|
44
|
+
const KEY_CAPTURE = "([A-Za-z0-9][A-Za-z0-9._-]*)";
|
|
45
|
+
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
+
/** Inter-token whitespace is tolerated on read, normalized on write. */
|
|
47
|
+
const GAP = "[ \\t]+";
|
|
48
|
+
/**
|
|
49
|
+
* Compiled matchers are cached per dialect instance. A `WeakMap` rather than
|
|
50
|
+
* a field keeps `SectionDialect` a pure schema class — v3 hung a non-schema
|
|
51
|
+
* private field off its definition class and had to hand-copy it on every
|
|
52
|
+
* derivation.
|
|
53
|
+
*/
|
|
54
|
+
const matcherCache = /* @__PURE__ */ new WeakMap();
|
|
55
|
+
/**
|
|
56
|
+
* The marker vocabulary: what phrase delimits a managed section, and which
|
|
57
|
+
* comment styles a document is scanned for.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* `styles` exists because reconciliation must **recognize sections it does
|
|
61
|
+
* not own** — a foreign tool's block in the same file is preserved verbatim
|
|
62
|
+
* and must never be mistaken for prose. That set cannot be derived from the
|
|
63
|
+
* caller's own sections, because a foreign block's style may appear nowhere
|
|
64
|
+
* in them.
|
|
65
|
+
*
|
|
66
|
+
* One phrase per dialect is deliberate: two marker families in one document
|
|
67
|
+
* make parsing ambiguous for no benefit anyone has asked for.
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
var SectionDialect = class SectionDialect extends Schema.Class("SectionDialect")({
|
|
72
|
+
/**
|
|
73
|
+
* The phrase between the key and the closing rule.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* Letters, digits, spaces and underscores only. Dashes are excluded so a
|
|
77
|
+
* phrase can never contain the `---` rule and make a marker ambiguous
|
|
78
|
+
* against itself.
|
|
79
|
+
*/
|
|
80
|
+
phrase: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9 _]*$/)),
|
|
81
|
+
/** Which comment styles the document scanner recognizes. At least one. */
|
|
82
|
+
styles: Schema.Array(CommentStyle).check(Schema.isMinLength(1))
|
|
83
|
+
}) {
|
|
84
|
+
/**
|
|
85
|
+
* The zero-configuration dialect: the phrase `MANAGED SECTION` and every
|
|
86
|
+
* preset comment style.
|
|
87
|
+
*/
|
|
88
|
+
static default = SectionDialect.make({
|
|
89
|
+
phrase: "MANAGED SECTION",
|
|
90
|
+
styles: CommentStyle.presets
|
|
91
|
+
});
|
|
92
|
+
/** True when a section written in this style can be scanned back. */
|
|
93
|
+
recognizes(style) {
|
|
94
|
+
return this.styles.some((candidate) => Equal.equals(candidate, style));
|
|
95
|
+
}
|
|
96
|
+
/** The opening marker line for an identity, without a line break. */
|
|
97
|
+
beginMarker(id) {
|
|
98
|
+
return this.marker("BEGIN", id);
|
|
99
|
+
}
|
|
100
|
+
/** The closing marker line for an identity, without a line break. */
|
|
101
|
+
endMarker(id) {
|
|
102
|
+
return this.marker("END", id);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A section as marker-delimited text, or a typed refusal.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* Fails rather than producing a document it could not read back: see
|
|
109
|
+
* {@link SectionRenderError}. Content is emitted with `eol` throughout,
|
|
110
|
+
* so a section rendered into a CRLF document stays CRLF.
|
|
111
|
+
*/
|
|
112
|
+
render(section, eol = "\n") {
|
|
113
|
+
if (!this.recognizes(section.commentStyle)) return Result.fail(new SectionRenderError({
|
|
114
|
+
reason: "unknownCommentStyle",
|
|
115
|
+
key: section.key
|
|
116
|
+
}));
|
|
117
|
+
if (this.containsMarker(section.content)) return Result.fail(new SectionRenderError({
|
|
118
|
+
reason: "markerInContent",
|
|
119
|
+
key: section.key
|
|
120
|
+
}));
|
|
121
|
+
const body = eol === "\n" ? section.content : section.content.replace(/\n/g, eol);
|
|
122
|
+
return Result.succeed(`${this.beginMarker(section.id)}${eol}${body}${eol}${this.endMarker(section.id)}`);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* True when `text` contains a line this dialect would read as a marker.
|
|
126
|
+
*
|
|
127
|
+
* @internal
|
|
128
|
+
*/
|
|
129
|
+
containsMarker(text) {
|
|
130
|
+
return this.matchers().some((matcher) => text.matchAll(matcher.regex).next().done === false);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One compiled scanner per recognized comment style, memoized.
|
|
134
|
+
*
|
|
135
|
+
* @remarks
|
|
136
|
+
* Each pattern is anchored per line, bounds the key with an explicit
|
|
137
|
+
* character class, and carries no nested quantifier, so scanning is linear
|
|
138
|
+
* in document length. Every caller-supplied fragment — prefix, suffix,
|
|
139
|
+
* phrase — is regex-escaped before interpolation.
|
|
140
|
+
*
|
|
141
|
+
* @internal
|
|
142
|
+
*/
|
|
143
|
+
matchers() {
|
|
144
|
+
const cached = matcherCache.get(this);
|
|
145
|
+
if (cached !== void 0) return cached;
|
|
146
|
+
const phrase = escapeRegex(this.phrase).replace(/ +/g, GAP);
|
|
147
|
+
const compiled = this.styles.map((style) => {
|
|
148
|
+
const tail = style.suffix === void 0 ? "" : `${GAP}${escapeRegex(style.suffix)}`;
|
|
149
|
+
return {
|
|
150
|
+
style,
|
|
151
|
+
regex: new RegExp(`^${escapeRegex(style.prefix)}${GAP}---${GAP}(BEGIN|END)${GAP}${KEY_CAPTURE}${GAP}${phrase}${GAP}---${tail}[ \\t]*(?=\r?$)`, "gm")
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
matcherCache.set(this, compiled);
|
|
155
|
+
return compiled;
|
|
156
|
+
}
|
|
157
|
+
marker(kind, id) {
|
|
158
|
+
const tail = id.commentStyle.suffix === void 0 ? "" : ` ${id.commentStyle.suffix}`;
|
|
159
|
+
return `${id.commentStyle.prefix} --- ${kind} ${id.key} ${this.phrase} ---${tail}`;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
//#endregion
|
|
164
|
+
export { SectionDialect, SectionRenderError };
|