@effected/yaml 0.10.0 → 0.11.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- Zero-dependency YAML 1.2 parsing, editing, formatting and linting expressed as Effect schemas and pure functions. Parse a single document or a multi-document stream into plain values or an offset-preserving AST, resolve anchors and aliases, read and preserve per-node comments, strip comments, compute byte-minimal edits, format, modify by path, walk a document as a `Stream` of AST events or positioned tokens, lint it against a rule catalog with surgical autofix, and decode straight into a validated domain schema.
8
+ Zero-dependency YAML 1.2 parsing, editing, formatting and linting expressed as Effect schemas and pure functions. Parse a single document or a multi-document stream into plain values or an offset-preserving AST, resolve anchors and aliases, read and preserve per-node comments, strip comments, compute byte-minimal edits, format, modify by path, walk a document as a `Stream` of AST events or positioned tokens, lint it against a rule catalog with surgical autofix — inferring the lint config from files you already have — and decode straight into a validated domain schema.
9
9
 
10
10
  > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
11
  > development against a single pinned Effect v4 prerelease. Packages graduate to
@@ -156,6 +156,23 @@ console.log(Result.isSuccess(fixed) ? fixed.success : "fatal parse error — not
156
156
 
157
157
  Positions are zero-based throughout, so the first finding above sits on the second line of the source. Fixes route through `YamlEdit.applyAll` rather than a reformat, so an autofix cannot move a comment it was not asked to touch, and a rule whose repair would need reflowing the document ships without a fix at all. `YamlLintConfig.default` and `YamlLintConfig.relaxed` are the presets; compose your own by spreading a preset's `rules` into `YamlLintConfig.make`. Config validation is rule-aware for the built-in catalog — a misspelled option key on a built-in rule or an attempt to switch off the always-on `parse-validity` rule fails schema validation instead of being quietly ignored — while an unknown (custom) rule id passes through with its options treated as opaque, left for the custom rule itself to validate. Custom rules are plain array concatenation — `YamlLint.run(text, [...YamlLint.builtins, myRule], config)` — and configuring `myRule` in that config works precisely because of the pass-through.
158
158
 
159
+ A repo with no lint config yet can infer one from what its files already do. `YamlLint.inferLenient` observes a text's style — quote type, indentation width, document markers and the rest — and returns the config the dominant style implies, plus the residual: the diagnostics that config still produces on the same text.
160
+
161
+ ```ts
162
+ import { YamlLint } from "@effected/yaml";
163
+
164
+ const workflow = ["---", "name: ci", "on:", " push:", " branches: ['main']", "jobs:", " build:", " runs-on: ubuntu-latest", ""].join("\n");
165
+
166
+ const { config, residual } = YamlLint.inferLenient(workflow, YamlLint.builtins);
167
+ console.log(config.rules["quoted-strings"], config.rules["document-start"], config.rules.indentation);
168
+ // { quoteType: 'single' } { present: true } { spaces: 2 }
169
+
170
+ for (const d of residual) console.log(`${d.line}:${d.character} ${d.rule} ${d.message}`);
171
+ // 2:0 truthy Truthy value "on" is not in the allowed spellings
172
+ ```
173
+
174
+ `YamlLint.inferStrict` is the same step in `Result` form: it fails with a structured `YamlStyleConflictError` — naming every conflicting dimension, all observed spellings, counts and positions — when the observed style disagrees with itself, instead of picking a winner. Across many files, compose the primitives instead of the conveniences: `YamlLint.observe` each file, merge the `StyleEvidence` values with `StyleEvidence.combine` (evidence is a monoid, so the order does not matter), then resolve once with `YamlLint.resolveStrict` or `YamlLint.resolveLenient`. Dimensions never observed fall back to the base config rather than failing. Policy rules with no detectable style — `truthy`, `key-duplicates` — stay default-driven, as do `line-length` and `empty-lines`, whose observations are floor-only: the longest line proves the limit is at least that long, not what it should be, so the evidence is carried but never resolved into config. A custom rule participates by implementing the optional `infer` hook on `YamlRule`.
175
+
159
176
  ## Migrating from Prettier
160
177
 
161
178
  `YamlFormat.formatToString(text, undefined, options)` replaces a `prettier --write` over YAML files. The option mapping:
@@ -163,14 +180,14 @@ Positions are zero-based throughout, so the first finding above sits on the seco
163
180
  | Prettier | `YamlFormattingOptions` |
164
181
  | --- | --- |
165
182
  | `tabWidth` | `indent` |
166
- | `singleQuote` | `quoteStyle: "single" \| "double"` |
183
+ | `singleQuote` | `quoteStyle: "single" \| "double"` + `requoteScalars: true` |
167
184
  | indented block sequences (always on) | `indentSequences: true` |
168
185
  | `printWidth` / `proseWrap` | no analog — see below |
169
186
  | comments (best-effort) | `preserveComments: true` (the default) |
170
187
 
171
188
  Two defaults differ from Prettier's output and are worth setting explicitly. The stringify default is `quoteStyle: "single"` for byte-compatibility with the kit's legacy form, so if you ran Prettier with its own default (`singleQuote: false`), set `quoteStyle: "double"`. Likewise `indentSequences` defaults to `false` — the flat legacy form with the `-` markers at the key's column — while Prettier always indented block sequences one level, so an ex-Prettier consumer almost certainly wants `indentSequences: true`.
172
189
 
173
- One expectation to reset when carrying over `singleQuote`: on the format path, `quoteStyle` governs quotes the stringifier *introduces* — it never re-quotes scalars that already carry quotes in your source. Formatting `a: 'x'` with `quoteStyle: "double"` leaves `'x'` untouched; Prettier would have rewritten it to `"x"`. Existing quote styles are source, and the formatter preserves source. If it looks like the option "does nothing" on an already-quoted corpus, this is why — the same class of format-path inertness as `lineWidth` below.
190
+ The `requoteScalars: true` half of the `singleQuote` row is what buys full parity. By default, `quoteStyle` on the format path governs only quotes the stringifier *introduces* — a scalar already quoted in your source keeps its own quote style, so formatting `a: 'x'` with `quoteStyle: "double"` leaves `'x'` untouched, where Prettier would have rewritten it to `"x"`. With `requoteScalars: true` (default `false`), `quoteStyle` applies to already-quoted source scalars too. A re-quote happens only when it provably preserves the parsed value: single→double applies proper double-quote escaping, and double→single is skipped when the value carries characters single quotes cannot express. Plain scalars stay plain, block scalars stay block, and tagged, anchored and multi-line scalars are never touched. One interaction to watch: `quoteStyle` omitted defaults to `"single"`, so `requoteScalars: true` on its own converts `"x"` to `'x'` — an ex-Prettier consumer who ran with `singleQuote: false` wants `quoteStyle: "double"` alongside it.
174
191
 
175
192
  `printWidth` and `proseWrap` have no analog here: `lineWidth` folding is a value-path feature only (`Yaml.stringify` / `Yaml.stringifyResult`), and the option is deliberately inert on the format path by contract — `YamlFormat` never reflows scalar content (the `lineWidth` TSDoc on `YamlStringifyOptions` pins this). Comments, which Prettier handled best-effort, are preserved exactly by default.
176
193
 
@@ -186,11 +203,11 @@ Multi-document streams — a Kubernetes manifest, or the two-document `pnpm-lock
186
203
  - `Yaml.schema` / `Yaml.fromString` / `Yaml.YamlFromString` / `Yaml.allFromString` — string→domain schema factories that decode a single document or a whole stream into a validated Effect `Schema` value.
187
204
  - `YamlNode` — an offset-preserving AST (`YamlScalar`, `YamlMap`, `YamlSeq`, `YamlPair`, `YamlAlias`) for locating and reading nodes by position or path; every node class except `YamlPair` carries its own `commentBefore`, `comment` and `spaceBefore`, and a pair is just its `key` and `value`.
188
205
  - `YamlDocument` — the full parsed AST plus the recovered `errors` and `warnings` arrays, so a partially valid document is still inspectable, and the header and trailing comment blocks as `commentBefore` / `comment`, the same two names the node classes use.
189
- - `YamlEdit` / `YamlFormat` — compute byte-minimal edit arrays for formatting and path-based modification, so a change preserves every comment and byte you did not touch; `preserveComments: false` opts out. `format` handles multi-document streams whole, re-emitting every document and its framing; `modify` is single-document by contract (a path names no particular document of a stream) and fails typed with `MultiDocumentStream` rather than guessing.
206
+ - `YamlEdit` / `YamlFormat` — compute byte-minimal edit arrays for formatting and path-based modification, so a change preserves every comment and byte you did not touch; `preserveComments: false` opts out, and opt-in `requoteScalars` applies `quoteStyle` to scalars already quoted in the source, semantics-preserving or skipped. `format` handles multi-document streams whole, re-emitting every document and its framing; `modify` is single-document by contract (a path names no particular document of a stream) and fails typed with `MultiDocumentStream` rather than guessing.
190
207
  - `YamlVisitor` — walk a parsed document as a `Stream` of a tagged-enum event union, with `Stream.take` early termination on large inputs and a `placement` on every comment event.
191
208
  - `YamlToken` / `YamlTokens` — the positioned token stream the lint rules read: `tokenize` is a synchronous `Result` that always succeeds, surfacing lexical problems as `error`-kind tokens, and `stream` derives an Effect `Stream` from the same walk.
192
- - `YamlLint` — `run`, `fix` and the `builtins` catalog: 14 rules covering parse validity, line length, trailing whitespace, blank lines, end-of-file newline, document markers, duplicate keys, quoting, truthy spellings, comment and colon and hyphen spacing, and indentation.
193
- - `YamlLintConfig` / `YamlLintRule` / `YamlLintDiagnostic` — the `default` and `relaxed` presets, a config schema validated against the rule catalog, and the model a custom rule implements.
209
+ - `YamlLint` — `run`, `fix` and the `builtins` catalog: 14 rules covering parse validity, line length, trailing whitespace, blank lines, end-of-file newline, document markers, duplicate keys, quoting, truthy spellings, comment and colon and hyphen spacing, and indentation. Plus config inference for adopting the linter on an existing corpus: `observe` produces mergeable `StyleEvidence`, `resolveStrict` fails typed with `YamlStyleConflictError` on conflicting spellings, `resolveLenient` picks the dominant style, and `inferStrict` / `inferLenient` do it in one step per text.
210
+ - `YamlLintConfig` / `YamlLintRule` / `YamlLintDiagnostic` — the `default` and `relaxed` presets, a config schema validated against the rule catalog, and the model a custom rule implements, including the optional `infer` hook that lets a custom rule participate in config inference.
194
211
  - `YamlParseError` / `YamlStringifyError` / `YamlModificationError` — tagged errors carrying structured, positional `YamlDiagnostic` arrays rather than opaque messages.
195
212
 
196
213
  ## License
package/YamlFormat.js CHANGED
@@ -6,6 +6,7 @@ import { YamlDiagnostic } from "./YamlDiagnostic.js";
6
6
  import { YamlStringifyOptions } from "./Yaml.js";
7
7
  import { YamlEdit, YamlRange } from "./YamlEdit.js";
8
8
  import { computeEdits } from "./internal/diff.js";
9
+ import { requoteScalarText } from "./internal/requote.js";
9
10
  import { Effect, Schema } from "effect";
10
11
 
11
12
  //#region src/YamlFormat.ts
@@ -13,9 +14,25 @@ import { Effect, Schema } from "effect";
13
14
  * Options controlling formatting behavior: every {@link YamlStringifyOptions}
14
15
  * field (derived, not hand-duplicated — including `indentSequences` and
15
16
  * `quoteStyle`) plus
16
- * `preserveComments` (default `true`) and `range` (restrict edits to a
17
+ * `preserveComments` (default `true`), `range` (restrict edits to a
17
18
  * region; see the module-level remarks on the `range` parameter vs. this
18
- * field).
19
+ * field) and `requoteScalars` (default `false`).
20
+ *
21
+ * `requoteScalars` makes `quoteStyle` apply to scalars **already quoted in
22
+ * the source** on the format path — by default formatting preserves an
23
+ * existing scalar's own quote style, and `quoteStyle` governs only quotes the
24
+ * stringifier introduces. When enabled, a re-quote happens only when it
25
+ * provably preserves the parsed value: single→double applies proper
26
+ * double-quote escaping, double→single is skipped whenever the value carries
27
+ * characters single quotes cannot express (newlines, tabs, control and other
28
+ * non-printable characters — single-quoted style can escape nothing but
29
+ * `'`). Plain scalars stay plain, block scalars stay block, and scalars
30
+ * carrying a tag or anchor — or spanning multiple source lines — are left
31
+ * untouched. The option exists only where a source quote exists to re-quote:
32
+ * it is read by {@link YamlFormat.format} / {@link YamlFormat.formatToString}
33
+ * and is deliberately absent from `Yaml.stringify` (which serializes plain
34
+ * values) and {@link YamlFormat.modify} (which takes a bare
35
+ * {@link YamlStringifyOptions}).
19
36
  *
20
37
  * Construct with the validated `YamlFormattingOptions.make({ ... })` static —
21
38
  * the kit convention (never `new`). Call sites that take a
@@ -37,7 +54,8 @@ import { Effect, Schema } from "effect";
37
54
  var YamlFormattingOptions = class extends Schema.Class("YamlFormattingOptions")({
38
55
  ...YamlStringifyOptions.fields,
39
56
  preserveComments: Schema.optionalKey(Schema.Boolean),
40
- range: Schema.optionalKey(YamlRange)
57
+ range: Schema.optionalKey(YamlRange),
58
+ requoteScalars: Schema.optionalKey(Schema.Boolean)
41
59
  }) {};
42
60
  /**
43
61
  * Raised when `YamlFormat.modify` cannot navigate the requested path against
@@ -104,6 +122,49 @@ function definedFields(fields) {
104
122
  for (const key of Object.keys(fields)) if (fields[key] !== void 0) out[key] = fields[key];
105
123
  return out;
106
124
  }
125
+ /**
126
+ * Walk a composed AST and flip the `style` of every scalar the shared
127
+ * escaping-mode helper deems re-quotable to the target quote's style. The
128
+ * stringifier then emits those scalars through the very renderers the helper
129
+ * used to prove the replacement value-preserving, so flip-and-stringify and
130
+ * the helper's replacement text cannot disagree. Everything the helper skips
131
+ * — plain scalars, block scalars, tagged/anchored nodes, multi-line source
132
+ * spans, impossible double→single escapes — keeps its node untouched, and
133
+ * the surrounding diff keeps the edit surgical per scalar span.
134
+ */
135
+ function requoteNode(node, text, quote) {
136
+ if (node instanceof YamlScalar) {
137
+ if (requoteScalarText(text, node, quote, "escaping") === void 0) return node;
138
+ return YamlScalar.make({
139
+ value: node.value,
140
+ style: quote === "\"" ? "double-quoted" : "single-quoted",
141
+ ...definedFields({
142
+ commentBefore: node.commentBefore,
143
+ comment: node.comment,
144
+ spaceBefore: node.spaceBefore,
145
+ raw: node.raw,
146
+ sourceMultiline: node.sourceMultiline
147
+ }),
148
+ offset: node.offset,
149
+ length: node.length
150
+ });
151
+ }
152
+ if (node instanceof YamlMap) return rebuildMap(node, node.items.map((pair) => YamlPair.make({
153
+ key: requoteNode(pair.key, text, quote),
154
+ value: pair.value === null ? null : requoteNode(pair.value, text, quote)
155
+ })));
156
+ if (node instanceof YamlSeq) return rebuildSeq(node, node.items.map((item) => requoteNode(item, text, quote)));
157
+ return node;
158
+ }
159
+ /** Apply the opt-in re-quoting pass to a composed document's contents. */
160
+ function requoteDocument(doc, text, options) {
161
+ if (options?.requoteScalars !== true || doc.contents === null) return doc;
162
+ const quote = (options.quoteStyle ?? "single") === "double" ? "\"" : "'";
163
+ return {
164
+ ...doc,
165
+ contents: requoteNode(doc.contents, text, quote)
166
+ };
167
+ }
107
168
  /** Rebuild a composed document for re-emission, stripping comments when asked. */
108
169
  function toOutputDocument(doc, preserveComments) {
109
170
  return {
@@ -132,12 +193,12 @@ function toOutputDocument(doc, preserveComments) {
132
193
  */
133
194
  function formatDocument(text, options) {
134
195
  const { documents, streamErrors } = composeAllDocuments(text, {});
135
- if (documents.length > 1) return formatStream(documents, streamErrors, options);
196
+ if (documents.length > 1) return formatStream(text, documents, streamErrors, options);
136
197
  if (streamErrors.some((e) => isFatalCode(e.code))) return void 0;
137
198
  const doc = documents[0] ?? EMPTY_DOCUMENT;
138
199
  if (doc.errors.some((e) => isFatalCode(e.code))) return void 0;
139
200
  if (doc.directives.length > 0) return void 0;
140
- return stringifyDocument(toOutputDocument(doc, options?.preserveComments ?? true), toStringifyInput(options));
201
+ return stringifyDocument(toOutputDocument(requoteDocument(doc, text, options), options?.preserveComments ?? true), toStringifyInput(options));
141
202
  }
142
203
  /**
143
204
  * Format a multi-document stream: every document is re-emitted in order with
@@ -158,7 +219,7 @@ function formatDocument(text, options) {
158
219
  * line while keeping the dependent shorthand tags produced unparseable
159
220
  * output (probe-verified downstream against an independent oracle).
160
221
  */
161
- function formatStream(documents, streamErrors, options) {
222
+ function formatStream(text, documents, streamErrors, options) {
162
223
  if (streamErrors.some((e) => isFatalCode(e.code))) return void 0;
163
224
  if (documents.some((d) => d.errors.some((e) => isFatalCode(e.code)))) return void 0;
164
225
  if (documents.some((d) => d.directives.length > 0)) return void 0;
@@ -172,7 +233,7 @@ function formatStream(documents, streamErrors, options) {
172
233
  ...base,
173
234
  finalNewline: true
174
235
  } : base;
175
- parts.push(stringifyDocument(toOutputDocument(doc, preserveComments), docOptions));
236
+ parts.push(stringifyDocument(toOutputDocument(requoteDocument(doc, text, options), preserveComments), docOptions));
176
237
  }
177
238
  return parts.join("");
178
239
  }
@@ -351,6 +412,12 @@ var YamlFormat = class YamlFormat {
351
412
  * `{ offset, length }` object as well as a {@link YamlRange} instance, so
352
413
  * callers do not need `YamlRange.make(...)` for the common case.
353
414
  *
415
+ * Formatting preserves an existing scalar's own quote style by default —
416
+ * `quoteStyle` governs only quotes the stringifier introduces. The opt-in
417
+ * `options.requoteScalars` makes `quoteStyle` apply to already-quoted
418
+ * source scalars too, re-quoting only where the parsed value is provably
419
+ * preserved; see {@link YamlFormattingOptions} for the exact skip rules.
420
+ *
354
421
  * A plain `<<` mapping key is preserved unquoted, keeping its merge-key
355
422
  * meaning (`tag:yaml.org,2002:merge`) — quoting it to `'<<'` would produce
356
423
  * an ordinary string key that merges nothing, changing what the document
package/YamlLint.js CHANGED
Binary file
package/YamlLintRule.js CHANGED
@@ -31,6 +31,55 @@ var YamlLintDiagnostic = class extends Schema.Class("YamlLintDiagnostic")({
31
31
  character: Schema.Number,
32
32
  fix: Schema.optionalKey(YamlEdit)
33
33
  }) {};
34
+ /**
35
+ * One categorical style observation (#345): a single occurrence of a style
36
+ * choice in the source, voting a `value` for an inference `dimension`.
37
+ *
38
+ * The `dimension` IS the rule's option key and the `value` IS that option's
39
+ * value (`"double"` for `quoteType`, `2` for `spaces`, `false` for
40
+ * `present`), so the inference resolvers can turn unanimous or dominant
41
+ * votes into a config entry with no per-rule knowledge — which is what lets
42
+ * custom rules participate in inference for free. The position points at the
43
+ * occurrence that voted, so a strict-resolution conflict can name where each
44
+ * spelling was seen.
45
+ *
46
+ * The `_tag` literal is the RUNTIME discriminator between the two
47
+ * observation kinds: class instances are structurally assignable, so a
48
+ * custom rule may yield a plain object shaped like a vote, and the evidence
49
+ * builder must still sort it into the right tally without `instanceof`.
50
+ * `.make` defaults it — construction sites never pass `_tag`.
51
+ *
52
+ * @public
53
+ */
54
+ var StyleVote = class extends Schema.TaggedClass()("StyleVote", {
55
+ dimension: Schema.String,
56
+ value: Schema.Union([
57
+ Schema.String,
58
+ Schema.Number,
59
+ Schema.Boolean
60
+ ]),
61
+ offset: Schema.Number,
62
+ length: Schema.Number,
63
+ line: Schema.Number,
64
+ character: Schema.Number
65
+ }) {};
66
+ /**
67
+ * One measured style floor (#345): a value the source PROVES is at least
68
+ * `value`, without proving what the configured limit should be — the longest
69
+ * observed line proves `line-length.max` is at least that long, not what it
70
+ * is. Floors are carried in the evidence for callers that want them and are
71
+ * never resolved into config options; fabricating a max from the largest
72
+ * value one happened to see would be lying with a straight face.
73
+ *
74
+ * The `_tag` literal discriminates a floor from a {@link StyleVote} at
75
+ * runtime (see there); `.make` defaults it.
76
+ *
77
+ * @public
78
+ */
79
+ var StyleFloor = class extends Schema.TaggedClass()("StyleFloor", {
80
+ dimension: Schema.String,
81
+ value: Schema.Number
82
+ }) {};
34
83
 
35
84
  //#endregion
36
- export { YamlLintDiagnostic, YamlLintSeverity };
85
+ export { StyleFloor, StyleVote, YamlLintDiagnostic, YamlLintSeverity };
package/index.d.ts CHANGED
@@ -1009,14 +1009,31 @@ declare const YamlFormattingOptions_base: Schema.Class<YamlFormattingOptions, Sc
1009
1009
  readonly forceDefaultStyles: Schema.optionalKey<Schema.Boolean>;
1010
1010
  readonly preserveComments: Schema.optionalKey<Schema.Boolean>;
1011
1011
  readonly range: Schema.optionalKey<typeof YamlRange>;
1012
+ readonly requoteScalars: Schema.optionalKey<Schema.Boolean>;
1012
1013
  }>, {}>;
1013
1014
  /**
1014
1015
  * Options controlling formatting behavior: every {@link YamlStringifyOptions}
1015
1016
  * field (derived, not hand-duplicated — including `indentSequences` and
1016
1017
  * `quoteStyle`) plus
1017
- * `preserveComments` (default `true`) and `range` (restrict edits to a
1018
+ * `preserveComments` (default `true`), `range` (restrict edits to a
1018
1019
  * region; see the module-level remarks on the `range` parameter vs. this
1019
- * field).
1020
+ * field) and `requoteScalars` (default `false`).
1021
+ *
1022
+ * `requoteScalars` makes `quoteStyle` apply to scalars **already quoted in
1023
+ * the source** on the format path — by default formatting preserves an
1024
+ * existing scalar's own quote style, and `quoteStyle` governs only quotes the
1025
+ * stringifier introduces. When enabled, a re-quote happens only when it
1026
+ * provably preserves the parsed value: single→double applies proper
1027
+ * double-quote escaping, double→single is skipped whenever the value carries
1028
+ * characters single quotes cannot express (newlines, tabs, control and other
1029
+ * non-printable characters — single-quoted style can escape nothing but
1030
+ * `'`). Plain scalars stay plain, block scalars stay block, and scalars
1031
+ * carrying a tag or anchor — or spanning multiple source lines — are left
1032
+ * untouched. The option exists only where a source quote exists to re-quote:
1033
+ * it is read by {@link YamlFormat.format} / {@link YamlFormat.formatToString}
1034
+ * and is deliberately absent from `Yaml.stringify` (which serializes plain
1035
+ * values) and {@link YamlFormat.modify} (which takes a bare
1036
+ * {@link YamlStringifyOptions}).
1020
1037
  *
1021
1038
  * Construct with the validated `YamlFormattingOptions.make({ ... })` static —
1022
1039
  * the kit convention (never `new`). Call sites that take a
@@ -1122,6 +1139,12 @@ declare class YamlFormat {
1122
1139
  * `{ offset, length }` object as well as a {@link YamlRange} instance, so
1123
1140
  * callers do not need `YamlRange.make(...)` for the common case.
1124
1141
  *
1142
+ * Formatting preserves an existing scalar's own quote style by default —
1143
+ * `quoteStyle` governs only quotes the stringifier introduces. The opt-in
1144
+ * `options.requoteScalars` makes `quoteStyle` apply to already-quoted
1145
+ * source scalars too, re-quoting only where the parsed value is provably
1146
+ * preserved; see {@link YamlFormattingOptions} for the exact skip rules.
1147
+ *
1125
1148
  * A plain `<<` mapping key is preserved unquoted, keeping its merge-key
1126
1149
  * meaning (`tag:yaml.org,2002:merge`) — quoting it to `'<<'` would produce
1127
1150
  * an ordinary string key that merges nothing, changing what the document
@@ -1337,6 +1360,60 @@ interface LintContext {
1337
1360
  readonly tokens: ReadonlyArray<YamlToken>;
1338
1361
  readonly document: YamlDocument;
1339
1362
  }
1363
+ declare const StyleVote_base: Schema.Class<StyleVote, Schema.TaggedStruct<"StyleVote", {
1364
+ readonly dimension: Schema.String;
1365
+ readonly value: Schema.Union<readonly [Schema.String, Schema.Number, Schema.Boolean]>;
1366
+ readonly offset: Schema.Number;
1367
+ readonly length: Schema.Number;
1368
+ readonly line: Schema.Number;
1369
+ readonly character: Schema.Number;
1370
+ }>, {}>;
1371
+ /**
1372
+ * One categorical style observation (#345): a single occurrence of a style
1373
+ * choice in the source, voting a `value` for an inference `dimension`.
1374
+ *
1375
+ * The `dimension` IS the rule's option key and the `value` IS that option's
1376
+ * value (`"double"` for `quoteType`, `2` for `spaces`, `false` for
1377
+ * `present`), so the inference resolvers can turn unanimous or dominant
1378
+ * votes into a config entry with no per-rule knowledge — which is what lets
1379
+ * custom rules participate in inference for free. The position points at the
1380
+ * occurrence that voted, so a strict-resolution conflict can name where each
1381
+ * spelling was seen.
1382
+ *
1383
+ * The `_tag` literal is the RUNTIME discriminator between the two
1384
+ * observation kinds: class instances are structurally assignable, so a
1385
+ * custom rule may yield a plain object shaped like a vote, and the evidence
1386
+ * builder must still sort it into the right tally without `instanceof`.
1387
+ * `.make` defaults it — construction sites never pass `_tag`.
1388
+ *
1389
+ * @public
1390
+ */
1391
+ declare class StyleVote extends StyleVote_base {}
1392
+ declare const StyleFloor_base: Schema.Class<StyleFloor, Schema.TaggedStruct<"StyleFloor", {
1393
+ readonly dimension: Schema.String;
1394
+ readonly value: Schema.Number;
1395
+ }>, {}>;
1396
+ /**
1397
+ * One measured style floor (#345): a value the source PROVES is at least
1398
+ * `value`, without proving what the configured limit should be — the longest
1399
+ * observed line proves `line-length.max` is at least that long, not what it
1400
+ * is. Floors are carried in the evidence for callers that want them and are
1401
+ * never resolved into config options; fabricating a max from the largest
1402
+ * value one happened to see would be lying with a straight face.
1403
+ *
1404
+ * The `_tag` literal discriminates a floor from a {@link StyleVote} at
1405
+ * runtime (see there); `.make` defaults it.
1406
+ *
1407
+ * @public
1408
+ */
1409
+ declare class StyleFloor extends StyleFloor_base {}
1410
+ /**
1411
+ * What a rule's `infer` hook yields per occurrence: a categorical
1412
+ * {@link StyleVote} or a measured {@link StyleFloor}.
1413
+ *
1414
+ * @public
1415
+ */
1416
+ type StyleObservation = StyleVote | StyleFloor;
1340
1417
  /**
1341
1418
  * The public rule interface — built-ins and custom rules are the same
1342
1419
  * shape, and config references either by `id`; there is no privileged
@@ -1347,11 +1424,19 @@ interface LintContext {
1347
1424
  * rules receive options already validated against their exported options
1348
1425
  * schema, custom rules validate their own.
1349
1426
  *
1427
+ * `infer` is the optional config-inference hook (#345): it reports the style
1428
+ * the source already follows as per-occurrence {@link StyleObservation}s, so
1429
+ * detection logic lives beside the check logic that polices the same
1430
+ * dimension (and shares its fixtures). Rules with no detectable style — the
1431
+ * policy rules, whose only evidence is violations — simply omit the hook and
1432
+ * stay default-driven under every resolver.
1433
+ *
1350
1434
  * @public
1351
1435
  */
1352
1436
  interface YamlRule {
1353
1437
  readonly id: string;
1354
1438
  readonly check: (ctx: LintContext, options: unknown) => Iterable<YamlLintDiagnostic>;
1439
+ readonly infer?: (ctx: LintContext) => Iterable<StyleObservation>;
1355
1440
  }
1356
1441
  //#endregion
1357
1442
  //#region src/YamlLint.d.ts
@@ -1399,6 +1484,118 @@ declare class YamlLintConfig extends YamlLintConfig_base {
1399
1484
  /** The relaxed preset: style rules demoted to warnings. */
1400
1485
  static readonly relaxed: YamlLintConfig;
1401
1486
  }
1487
+ declare const StyleVoteTally_base: Schema.Class<StyleVoteTally, Schema.Struct<{
1488
+ readonly rule: Schema.String;
1489
+ readonly dimension: Schema.String;
1490
+ readonly value: Schema.Union<readonly [Schema.String, Schema.Number, Schema.Boolean]>;
1491
+ readonly count: Schema.Number;
1492
+ readonly offset: Schema.Number;
1493
+ readonly length: Schema.Number;
1494
+ readonly line: Schema.Number;
1495
+ readonly character: Schema.Number;
1496
+ }>, {}>;
1497
+ /**
1498
+ * An accumulated tally of one {@link StyleVote} spelling: how many times a
1499
+ * `value` was voted for a `(rule, dimension)` pair, and the position of the
1500
+ * FIRST occurrence seen (merging keeps the left operand's position, so on a
1501
+ * multi-file merge the first file that exhibited the spelling names it).
1502
+ *
1503
+ * @public
1504
+ */
1505
+ declare class StyleVoteTally extends StyleVoteTally_base {}
1506
+ declare const StyleFloorTally_base: Schema.Class<StyleFloorTally, Schema.Struct<{
1507
+ readonly rule: Schema.String;
1508
+ readonly dimension: Schema.String;
1509
+ readonly value: Schema.Number;
1510
+ }>, {}>;
1511
+ /**
1512
+ * An accumulated {@link StyleFloor} for a `(rule, dimension)` pair: the
1513
+ * largest value the observed sources prove the limit is AT LEAST. Merging
1514
+ * takes the maximum. Floors are informational — never resolved into config
1515
+ * options (see {@link StyleFloor}).
1516
+ *
1517
+ * @public
1518
+ */
1519
+ declare class StyleFloorTally extends StyleFloorTally_base {}
1520
+ declare const StyleEvidence_base: Schema.Class<StyleEvidence, Schema.Struct<{
1521
+ readonly votes: Schema.$Array<typeof StyleVoteTally>;
1522
+ readonly floors: Schema.$Array<typeof StyleFloorTally>;
1523
+ }>, {}>;
1524
+ /**
1525
+ * Per-dimension style evidence (#345): what the observed sources say about
1526
+ * each inferable `(rule, dimension)` — vote histograms with first-seen
1527
+ * positions, and measured floors.
1528
+ *
1529
+ * Evidence is a MONOID: {@link StyleEvidence.empty} is the identity and
1530
+ * {@link StyleEvidence.combine} is associative, so multi-file inference is
1531
+ * observe-per-file, merge, resolve — and the N-file loop stays the caller's
1532
+ * (no IO enters the package). `combine` normalizes: counts of the same
1533
+ * `(rule, dimension, value)` add, the left operand's first-seen position
1534
+ * wins, floors take the maximum, and the result is canonically sorted — so
1535
+ * every value `observe`/`combine`/`fromObservations` produces is canonical
1536
+ * and the monoid laws hold structurally over them.
1537
+ *
1538
+ * @public
1539
+ */
1540
+ declare class StyleEvidence extends StyleEvidence_base {
1541
+ /** The monoid identity: no observations. */
1542
+ static readonly empty: StyleEvidence;
1543
+ /**
1544
+ * Merge two bodies of evidence (associative; {@link StyleEvidence.empty}
1545
+ * is the identity). Vote counts add, the left operand's first-seen
1546
+ * position wins per spelling, floors take the maximum.
1547
+ */
1548
+ static combine(a: StyleEvidence, b: StyleEvidence): StyleEvidence;
1549
+ /**
1550
+ * Build canonical evidence from one rule's raw observations — the
1551
+ * homomorphism from observation lists into the monoid
1552
+ * (`fromObservations(r, [...a, ...b])` equals
1553
+ * `combine(fromObservations(r, a), fromObservations(r, b))`). `observe`
1554
+ * uses it per rule; it is public so custom tooling can construct evidence
1555
+ * without a {@link LintContext}.
1556
+ */
1557
+ static fromObservations(rule: string, observations: Iterable<StyleObservation>): StyleEvidence;
1558
+ }
1559
+ declare const StyleConflict_base: Schema.Class<StyleConflict, Schema.Struct<{
1560
+ readonly rule: Schema.String;
1561
+ readonly dimension: Schema.String;
1562
+ readonly candidates: Schema.$Array<typeof StyleVoteTally>;
1563
+ }>, {}>;
1564
+ /**
1565
+ * One strict-resolution conflict: a `(rule, dimension)` whose observed
1566
+ * spellings disagree. `candidates` carries every spelling with its count and
1567
+ * first-seen position, ordered by count descending (dominant first).
1568
+ *
1569
+ * @public
1570
+ */
1571
+ declare class StyleConflict extends StyleConflict_base {}
1572
+ declare const YamlStyleConflictError_base: Schema.Class<YamlStyleConflictError, Schema.TaggedStruct<"YamlStyleConflictError", {
1573
+ readonly conflicts: Schema.$Array<typeof StyleConflict>;
1574
+ }>, import("effect/Cause").YieldableError>;
1575
+ /**
1576
+ * Raised by strict config inference when observed evidence is not unanimous:
1577
+ * carries every conflicting `(rule, dimension)` as a structured
1578
+ * {@link StyleConflict} — dimension, all spellings, counts and positions —
1579
+ * never a collapsed `reason` string (the structure-preserving-errors house
1580
+ * rule). Unobserved dimensions never conflict: they fall back to the base
1581
+ * config's defaults.
1582
+ *
1583
+ * @public
1584
+ */
1585
+ declare class YamlStyleConflictError extends YamlStyleConflictError_base {
1586
+ get message(): string;
1587
+ }
1588
+ /**
1589
+ * A lenient inference report: the inferred config plus the residual — the
1590
+ * diagnostics that config still produces on the observed text ("here is your
1591
+ * config, and the places that do not match it").
1592
+ *
1593
+ * @public
1594
+ */
1595
+ interface YamlLintInference {
1596
+ readonly config: YamlLintConfig;
1597
+ readonly residual: ReadonlyArray<YamlLintDiagnostic>;
1598
+ }
1402
1599
  /**
1403
1600
  * Linting statics. Not instantiable.
1404
1601
  *
@@ -1447,6 +1644,52 @@ declare class YamlLint {
1447
1644
  * is dropped (its diagnostic remains reported by {@link YamlLint.run}).
1448
1645
  */
1449
1646
  static fix(text: string, rules: ReadonlyArray<YamlRule>, config: YamlLintConfig): Result.Result<string, YamlParseError>;
1647
+ /**
1648
+ * Observe the style `text` already follows (#345): run every rule's
1649
+ * optional `infer` hook over one eagerly-built context and merge the
1650
+ * observations into canonical {@link StyleEvidence}. Pure and total —
1651
+ * strings in, evidence out; observing N files is N `observe` calls merged
1652
+ * with {@link StyleEvidence.combine}, and the N-file loop stays the
1653
+ * caller's (no IO enters the package).
1654
+ */
1655
+ static observe(text: string, rules: ReadonlyArray<YamlRule>): StyleEvidence;
1656
+ /**
1657
+ * Strict resolution: every OBSERVED dimension must be unanimous, and the
1658
+ * unanimous picks overlay `base` (default {@link YamlLintConfig.default})
1659
+ * into an exact config. Conflicting evidence fails with
1660
+ * {@link YamlStyleConflictError} naming every conflicting dimension, all
1661
+ * spellings, counts and first-seen positions. Unobserved is NOT
1662
+ * conflicting: a corpus with no comments says nothing about
1663
+ * `comments-spacing`, so that dimension falls back to `base` rather than
1664
+ * failing. A rule `base` sets to `"off"` stays off — an explicit disable
1665
+ * outranks inference.
1666
+ */
1667
+ static resolveStrict(evidence: StyleEvidence, base?: YamlLintConfig): Result.Result<YamlLintConfig, YamlStyleConflictError>;
1668
+ /**
1669
+ * Lenient resolution: the dominant (plurality) spelling per observed
1670
+ * dimension overlays `base` (default {@link YamlLintConfig.default}); a
1671
+ * tie breaks deterministically to the first candidate in canonical
1672
+ * value order. Total — lenient resolution cannot fail; the places that do
1673
+ * not match the inferred config surface as the residual report (run the
1674
+ * lint with the inferred config, or use {@link YamlLint.inferLenient}).
1675
+ */
1676
+ static resolveLenient(evidence: StyleEvidence, base?: YamlLintConfig): YamlLintConfig;
1677
+ /**
1678
+ * Single-text strict inference: `observe` then {@link YamlLint.resolveStrict}
1679
+ * in one step. Multi-file callers observe each file and merge with
1680
+ * {@link StyleEvidence.combine} before resolving.
1681
+ */
1682
+ static inferStrict(text: string, rules: ReadonlyArray<YamlRule>, base?: YamlLintConfig): Result.Result<YamlLintConfig, YamlStyleConflictError>;
1683
+ /**
1684
+ * Single-text lenient inference: `observe`, resolve dominant picks over
1685
+ * `base`, then run the lint with the inferred config — returning the
1686
+ * config AND the residual diagnostics it still produces ("here is your
1687
+ * config, and the places that do not match it"). One context serves both
1688
+ * the observation and the residual run. Multi-file callers compose the
1689
+ * primitives instead: observe each file, {@link StyleEvidence.combine},
1690
+ * {@link YamlLint.resolveLenient}, then {@link YamlLint.run} per file.
1691
+ */
1692
+ static inferLenient(text: string, rules: ReadonlyArray<YamlRule>, base?: YamlLintConfig): YamlLintInference;
1450
1693
  }
1451
1694
  //#endregion
1452
1695
  //#region src/YamlVisitor.d.ts
@@ -2051,5 +2294,5 @@ declare class YamlVisitor {
2051
2294
  static visit(text: string, options?: YamlParseOptions): Stream.Stream<YamlVisitorEvent>;
2052
2295
  }
2053
2296
  //#endregion
2054
- export { CollectionStyle, type LintContext, type LintLine, QuoteStyle, ScalarChomp, ScalarStyle, Yaml, YamlAlias, type YamlAliasEncoded, type YamlBoundCodec, YamlComposerErrorCode, YamlDiagnostic, YamlDirective, YamlDocument, YamlEdit, YamlErrorCode, YamlFormat, YamlFormattingOptions, YamlLexErrorCode, YamlLint, YamlLintConfig, YamlLintDiagnostic, YamlLintRuleSetting, YamlLintSeverity, YamlMap, type YamlMapEncoded, YamlModificationError, YamlModifyErrorCode, YamlNode, YamlPair, YamlParseError, YamlParseErrorCode, YamlParseOptions, type YamlPath, YamlRange, type YamlRangeLike, type YamlRule, YamlScalar, type YamlScalarEncoded, type YamlSegment, YamlSeq, type YamlSeqEncoded, YamlStringifyError, YamlStringifyErrorCode, YamlStringifyOptions, YamlToken, YamlTokenKind, YamlTokens, YamlVisitor, YamlVisitorEvent };
2297
+ export { CollectionStyle, type LintContext, type LintLine, QuoteStyle, ScalarChomp, ScalarStyle, StyleConflict, StyleEvidence, StyleFloor, StyleFloorTally, type StyleObservation, StyleVote, StyleVoteTally, Yaml, YamlAlias, type YamlAliasEncoded, type YamlBoundCodec, YamlComposerErrorCode, YamlDiagnostic, YamlDirective, YamlDocument, YamlEdit, YamlErrorCode, YamlFormat, YamlFormattingOptions, YamlLexErrorCode, YamlLint, YamlLintConfig, YamlLintDiagnostic, type YamlLintInference, YamlLintRuleSetting, YamlLintSeverity, YamlMap, type YamlMapEncoded, YamlModificationError, YamlModifyErrorCode, YamlNode, YamlPair, YamlParseError, YamlParseErrorCode, YamlParseOptions, type YamlPath, YamlRange, type YamlRangeLike, type YamlRule, YamlScalar, type YamlScalarEncoded, type YamlSegment, YamlSeq, type YamlSeqEncoded, YamlStringifyError, YamlStringifyErrorCode, YamlStringifyOptions, YamlStyleConflictError, YamlToken, YamlTokenKind, YamlTokens, YamlVisitor, YamlVisitorEvent };
2055
2298
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -4,9 +4,9 @@ import { Yaml, YamlParseError, YamlParseOptions, YamlStringifyError, YamlStringi
4
4
  import { YamlDirective, YamlDocument } from "./YamlDocument.js";
5
5
  import { YamlEdit, YamlRange } from "./YamlEdit.js";
6
6
  import { YamlFormat, YamlFormattingOptions, YamlModificationError } from "./YamlFormat.js";
7
- import { YamlLintDiagnostic, YamlLintSeverity } from "./YamlLintRule.js";
7
+ import { StyleFloor, StyleVote, YamlLintDiagnostic, YamlLintSeverity } from "./YamlLintRule.js";
8
8
  import { YamlToken, YamlTokenKind, YamlTokens } from "./YamlToken.js";
9
- import { YamlLint, YamlLintConfig, YamlLintRuleSetting } from "./YamlLint.js";
9
+ import { StyleConflict, StyleEvidence, StyleFloorTally, StyleVoteTally, YamlLint, YamlLintConfig, YamlLintRuleSetting, YamlStyleConflictError } from "./YamlLint.js";
10
10
  import { YamlVisitor, YamlVisitorEvent } from "./YamlVisitor.js";
11
11
 
12
- export { CollectionStyle, QuoteStyle, ScalarChomp, ScalarStyle, Yaml, YamlAlias, YamlComposerErrorCode, YamlDiagnostic, YamlDirective, YamlDocument, YamlEdit, YamlErrorCode, YamlFormat, YamlFormattingOptions, YamlLexErrorCode, YamlLint, YamlLintConfig, YamlLintDiagnostic, YamlLintRuleSetting, YamlLintSeverity, YamlMap, YamlModificationError, YamlModifyErrorCode, YamlNode, YamlPair, YamlParseError, YamlParseErrorCode, YamlParseOptions, YamlRange, YamlScalar, YamlSeq, YamlStringifyError, YamlStringifyErrorCode, YamlStringifyOptions, YamlToken, YamlTokenKind, YamlTokens, YamlVisitor, YamlVisitorEvent };
12
+ export { CollectionStyle, QuoteStyle, ScalarChomp, ScalarStyle, StyleConflict, StyleEvidence, StyleFloor, StyleFloorTally, StyleVote, StyleVoteTally, Yaml, YamlAlias, YamlComposerErrorCode, YamlDiagnostic, YamlDirective, YamlDocument, YamlEdit, YamlErrorCode, YamlFormat, YamlFormattingOptions, YamlLexErrorCode, YamlLint, YamlLintConfig, YamlLintDiagnostic, YamlLintRuleSetting, YamlLintSeverity, YamlMap, YamlModificationError, YamlModifyErrorCode, YamlNode, YamlPair, YamlParseError, YamlParseErrorCode, YamlParseOptions, YamlRange, YamlScalar, YamlSeq, YamlStringifyError, YamlStringifyErrorCode, YamlStringifyOptions, YamlStyleConflictError, YamlToken, YamlTokenKind, YamlTokens, YamlVisitor, YamlVisitorEvent };
@@ -0,0 +1,48 @@
1
+ import { isControlChar } from "./fold.js";
2
+ import { renderDoubleQuoted, renderSingleQuoted } from "./stringifier.js";
3
+
4
+ //#region src/internal/requote.ts
5
+ /**
6
+ * True when `value` can be carried by a single-quoted flow scalar on one
7
+ * line. Single-quoted style has exactly one escape (`''`), so any character
8
+ * double-quote escape sequences exist to encode — newline, carriage return,
9
+ * tab, C0 controls, DEL and the C1 range — has no single-quoted spelling and
10
+ * makes the conversion impossible rather than lossy-but-tempting.
11
+ */
12
+ function isSingleQuotable(value) {
13
+ for (let i = 0; i < value.length; i++) {
14
+ const code = value.charCodeAt(i);
15
+ if (code === 9 || code === 10 || code === 13) return false;
16
+ if (isControlChar(code)) return false;
17
+ if (code === 127 || code >= 128 && code <= 159) return false;
18
+ }
19
+ return true;
20
+ }
21
+ /**
22
+ * The replacement raw text that re-quotes `scalar` with `quote`, or
23
+ * `undefined` when no value-preserving replacement exists under `mode`
24
+ * (skipping is always correct; corrupting never is).
25
+ */
26
+ function requoteScalarText(text, scalar, quote, mode) {
27
+ if (scalar.tag !== void 0 || scalar.anchor !== void 0) return void 0;
28
+ if (typeof scalar.value !== "string") return void 0;
29
+ const raw = text.slice(scalar.offset, scalar.offset + scalar.length);
30
+ if (raw.includes("\n") || raw.includes("\r")) return void 0;
31
+ if (mode === "conservative") {
32
+ const inner = scalar.style === "plain" ? raw : raw.slice(1, -1);
33
+ if (inner !== scalar.value) return void 0;
34
+ if (inner.includes(quote)) return void 0;
35
+ if (quote === "\"" && inner.includes("\\")) return void 0;
36
+ return `${quote}${inner}${quote}`;
37
+ }
38
+ if (quote === "\"") {
39
+ if (scalar.style !== "single-quoted") return void 0;
40
+ return renderDoubleQuoted(scalar.value);
41
+ }
42
+ if (scalar.style !== "double-quoted") return void 0;
43
+ if (!isSingleQuotable(scalar.value)) return void 0;
44
+ return renderSingleQuoted(scalar.value);
45
+ }
46
+
47
+ //#endregion
48
+ export { requoteScalarText };
@@ -1,5 +1,5 @@
1
1
  import { YamlEdit } from "../../YamlEdit.js";
2
- import { YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
2
+ import { StyleVote, YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
3
3
  import { nonNegativeIntegerOption } from "./util.js";
4
4
  import { Schema } from "effect";
5
5
 
@@ -14,6 +14,25 @@ const commentsSpacingOptions = Schema.Struct({
14
14
  minSpacesBefore: Schema.optionalKey(nonNegativeIntegerOption),
15
15
  requireSpaceAfter: Schema.optionalKey(Schema.Boolean)
16
16
  });
17
+ /**
18
+ * The horizontal whitespace run directly before a comment token, and whether
19
+ * line content precedes it (a TRAILING comment) — shared by the check and
20
+ * the inference hook so the two cannot disagree about what "before" means.
21
+ */
22
+ const spacingBefore = (ctx, token) => {
23
+ let i = token.offset - 1;
24
+ let spaces = 0;
25
+ while (i >= 0 && (ctx.text[i] === " " || ctx.text[i] === " ")) {
26
+ spaces++;
27
+ i--;
28
+ }
29
+ return {
30
+ spaces,
31
+ hasContentBefore: i >= 0 && ctx.text[i] !== "\n" && ctx.text[i] !== "\r"
32
+ };
33
+ };
34
+ /** Shebang exemption: `#!` at the very start of the stream. */
35
+ const isShebang = (token) => token.offset === 0 && token.text.startsWith("#!");
17
36
  /** Comment spacing: after the `#`, and before a trailing comment's `#`. */
18
37
  const commentsSpacing = {
19
38
  id: "comments-spacing",
@@ -24,7 +43,7 @@ const commentsSpacing = {
24
43
  const out = [];
25
44
  for (const token of ctx.tokens) {
26
45
  if (token.kind !== "comment") continue;
27
- if (token.offset === 0 && token.text.startsWith("#!")) continue;
46
+ if (isShebang(token)) continue;
28
47
  if (requireAfter && token.text.length > 1 && !/[ \t]/.test(token.text[1])) out.push(new YamlLintDiagnostic({
29
48
  rule: "comments-spacing",
30
49
  severity: "error",
@@ -39,13 +58,8 @@ const commentsSpacing = {
39
58
  content: " "
40
59
  })
41
60
  }));
42
- let i = token.offset - 1;
43
- let spaces = 0;
44
- while (i >= 0 && (ctx.text[i] === " " || ctx.text[i] === " ")) {
45
- spaces++;
46
- i--;
47
- }
48
- if (i >= 0 && ctx.text[i] !== "\n" && ctx.text[i] !== "\r" && spaces < minBefore) out.push(new YamlLintDiagnostic({
61
+ const { spaces, hasContentBefore } = spacingBefore(ctx, token);
62
+ if (hasContentBefore && spaces < minBefore) out.push(new YamlLintDiagnostic({
49
63
  rule: "comments-spacing",
50
64
  severity: "error",
51
65
  message: `Too few spaces before comment (${spaces} < ${minBefore})`,
@@ -61,6 +75,31 @@ const commentsSpacing = {
61
75
  }));
62
76
  }
63
77
  return out;
78
+ },
79
+ infer: (ctx) => {
80
+ const out = [];
81
+ for (const token of ctx.tokens) {
82
+ if (token.kind !== "comment") continue;
83
+ if (isShebang(token)) continue;
84
+ if (token.text.length > 1) out.push(StyleVote.make({
85
+ dimension: "requireSpaceAfter",
86
+ value: /[ \t]/.test(token.text[1]),
87
+ offset: token.offset,
88
+ length: token.length,
89
+ line: token.line,
90
+ character: token.character
91
+ }));
92
+ const { spaces, hasContentBefore } = spacingBefore(ctx, token);
93
+ if (hasContentBefore) out.push(StyleVote.make({
94
+ dimension: "minSpacesBefore",
95
+ value: spaces,
96
+ offset: token.offset - spaces,
97
+ length: spaces,
98
+ line: token.line,
99
+ character: token.character - spaces
100
+ }));
101
+ }
102
+ return out;
64
103
  }
65
104
  };
66
105
 
@@ -1,5 +1,5 @@
1
1
  import { YamlEdit } from "../../YamlEdit.js";
2
- import { YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
2
+ import { StyleVote, YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
3
3
  import { Schema } from "effect";
4
4
 
5
5
  //#region src/internal/rules/document-end.ts
@@ -14,31 +14,43 @@ const TRIVIA = /* @__PURE__ */ new Set([
14
14
  "comment",
15
15
  "byte-order-mark"
16
16
  ]);
17
+ /** The last non-trivia token: it decides whether the stream ends with `...`. */
18
+ const tailToken = (ctx) => [...ctx.tokens].reverse().find((t) => !TRIVIA.has(t.kind));
19
+ /**
20
+ * The end-of-stream position, shared by the missing-marker diagnostic and
21
+ * the absent-marker vote so the two cannot drift apart. `line`/`character`
22
+ * must agree with `offset` (= text length): when the text ends with a
23
+ * newline, that offset sits at the head of the line AFTER the last content
24
+ * line `buildLines` kept.
25
+ */
26
+ const endOfStreamPosition = (ctx) => {
27
+ const lastLine = ctx.lines[ctx.lines.length - 1];
28
+ const trailingNewline = ctx.text.endsWith("\n");
29
+ return {
30
+ offset: ctx.text.length,
31
+ length: 0,
32
+ line: (lastLine?.number ?? 0) + (trailingNewline ? 1 : 0),
33
+ character: trailingNewline ? 0 : lastLine?.text.length ?? 0
34
+ };
35
+ };
17
36
  /** The `...` marker at the tail of the stream. */
18
37
  const documentEnd = {
19
38
  id: "document-end",
20
39
  check: (ctx, options) => {
21
40
  const present = options?.present ?? true;
22
- const tail = [...ctx.tokens].reverse().find((t) => !TRIVIA.has(t.kind));
41
+ const tail = tailToken(ctx);
23
42
  const ended = tail?.kind === "document-end";
24
- if (present && !ended && ctx.text.trim() !== "") {
25
- const lastLine = ctx.lines[ctx.lines.length - 1];
26
- const trailingNewline = ctx.text.endsWith("\n");
27
- return [new YamlLintDiagnostic({
28
- rule: "document-end",
29
- severity: "error",
30
- message: "Missing \"...\" document end marker",
43
+ if (present && !ended && ctx.text.trim() !== "") return [new YamlLintDiagnostic({
44
+ rule: "document-end",
45
+ severity: "error",
46
+ message: "Missing \"...\" document end marker",
47
+ ...endOfStreamPosition(ctx),
48
+ fix: YamlEdit.make({
31
49
  offset: ctx.text.length,
32
50
  length: 0,
33
- line: (lastLine?.number ?? 0) + (trailingNewline ? 1 : 0),
34
- character: trailingNewline ? 0 : lastLine?.text.length ?? 0,
35
- fix: YamlEdit.make({
36
- offset: ctx.text.length,
37
- length: 0,
38
- content: ctx.text.endsWith("\n") ? "...\n" : "\n...\n"
39
- })
40
- })];
41
- }
51
+ content: ctx.text.endsWith("\n") ? "...\n" : "\n...\n"
52
+ })
53
+ })];
42
54
  if (!present && tail !== void 0 && tail.kind === "document-end") {
43
55
  const alone = (ctx.lines[tail.line]?.text ?? "").trim() === "...";
44
56
  const terminator = ctx.text.startsWith("\r\n", tail.offset + tail.length) ? 2 : 1;
@@ -58,6 +70,24 @@ const documentEnd = {
58
70
  })];
59
71
  }
60
72
  return [];
73
+ },
74
+ infer: (ctx) => {
75
+ if (ctx.text.trim() === "") return [];
76
+ const tail = tailToken(ctx);
77
+ if (tail === void 0) return [];
78
+ if (tail.kind === "document-end") return [StyleVote.make({
79
+ dimension: "present",
80
+ value: true,
81
+ offset: tail.offset,
82
+ length: tail.length,
83
+ line: tail.line,
84
+ character: tail.character
85
+ })];
86
+ return [StyleVote.make({
87
+ dimension: "present",
88
+ value: false,
89
+ ...endOfStreamPosition(ctx)
90
+ })];
61
91
  }
62
92
  };
63
93
 
@@ -1,5 +1,5 @@
1
1
  import { YamlEdit } from "../../YamlEdit.js";
2
- import { YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
2
+ import { StyleVote, YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
3
3
  import { Schema } from "effect";
4
4
 
5
5
  //#region src/internal/rules/document-start.ts
@@ -15,13 +15,17 @@ const TRIVIA = /* @__PURE__ */ new Set([
15
15
  "byte-order-mark",
16
16
  "directive"
17
17
  ]);
18
+ /** True when the stream carries `%YAML`/`%TAG` directives — which REQUIRE `---`. */
19
+ const hasDirectives = (ctx) => ctx.tokens.some((t) => t.kind === "directive");
20
+ /** The first non-trivia token: it decides whether the stream is headed by `---`. */
21
+ const headToken = (ctx) => ctx.tokens.find((t) => !TRIVIA.has(t.kind));
18
22
  /** The `---` marker at the head of the stream. */
19
23
  const documentStart = {
20
24
  id: "document-start",
21
25
  check: (ctx, options) => {
22
26
  const present = options?.present ?? true;
23
- const hasDirectives = ctx.tokens.some((t) => t.kind === "directive");
24
- const first = ctx.tokens.find((t) => !TRIVIA.has(t.kind));
27
+ const directives = hasDirectives(ctx);
28
+ const first = headToken(ctx);
25
29
  const headed = first?.kind === "document-start";
26
30
  if (present && !headed && ctx.text.trim() !== "") return [new YamlLintDiagnostic({
27
31
  rule: "document-start",
@@ -31,7 +35,7 @@ const documentStart = {
31
35
  length: 0,
32
36
  line: 0,
33
37
  character: 0,
34
- ...hasDirectives ? {} : { fix: YamlEdit.make({
38
+ ...directives ? {} : { fix: YamlEdit.make({
35
39
  offset: 0,
36
40
  length: 0,
37
41
  content: "---\n"
@@ -39,7 +43,7 @@ const documentStart = {
39
43
  })];
40
44
  if (!present && headed && first !== void 0) {
41
45
  const lineText = ctx.lines[first.line]?.text ?? "";
42
- const alone = !hasDirectives && lineText.trim() === "---";
46
+ const alone = !directives && lineText.trim() === "---";
43
47
  const terminator = ctx.text.startsWith("\r\n", first.offset + first.length) ? 2 : 1;
44
48
  return [new YamlLintDiagnostic({
45
49
  rule: "document-start",
@@ -57,6 +61,28 @@ const documentStart = {
57
61
  })];
58
62
  }
59
63
  return [];
64
+ },
65
+ infer: (ctx) => {
66
+ if (ctx.text.trim() === "") return [];
67
+ const first = headToken(ctx);
68
+ if (first === void 0) return [];
69
+ const headed = first.kind === "document-start";
70
+ if (!headed && hasDirectives(ctx)) return [];
71
+ return [StyleVote.make(headed ? {
72
+ dimension: "present",
73
+ value: true,
74
+ offset: first.offset,
75
+ length: first.length,
76
+ line: first.line,
77
+ character: first.character
78
+ } : {
79
+ dimension: "present",
80
+ value: false,
81
+ offset: 0,
82
+ length: 0,
83
+ line: 0,
84
+ character: 0
85
+ })];
60
86
  }
61
87
  };
62
88
 
@@ -1,5 +1,5 @@
1
1
  import { YamlEdit } from "../../YamlEdit.js";
2
- import { YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
2
+ import { StyleFloor, YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
3
3
  import { insideScalarSpan, nonNegativeIntegerOption } from "./util.js";
4
4
  import { Schema } from "effect";
5
5
 
@@ -63,6 +63,26 @@ const emptyLines = {
63
63
  i = end;
64
64
  }
65
65
  return out;
66
+ },
67
+ infer: (ctx) => {
68
+ let longest = 0;
69
+ const lines = ctx.lines;
70
+ let i = 0;
71
+ while (i < lines.length) {
72
+ const line = lines[i];
73
+ if (line === void 0 || line.text !== "" || insideScalarSpan(ctx.tokens, line.offset)) {
74
+ i++;
75
+ continue;
76
+ }
77
+ let end = i;
78
+ while (end < lines.length && (lines[end]?.text ?? "x") === "") end++;
79
+ if (i !== 0 && end !== lines.length && end - i > longest) longest = end - i;
80
+ i = end;
81
+ }
82
+ return longest > 0 ? [StyleFloor.make({
83
+ dimension: "max",
84
+ value: longest
85
+ })] : [];
66
86
  }
67
87
  };
68
88
 
@@ -1,4 +1,4 @@
1
- import { YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
1
+ import { StyleVote, YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
2
2
  import { coveringToken, isScalarContinuationLine, nonNegativeIntegerOption } from "./util.js";
3
3
  import { Schema } from "effect";
4
4
 
@@ -30,6 +30,44 @@ const flowDepthAtLineStarts = (ctx) => {
30
30
  }
31
31
  return depths;
32
32
  };
33
+ /**
34
+ * The content lines block structure speaks about — shared by the check and
35
+ * the inference hook so the two read the same lines. Skips blanks, comment
36
+ * lines, directives, tab-indented lines (the parser's error), lines inside
37
+ * flow collections, scalar continuations and document markers.
38
+ */
39
+ const contentLines = (ctx) => {
40
+ const flowDepths = flowDepthAtLineStarts(ctx);
41
+ const content = [];
42
+ for (const line of ctx.lines) {
43
+ const indent = line.text.length - line.text.trimStart().length;
44
+ const firstChar = line.text[indent];
45
+ if (firstChar === void 0) continue;
46
+ if (firstChar === "#") continue;
47
+ if (firstChar === "%") continue;
48
+ if (line.text.slice(0, indent).includes(" ")) continue;
49
+ if ((flowDepths[line.number] ?? 0) > 0) continue;
50
+ if (isScalarContinuationLine(ctx.tokens, line.offset, line.offset + indent)) continue;
51
+ if (line.text.slice(indent).startsWith("---") || line.text.slice(indent).startsWith("...")) continue;
52
+ content.push({
53
+ line,
54
+ indent,
55
+ firstChar
56
+ });
57
+ }
58
+ return content;
59
+ };
60
+ /**
61
+ * True when consecutive content lines `prev` → `curr` are a mapping key
62
+ * followed by a sequence entry — the pair the `indentSequences` policy is
63
+ * about. Shared by the check and the inference hook.
64
+ */
65
+ const isKeyThenSeqEntry = (ctx, prev, curr, unit) => {
66
+ if (curr.firstChar !== "-") return false;
67
+ if (coveringToken(ctx.tokens, curr.line.offset + curr.indent)?.kind !== "block-seq-entry") return false;
68
+ if (!prev.line.text.replace(/ #.*$/, "").trimEnd().endsWith(":")) return false;
69
+ return curr.indent === prev.indent || curr.indent === prev.indent + (unit ?? curr.indent - prev.indent);
70
+ };
33
71
  /** Block-structure indent style. */
34
72
  const indentation = {
35
73
  id: "indentation",
@@ -38,24 +76,7 @@ const indentation = {
38
76
  const spacesOpt = opts.spaces ?? "consistent";
39
77
  const seqOpt = opts.indentSequences ?? "consistent";
40
78
  const out = [];
41
- const flowDepths = flowDepthAtLineStarts(ctx);
42
- const content = [];
43
- for (const line of ctx.lines) {
44
- const indent = line.text.length - line.text.trimStart().length;
45
- const firstChar = line.text[indent];
46
- if (firstChar === void 0) continue;
47
- if (firstChar === "#") continue;
48
- if (firstChar === "%") continue;
49
- if (line.text.slice(0, indent).includes(" ")) continue;
50
- if ((flowDepths[line.number] ?? 0) > 0) continue;
51
- if (isScalarContinuationLine(ctx.tokens, line.offset, line.offset + indent)) continue;
52
- if (line.text.slice(indent).startsWith("---") || line.text.slice(indent).startsWith("...")) continue;
53
- content.push({
54
- line,
55
- indent,
56
- firstChar
57
- });
58
- }
79
+ const content = contentLines(ctx);
59
80
  let unit = typeof spacesOpt === "number" ? spacesOpt : void 0;
60
81
  const stack = [0];
61
82
  for (const { line, indent } of content) {
@@ -79,10 +100,7 @@ const indentation = {
79
100
  for (let i = 1; i < content.length; i++) {
80
101
  const prev = content[i - 1];
81
102
  const curr = content[i];
82
- if (curr.firstChar !== "-") continue;
83
- if (coveringToken(ctx.tokens, curr.line.offset + curr.indent)?.kind !== "block-seq-entry") continue;
84
- if (!prev.line.text.replace(/ #.*$/, "").trimEnd().endsWith(":")) continue;
85
- if (curr.indent !== prev.indent && curr.indent !== prev.indent + (unit ?? curr.indent - prev.indent)) continue;
103
+ if (!isKeyThenSeqEntry(ctx, prev, curr, unit)) continue;
86
104
  const indented = curr.indent > prev.indent;
87
105
  if (seqIndented === void 0) seqIndented = indented;
88
106
  else if (indented !== seqIndented) out.push(new YamlLintDiagnostic({
@@ -96,6 +114,41 @@ const indentation = {
96
114
  }));
97
115
  }
98
116
  return out;
117
+ },
118
+ infer: (ctx) => {
119
+ const out = [];
120
+ const content = contentLines(ctx);
121
+ let unit;
122
+ const stack = [0];
123
+ for (const { line, indent } of content) {
124
+ const top = stack[stack.length - 1];
125
+ if (indent > top) {
126
+ out.push(StyleVote.make({
127
+ dimension: "spaces",
128
+ value: indent - top,
129
+ offset: line.offset,
130
+ length: indent,
131
+ line: line.number,
132
+ character: 0
133
+ }));
134
+ if (unit === void 0) unit = indent - top;
135
+ stack.push(indent);
136
+ } else if (indent < top) while (stack.length > 1 && stack[stack.length - 1] > indent) stack.pop();
137
+ }
138
+ for (let i = 1; i < content.length; i++) {
139
+ const prev = content[i - 1];
140
+ const curr = content[i];
141
+ if (!isKeyThenSeqEntry(ctx, prev, curr, unit)) continue;
142
+ out.push(StyleVote.make({
143
+ dimension: "indentSequences",
144
+ value: curr.indent > prev.indent,
145
+ offset: curr.line.offset,
146
+ length: curr.indent,
147
+ line: curr.line.number,
148
+ character: 0
149
+ }));
150
+ }
151
+ return out;
99
152
  }
100
153
  };
101
154
 
@@ -1,4 +1,4 @@
1
- import { YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
1
+ import { StyleFloor, YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
2
2
  import { nonNegativeIntegerOption } from "./util.js";
3
3
  import { Schema } from "effect";
4
4
 
@@ -29,6 +29,14 @@ const lineLength = {
29
29
  character: max
30
30
  }));
31
31
  return out;
32
+ },
33
+ infer: (ctx) => {
34
+ let longest = 0;
35
+ for (const line of ctx.lines) if (line.text.length > longest) longest = line.text.length;
36
+ return longest > 0 ? [StyleFloor.make({
37
+ dimension: "max",
38
+ value: longest
39
+ })] : [];
32
40
  }
33
41
  };
34
42
 
@@ -1,5 +1,6 @@
1
1
  import { YamlEdit } from "../../YamlEdit.js";
2
- import { YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
2
+ import { requoteScalarText } from "../requote.js";
3
+ import { StyleVote, YamlLintDiagnostic, YamlLintSeverity } from "../../YamlLintRule.js";
3
4
  import { positionAt, walkScalars } from "./util.js";
4
5
  import { Schema } from "effect";
5
6
 
@@ -14,20 +15,19 @@ const quotedStringsOptions = Schema.Struct({
14
15
  quoteType: Schema.optionalKey(Schema.Literals(["single", "double"])),
15
16
  required: Schema.optionalKey(Schema.Boolean)
16
17
  });
17
- /** A value-preserving requote/wrap edit, or undefined when none is safe. */
18
+ /**
19
+ * A value-preserving requote/wrap edit, or undefined when none is safe.
20
+ * Delegates to the shared helper's CONSERVATIVE mode (#347) — the shipped
21
+ * fix behavior stays exactly as released; the escaping-capable mode belongs
22
+ * to the format path's opt-in `requoteScalars`, not the lint fix.
23
+ */
18
24
  const safeQuoteFix = (ctx, scalar, quote) => {
19
- if (scalar.tag !== void 0 || scalar.anchor !== void 0) return void 0;
20
- if (typeof scalar.value !== "string") return void 0;
21
- const raw = ctx.text.slice(scalar.offset, scalar.offset + scalar.length);
22
- if (raw.includes("\n")) return void 0;
23
- const inner = scalar.style === "plain" ? raw : raw.slice(1, -1);
24
- if (inner !== scalar.value) return void 0;
25
- if (inner.includes(quote)) return void 0;
26
- if (quote === "\"" && inner.includes("\\")) return void 0;
25
+ const content = requoteScalarText(ctx.text, scalar, quote, "conservative");
26
+ if (content === void 0) return void 0;
27
27
  return YamlEdit.make({
28
28
  offset: scalar.offset,
29
29
  length: scalar.length,
30
- content: `${quote}${inner}${quote}`
30
+ content
31
31
  });
32
32
  };
33
33
  /** Quote-style policy for string value scalars. */
@@ -74,6 +74,24 @@ const quotedStrings = {
74
74
  }
75
75
  });
76
76
  return out;
77
+ },
78
+ infer: (ctx) => {
79
+ const out = [];
80
+ walkScalars(ctx.document.contents, "root", (scalar, role) => {
81
+ if (role === "key") return;
82
+ if (typeof scalar.value !== "string") return;
83
+ if (scalar.style !== "single-quoted" && scalar.style !== "double-quoted") return;
84
+ const pos = positionAt(ctx.lines, scalar.offset);
85
+ out.push(StyleVote.make({
86
+ dimension: "quoteType",
87
+ value: scalar.style === "single-quoted" ? "single" : "double",
88
+ offset: scalar.offset,
89
+ length: scalar.length,
90
+ line: pos.line,
91
+ character: pos.character
92
+ }));
93
+ });
94
+ return out;
77
95
  }
78
96
  };
79
97
 
@@ -1134,4 +1134,4 @@ function stringifyDocument(doc, options) {
1134
1134
  }
1135
1135
 
1136
1136
  //#endregion
1137
- export { StringifyDepthExceeded, StringifyFailure, stringifyDocument, stringifyValue, stripNodeComments };
1137
+ export { StringifyDepthExceeded, StringifyFailure, renderDoubleQuoted, renderSingleQuoted, stringifyDocument, stringifyValue, stripNodeComments };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/yaml",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Zero-dependency YAML parsing, editing, formatting and linting as Effect schemas.",
6
6
  "keywords": [
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.58.12"
8
+ "packageVersion": "7.59.0"
9
9
  }
10
10
  ]
11
11
  }