Skip to content

[yaml] Add optional layout retention for comments, whitespace, and delimiter spans - #2595

Closed
sigurdm wants to merge 2 commits into
dart-lang:mainfrom
sigurdm:yaml-layout-retention
Closed

sigurdm wants to merge 2 commits into
dart-lang:mainfrom
sigurdm:yaml-layout-retention

Conversation

@sigurdm

@sigurdm sigurdm commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR adds optional layout retention to package:yaml without impacting existing parsing performance or backwards compatibility when disabled.

When retainLayout: true is passed to loadYaml, loadYamlNode, loadYamlDocument, loadYamlStream, or Loader:

  • Captures source layout elements (LayoutElement: CommentElement, WhitespaceElement, NewlineElement) in leadingLayout and trailingLayout on tokens, events, YamlNode, and YamlDocument.
  • Distinguishes same-line trailing comments (CommentElement.isTrailing: true) from full-line/preceding comments (CommentElement.isTrailing: false).
  • Captures delimiter spans:
    • YamlMap.colonSpan(key): the SourceSpan of the association colon (:) for mapping entries.
    • YamlList.dashSpan(index): the SourceSpan of the sequence hyphen (-) for sequence entries.
  • Pattern matching support via sealed LayoutElement hierarchy.

When retainLayout: false (default):

  • 100% backwards compatible.
  • Zero extra allocations for layout element lists and span maps.
  • All 585 existing tests in package:yaml pass without modification.

TAG=agy
CONV=eabffc54-0045-490f-beb6-67e4668362d1

…limiter spans

Adds retainLayout: false (default) to loadYaml, loadYamlNode, loadYamlDocument, and Loader. When enabled:
- Preserves layout elements (LayoutElement: CommentElement, WhitespaceElement, NewlineElement) in leadingLayout and trailingLayout on tokens, events, YamlNode, and YamlDocument.
- Preserves delimiter source spans: YamlMap.colonSpan(key) and YamlList.dashSpan(index).
- Has zero overhead and maintains 100% backwards compatibility when retainLayout is false.

TAG=agy
CONV=eabffc54-0045-490f-beb6-67e4668362d1

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a layout retention feature to the yaml package, allowing the parser to preserve non-semantic source layout elements (such as comments, whitespace, and newlines) as well as source spans for colons and hyphens when retainLayout is enabled. The review feedback focuses on optimizing memory usage and avoiding unnecessary allocations when retainLayout is disabled (the default). Specifically, the reviewer suggests lazily allocating trailingLayout lists on Tokens, as well as lazily initializing dashSpans and colonSpans in the Loader class only when they are actually encountered.

Comment on lines +19 to +26
final List<LayoutElement> trailingLayout;

Token(this.type, this.span);
Token(
this.type,
this.span, {
this.leadingLayout = const [],
List<LayoutElement>? trailingLayout,
}) : trailingLayout = trailingLayout ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid allocating a new empty growable list [] for trailingLayout on every single token when layout retention is disabled, we can make trailingLayout non-final, default it to const [], and provide a helper method addTrailingLayout that lazily allocates the growable list only when a trailing layout element is actually added.

Suggested change
final List<LayoutElement> trailingLayout;
Token(this.type, this.span);
Token(
this.type,
this.span, {
this.leadingLayout = const [],
List<LayoutElement>? trailingLayout,
}) : trailingLayout = trailingLayout ?? [];
List<LayoutElement> trailingLayout;
Token(
this.type,
this.span, {
this.leadingLayout = const [],
this.trailingLayout = const [],
});
void addTrailingLayout(LayoutElement element) {
if (identical(trailingLayout, const <LayoutElement>[])) {
trailingLayout = <LayoutElement>[];
}
trailingLayout.add(element);
}

