// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import '../comment_type.dart';
import '../constants.dart';
import '../dart_formatter.dart';
import '../debug.dart' as debug;
import '../profile.dart';
import '../source_code.dart';
import 'chunk.dart';
import 'line_writer.dart';
import 'nesting_builder.dart';
import 'nesting_level.dart';
import 'rule/rule.dart';
import 'source_comment.dart';

/// Matches if the last character of a string is an identifier character.
final _trailingIdentifierChar = RegExp(r'[a-zA-Z0-9_]$');

/// Takes the incremental serialized output of [SourceVisitor]--the source text
/// along with any comments and preserved whitespace--and produces a coherent
/// tree of [Chunk]s which can then be split into physical lines.
///
/// Keeps track of leading indentation, expression nesting, and all of the hairy
/// code required to seamlessly integrate existing comments into the pure
/// output produced by [SourceVisitor].
final class ChunkBuilder {
  final DartFormatter _formatter;

  /// The builder for the code surrounding the block that this writer is for, or
  /// `null` if this is writing the top-level code.
  final ChunkBuilder? _parent;

  final SourceCode _source;

  final List<Chunk> _chunks;

  /// The number of newlines that should be written before the next
  /// non-whitespace token.
  ///
  /// This will always be 0, 1, or 2.
  int _pendingNewlines = 0;

  /// Whether a non-breaking space should be written before the next text.
  bool _pendingSpace = false;

  /// Whether the next chunk should use expression nesting.
  bool _pendingNested = false;

  /// Whether the next chunk should be flush left.
  bool _pendingFlushLeft = false;

  /// Whether the most recently written output was a comment.
  bool _afterComment = false;

  /// The nested stack of rules that are currently in use.
  ///
  /// New chunks are implicitly split by the innermost rule when the chunk is
  /// ended.
  final _rules = <Rule>[];

  /// The set of rules known to contain hard splits that will in turn force
  /// these rules to harden.
  ///
  /// This is accumulated lazily while chunks are being built. Then, once they
  /// are all done, the rules are all hardened. We do this later because some
  /// rules may not have all of their constraints fully wired up until after
  /// the hard split appears. For example, a hard split in a positional
  /// argument list needs to force the named arguments to split too, but we
  /// don't create that rule until after the positional arguments are done.
  final _hardSplitRules = <Rule>{};

  /// The list of rules that are waiting until the next whitespace has been
  /// written before they start.
  final _lazyRules = <Rule>[];

  /// The nested stack of spans that are currently being written.
  final _openSpans = <OpenSpan>[];

  /// The current state.
  final _nesting = NestingBuilder();

  /// The stack of nesting levels where block arguments may start.
  ///
  /// A block argument's contents will nest at the last level in this stack.
  final _blockArgumentNesting = <NestingLevel>[];

  /// The number of calls to [preventSplit()] that have not been ended by a
  /// call to [endPreventSplit()].
  ///
  /// Splitting is completely disabled inside string interpolation. We do want
  /// to fix the whitespace inside interpolation, though, so we still format
  /// them. This tracks whether we're inside an interpolation. We can't use a
  /// simple bool because interpolation can nest.
  ///
  /// When this is non-zero, splits are ignored.
  int _preventSplitNesting = 0;

  /// The number of characters of code that can fit in a single line.
  int get pageWidth => _formatter.pageWidth;

  /// The current innermost rule.
  Rule get rule => _rules.last;

  ChunkBuilder(this._formatter, this._source) : _parent = null, _chunks = [] {
    indent(_formatter.indent);
    startBlockArgumentNesting();
  }

  ChunkBuilder._(this._parent, this._formatter, this._source, this._chunks) {
    startBlockArgumentNesting();
  }

  /// Writes [string], the text for a single token, to the output.
  ///
  /// By default, this also implicitly adds one level of nesting if we aren't
  /// currently nested at all. We do this here so that if a comment appears
  /// after any token within a statement or top-level form and that comment
  /// leads to splitting, we correctly nest. Even pathological cases like:
  ///
  ///
  ///     import // comment
  ///         "this_gets_nested.dart";
  ///
  /// If we didn't do this here, we'd have to call [nestExpression] after the
  /// first token of practically every grammar production.
  ///
  /// If [mergeEmptySplits] is `true`, the default, then any pending split
  /// information will be combined with a previously created split if no text
  /// has been written since. This generally comes into play when text is
  /// written after a comment. The comment may leave some pending split
  /// information while [SourceVisitor] may have also created a split and we
  /// want to combine those.
  ///
  /// It is only `false` when writing the contents of a multiline string. There,
  /// we may *want* to have a series of empty chunks because those represent
  /// empty lines in the multiline string.
  void write(String string, {bool mergeEmptySplits = true}) {
    _emitPendingWhitespace(mergeEmptySplits: mergeEmptySplits);
    _writeText(string);

    _lazyRules.forEach(_activateRule);
    _lazyRules.clear();

    _nesting.commitNesting();
    _afterComment = false;
  }

  /// Writes one or two hard newlines.
  ///
  /// Doesn't immediately write them. That way line breaking is correctly
  /// interleaved with any comments that appear before the next token.
  ///
  /// If [isDouble] is `true`, inserts an extra blank line. If [flushLeft] is
  /// `true`, the next line will start at column 1 and ignore indentation and
  /// nesting. If [nest] is `true` then the next line will use expression
  /// nesting.
  void writeNewline({
    bool isDouble = false,
    bool flushLeft = false,
    bool nest = false,
  }) {
    _pendingNewlines = isDouble ? 2 : 1;
    _pendingFlushLeft = flushLeft;
    _pendingNested = nest;
  }

  /// Writes a space before the subsequent non-whitespace text.
  void writeSpace() {
    _pendingSpace = true;
  }

  /// Write a split owned by the current innermost rule.
  ///
  /// If [nest] is `false`, ignores any current expression nesting. Otherwise,
  /// uses the current nesting level. If unsplit, it expands to a space if
  /// [space] is `true`.
  Chunk split({bool nest = true, bool space = false}) {
    // If we are not allowed to split at all, don't. Returning null for the
    // chunk is safe since the rule that uses the chunk will itself get
    // discarded because no chunk references it.
    if (_preventSplitNesting > 0) {
      _pendingNewlines = 0;
      _pendingNested = false;

      if (space) _pendingSpace = true;
      return Chunk.dummy();
    }

    // If a hard split after a comment is already pending, then prefer that over
    // a soft split.
    if (_pendingNewlines > 0) return Chunk.dummy();

    return _writeSplit(isHard: false, nest: nest, space: space);
  }

  /// Outputs the series of [comments] and associated whitespace that appear
  /// before [token] (which is not written by this).
  ///
  /// The list contains each comment as it appeared in the source between the
  /// last token written and the next one that's about to be written.
  ///
  /// [linesBeforeToken] is the number of lines between the last comment (or
  /// previous token if there are no comments) and the next token.
  void writeComments(
    List<SourceComment> comments,
    int linesBeforeToken,
    String token,
  ) {
    // Edge case: if we require a blank line, but there exists one between
    // some of the comments, or after the last one, then we don't need to
    // enforce one before the first comment. Example:
    //
    //     library foo;
    //     // comment
    //
    //     class Bar {}
    //
    // Normally, a blank line is required after `library`, but since there is
    // one after the comment, we don't need one before it. This is mainly so
    // that commented out directives stick with their preceding group.
    if (_pendingNewlines == 2 && comments.first.linesBefore < 2) {
      if (linesBeforeToken > 1) {
        writeNewline();
      } else {
        for (var i = 1; i < comments.length; i++) {
          if (comments[i].linesBefore > 1) {
            writeNewline();
            break;
          }
        }
      }
    }

    // Edge case: if the comments are completely inline (i.e. just a series of
    // block comments with no newlines before, after, or between them), then
    // they will eat any pending newlines. Make sure that doesn't happen by
    // putting the pending whitespace before the first comment. Turns this:
    //
    //     library foo; /* a */ /* b */ import 'a.dart';
    //
    // into:
    //
    //     library foo;
    //
    //     /* a */ /* b */ import 'a.dart';
    if (linesBeforeToken == 0 &&
        _pendingNewlines > comments.first.linesBefore &&
        comments.every((comment) => comment.type == CommentType.inlineBlock)) {
      comments.first.linesBefore = _pendingNewlines;
    }

    // Write each comment and the whitespace between them.
    for (var i = 0; i < comments.length; i++) {
      var comment = comments[i];

      // See if the comment should follow text on the current line.
      var chunk = _chunkForComment(comment, token);
      if (chunk != null) {
        // The comment follows other text, so decide if it gets a space before
        // it.
        _pendingSpace = _needsSpaceBeforeComment(comment, chunk);
        if (_pendingSpace && chunk != _chunks.last) {
          // We've already created a split after the comment, so if it doesn't
          // split, it should get a space.
          _chunks.last.updateSplit(space: true);
        }
      } else {
        // Split before the comment if it starts a line.
        if (_pendingNewlines == 0) {
          if (comment.linesBefore > 0 &&
              (_afterComment || comment.type != CommentType.inlineBlock)) {
            writeNewline(
              isDouble: _needsBlankLineBeforeComment(comment),
              flushLeft: comment.flushLeft,
              nest: true,
            );
          } else if (_chunks.isNotEmpty) {
            _pendingSpace = _needsSpaceBeforeComment(comment, _chunks.last);
          }
        } else {
          _pendingFlushLeft = comment.flushLeft;
        }

        _emitPendingWhitespace(isDouble: _needsBlankLineBeforeComment(comment));
      }

      _writeText(comment.text, chunk);

      if (comment.selectionStart != null) {
        startSelectionFromEnd(comment.text.length - comment.selectionStart!);
      }

      if (comment.selectionEnd != null) {
        endSelectionFromEnd(comment.text.length - comment.selectionEnd!);
      }

      // Make sure there is at least one newline after a line comment and allow
      // one or two after a block comment that has nothing after it.
      int linesAfter;
      if (i < comments.length - 1) {
        linesAfter = comments[i + 1].linesBefore;
      } else {
        linesAfter = linesBeforeToken;

        // Always force a newline after multi-line block comments. Prevents
        // mistakes like:
        //
        //     /**
        //      * Some doc comment.
        //      */ someFunction() { ... }
        if (linesAfter == 0 && comments.last.text.contains('\n')) {
          linesAfter = 1;
        }
      }

      if (linesAfter > 0) {
        writeNewline(
          isDouble: _pendingNewlines == 2 || linesAfter > 1,
          nest: true,
        );
      }
    }

    // If the comment has text following it (aside from a grouping character),
    // it needs a trailing space.
    _pendingSpace = _needsSpaceAfterComment(comments.last, token);
    _afterComment = true;
  }

  /// Creates a new indentation level [spaces] deeper than the current one.
  ///
  /// If omitted, [spaces] defaults to [Indent.block].
  void indent([int? spaces]) {
    _nesting.indent(spaces);
  }

  /// Discards the most recent indentation level.
  void unindent() {
    _nesting.unindent();
  }

  /// Starts a new span with [cost].
  ///
  /// Each call to this needs a later matching call to [endSpan].
  void startSpan([int cost = Cost.normal]) {
    _openSpans.add(OpenSpan(_chunks.length, cost));
  }

  /// Ends the innermost span.
  void endSpan() {
    var openSpan = _openSpans.removeLast();

    // A span that just covers a single chunk can't be split anyway.
    var end = _chunks.length;
    if (openSpan.start == end) return;

    // Add the span to every chunk that can split it.
    var span = Span(openSpan.cost);
    for (var i = openSpan.start; i < end; i++) {
      var chunk = _chunks[i];
      if (!chunk.rule.isHardened) chunk.spans.add(span);
    }
  }

  /// Starts a new [Rule].
  ///
  /// If omitted, defaults to a new [Rule].
  void startRule([Rule? rule]) {
    rule ??= Rule();

    // If there are any pending lazy rules, start them now so that the proper
    // stack ordering of rules is maintained.
    _lazyRules.forEach(_activateRule);
    _lazyRules.clear();

    _activateRule(rule);
  }

  void _activateRule(Rule rule) {
    // See if any of the rules that contain this one care if it splits.
    for (var outer in _rules) {
      if (!outer.splitsOnInnerRules) continue;
      rule.constrainWhenSplit(outer);
    }
    _rules.add(rule);
  }

  /// Starts a new [Rule] that comes into play *after* the next whitespace
  /// (including comments) is written.
  ///
  /// This is used for operators who want to start a rule before the first
  /// operand but not get forced to split if a comment appears before the
  /// entire expression.
  ///
  /// If [rule] is omitted, defaults to a new [Rule].
  void startLazyRule([Rule? rule]) {
    rule ??= Rule();

    _lazyRules.add(rule);
  }

  /// Ends the innermost rule.
  void endRule() {
    if (_lazyRules.isNotEmpty) {
      _lazyRules.removeLast();
    } else {
      _rules.removeLast();
    }
  }

  /// Pre-emptively forces all of the current rules to become hard splits.
  ///
  /// This is called by [SourceVisitor] when it can determine that a rule will
  /// will always be split. Turning it (and the surrounding rules) into hard
  /// splits lets the writer break the output into smaller pieces for the line
  /// splitter, which helps performance and avoids failing on very large input.
  ///
  /// In particular, it's easy for the visitor to know that collections with a
  /// large number of items must split. Doing that early avoids crashing the
  /// splitter when it tries to recurse on huge collection literals.
  void forceRules() => _handleHardSplit();

  /// Begins a new expression nesting level [indent] spaces deeper than the
  /// current one if it splits.
  ///
  /// If [indent] is omitted, defaults to [Indent.expression]. If [now] is
  /// `true`, commits the nesting change immediately instead of waiting until
  /// after the next chunk of text is written.
  void nestExpression({int? indent, bool? now}) {
    now ??= false;

    _nesting.nest(indent);
    if (now) _nesting.commitNesting();
  }

  /// Discards the most recent level of expression nesting.
  ///
  /// Expressions that are more nested will get increased indentation when split
  /// if the previous line has a lower level of nesting.
  ///
  /// If [now] is `false`, does not commit the nesting change until after the
  /// next chunk of text is written.
  void unnest({bool? now}) {
    now ??= true;

    _nesting.unnest();
    if (now) _nesting.commitNesting();
  }

  /// Marks the selection starting point as occurring [fromEnd] characters to
  /// the left of the end of what's currently been written.
  ///
  /// It counts backwards from the end because this is called *after* the chunk
  /// of text containing the selection has been output.
  void startSelectionFromEnd(int fromEnd) {
    assert(_chunks.isNotEmpty);
    _chunks.last.startSelectionFromEnd(fromEnd);
  }

  /// Marks the selection ending point as occurring [fromEnd] characters to the
  /// left of the end of what's currently been written.
  ///
  /// It counts backwards from the end because this is called *after* the chunk
  /// of text containing the selection has been output.
  void endSelectionFromEnd(int fromEnd) {
    assert(_chunks.isNotEmpty);

    // If the selection marker is right on a split, then put it before the
    // newline.
    if (_chunks.last.text.isNotEmpty) {
      _chunks.last.endSelectionFromEnd(fromEnd);
    } else {
      _chunks[_chunks.length - 2].endSelectionFromEnd(fromEnd);
    }
  }

  /// Captures the current nesting level as marking where subsequent block
  /// arguments should start.
  void startBlockArgumentNesting() {
    _blockArgumentNesting.add(_nesting.currentNesting);
  }

  /// Releases the last nesting level captured by [startBlockArgumentNesting].
  void endBlockArgumentNesting() {
    _blockArgumentNesting.removeLast();
  }

  /// Starts a new block chunk and returns the [ChunkBuilder] for it.
  ///
  /// Nested blocks are handled using their own independent [LineWriter].
  ChunkBuilder startBlock({
    Chunk? argumentChunk,
    bool indent = true,
    bool space = false,
  }) {
    // Start a block chunk for the block. It will contain the chunks for the
    // contents of the block, and its own text will be the closing block
    // delimiter.
    var chunk = BlockChunk(
      argumentChunk,
      _rules.last,
      _nesting.indentation,
      _blockArgumentNesting.last,
      space: space,
      flushLeft: _pendingFlushLeft,
    );
    _chunks.add(chunk);
    _pendingFlushLeft = false;

    var builder = ChunkBuilder._(this, _formatter, _source, chunk.children);

    if (indent) builder.indent();

    // Create a hard split for the contents. The rule on the parent BlockChunk
    // determines whether the body is split or not. This hard rule is only when
    // the block's contents are split.
    var rule = Rule.hard();
    builder.startRule(rule);
    builder.split(nest: false, space: space);

    return builder;
  }

  /// Ends this [ChunkBuilder], which must have been created by [startBlock()].
  ///
  /// Forces the chunk that owns the block to split if it can tell that the
  /// block contents will always split. It does that by looking for hard splits
  /// in the block that aren't for top level elements in the block. If
  /// [forceSplit] is `true`, the block always splits.
  ///
  /// Returns the previous writer for the surrounding block.
  ChunkBuilder endBlock({bool forceSplit = true}) {
    _divideChunks();

    // If the last chunk ends with a comment that wants a newline after it,
    // then force the block contents to split.
    forceSplit |= _pendingNested;

    // If we don't already know if the block is going to split, see if it
    // contains any hard splits or is longer than a page.
    if (!forceSplit) {
      var length = 0;
      for (var chunk in _chunks) {
        length += chunk.length + chunk.unsplitBlockLength;
        if (length > _formatter.pageWidth) {
          forceSplit = true;
          break;
        }

        // If there are any hardened splits in the chunks (aside from ones
        // using the initial hard rule created by [startBlock()] which are for
        // the top level elements in the block), then force the block to split.
        if (chunk.rule.isHardened && chunk.rule != _rules.first) {
          forceSplit = true;
          break;
        }
      }
    }

    // If there is a hard newline within the block, force the surrounding rule
    // for it so that we apply that constraint.
    var parent = _parent!;
    if (forceSplit) parent.forceRules();
    return parent;
  }

  /// Finishes writing and returns a [SourceCode] containing the final output
  /// and updated selection, if any.
  SourceCode end() {
    assert(_rules.isEmpty);

    _divideChunks();

    if (debug.traceChunkBuilder) {
      debug.log(debug.green('\nBuilt:'));
      debug.dumpChunks(0, _chunks);
      debug.log();
    }

    Profile.begin('ChunkBuilder run line splitter');

    var writer = LineWriter(_formatter, _chunks);
    var result = writer.writeLines(
      isCompilationUnit: _source.isCompilationUnit,
    );

    Profile.end('ChunkBuilder run line splitter');

    int? selectionStart;
    int? selectionLength;
    if (_source.selectionStart != null) {
      selectionStart = result.selectionStart;
      var selectionEnd = result.selectionEnd;

      // If we haven't hit the beginning and/or end of the selection yet, they
      // must be at the very end of the code.
      selectionStart ??= writer.length;
      selectionEnd ??= writer.length;

      selectionLength = selectionEnd - selectionStart;
    }

    return SourceCode(
      result.text,
      uri: _source.uri,
      isCompilationUnit: _source.isCompilationUnit,
      selectionStart: selectionStart,
      selectionLength: selectionLength,
    );
  }

  void preventSplit() {
    _preventSplitNesting++;
  }

  void endPreventSplit() {
    _preventSplitNesting--;
    assert(_preventSplitNesting >= 0, 'Mismatched calls.');
  }

  /// Writes the current pending [Whitespace] to the output, if any.
  ///
  /// This should only be called after source lines have been preserved to turn
  /// any ambiguous whitespace into a concrete choice.
  void _emitPendingWhitespace({
    bool isDouble = false,
    bool mergeEmptySplits = true,
  }) {
    if (_pendingNewlines == 0) return;

    if (_pendingNewlines == 2) isDouble = true;
    _writeSplit(
      isDouble: isDouble,
      nest: _pendingNested,
      mergeEmptySplits: mergeEmptySplits,
    );
  }

  /// Tries to find an existing chunk to append [comment] to.
  ///
  /// If [comment] should be appending to an existing line (in other words,
  /// should be moved before a split), then this returns that [Chunk].
  /// Otherwise, returns `null`.
  Chunk? _chunkForComment(SourceComment comment, String token) {
    // Not if there is nothing before it.
    if (_chunks.isEmpty) return null;

    // Don't move a comment to a preceding line.
    if (comment.linesBefore != 0) return null;

    // Multi-line comments are always pushed to the next line.
    if (comment.type == CommentType.doc) return null;
    if (comment.type == CommentType.block) return null;

    var chunk = _chunks.last;

    // We may have started a split for a new chunk but not written any text yet.
    // In that case, the comment may get written to the previous chunk. Keep a
    // generic method comment before '(' with the '(', so don't move it before
    // the split.
    if (chunk.text.isEmpty &&
        _chunks.length > 1 &&
        (!_isGenericMethodComment(comment) || token != '(')) {
      chunk = _chunks[_chunks.length - 2];
    }

    // A block comment following a comma probably refers to the following item.
    var text = chunk.text;
    if (text.endsWith(',') && comment.type == CommentType.inlineBlock) {
      return null;
    }

    // If the text before the split is an open grouping character, it looks
    // better to keep it with the elements than with the bracket itself.
    if (text.endsWith('(') ||
        text.endsWith('[') ||
        (text.endsWith('{') && !text.endsWith('\${'))) {
      return null;
    }

    return chunk;
  }

  /// Returns `true` if [comment] appears to be a magic generic method comment.
  ///
  /// Those get spaced a little differently to look more like real syntax:
  ///
  ///     int f/*<S, T>*/(int x) => 3;
  bool _isGenericMethodComment(SourceComment comment) {
    return comment.text.startsWith('/*<') || comment.text.startsWith('/*=');
  }

  /// Returns `true` if a space should be output between the end of the current
  /// output and the subsequent comment which is about to be written.
  ///
  /// This is only called if the comment is trailing text in the unformatted
  /// source. In most cases, a space will be output to separate the comment
  /// from what precedes it. This returns false if:
  ///
  /// *   This comment does begin the line in the output even if it didn't in
  ///     the source.
  /// *   The comment is a block comment immediately following a grouping
  ///     character (`(`, `[`, or `{`). This is to allow `foo(/* comment */)`,
  ///     et. al.
  bool _needsSpaceBeforeComment(SourceComment comment, Chunk chunk) {
    // Not at the beginning of a line.
    var text = chunk.text;
    if (text.isEmpty) return false;

    // Always put a space before line comments.
    if (comment.type == CommentType.line) return true;

    // Magic generic method comments like "Foo/*<T>*/" don't get spaces.
    if (_isGenericMethodComment(comment) &&
        _trailingIdentifierChar.hasMatch(text)) {
      return false;
    }

    // Block comments do not get a space if following a grouping character.
    return !text.endsWith('(') && !text.endsWith('[') && !text.endsWith('{');
  }

  /// Returns `true` if a space should be output after the last comment which
  /// was just written and the token that will be written.
  bool _needsSpaceAfterComment(SourceComment comment, String token) {
    // Not at the beginning of a line.
    if (_chunks.last.text.isEmpty) return false;

    if (_pendingNewlines > 0) return false;

    // Magic generic method comments like "Foo/*<T>*/" don't get spaces.
    if (_isGenericMethodComment(comment) && token == '(') {
      return false;
    }

    // Otherwise, it gets a space if the following token is not a delimiter or
    // the empty string, for EOF.
    return token != ')' &&
        token != ']' &&
        token != '}' &&
        token != ',' &&
        token != ';' &&
        token != '';
  }

  bool _needsBlankLineBeforeComment(SourceComment comment) {
    // Only if the source code has a blank line.
    if (comment.linesBefore < 2) return false;

    // Don't allow blank lines at the beginning of a block.
    if (_chunks.isEmpty) return false;

    // Don't allow blank lines at the beginning of a child block.
    var text = _chunks.last.text;
    if (text.endsWith('{') || text.endsWith('[')) return false;

    return true;
  }

  /// Starts a new chunk with the given split information.
  ///
  /// Returns the chunk.
  Chunk _writeSplit({
    bool isHard = true,
    bool isDouble = false,
    required bool nest,
    bool space = false,
    bool mergeEmptySplits = true,
  }) {
    Chunk chunk;
    // If we've already just created a split (i.e. we have a new chunk but it's
    // still empty) then update that split with the new information. This avoids
    // duplicate splits when line comments occur in places where SourceVisitor
    // also inserts splits.
    if (mergeEmptySplits && _chunks.isNotEmpty && _chunks.last.text.isEmpty) {
      chunk = _chunks.last;

      // Don't allow a blank newline at the top of a block.
      if (isDouble) {
        if (_chunks.length > 1 &&
            _chunks[_chunks.length - 2].text.endsWith('{')) {
          isDouble = false;
        }
      }

      // Must split before it so that there is a newline after the line comment.
      chunk.rule.harden();

      chunk.updateSplit(flushLeft: _pendingFlushLeft, isDouble: isDouble);
    } else {
      chunk = _startChunk(
        nest ? _nesting.nesting : NestingLevel(),
        isHard: isHard,
        isDouble: isDouble,
        space: space,
      );
    }

    if (chunk.rule.isHardened) _handleHardSplit();

    _pendingNewlines = 0;
    _pendingNested = false;
    return chunk;
  }

  /// Writes [text] to either the current chunk or a new one if the current
  /// chunk is complete.
  void _writeText(String text, [Chunk? chunk]) {
    if (chunk == null) {
      if (_chunks.isEmpty) {
        _startChunk(NestingLevel(), isHard: true);
      }

      chunk = _chunks.last;
    }

    if (_pendingSpace && chunk.text.isNotEmpty) chunk.appendText(' ');
    _pendingSpace = false;

    chunk.appendText(text);
  }

  Chunk _startChunk(
    NestingLevel nesting, {
    required bool isHard,
    bool isDouble = false,
    bool space = false,
  }) {
    var rule = isHard ? Rule.hard() : _rules.last;

    var chunk = Chunk(
      rule,
      _nesting.indentation,
      nesting,
      space: space,
      flushLeft: _pendingFlushLeft,
      isDouble: isDouble,
    );
    _chunks.add(chunk);

    _pendingFlushLeft = false;
    return chunk;
  }

  /// Pre-processes the chunks after they are done being written by the visitor
  /// but before they are run through the line splitter.
  ///
  /// Marks ranges of chunks that can be line split independently to keep the
  /// batches we send to [LineSplitter] small.
  void _divideChunks() {
    // Harden all of the rules that we know get forced by containing hard
    // splits, along with all of the other rules they constrain.
    _hardenRules();

    for (var i = 0; i < _chunks.length; i++) {
      var chunk = _chunks[i];
      if (!_canDivide(chunk)) chunk.preventDivide();
    }
  }

  /// Returns true if we can divide the chunks at [index] and line split the
  /// ones before and after that separately.
  bool _canDivide(Chunk chunk) {
    // Don't divide at the first chunk.
    if (chunk == _chunks.first) return false;

    // Can't divide soft rules.
    if (!chunk.rule.isHardened) return false;

    // Can't divide in the middle of expression nesting.
    if (chunk.nesting.isNested) return false;

    // If the chunk is the ending delimiter of a block, then don't separate it
    // and its children from the preceding beginning of the block.
    if (chunk is BlockChunk) return false;

    return true;
  }

  /// Hardens the active rules when a hard split occurs within them.
  void _handleHardSplit() {
    if (_rules.isEmpty) return;

    // If the current rule doesn't care, it will "eat" the hard split and no
    // others will care either.
    if (!_rules.last.splitsOnInnerRules) return;

    // Start with the innermost rule. This will traverse the other rules it
    // constrains.
    _hardSplitRules.add(_rules.last);
  }

  /// Replaces all of the previously hardened rules with hard splits, along
  /// with every rule that those constrain to also split.
  ///
  /// This should only be called after all chunks have been written.
  void _hardenRules() {
    if (_hardSplitRules.isEmpty) return;

    void walkConstraints(Rule rule) {
      rule.harden();

      // Follow this rule's constraints, recursively.
      for (var other in rule.constrainedRules) {
        if (other == rule) continue;

        if (!other.isHardened &&
            rule.constrain(rule.fullySplitValue, other) ==
                other.fullySplitValue) {
          walkConstraints(other);
        }
      }
    }

    for (var rule in _hardSplitRules) {
      walkConstraints(rule);
    }

    // Discard spans in hardened chunks since we know for certain they will
    // split anyway.
    for (var chunk in _chunks) {
      if (chunk.rule.isHardened) {
        chunk.spans.clear();
      }
    }
  }
}