Comment on lines +871 to +873
if (!afterLineBreak && _lastToken != null) {
_lastToken!.trailingLayout.add(elem);
} else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the new addTrailingLayout helper method on Token to lazily allocate the trailingLayout list only when needed.

          if (!afterLineBreak && _lastToken != null) {
            _lastToken!.addTrailingLayout(elem);
          } else {

Comment on lines +890 to +893
if (!afterLineBreak && _lastToken != null) {
_lastToken!.trailingLayout
.add(CommentElement(commentSpan, isTrailing: true));
} else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the new addTrailingLayout helper method on Token to lazily allocate the trailingLayout list only when needed.

          if (!afterLineBreak && _lastToken != null) {
            _lastToken!.addTrailingLayout(
                CommentElement(commentSpan, isTrailing: true));
          } else {

Comment on lines +948 to +957
if (_scanner.state.position > wsStart.position) {
token.trailingLayout.add(WhitespaceElement(_scanner.spanFrom(wsStart)));
}
if (_scanner.peekChar() == HASH) {
var commentStart = _scanner.state;
_skipComment();
token.trailingLayout.add(CommentElement(
_scanner.spanFrom(commentStart),
isTrailing: true));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the new addTrailingLayout helper method on Token to lazily allocate the trailingLayout list only when needed.

      if (_scanner.state.position > wsStart.position) {
        token.addTrailingLayout(WhitespaceElement(_scanner.spanFrom(wsStart)));
      }
      if (_scanner.peekChar() == HASH) {
        var commentStart = _scanner.state;
        _skipComment();
        token.addTrailingLayout(CommentElement(
            _scanner.spanFrom(commentStart),
            isTrailing: true));
      }

Comment on lines 153 to +154
var children = <YamlNode>[];
var dashSpans = <SourceSpan?>[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure zero extra allocations when retainLayout is false, lazily initialize dashSpans only when a non-null dash span is actually encountered.

Suggested change
var children = <YamlNode>[];
var dashSpans = <SourceSpan?>[];
var children = <YamlNode>[];
List<SourceSpan?>? dashSpans;

Comment on lines 162 to 166
while (event.type != EventType.sequenceEnd) {
dashSpans.add(_parser.lastDashSpan);
children.add(_loadNode(event));
event = _parser.parse();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Lazily populate dashSpans only if we encounter a non-null dash span, padding with nulls up to the current index if necessary.

      while (event.type != EventType.sequenceEnd) {
        var dashSpan = _parser.lastDashSpan;
        if (dashSpan != null) {
          dashSpans ??= List<SourceSpan?>.filled(children.length, null, growable: true);
          dashSpans.add(dashSpan);
        } else if (dashSpans != null) {
          dashSpans.add(null);
        }
        children.add(_loadNode(event));
        event = _parser.parse();
      }

Comment thread pkgs/yaml/lib/src/loader.dart Outdated
Comment on lines +174 to +176
if (dashSpans.any((s) => s != null)) {
setDashSpans(node, dashSpans);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Only call setDashSpans if dashSpans was actually allocated.

Suggested change
if (dashSpans.any((s) => s != null)) {
setDashSpans(node, dashSpans);
}
if (dashSpans != null) {
setDashSpans(node, dashSpans);
}

Comment on lines 188 to +189
var children = deepEqualsMap<dynamic, YamlNode>();
var colonSpans = deepEqualsMap<dynamic, SourceSpan>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure zero extra allocations when retainLayout is false, lazily initialize colonSpans only when a non-null colon span is actually encountered.

Suggested change
var children = deepEqualsMap<dynamic, YamlNode>();
var colonSpans = deepEqualsMap<dynamic, SourceSpan>();
var children = deepEqualsMap<dynamic, YamlNode>();
Map<dynamic, SourceSpan>? colonSpans;

Comment on lines +200 to +203
var colonSpan = _parser.lastColonSpan;
if (colonSpan != null) {
colonSpans[key] = colonSpan;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Lazily allocate the colonSpans map only when a non-null colon span is encountered.

Suggested change
var colonSpan = _parser.lastColonSpan;
if (colonSpan != null) {
colonSpans[key] = colonSpan;
}
var colonSpan = _parser.lastColonSpan;
if (colonSpan != null) {
colonSpans ??= deepEqualsMap<dynamic, SourceSpan>();
colonSpans[key] = colonSpan;
}

Comment thread pkgs/yaml/lib/src/loader.dart Outdated
Comment on lines +219 to +221
if (colonSpans.isNotEmpty) {
setColonSpans(node, colonSpans);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Only call setColonSpans if colonSpans was actually allocated.

Suggested change
if (colonSpans.isNotEmpty) {
setColonSpans(node, colonSpans);
}
if (colonSpans != null) {
setColonSpans(node, colonSpans);
}

sigurdm added a commit to sigurdm/tools that referenced this pull request Sep 10, 2026
cst.dart was an exploratory prototype for a standalone CST with trivia
definitions. Now that layout retention (LayoutElement, colonSpan, dashSpan)
is provided directly by package:yaml (PR dart-lang#2595), this file is completely
unused and redundant.

TAG=agy
CONV=eabffc54-0045-490f-beb6-67e4668362d1
…t retention

- Add anchorSpan, aliasSpan, entrySpan, commaSpan, openSpan, closeSpan,
  startMarkerSpan, and endMarkerSpan accessors to YamlNode, YamlMap,
  YamlList, and YamlDocument.
- In Loader, construct entry spans for mapping and sequence items preserving
  leading comments and indentation trivia.
- In YamlMapWrapper and YamlListWrapper, forward layout span accessors.
- Add comprehensive tests in test/layout_test.dart.

TAG=agy
CONV=eabffc54-0045-490f-beb6-67e4668362d1
@sigurdm

sigurdm commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by and merged into unified monorepo PR #2593.

@sigurdm sigurdm closed this Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant