content: // Copyright 2016 Google Inc. Use of this source code is governed by an
  // MIT-style license that can be found in the LICENSE file or at
  // https://opensource.org/licenses/MIT.
  
  import 'package:charcode/charcode.dart';
  import 'package:meta/meta.dart';
  import 'package:path/path.dart' as p;
  import 'package:source_span/source_span.dart';
  import 'package:string_scanner/string_scanner.dart';
  
  import '../ast/sass.dart';
  import '../color_names.dart';
  import '../deprecation.dart';
  import '../exception.dart';
  import '../interpolation_buffer.dart';
  import '../util/character.dart';
  import '../utils.dart';
  import '../util/multi_span.dart';
  import '../util/nullable.dart';
  import '../value.dart';
  import 'parser.dart';
  
  /// The base class for both the SCSS and indented syntax parsers.
  ///
  /// Having a base class that's separate from both parsers allows us to make
  /// explicit exactly which methods are different between the two. This allows
  /// the author to know that if they're modifying the base class, the subclasses
  /// generally won't need modification. Conversely, if they're modifying one
  /// subclass, the other will likely need a parallel change.
  ///
  /// All methods that are not intended to be accessed by external callers are
  /// private, except where they have to be public for subclasses to refer to
  /// them.
  abstract class StylesheetParser extends Parser {
    /// Whether we've consumed a rule other than `@charset`, `@forward`, or
    /// `@use`.
    var _isUseAllowed = true;
  
    /// Whether the parser is currently parsing the contents of a mixin
    /// declaration.
    var _inMixin = false;
  
    /// Whether the parser is currently parsing a content block passed to a mixin.
    var _inContentBlock = false;
  
    /// Whether the parser is currently parsing a control directive such as `@if`
    /// or `@each`.
    var _inControlDirective = false;
  
    /// Whether the parser is currently parsing an unknown rule.
    var _inUnknownAtRule = false;
  
    /// Whether the parser is currently parsing a style rule.
    var _inStyleRule = false;
  
    /// Whether the parser is currently within a parenthesized expression.
    var _inParentheses = false;
  
    /// Whether the parser is currently within an expression.
    @protected
    bool get inExpression => _inExpression;
    var _inExpression = false;
  
    /// A map from all variable names that are assigned with `!global` in the
    /// current stylesheet to the spans where they're defined.
    ///
    /// These are collected at parse time because they affect the variables
    /// exposed by the module generated for this stylesheet, *even if they aren't
    /// evaluated*. This allows us to ensure that the stylesheet always exposes
    /// the same set of variable names no matter how it's evaluated.
    final _globalVariables = <String, FileSpan>{};
  
    /// Warnings discovered while parsing that should be emitted during
    /// evaluation once a proper logger is available.
    @protected
    final warnings = <ParseTimeWarning>[];
  
    /// The silent comment this parser encountered previously.
    @protected
    SilentComment? lastSilentComment;
  
    StylesheetParser(super.contents, {super.url});
  
    // ## Statements
  
    Stylesheet parse() {
      return wrapSpanFormatException(() {
        var start = scanner.state;
        // Allow a byte-order mark at the beginning of the document.
        scanner.scanChar(0xFEFF);
        var statements = this.statements(() {
          // Handle this specially so that [atRule] always returns a non-nullable
          // Statement.
          if (scanner.scan('@charset')) {
            whitespace(consumeNewlines: false);
            string();
            return null;
          }
  
          return _statement(root: true);
        });
        scanner.expectDone();
  
        return Stylesheet.internal(
          statements,
          scanner.spanFrom(start),
          warnings,
          plainCss: plainCss,
          globalVariables: _globalVariables,
        );
      });
    }
  
    ParameterList parseParameterList() => _parseSingleProduction(() {
          scanner.expectChar($at, name: "@-rule");
          identifier();
          whitespace(consumeNewlines: true);
          identifier();
          var parameters = _parameterList();
          whitespace(consumeNewlines: true);
          scanner.expectChar($lbrace);
          return parameters;
        });
  
    (Expression, List<ParseTimeWarning>) parseExpression() => (
          _parseSingleProduction(_expression),
          warnings,
        );
  
    SassNumber parseNumber() {
      var expression = _parseSingleProduction(_number);
      return SassNumber(expression.value, expression.unit);
    }
  
    (VariableDeclaration, List<ParseTimeWarning>) parseVariableDeclaration() => (
          _parseSingleProduction(
            () => lookingAtIdentifier()
                ? _variableDeclarationWithNamespace()
                : variableDeclarationWithoutNamespace(),
          ),
          warnings,
        );
  
    (UseRule, List<ParseTimeWarning>) parseUseRule() => (
          _parseSingleProduction(() {
            var start = scanner.state;
            scanner.expectChar($at, name: "@-rule");
            expectIdentifier("use");
            whitespace(consumeNewlines: true);
            return _useRule(start);
          }),
          warnings,
        );
  
    /// Parses and returns [production] as the entire contents of [scanner].
    T _parseSingleProduction<T>(T production()) {
      return wrapSpanFormatException(() {
        var result = production();
        scanner.expectDone();
        return result;
      });
    }
  
    /// Parses a function signature of the format allowed by Node Sass's functions
    /// option and returns its name and declaration.
    ///
    /// If [requireParens] is `false`, this allows parentheses to be omitted.
    (String name, ParameterList) parseSignature({bool requireParens = true}) {
      return wrapSpanFormatException(() {
        var name = identifier();
        var parameters = requireParens || scanner.peekChar() == $lparen
            ? _parameterList()
            : ParameterList.empty(scanner.emptySpan);
        scanner.expectDone();
        return (name, parameters);
      });
    }
  
    /// Consumes a statement that's allowed at the top level of the stylesheet or
    /// within nested style and at rules.
    ///
    /// If [root] is `true`, this parses at-rules that are allowed only at the
    /// root of the stylesheet.
    Statement _statement({bool root = false}) {
      switch (scanner.peekChar()) {
        case $at:
          return atRule(() => _statement(), root: root);
  
        case $plus:
          if (!indented || !lookingAtIdentifier(1)) return _styleRule();
          _isUseAllowed = false;
          var start = scanner.state;
          scanner.readChar();
          return _includeRule(start);
  
        case $equal:
          if (!indented) return _styleRule();
          _isUseAllowed = false;
          var start = scanner.state;
          scanner.readChar();
          whitespace(consumeNewlines: true);
          return _mixinRule(start);
  
        case $rbrace:
          scanner.error('unmatched "}".', length: 1);
  
        case _:
          return _inStyleRule || _inUnknownAtRule || _inMixin || _inContentBlock
              ? _declarationOrStyleRule()
              : _variableDeclarationOrStyleRule();
      }
    }
  
    /// Consumes a namespaced variable declaration.
    VariableDeclaration _variableDeclarationWithNamespace() {
      var start = scanner.state;
      var namespace = identifier();
      scanner.expectChar($dot);
      return variableDeclarationWithoutNamespace(namespace, start);
    }
  
    /// Consumes a variable declaration.
    ///
    /// This never *consumes* a namespace, but if [namespace] is passed it will be
    /// used for the declaration.
    @protected
    VariableDeclaration variableDeclarationWithoutNamespace([
      String? namespace,
      LineScannerState? start_,
    ]) {
      var precedingComment = lastSilentComment;
      lastSilentComment = null;
      var start = start_ ?? scanner.state; // dart-lang/sdk#45348
  
      var name = variableName();
      if (namespace != null) _assertPublic(name, () => scanner.spanFrom(start));
  
      if (plainCss) {
        error(
          "Sass variables aren't allowed in plain CSS.",
          scanner.spanFrom(start),
        );
      }
  
      whitespace(consumeNewlines: true);
      scanner.expectChar($colon);
      whitespace(consumeNewlines: true);
  
      var value = _expression();
  
      var guarded = false;
      var global = false;
      var flagStart = scanner.state;
      while (scanner.scanChar($exclamation)) {
        switch (identifier()) {
          case 'default':
            if (guarded) {
              warnings.add((
                deprecation: Deprecation.duplicateVarFlags,
                message:
                    '!default should only be written once for each variable.\n'
                    'This will be an error in Dart Sass 2.0.0.',
                span: scanner.spanFrom(flagStart),
              ));
            }
            guarded = true;
  
          case 'global':
            if (namespace != null) {
              error(
                "!global isn't allowed for variables in other modules.",
                scanner.spanFrom(flagStart),
              );
            } else if (global) {
              warnings.add((
                deprecation: Deprecation.duplicateVarFlags,
                message:
                    '!global should only be written once for each variable.\n'
                    'This will be an error in Dart Sass 2.0.0.',
                span: scanner.spanFrom(flagStart),
              ));
            }
            global = true;
  
          case _:
            error("Invalid flag name.", scanner.spanFrom(flagStart));
        }
  
        whitespace(consumeNewlines: false);
        flagStart = scanner.state;
      }
  
      expectStatementSeparator("variable declaration");
      var declaration = VariableDeclaration(
        name,
        value,
        scanner.spanFrom(start),
        namespace: namespace,
        guarded: guarded,
        global: global,
        comment: precedingComment,
      );
      if (global) _globalVariables.putIfAbsent(name, () => declaration.span);
      return declaration;
    }
  
    /// Consumes a namespaced [VariableDeclaration] or a [StyleRule].
    Statement _variableDeclarationOrStyleRule() {
      if (plainCss) return _styleRule();
  
      // The indented syntax allows a single backslash to distinguish a style rule
      // from old-style property syntax. We don't support old property syntax, but
      // we do support the backslash because it's easy to do.
      if (indented && scanner.scanChar($backslash)) return _styleRule();
  
      if (!lookingAtIdentifier()) return _styleRule();
  
      var start = scanner.state;
      var variableOrInterpolation = _variableDeclarationOrInterpolation();
      return variableOrInterpolation is VariableDeclaration
          ? variableOrInterpolation
          : _styleRule(
              InterpolationBuffer()
                ..addInterpolation(variableOrInterpolation as Interpolation),
              start,
            );
    }
  
    /// Consumes a [VariableDeclaration], a [Declaration], or a [StyleRule].
    ///
    /// When parsing the children of a style rule, property declarations,
    /// namespaced variable declarations, and nested style rules can all begin
    /// with bare identifiers. In order to know which statement type to produce,
    /// we need to disambiguate them. We use the following criteria:
    ///
    /// * If the entity starts with an identifier followed by a period and a
    ///   dollar sign, it's a variable declaration. This is the simplest case,
    ///   because `.$` is used in and only in variable declarations.
    ///
    /// * If the entity doesn't start with an identifier followed by a colon,
    ///   it's a selector. There are some additional mostly-unimportant cases
    ///   here to support various declaration hacks.
    ///
    /// * If the colon is followed by another colon, it's a selector.
    ///
    /// * Otherwise, if the colon is followed by anything other than
    ///   interpolation or a character that's valid as the beginning of an
    ///   identifier, it's a declaration.
    ///
    /// * If the colon is followed by interpolation or a valid identifier, try
    ///   parsing it as a declaration value. If this fails, backtrack and parse
    ///   it as a selector.
    ///
    /// * If the declaration value is valid but is followed by "{", backtrack and
    ///   parse it as a selector anyway. This ensures that ".foo:bar {" is always
    ///   parsed as a selector and never as a property with nested properties
    ///   beneath it.
    Statement _declarationOrStyleRule() {
      // The indented syntax allows a single backslash to distinguish a style rule
      // from old-style property syntax. We don't support old property syntax, but
      // we do support the backslash because it's easy to do.
      if (indented && scanner.scanChar($backslash)) return _styleRule();
  
      var start = scanner.state;
      var declarationOrBuffer = _declarationOrBuffer();
      return declarationOrBuffer is Statement
          ? declarationOrBuffer
          : _styleRule(declarationOrBuffer as InterpolationBuffer, start);
    }
  
    /// Tries to parse a variable or property declaration, and returns the value
    /// parsed so far if it fails.
    ///
    /// This can return either an [InterpolationBuffer], indicating that it
    /// couldn't consume a declaration and that selector parsing should be
    /// attempted; or it can return a [Declaration] or a [VariableDeclaration],
    /// indicating that it successfully consumed a declaration.
    dynamic _declarationOrBuffer() {
      var start = scanner.state;
      var nameBuffer = InterpolationBuffer();
  
      var startsWithPunctuation = false;
      if (_lookingAtPotentialPropertyHack()) {
        startsWithPunctuation = true;
        nameBuffer.writeCharCode(scanner.readChar());
        nameBuffer.write(rawText(() => whitespace(consumeNewlines: false)));
      }
  
      if (!_lookingAtInterpolatedIdentifier()) return nameBuffer;
  
      var variableOrInterpolation = startsWithPunctuation
          ? interpolatedIdentifier()
          : _variableDeclarationOrInterpolation();
      if (variableOrInterpolation is VariableDeclaration) {
        return variableOrInterpolation;
      } else {
        nameBuffer.addInterpolation(variableOrInterpolation as Interpolation);
      }
  
      _isUseAllowed = false;
      if (scanner.matches("/*")) nameBuffer.write(rawText(loudComment));
  
      var midBuffer = StringBuffer();
      midBuffer.write(rawText(() => whitespace(consumeNewlines: false)));
      var beforeColon = scanner.state;
      if (!scanner.scanChar($colon)) {
        if (midBuffer.isNotEmpty) nameBuffer.writeCharCode($space);
        return nameBuffer;
      }
      midBuffer.writeCharCode($colon);
  
      // Parse custom properties as declarations no matter what.
      var name = nameBuffer.interpolation(scanner.spanFrom(start, beforeColon));
      if (name.initialPlain.startsWith('--')) {
        var value = StringExpression(
          atEndOfStatement()
              ? Interpolation(const [], const [], scanner.emptySpan)
              : _interpolatedDeclarationValue(silentComments: false),
        );
        expectStatementSeparator("custom property");
        return Declaration(name, value, scanner.spanFrom(start));
      }
  
      if (scanner.scanChar($colon)) {
        return nameBuffer
          ..write(midBuffer)
          ..writeCharCode($colon);
      } else if (indented && _lookingAtInterpolatedIdentifier()) {
        // In the indented syntax, `foo:bar` is always considered a selector
        // rather than a property.
        return nameBuffer..write(midBuffer);
      }
  
      var postColonWhitespace = rawText(() => whitespace(consumeNewlines: false));
      if (_tryDeclarationChildren(name, start) case var nested?) return nested;
  
      midBuffer.write(postColonWhitespace);
      var couldBeSelector =
          postColonWhitespace.isEmpty && _lookingAtInterpolatedIdentifier();
  
      var beforeDeclaration = scanner.state;
      Expression value;
      try {
        value = _expression();
  
        if (lookingAtChildren()) {
          // Properties that are ambiguous with selectors can't have additional
          // properties nested beneath them, so we force an error. This will be
          // caught below and cause the text to be reparsed as a selector.
          if (couldBeSelector) expectStatementSeparator();
        } else if (!atEndOfStatement()) {
          // Force an exception if there isn't a valid end-of-property character
          // but don't consume that character. This will also cause the text to be
          // reparsed.
          expectStatementSeparator();
        }
      } on FormatException catch (_) {
        if (!couldBeSelector) rethrow;
  
        // If the value would be followed by a semicolon, it's definitely supposed
        // to be a property, not a selector.
        scanner.state = beforeDeclaration;
        var additional = almostAnyValue();
        if (!indented && scanner.peekChar() == $semicolon) rethrow;
  
        nameBuffer.write(midBuffer);
        nameBuffer.addInterpolation(additional);
        return nameBuffer;
      }
  
      if (_tryDeclarationChildren(name, start, value: value) case var nested?) {
        return nested;
      } else {
        expectStatementSeparator();
        return Declaration(name, value, scanner.spanFrom(start));
      }
    }
  
    /// Tries to parse a namespaced [VariableDeclaration], and returns the value
    /// parsed so far if it fails.
    ///
    /// This can return either an [Interpolation], indicating that it couldn't
    /// consume a variable declaration and that property declaration or selector
    /// parsing should be attempted; or it can return a [VariableDeclaration],
    /// indicating that it successfully consumed a variable declaration.
    dynamic _variableDeclarationOrInterpolation() {
      if (!lookingAtIdentifier()) return interpolatedIdentifier();
  
      var start = scanner.state;
      var identifier = this.identifier();
      if (scanner.matches(".\$")) {
        scanner.readChar();
        return variableDeclarationWithoutNamespace(identifier, start);
      } else {
        var buffer = InterpolationBuffer()..write(identifier);
  
        // Parse the rest of an interpolated identifier if one exists, so callers
        // don't have to.
        if (_lookingAtInterpolatedIdentifierBody()) {
          buffer.addInterpolation(interpolatedIdentifier());
        }
  
        return buffer.interpolation(scanner.spanFrom(start));
      }
    }
  
    /// Consumes a [StyleRule], optionally with a [buffer] that may contain some
    /// text that has already been parsed.
    StyleRule _styleRule([
      InterpolationBuffer? buffer,
      LineScannerState? start_,
    ]) {
      _isUseAllowed = false;
      var start = start_ ?? scanner.state; // dart-lang/sdk#45348
  
      var interpolation = styleRuleSelector();
      if (buffer != null) {
        buffer.addInterpolation(interpolation);
        interpolation = buffer.interpolation(scanner.spanFrom(start));
      }
      if (interpolation.contents.isEmpty) scanner.error('expected "}".');
  
      var wasInStyleRule = _inStyleRule;
      _inStyleRule = true;
  
      return _withChildren(_statement, start, (children, span) {
        if (indented && children.isEmpty) {
          warnings.add((
            deprecation: null,
            message: "This selector doesn't have any properties and won't be "
                "rendered.",
            span: interpolation.span,
          ));
        }
  
        _inStyleRule = wasInStyleRule;
  
        return StyleRule(interpolation, children, scanner.spanFrom(start));
      });
    }
  
    /// Consumes either a property declaration or a namespaced variable
    /// declaration.
    ///
    /// This is only used in contexts where declarations are allowed but style
    /// rules are not, such as nested declarations. Otherwise,
    /// [_declarationOrStyleRule] is used instead.
    ///
    /// If [parseCustomProperties] is `true`, properties that begin with `--` will
    /// be parsed using custom property parsing rules.
    Statement _propertyOrVariableDeclaration({
      bool parseCustomProperties = true,
    }) {
      var start = scanner.state;
  
      Interpolation name;
      if (_lookingAtPotentialPropertyHack()) {
        var nameBuffer = InterpolationBuffer();
        nameBuffer.writeCharCode(scanner.readChar());
        nameBuffer.write(rawText(() => whitespace(consumeNewlines: false)));
        nameBuffer.addInterpolation(interpolatedIdentifier());
        name = nameBuffer.interpolation(scanner.spanFrom(start));
      } else if (!plainCss) {
        var variableOrInterpolation = _variableDeclarationOrInterpolation();
        if (variableOrInterpolation is VariableDeclaration) {
          return variableOrInterpolation;
        } else {
          name = variableOrInterpolation as Interpolation;
        }
      } else {
        name = interpolatedIdentifier();
      }
  
      whitespace(consumeNewlines: false);
      scanner.expectChar($colon);
  
      if (parseCustomProperties && name.initialPlain.startsWith('--')) {
        var value = StringExpression(
          _interpolatedDeclarationValue(silentComments: false),
        );
        expectStatementSeparator("custom property");
        return Declaration(name, value, scanner.spanFrom(start));
      }
  
      whitespace(consumeNewlines: false);
      if (_tryDeclarationChildren(name, start) case var nested?) return nested;
  
      var value = _expression();
      if (_tryDeclarationChildren(name, start, value: value) case var nested?) {
        return nested;
      } else {
        expectStatementSeparator();
        return Declaration(name, value, scanner.spanFrom(start));
      }
    }
  
    /// Tries parsing nested children of a declaration whose [name] has already
    /// been parsed, and returns `null` if it doesn't have any.
    ///
    /// If [value] is passed, it's used as the value of the property without
    /// nesting.
    Declaration? _tryDeclarationChildren(
      Interpolation name,
      LineScannerState start, {
      Expression? value,
    }) {
      if (!lookingAtChildren()) return null;
      if (plainCss) {
        scanner.error("Nested declarations aren't allowed in plain CSS.");
      }
      return _withChildren(
        _declarationChild,
        start,
        (children, span) =>
            Declaration.nested(name, children, span, value: value),
      );
    }
  
    /// Consumes a statement that's allowed within a declaration.
    Statement _declarationChild() => scanner.peekChar() == $at
        ? _declarationAtRule()
        : _propertyOrVariableDeclaration(parseCustomProperties: false);
  
    // ## At Rules
  
    /// Consumes an at-rule.
    ///
    /// This consumes at-rules that are allowed at all levels of the document; the
    /// [child] parameter is called to consume any at-rules that are specifically
    /// allowed in the caller's context.
    ///
    /// If [root] is `true`, this parses at-rules that are allowed only at the
    /// root of the stylesheet.
    @protected
    Statement atRule(Statement child(), {bool root = false}) {
      // NOTE: this logic is largely duplicated in CssParser.atRule. Most changes
      // here should be mirrored there.
  
      var start = scanner.state;
      scanner.expectChar($at, name: "@-rule");
      var name = interpolatedIdentifier();
  
      // We want to set [_isUseAllowed] to `false` *unless* we're parsing
      // `@charset`, `@forward`, or `@use`. To avoid double-comparing the rule
      // name, we always set it to `false` and then set it back to its previous
      // value if we're parsing an allowed rule.
      var wasUseAllowed = _isUseAllowed;
      _isUseAllowed = false;
  
      switch (name.asPlain) {
        case "at-root":
          return _atRootRule(start);
        case "content":
          return _contentRule(start);
        case "debug":
          return _debugRule(start);
        case "each":
          return _eachRule(start, child);
        case "else":
          return _disallowedAtRule(start);
        case "error":
          return _errorRule(start);
        case "extend":
          return _extendRule(start);
        case "for":
          return _forRule(start, child);
        case "forward":
          _isUseAllowed = wasUseAllowed;
          if (!root) _disallowedAtRule(start);
          return _forwardRule(start);
        case "function":
          return _functionRule(start);
        case "if":
          return _ifRule(start, child);
        case "import":
          return _importRule(start);
        case "include":
          return _includeRule(start);
        case "media":
          return mediaRule(start);
        case "mixin":
          return _mixinRule(start);
        case "-moz-document":
          return mozDocumentRule(start, name);
        case "return":
          return _disallowedAtRule(start);
        case "supports":
          return supportsRule(start);
        case "use":
          _isUseAllowed = wasUseAllowed;
          if (!root) _disallowedAtRule(start);
          return _useRule(start);
        case "warn":
          return _warnRule(start);
        case "while":
          return _whileRule(start, child);
        default:
          return unknownAtRule(start, name);
      }
    }
  
    /// Consumes an at-rule allowed within a property declaration.
    Statement _declarationAtRule() {
      var start = scanner.state;
      return switch (_plainAtRuleName()) {
        "content" => _contentRule(start),
        "debug" => _debugRule(start),
        "each" => _eachRule(start, _declarationChild),
        "else" => _disallowedAtRule(start),
        "error" => _errorRule(start),
        "for" => _forRule(start, _declarationChild),
        "if" => _ifRule(start, _declarationChild),
        "include" => _includeRule(start),
        "warn" => _warnRule(start),
        "while" => _whileRule(start, _declarationChild),
        _ => _disallowedAtRule(start),
      };
    }
  
    /// Consumes a statement allowed within a function.
    Statement _functionChild() {
      if (scanner.peekChar() != $at) {
        var state = scanner.state;
        try {
          return _variableDeclarationWithNamespace();
        } on SourceSpanFormatException catch (variableDeclarationError, stackTrace) {
          scanner.state = state;
  
          // If a variable declaration failed to parse, it's possible the user
          // thought they could write a style rule or property declaration in a
          // function. If so, throw a more helpful error message.
          Statement statement;
          try {
            statement = _declarationOrStyleRule();
          } on SourceSpanFormatException catch (_) {
            throw variableDeclarationError;
          }
  
          error(
            "@function rules may not contain "
            "${statement is StyleRule ? "style rules" : "declarations"}.",
            statement.span,
            stackTrace,
          );
        }
      }
  
      var start = scanner.state;
      return switch (_plainAtRuleName()) {
        "debug" => _debugRule(start),
        "each" => _eachRule(start, _functionChild),
        "else" => _disallowedAtRule(start),
        "error" => _errorRule(start),
        "for" => _forRule(start, _functionChild),
        "if" => _ifRule(start, _functionChild),
        "return" => _returnRule(start),
        "warn" => _warnRule(start),
        "while" => _whileRule(start, _functionChild),
        _ => _disallowedAtRule(start),
      };
    }
  
    /// Consumes an at-rule's name, with interpolation disallowed.
    String _plainAtRuleName() {
      scanner.expectChar($at, name: "@-rule");
      var name = identifier();
      return name;
    }
  
    /// Consumes an `@at-root` rule.
    ///
    /// [start] should point before the `@`.
    AtRootRule _atRootRule(LineScannerState start) {
      whitespace(consumeNewlines: false);
      if (scanner.peekChar() == $lparen) {
        var query = _atRootQuery();
        return _withChildren(
          _statement,
          start,
          (children, span) => AtRootRule(children, span, query: query),
        );
      } else if (lookingAtChildren() || (indented && atEndOfStatement())) {
        return _withChildren(
          _statement,
          start,
          (children, span) => AtRootRule(children, span),
        );
      } else {
        var child = _styleRule();
        return AtRootRule([child], scanner.spanFrom(start));
      }
    }
  
    /// Consumes a query expression of the form `(foo: bar)`.
    Interpolation _atRootQuery() {
      var start = scanner.state;
      var buffer = InterpolationBuffer();
      scanner.expectChar($lparen);
      buffer.writeCharCode($lparen);
      whitespace(consumeNewlines: true);
  
      _addOrInject(buffer, _expression(consumeNewlines: true));
      if (scanner.scanChar($colon)) {
        whitespace(consumeNewlines: true);
        buffer.writeCharCode($colon);
        buffer.writeCharCode($space);
        _addOrInject(buffer, _expression(consumeNewlines: true));
      }
  
      scanner.expectChar($rparen);
      whitespace(consumeNewlines: false);
      buffer.writeCharCode($rparen);
  
      return buffer.interpolation(scanner.spanFrom(start));
    }
  
    /// Consumes a `@content` rule.
    ///
    /// [start] should point before the `@`.
    ContentRule _contentRule(LineScannerState start) {
      if (!_inMixin) {
        error(
          "@content is only allowed within mixin declarations.",
          scanner.spanFrom(start),
        );
      }
  
      var beforeWhitespace = scanner.location;
      whitespace(consumeNewlines: false);
      ArgumentList arguments;
      if (scanner.peekChar() == $lparen) {
        arguments = _argumentInvocation(mixin: true);
        whitespace(consumeNewlines: false);
      } else {
        arguments = ArgumentList.empty(beforeWhitespace.pointSpan());
      }
  
      expectStatementSeparator("@content rule");
      return ContentRule(arguments, scanner.spanFrom(start));
    }
  
    /// Consumes a `@debug` rule.
    ///
    /// [start] should point before the `@`.
    DebugRule _debugRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var value = _expression();
      var expressionEnd = scanner.state;
      expectStatementSeparator("@debug rule");
      return DebugRule(value, scanner.spanFrom(start, expressionEnd));
    }
  
    /// Consumes an `@each` rule.
    ///
    /// [start] should point before the `@`. [child] is called to consume any
    /// children that are specifically allowed in the caller's context.
    EachRule _eachRule(LineScannerState start, Statement child()) {
      whitespace(consumeNewlines: true);
      var wasInControlDirective = _inControlDirective;
      _inControlDirective = true;
  
      var variables = [variableName()];
      whitespace(consumeNewlines: true);
      while (scanner.scanChar($comma)) {
        whitespace(consumeNewlines: true);
        variables.add(variableName());
        whitespace(consumeNewlines: true);
      }
      whitespace(consumeNewlines: true);
      expectIdentifier("in");
      whitespace(consumeNewlines: true);
  
      var list = _expression();
  
      return _withChildren(child, start, (children, span) {
        _inControlDirective = wasInControlDirective;
        return EachRule(variables, list, children, span);
      });
    }
  
    /// Consumes an `@error` rule.
    ///
    /// [start] should point before the `@`.
    ErrorRule _errorRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var value = _expression();
      var expressionEnd = scanner.state;
      expectStatementSeparator("@error rule");
      return ErrorRule(value, scanner.spanFrom(start, expressionEnd));
    }
  
    /// Consumes an `@extend` rule.
    ///
    /// [start] should point before the `@`.
    ExtendRule _extendRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      if (!_inStyleRule && !_inMixin && !_inContentBlock) {
        error(
          "@extend may only be used within style rules.",
          scanner.spanFrom(start),
        );
      }
  
      var value = almostAnyValue();
      var optional = scanner.scanChar($exclamation);
      if (optional) {
        expectIdentifier("optional");
        whitespace(consumeNewlines: false);
      }
      expectStatementSeparator("@extend rule");
      return ExtendRule(value, scanner.spanFrom(start), optional: optional);
    }
  
    /// Consumes a function declaration.
    ///
    /// [start] should point before the `@`.
    FunctionRule _functionRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var precedingComment = lastSilentComment;
      lastSilentComment = null;
      var beforeName = scanner.state;
      var name = identifier();
  
      if (name.startsWith('--')) {
        warnings.add((
          deprecation: Deprecation.cssFunctionMixin,
          message:
              'Sass @function names beginning with -- are deprecated for forward-'
              'compatibility with plain CSS functions.\n'
              '\n'
              'For details, see https://sass-lang.com/d/css-function-mixin',
          span: scanner.spanFrom(beforeName),
        ));
      } else if (equalsIgnoreCase(name, 'type')) {
        error('This name is reserved for the plain-CSS function.',
            scanner.spanFrom(beforeName));
      }
  
      whitespace(consumeNewlines: true);
      var parameters = _parameterList();
  
      if (_inMixin || _inContentBlock) {
        error(
          "Mixins may not contain function declarations.",
          scanner.spanFrom(start),
        );
      } else if (_inControlDirective) {
        error(
          "Functions may not be declared in control directives.",
          scanner.spanFrom(start),
        );
      }
  
      if (unvendor(name)
          case "calc" ||
              "element" ||
              "expression" ||
              "url" ||
              "and" ||
              "or" ||
              "not" ||
              "clamp") {
        error("Invalid function name.", scanner.spanFrom(start));
      }
  
      whitespace(consumeNewlines: false);
      return _withChildren(
        _functionChild,
        start,
        (children, span) => FunctionRule(
          name,
          parameters,
          children,
          span,
          comment: precedingComment,
        ),
      );
    }
  
    /// Consumes a `@for` rule.
    ///
    /// [start] should point before the `@`. [child] is called to consume any
    /// children that are specifically allowed in the caller's context.
    ForRule _forRule(LineScannerState start, Statement child()) {
      whitespace(consumeNewlines: true);
      var wasInControlDirective = _inControlDirective;
      _inControlDirective = true;
      var variable = variableName();
      whitespace(consumeNewlines: true);
  
      expectIdentifier("from");
      whitespace(consumeNewlines: true);
  
      bool? exclusive;
      var from = _expression(
        consumeNewlines: true,
        until: () {
          if (!lookingAtIdentifier()) return false;
          if (scanIdentifier("to")) {
            exclusive = true;
            return true;
          } else if (scanIdentifier("through")) {
            exclusive = false;
            return true;
          } else {
            return false;
          }
        },
      );
      if (exclusive == null) scanner.error('Expected "to" or "through".');
  
      whitespace(consumeNewlines: true);
      var to = _expression();
  
      return _withChildren(child, start, (children, span) {
        _inControlDirective = wasInControlDirective;
        return ForRule(
          variable,
          from,
          to,
          children,
          span,
          exclusive: exclusive!,
        ); // dart-lang/sdk#45348
      });
    }
  
    /// Consumes a `@forward` rule.
    ///
    /// [start] should point before the `@`.
    ForwardRule _forwardRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var url = _urlString();
      whitespace(consumeNewlines: false);
  
      String? prefix;
      if (scanIdentifier("as")) {
        whitespace(consumeNewlines: true);
        prefix = identifier(normalize: true);
        scanner.expectChar($asterisk);
        whitespace(consumeNewlines: false);
      }
  
      Set<String>? shownMixinsAndFunctions;
      Set<String>? shownVariables;
      Set<String>? hiddenMixinsAndFunctions;
      Set<String>? hiddenVariables;
      if (scanIdentifier("show")) {
        whitespace(consumeNewlines: true);
        (shownMixinsAndFunctions, shownVariables) = _memberList();
      } else if (scanIdentifier("hide")) {
        whitespace(consumeNewlines: true);
        (hiddenMixinsAndFunctions, hiddenVariables) = _memberList();
      }
  
      var configuration = _configuration(allowGuarded: true);
      whitespace(consumeNewlines: false);
  
      expectStatementSeparator("@forward rule");
      var span = scanner.spanFrom(start);
      if (!_isUseAllowed) {
        error("@forward rules must be written before any other rules.", span);
      }
  
      if (shownMixinsAndFunctions != null) {
        return ForwardRule.show(
          url,
          shownMixinsAndFunctions,
          shownVariables!,
          span,
          prefix: prefix,
          configuration: configuration,
        );
      } else if (hiddenMixinsAndFunctions != null) {
        return ForwardRule.hide(
          url,
          hiddenMixinsAndFunctions,
          hiddenVariables!,
          span,
          prefix: prefix,
          configuration: configuration,
        );
      } else {
        return ForwardRule(
          url,
          span,
          prefix: prefix,
          configuration: configuration,
        );
      }
    }
  
    /// Consumes a list of members that may contain either plain identifiers or
    /// variable names.
    ///
    /// The plain identifiers are returned in the first set, and the variable
    /// names in the second.
    (Set<String>, Set<String>) _memberList() {
      var identifiers = <String>{};
      var variables = <String>{};
      do {
        whitespace(consumeNewlines: true);
        withErrorMessage("Expected variable, mixin, or function name", () {
          if (scanner.peekChar() == $dollar) {
            variables.add(variableName());
          } else {
            identifiers.add(identifier(normalize: true));
          }
        });
        whitespace(consumeNewlines: false);
      } while (scanner.scanChar($comma));
  
      return (identifiers, variables);
    }
  
    /// Consumes an `@if` rule.
    ///
    /// [start] should point before the `@`. [child] is called to consume any
    /// children that are specifically allowed in the caller's context.
    IfRule _ifRule(LineScannerState start, Statement child()) {
      whitespace(consumeNewlines: true);
      var ifIndentation = currentIndentation;
      var wasInControlDirective = _inControlDirective;
      _inControlDirective = true;
      var condition = _expression();
      var children = this.children(child);
      whitespaceWithoutComments(consumeNewlines: false);
  
      var clauses = [IfClause(condition, children)];
      ElseClause? lastClause;
  
      while (scanElse(ifIndentation)) {
        whitespace(consumeNewlines: false);
        if (scanIdentifier("if")) {
          whitespace(consumeNewlines: true);
          clauses.add(IfClause(_expression(), this.children(child)));
        } else {
          lastClause = ElseClause(this.children(child));
          break;
        }
      }
      _inControlDirective = wasInControlDirective;
  
      var span = scanner.spanFrom(start);
      whitespaceWithoutComments(consumeNewlines: false);
      return IfRule(clauses, span, lastClause: lastClause);
    }
  
    /// Consumes an `@import` rule.
    ///
    /// [start] should point before the `@`.
    ImportRule _importRule(LineScannerState start) {
      var imports = <Import>[];
      do {
        whitespace(consumeNewlines: false);
        var argument = importArgument();
        if (argument is DynamicImport) {
          warnings.add((
            deprecation: Deprecation.import,
            message:
                'Sass @import rules are deprecated and will be removed in Dart '
                'Sass 3.0.0.\n\n'
                'More info and automated migrator: '
                'https://sass-lang.com/d/import',
            span: argument.span,
          ));
        }
        if ((_inControlDirective || _inMixin) && argument is DynamicImport) {
          _disallowedAtRule(start);
        }
  
        imports.add(argument);
        whitespace(consumeNewlines: false);
      } while (scanner.scanChar($comma));
      expectStatementSeparator("@import rule");
  
      return ImportRule(imports, scanner.spanFrom(start));
    }
  
    /// Consumes an argument to an `@import` rule.
    ///
    /// [ruleStart] should point before the `@`.
    @protected
    Import importArgument() {
      var start = scanner.state;
      if (scanner.peekChar() case $u || $U) {
        var url = dynamicUrl();
        whitespace(consumeNewlines: false);
        var modifiers = tryImportModifiers();
        return StaticImport(
          url is StringExpression
              ? url.text
              : Interpolation([url], [url.span], url.span),
          scanner.spanFrom(start),
          modifiers: modifiers,
        );
      }
  
      var url = string();
      var urlSpan = scanner.spanFrom(start);
      whitespace(consumeNewlines: false);
      var modifiers = tryImportModifiers();
      if (isPlainImportUrl(url) || modifiers != null) {
        return StaticImport(
          Interpolation.plain(urlSpan.text, urlSpan),
          scanner.spanFrom(start),
          modifiers: modifiers,
        );
      } else {
        try {
          return DynamicImport(parseImportUrl(url), urlSpan);
        } on FormatException catch (innerError, stackTrace) {
          error("Invalid URL: ${innerError.message}", urlSpan, stackTrace);
        }
      }
    }
  
    /// Parses [url] as an import URL.
    @protected
    String parseImportUrl(String url) {
      // Backwards-compatibility for implementations that allow absolute Windows
      // paths in imports.
      if (p.windows.isAbsolute(url) && !p.url.isRootRelative(url)) {
        return p.windows.toUri(url).toString();
      }
  
      // Throw a [FormatException] if [url] is invalid.
      Uri.parse(url);
      return url;
    }
  
    /// Returns whether [url] indicates that an `@import` is a plain CSS import.
    @protected
    bool isPlainImportUrl(String url) {
      if (url.length < 5) return false;
      if (url.endsWith(".css")) return true;
  
      return switch (url.codeUnitAt(0)) {
        $slash => url.codeUnitAt(1) == $slash,
        $h => url.startsWith("http://") || url.startsWith("https://"),
        _ => false,
      };
    }
  
    /// Consumes a sequence of modifiers (such as media or supports queries)
    /// after an import argument.
    ///
    /// Returns `null` if there are no modifiers.
    Interpolation? tryImportModifiers() {
      // Exit before allocating anything if we're not looking at any modifiers, as
      // is the most common case.
      if (!_lookingAtInterpolatedIdentifier() && scanner.peekChar() != $lparen) {
        return null;
      }
  
      var start = scanner.state;
      var buffer = InterpolationBuffer();
      while (true) {
        if (_lookingAtInterpolatedIdentifier()) {
          if (!buffer.isEmpty) buffer.writeCharCode($space);
  
          var identifier = interpolatedIdentifier();
          buffer.addInterpolation(identifier);
  
          var name = identifier.asPlain?.toLowerCase();
          if (name != "and" && scanner.scanChar($lparen)) {
            if (name == "supports") {
              var query = _importSupportsQuery();
              if (query is! SupportsDeclaration) buffer.writeCharCode($lparen);
              buffer.add(SupportsExpression(query), query.span);
              if (query is! SupportsDeclaration) buffer.writeCharCode($rparen);
            } else {
              buffer.writeCharCode($lparen);
              buffer.addInterpolation(
                _interpolatedDeclarationValue(
                  allowEmpty: true,
                  allowSemicolon: true,
                  consumeNewlines: true,
                ),
              );
              buffer.writeCharCode($rparen);
            }
            scanner.expectChar($rparen);
            whitespace(consumeNewlines: false);
          } else {
            whitespace(consumeNewlines: false);
            if (scanner.scanChar($comma)) {
              buffer.write(", ");
              buffer.addInterpolation(_mediaQueryList());
              return buffer.interpolation(scanner.spanFrom(start));
            }
          }
        } else if (scanner.peekChar() == $lparen) {
          if (!buffer.isEmpty) buffer.writeCharCode($space);
          buffer.addInterpolation(_mediaQueryList());
          return buffer.interpolation(scanner.spanFrom(start));
        } else {
          return buffer.interpolation(scanner.spanFrom(start));
        }
      }
    }
  
    /// Consumes the contents of a `supports()` function after an `@import` rule
    /// (but not the function name or parentheses).
    SupportsCondition _importSupportsQuery() {
      whitespace(consumeNewlines: true);
      if (scanIdentifier("not")) {
        whitespace(consumeNewlines: true);
        var start = scanner.state;
        return SupportsNegation(
          _supportsConditionInParens(),
          scanner.spanFrom(start),
        );
      } else if (scanner.peekChar() == $lparen) {
        return _supportsCondition(inParentheses: true);
      } else {
        if (_tryImportSupportsFunction() case var function?) return function;
  
        var start = scanner.state;
        var name = _expression(consumeNewlines: true);
        scanner.expectChar($colon);
        return SupportsDeclaration(
          name,
          _supportsDeclarationValue(name),
          scanner.spanFrom(start),
        );
      }
    }
  
    /// Consumes a function call within a `supports()` function after an
    /// `@import` if available.
    SupportsFunction? _tryImportSupportsFunction() {
      if (!_lookingAtInterpolatedIdentifier()) return null;
  
      var start = scanner.state;
      var name = interpolatedIdentifier();
      assert(name.asPlain != "not");
  
      if (!scanner.scanChar($lparen)) {
        scanner.state = start;
        return null;
      }
  
      var value = _interpolatedDeclarationValue(
        allowEmpty: true,
        allowSemicolon: true,
        consumeNewlines: true,
      );
      scanner.expectChar($rparen);
  
      return SupportsFunction(name, value, scanner.spanFrom(start));
    }
  
    /// Consumes an `@include` rule.
    ///
    /// [start] should point before the `@`.
    IncludeRule _includeRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      String? namespace;
      var name = identifier();
      if (scanner.scanChar($dot)) {
        namespace = name;
        name = _publicIdentifier();
      }
  
      whitespace(consumeNewlines: false);
      var arguments = scanner.peekChar() == $lparen
          ? _argumentInvocation(mixin: true)
          : ArgumentList.empty(scanner.emptySpan);
      whitespace(consumeNewlines: false);
  
      ParameterList? contentParameters;
      if (scanIdentifier("using")) {
        whitespace(consumeNewlines: true);
        contentParameters = _parameterList();
        whitespace(consumeNewlines: false);
      }
  
      ContentBlock? content;
      if (contentParameters != null || lookingAtChildren()) {
        var contentParameters_ =
            contentParameters ?? ParameterList.empty(scanner.emptySpan);
        var wasInContentBlock = _inContentBlock;
        _inContentBlock = true;
        content = _withChildren(
          _statement,
          start,
          (children, span) => ContentBlock(contentParameters_, children, span),
        );
        _inContentBlock = wasInContentBlock;
      } else {
        expectStatementSeparator();
      }
  
      var span =
          scanner.spanFrom(start, start).expand((content ?? arguments).span);
      return IncludeRule(
        name,
        arguments,
        span,
        namespace: namespace,
        content: content,
      );
    }
  
    /// Consumes a `@media` rule.
    ///
    /// [start] should point before the `@`.
    @protected
    MediaRule mediaRule(LineScannerState start) {
      whitespace(consumeNewlines: false);
      var query = _mediaQueryList();
      return _withChildren(
        _statement,
        start,
        (children, span) => MediaRule(query, children, span),
      );
    }
  
    /// Consumes a mixin declaration.
    ///
    /// [start] should point before the `@`.
    MixinRule _mixinRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var precedingComment = lastSilentComment;
      lastSilentComment = null;
      var beforeName = scanner.state;
      var name = identifier();
  
      if (name.startsWith('--')) {
        warnings.add((
          deprecation: Deprecation.cssFunctionMixin,
          message:
              'Sass @mixin names beginning with -- are deprecated for forward-'
              'compatibility with plain CSS mixins.\n'
              '\n'
              'For details, see https://sass-lang.com/d/css-function-mixin',
          span: scanner.spanFrom(beforeName),
        ));
      }
  
      whitespace(consumeNewlines: false);
      var parameters = scanner.peekChar() == $lparen
          ? _parameterList()
          : ParameterList.empty(scanner.emptySpan);
  
      if (_inMixin || _inContentBlock) {
        error(
          "Mixins may not contain mixin declarations.",
          scanner.spanFrom(start),
        );
      } else if (_inControlDirective) {
        error(
          "Mixins may not be declared in control directives.",
          scanner.spanFrom(start),
        );
      }
  
      whitespace(consumeNewlines: false);
      _inMixin = true;
  
      return _withChildren(_statement, start, (children, span) {
        _inMixin = false;
        return MixinRule(
          name,
          parameters,
          children,
          span,
          comment: precedingComment,
        );
      });
    }
  
    /// Consumes a `@moz-document` rule.
    ///
    /// Gecko's `@-moz-document` diverges from [the specification][] allows the
    /// `url-prefix` and `domain` functions to omit quotation marks, contrary to
    /// the standard.
    ///
    /// [the specification]: http://www.w3.org/TR/css3-conditional/
    @protected
    AtRule mozDocumentRule(LineScannerState start, Interpolation name) {
      whitespace(consumeNewlines: false);
      var valueStart = scanner.state;
      var buffer = InterpolationBuffer();
      var needsDeprecationWarning = false;
      while (true) {
        if (scanner.peekChar() == $hash) {
          var (expression, span) = singleInterpolation();
          buffer.add(expression, span);
          needsDeprecationWarning = true;
        } else {
          var identifierStart = scanner.state;
          var identifier = this.identifier();
          switch (identifier) {
            case "url" || "url-prefix" || "domain":
              if (_tryUrlContents(identifierStart, name: identifier)
                  case var contents?) {
                buffer.addInterpolation(contents);
              } else {
                scanner.expectChar($lparen);
                whitespace(consumeNewlines: false);
                var argument = interpolatedString();
                scanner.expectChar($rparen);
  
                buffer
                  ..write(identifier)
                  ..writeCharCode($lparen)
                  ..addInterpolation(argument.asInterpolation())
                  ..writeCharCode($rparen);
              }
  
              // A url-prefix with no argument, or with an empty string as an
              // argument, is not (yet) deprecated.
              var trailing = buffer.trailingString;
              if (!trailing.endsWith("url-prefix()") &&
                  !trailing.endsWith("url-prefix('')") &&
                  !trailing.endsWith('url-prefix("")')) {
                needsDeprecationWarning = true;
              }
  
            case "regexp":
              buffer.write("regexp(");
              scanner.expectChar($lparen);
              buffer.addInterpolation(interpolatedString().asInterpolation());
              scanner.expectChar($rparen);
              buffer.writeCharCode($rparen);
              needsDeprecationWarning = true;
  
            default:
              error("Invalid function name.", scanner.spanFrom(identifierStart));
          }
        }
  
        whitespace(consumeNewlines: false);
        if (!scanner.scanChar($comma)) break;
  
        buffer.writeCharCode($comma);
        buffer.write(rawText(() => whitespace(consumeNewlines: false)));
      }
  
      var value = buffer.interpolation(scanner.spanFrom(valueStart));
      return _withChildren(_statement, start, (children, span) {
        if (needsDeprecationWarning) {
          warnings.add((
            deprecation: Deprecation.mozDocument,
            message:
                "@-moz-document is deprecated and support will be removed in "
                "Dart Sass 2.0.0.\n"
                "\n"
                "For details, see https://sass-lang.com/d/moz-document.",
            span: span,
          ));
        }
  
        return AtRule(name, span, value: value, children: children);
      });
    }
  
    /// Consumes a `@return` rule.
    ///
    /// [start] should point before the `@`.
    ReturnRule _returnRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var value = _expression();
      expectStatementSeparator("@return rule");
      return ReturnRule(value, scanner.spanFrom(start));
    }
  
    /// Consumes a `@supports` rule.
    ///
    /// [start] should point before the `@`.
    @protected
    SupportsRule supportsRule(LineScannerState start) {
      whitespace(consumeNewlines: false);
      var condition = _supportsCondition();
      whitespace(consumeNewlines: false);
      return _withChildren(
        _statement,
        start,
        (children, span) => SupportsRule(condition, children, span),
      );
    }
  
    /// Consumes a `@use` rule.
    ///
    /// [start] should point before the `@`.
    UseRule _useRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var url = _urlString();
      whitespace(consumeNewlines: false);
  
      var namespace = _useNamespace(url, start);
      whitespace(consumeNewlines: false);
      var configuration = _configuration();
      whitespace(consumeNewlines: false);
  
      var span = scanner.spanFrom(start);
      if (!_isUseAllowed) {
        error("@use rules must be written before any other rules.", span);
      }
      expectStatementSeparator("@use rule");
  
      return UseRule(url, namespace, span, configuration: configuration);
    }
  
    /// Parses the namespace of a `@use` rule from an `as` clause, or returns the
    /// default namespace from its URL.
    ///
    /// Returns `null` to indicate a `@use` rule without a URL.
    String? _useNamespace(Uri url, LineScannerState start) {
      if (scanIdentifier("as")) {
        whitespace(consumeNewlines: true);
        return scanner.scanChar($asterisk) ? null : identifier();
      }
  
      var basename = url.pathSegments.isEmpty ? "" : url.pathSegments.last;
      var dot = basename.indexOf(".");
      var namespace = basename.substring(
        basename.startsWith("_") ? 1 : 0,
        dot == -1 ? basename.length : dot,
      );
      try {
        return Parser.parseIdentifier(namespace);
      } on SassFormatException {
        error(
          'The default namespace "$namespace" is not a valid Sass identifier.\n'
          "\n"
          'Recommendation: add an "as" clause to define an explicit namespace.',
          scanner.spanFrom(start),
        );
      }
    }
  
    /// Returns the list of configured variables from a `@use` or `@forward`
    /// rule's `with` clause.
    ///
    /// If `allowGuarded` is `true`, this will allow configured variable with the
    /// `!default` flag.
    ///
    /// Returns `null` if there is no `with` clause.
    List<ConfiguredVariable>? _configuration({bool allowGuarded = false}) {
      if (!scanIdentifier("with")) return null;
  
      var variableNames = <String>{};
      var configuration = <ConfiguredVariable>[];
      whitespace(consumeNewlines: true);
      scanner.expectChar($lparen);
  
      while (true) {
        whitespace(consumeNewlines: true);
  
        var variableStart = scanner.state;
        var name = variableName();
        if (name.startsWith('-')) {
          warnings.add((
            deprecation: Deprecation.withPrivate,
            message: 'Configuring private variables is deprecated.\n'
                'This will be an error in Dart Sass 2.0.0.',
            span: scanner.spanFrom(variableStart),
          ));
        }
  
        whitespace(consumeNewlines: true);
        scanner.expectChar($colon);
        whitespace(consumeNewlines: true);
  
        var expression = expressionUntilComma();
  
        var guarded = false;
        var flagStart = scanner.state;
        if (allowGuarded && scanner.scanChar($exclamation)) {
          if (identifier() == 'default') {
            guarded = true;
            whitespace(consumeNewlines: true);
          } else {
            error("Invalid flag name.", scanner.spanFrom(flagStart));
          }
        }
  
        var span = scanner.spanFrom(variableStart);
        if (variableNames.contains(name)) {
          error("The same variable may only be configured once.", span);
        }
        variableNames.add(name);
        configuration.add(
          ConfiguredVariable(name, expression, span, guarded: guarded),
        );
  
        if (!scanner.scanChar($comma)) break;
        whitespace(consumeNewlines: true);
        if (!_lookingAtExpression()) break;
      }
  
      scanner.expectChar($rparen);
      return configuration;
    }
  
    /// Consumes a `@warn` rule.
    ///
    /// [start] should point before the `@`.
    WarnRule _warnRule(LineScannerState start) {
      whitespace(consumeNewlines: true);
      var value = _expression();
      var expressionEnd = scanner.state;
      expectStatementSeparator("@warn rule");
      return WarnRule(value, scanner.spanFrom(start, expressionEnd));
    }
  
    /// Consumes a `@while` rule.
    ///
    /// [start] should point before the `@`. [child] is called to consume any
    /// children that are specifically allowed in the caller's context.
    WhileRule _whileRule(LineScannerState start, Statement child()) {
      whitespace(consumeNewlines: true);
      var wasInControlDirective = _inControlDirective;
      _inControlDirective = true;
      var condition = _expression();
      return _withChildren(child, start, (children, span) {
        _inControlDirective = wasInControlDirective;
        return WhileRule(condition, children, span);
      });
    }
  
    /// Consumes an at-rule that's not explicitly supported by Sass.
    ///
    /// [start] should point before the `@`. [name] is the name of the at-rule.
    @protected
    AtRule unknownAtRule(LineScannerState start, Interpolation name) {
      var wasInUnknownAtRule = _inUnknownAtRule;
      _inUnknownAtRule = true;
  
      whitespace(consumeNewlines: false);
  
      Interpolation? value;
      if (scanner.peekChar() != $exclamation && !atEndOfStatement()) {
        value = _interpolatedDeclarationValue(allowOpenBrace: false);
      }
  
      AtRule rule;
      if (lookingAtChildren()) {
        rule = _withChildren(
          _statement,
          start,
          (children, span) =>
              AtRule(name, span, value: value, children: children),
        );
      } else {
        expectStatementSeparator();
        rule = AtRule(name, scanner.spanFrom(start), value: value);
      }
  
      _inUnknownAtRule = wasInUnknownAtRule;
      return rule;
    }
  
    /// Throws a [StringScannerException] indicating that the at-rule starting at
    /// [start] is not allowed in the current context.
    ///
    /// This declares a return type of [Statement] so that it can be returned
    /// within case statements.
    Statement _disallowedAtRule(LineScannerState start) {
      whitespace(consumeNewlines: false);
      _interpolatedDeclarationValue(allowEmpty: true, allowOpenBrace: false);
      error("This at-rule is not allowed here.", scanner.spanFrom(start));
    }
  
    /// Consumes a parameter list.
    ParameterList _parameterList() {
      var start = scanner.state;
      scanner.expectChar($lparen);
      whitespace(consumeNewlines: true);
      var parameters = <Parameter>[];
      var named = <String>{};
      String? restParameter;
      while (scanner.peekChar() == $dollar) {
        var variableStart = scanner.state;
        var name = variableName();
        whitespace(consumeNewlines: true);
  
        Expression? defaultValue;
        if (scanner.scanChar($colon)) {
          whitespace(consumeNewlines: true);
          defaultValue = expressionUntilComma();
        } else if (scanner.scanChar($dot)) {
          scanner.expectChar($dot);
          scanner.expectChar($dot);
          whitespace(consumeNewlines: true);
          if (scanner.scanChar($comma)) whitespace(consumeNewlines: true);
          restParameter = name;
          break;
        }
  
        parameters.add(
          Parameter(
            name,
            scanner.spanFrom(variableStart),
            defaultValue: defaultValue,
          ),
        );
        if (!named.add(name)) {
          error("Duplicate parameter.", parameters.last.span);
        }
  
        if (!scanner.scanChar($comma)) break;
        whitespace(consumeNewlines: true);
      }
      scanner.expectChar($rparen);
      return ParameterList(
        parameters,
        scanner.spanFrom(start),
        restParameter: restParameter,
      );
    }
  
    // ## Expressions
  
    /// Consumes an argument invocation.
    ///
    /// If [mixin] is `true`, this is parsed as a mixin invocation. Mixin
    /// invocations don't allow the Microsoft-style `=` operator at the top level,
    /// but function invocations do.
    ///
    /// If [allowEmptySecondArg] is `true`, this allows the second argument to be
    /// omitted, in which case an unquoted empty string will be passed in its
    /// place.
    ArgumentList _argumentInvocation({
      bool mixin = false,
      bool allowEmptySecondArg = false,
    }) {
      var start = scanner.state;
      scanner.expectChar($lparen);
      whitespace(consumeNewlines: true);
  
      var positional = <Expression>[];
      var named = <String, Expression>{};
      Expression? rest;
      Expression? keywordRest;
      var emittedRestDeprecation = false;
      while (_lookingAtExpression()) {
        var expression = expressionUntilComma(singleEquals: !mixin);
        whitespace(consumeNewlines: true);
  
        if (expression is VariableExpression && scanner.scanChar($colon)) {
          whitespace(consumeNewlines: true);
          if (named.containsKey(expression.name)) {
            error("Duplicate argument.", expression.span);
          }
          named[expression.name] = expressionUntilComma(singleEquals: !mixin);
  
          if (rest != null && !emittedRestDeprecation) {
            emittedRestDeprecation = true;
            warnings.add((
              deprecation: Deprecation.misplacedRest,
              message: 'Named arguments must come before rest arguments.\n'
                  'This will be an error in Dart Sass 2.0.0.',
              span: MultiSpan(
                  scanner.spanFromPosition(expression.span.start.offset),
                  'named argument',
                  {rest.span: 'rest argument'})
            ));
          }
        } else if (scanner.scanChar($dot)) {
          scanner.expectChar($dot);
          scanner.expectChar($dot);
          if (rest == null) {
            rest = expression;
          } else {
            keywordRest = expression;
            whitespace(consumeNewlines: true);
            if (scanner.scanChar($comma)) whitespace(consumeNewlines: true);
            break;
          }
        } else if (named.isNotEmpty) {
          error(
            "Positional arguments must come before keyword arguments.",
            expression.span,
          );
        } else {
          positional.add(expression);
  
          if (rest != null && !emittedRestDeprecation) {
            emittedRestDeprecation = true;
            warnings.add((
              deprecation: Deprecation.misplacedRest,
              message: 'Positional arguments must come before rest arguments.\n'
                  'This will be an error in Dart Sass 2.0.0.',
              span: MultiSpan(expression.span, 'positional argument',
                  {rest.span: 'rest argument'})
            ));
          }
        }
  
        whitespace(consumeNewlines: true);
        if (!scanner.scanChar($comma)) break;
        whitespace(consumeNewlines: true);
  
        if (allowEmptySecondArg &&
            positional.length == 1 &&
            named.isEmpty &&
            rest == null &&
            scanner.peekChar() == $rparen) {
          positional.add(StringExpression.plain('', scanner.emptySpan));
          break;
        }
      }
      scanner.expectChar($rparen);
  
      return ArgumentList(
        positional,
        named,
        scanner.spanFrom(start),
        rest: rest,
        keywordRest: keywordRest,
      );
    }
  
    /// Consumes an expression.
    ///
    /// If [bracketList] is true, parses this expression as the contents of a
    /// bracketed list.
    ///
    /// If [singleEquals] is true, allows the Microsoft-style `=` operator at the
    /// top level.
    ///
    /// If [until] is passed, it's called each time the expression could end and
    /// still be a valid expression. When it returns `true`, this returns the
    /// expression.
    ///
    /// If [consumeNewlines] is `true`, the indented syntax will consume newlines
    /// as whitespace. It should only be set to `true` in positions when a
    /// statement can't end.
    @protected
    Expression _expression({
      bool bracketList = false,
      bool singleEquals = false,
      bool consumeNewlines = false,
      bool until()?,
    }) {
      if (until != null && until()) scanner.error("Expected expression.");
  
      LineScannerState? beforeBracket;
      if (bracketList) {
        beforeBracket = scanner.state;
        scanner.expectChar($lbracket);
        whitespace(consumeNewlines: true);
  
        if (scanner.scanChar($rbracket)) {
          return ListExpression(
            [],
            ListSeparator.undecided,
            scanner.spanFrom(beforeBracket),
            brackets: true,
          );
        }
      }
  
      var start = scanner.state;
      var wasInExpression = _inExpression;
      var wasInParentheses = _inParentheses;
      _inExpression = true;
  
      // We use the convention below of referring to nullable variables that are
      // shared across anonymous functions in this method with a trailing
      // underscore. This allows us to copy them to non-underscored local
      // variables to make it easier for Dart's type system to reason about their
      // local nullability.
  
      List<Expression>? commaExpressions_;
  
      List<Expression>? spaceExpressions_;
  
      // Operators whose right-hand operands_ are not fully parsed yet, in order of
      // appearance in the document. Because a low-precedence operator will cause
      // parsing to finish for all preceding higher-precedence operators_, this is
      // naturally ordered from lowest to highest precedence.
      List<BinaryOperator>? operators_;
  
      // The left-hand sides of [operators_]. `operands_[n]` is the left-hand side
      // of `operators_[n]`.
      List<Expression>? operands_;
  
      /// Whether the single expression parsed so far may be interpreted as
      /// slash-separated numbers.
      var allowSlash = true;
  
      /// The leftmost expression that's been fully-parsed. This can be null in
      /// special cases where the expression begins with a sub-expression but has
      /// a later character that indicates that the outer expression isn't done,
      /// as here:
      ///
      ///     foo, bar
      ///         ^
      Expression? singleExpression_ = _singleExpression();
  
      // Resets the scanner state to the state it was at at the beginning of the
      // expression, except for [_inParentheses].
      void resetState() {
        commaExpressions_ = null;
        spaceExpressions_ = null;
        operators_ = null;
        operands_ = null;
        scanner.state = start;
        allowSlash = true;
        singleExpression_ = _singleExpression();
      }
  
      void resolveOneOperation() {
        var operator = operators_!.removeLast();
        var operands = operands_!;
  
        var left = operands.removeLast();
        var right = singleExpression_;
        if (right == null) {
          scanner.error(
            "Expected expression.",
            position: scanner.position - operator.operator.length,
            length: operator.operator.length,
          );
        }
  
        if (allowSlash &&
            !_inParentheses &&
            operator == BinaryOperator.dividedBy &&
            _isSlashOperand(left) &&
            _isSlashOperand(right)) {
          singleExpression_ = BinaryOperationExpression.slash(left, right);
        } else {
          singleExpression_ = BinaryOperationExpression(operator, left, right);
          allowSlash = false;
  
          if (operator case BinaryOperator.plus || BinaryOperator.minus) {
            if (scanner.string.substring(
                      right.span.start.offset - 1,
                      right.span.start.offset,
                    ) ==
                    operator.operator &&
                scanner.string.codeUnitAt(left.span.end.offset).isWhitespace) {
              warnings.add((
                deprecation: Deprecation.strictUnary,
                message: "This operation is parsed as:\n"
                    "\n"
                    "    $left ${operator.operator} $right\n"
                    "\n"
                    "but you may have intended it to mean:\n"
                    "\n"
                    "    $left (${operator.operator}$right)\n"
                    "\n"
                    "Add a space after ${operator.operator} to clarify that it's "
                    "meant to be a binary operation, or wrap\n"
                    "it in parentheses to make it a unary operation. This will be "
                    "an error in future\n"
                    "versions of Sass.\n"
                    "\n"
                    "More info and automated migrator: "
                    "https://sass-lang.com/d/strict-unary",
                span: singleExpression_!.span,
              ));
            }
          }
        }
      }
  
      void resolveOperations() {
        var operators = operators_;
        if (operators == null) return;
        while (operators.isNotEmpty) {
          resolveOneOperation();
        }
      }
  
      void addSingleExpression(Expression expression) {
        if (singleExpression_ != null) {
          // If we discover we're parsing a list whose first element is a division
          // operation, and we're in parentheses, reparse outside of a paren
          // context. This ensures that `(1/2 1)` doesn't perform division on its
          // first element.
          if (_inParentheses) {
            _inParentheses = false;
            if (allowSlash) {
              resetState();
              return;
            }
          }
  
          var spaceExpressions = spaceExpressions_ ??= [];
          resolveOperations();
  
          // [singleExpression_] was non-null before, and [resolveOperations]
          // can't make it null, it can only change it.
          spaceExpressions.add(singleExpression_!);
          allowSlash = true;
        }
  
        singleExpression_ = expression;
      }
  
      void addOperator(BinaryOperator operator) {
        if (plainCss &&
            operator != BinaryOperator.singleEquals &&
            // These are allowed in calculations, so we have to check them at
            // evaluation time.
            operator != BinaryOperator.plus &&
            operator != BinaryOperator.minus &&
            operator != BinaryOperator.times &&
            operator != BinaryOperator.dividedBy) {
          scanner.error(
            "Operators aren't allowed in plain CSS.",
            position: scanner.position - operator.operator.length,
            length: operator.operator.length,
          );
        }
  
        allowSlash = allowSlash && operator == BinaryOperator.dividedBy;
  
        var operators = operators_ ??= [];
        var operands = operands_ ??= [];
        while (operators.isNotEmpty &&
            operators.last.precedence >= operator.precedence) {
          resolveOneOperation();
        }
  
        var singleExpression = singleExpression_;
        if (singleExpression == null) {
          scanner.error(
            "Expected expression.",
            position: scanner.position - operator.operator.length,
            length: operator.operator.length,
          );
        }
        whitespace(consumeNewlines: true);
  
        if (operator == BinaryOperator.modulo && !_lookingAtExpression()) {
          addSingleExpression(StringExpression.plain(
              '%', scanner.spanFromPosition(scanner.position - 1)));
        } else {
          operators.add(operator);
          operands.add(singleExpression);
          singleExpression_ = _singleExpression();
        }
      }
  
      void resolveSpaceExpressions() {
        resolveOperations();
  
        var spaceExpressions = spaceExpressions_;
        if (spaceExpressions == null) return;
  
        var singleExpression = singleExpression_;
        if (singleExpression == null) scanner.error("Expected expression.");
  
        spaceExpressions.add(singleExpression);
        singleExpression_ = ListExpression(
          spaceExpressions,
          ListSeparator.space,
          spaceExpressions.first.span.expand(singleExpression.span),
        );
        spaceExpressions_ = null;
      }
  
      loop:
      while (true) {
        whitespace(consumeNewlines: consumeNewlines || bracketList);
        if (until != null && until()) break;
  
        switch (scanner.peekChar()) {
          case null:
            break loop;
  
          case $lparen:
            // Parenthesized numbers can't be slash-separated.
            addSingleExpression(parentheses());
  
          case $lbracket:
            addSingleExpression(_expression(bracketList: true));
  
          case $dollar:
            addSingleExpression(_variable());
  
          case $ampersand:
            addSingleExpression(_selector());
  
          case $single_quote || $double_quote:
            addSingleExpression(interpolatedString());
  
          case $hash:
            addSingleExpression(_hashExpression());
  
          case $equal:
            scanner.readChar();
            if (singleEquals && scanner.peekChar() != $equal) {
              addOperator(BinaryOperator.singleEquals);
            } else {
              scanner.expectChar($equal);
              addOperator(BinaryOperator.equals);
            }
  
          case $exclamation:
            switch (scanner.peekChar(1)) {
              case $equal:
                scanner.readChar();
                scanner.readChar();
                addOperator(BinaryOperator.notEquals);
              case null || $i || $I || int(isWhitespace: true):
                addSingleExpression(_importantExpression());
              case _:
                break loop;
            }
  
          case $langle:
            scanner.readChar();
            addOperator(
              scanner.scanChar($equal)
                  ? BinaryOperator.lessThanOrEquals
                  : BinaryOperator.lessThan,
            );
  
          case $rangle:
            scanner.readChar();
            addOperator(
              scanner.scanChar($equal)
                  ? BinaryOperator.greaterThanOrEquals
                  : BinaryOperator.greaterThan,
            );
  
          case $asterisk:
            scanner.readChar();
            addOperator(BinaryOperator.times);
  
          case $plus when singleExpression_ == null:
            addSingleExpression(_unaryOperation());
  
          case $plus:
            scanner.readChar();
            addOperator(BinaryOperator.plus);
  
          case $minus:
            if (scanner.peekChar(1) case int(isDigit: true) || $dot
                // Make sure `1-2` parses as `1 - 2`, not `1 (-2)`.
                when singleExpression_ == null ||
                    scanner.peekChar(-1).isWhitespace) {
              addSingleExpression(_number());
            } else if (_lookingAtInterpolatedIdentifier()) {
              addSingleExpression(identifierLike());
            } else if (singleExpression_ == null) {
              addSingleExpression(_unaryOperation());
            } else {
              scanner.readChar();
              addOperator(BinaryOperator.minus);
            }
  
          case $slash when singleExpression_ == null:
            addSingleExpression(_unaryOperation());
  
          case $slash:
            scanner.readChar();
            addOperator(BinaryOperator.dividedBy);
  
          case $percent:
            scanner.readChar();
            addOperator(BinaryOperator.modulo);
  
          // dart-lang/sdk#52740
          // ignore: non_constant_relational_pattern_expression
          case >= $0 && <= $9:
            addSingleExpression(_number());
  
          case $dot when scanner.peekChar(1) == $dot:
            break loop;
  
          case $dot:
            addSingleExpression(_number());
  
          case $a when !plainCss && scanIdentifier("and"):
            addOperator(BinaryOperator.and);
  
          case $o when !plainCss && scanIdentifier("or"):
            addOperator(BinaryOperator.or);
  
          // dart-lang/sdk#52740
          // ignore: non_constant_relational_pattern_expression
          case $u || $U when scanner.peekChar(1) == $plus:
            addSingleExpression(_unicodeRange());
  
          // ignore: non_constant_relational_pattern_expression
          case (>= $a && <= $z) ||
                // ignore: non_constant_relational_pattern_expression
                (>= $A && <= $Z) ||
                $_ ||
                $backslash ||
                >= 0x80:
            addSingleExpression(identifierLike());
  
          case $comma:
            // If we discover we're parsing a list whose first element is a
            // division operation, and we're in parentheses, reparse outside of a
            // paren context. This ensures that `(1/2, 1)` doesn't perform division
            // on its first element.
            if (_inParentheses) {
              _inParentheses = false;
              if (allowSlash) {
                resetState();
                break;
              }
            }
  
            var commaExpressions = commaExpressions_ ??= [];
  
            if (singleExpression_ == null) scanner.error("Expected expression.");
            resolveSpaceExpressions();
  
            // [resolveSpaceExpressions can modify [singleExpression_], but it
            // can't set it to null`.
            commaExpressions.add(singleExpression_!);
  
            scanner.readChar();
            allowSlash = true;
            singleExpression_ = null;
  
          case _:
            break loop;
        }
      }
  
      if (bracketList) scanner.expectChar($rbracket);
  
      // TODO(dart-lang/sdk#52756): Use patterns to null-check these values.
      var commaExpressions = commaExpressions_;
      var spaceExpressions = spaceExpressions_;
      if (commaExpressions != null) {
        resolveSpaceExpressions();
        _inParentheses = wasInParentheses;
        var singleExpression = singleExpression_;
        if (singleExpression != null) commaExpressions.add(singleExpression);
        _inExpression = wasInExpression;
        return ListExpression(
          commaExpressions,
          ListSeparator.comma,
          scanner.spanFrom(beforeBracket ?? start),
          brackets: bracketList,
        );
      } else if (bracketList && spaceExpressions != null) {
        resolveOperations();
        _inExpression = wasInExpression;
        return ListExpression(
          spaceExpressions..add(singleExpression_!),
          ListSeparator.space,
          scanner.spanFrom(beforeBracket!),
          brackets: true,
        );
      } else {
        resolveSpaceExpressions();
        if (bracketList) {
          singleExpression_ = ListExpression(
            [singleExpression_!],
            ListSeparator.undecided,
            scanner.spanFrom(beforeBracket!),
            brackets: true,
          );
        }
        _inExpression = wasInExpression;
        return singleExpression_!;
      }
    }
  
    /// Consumes an expression, inlcuding newlines, until it reaches a top-level
    /// comma.
    ///
    /// If [singleEquals] is true, this will allow the Microsoft-style `=`
    /// operator at the top level.
    Expression expressionUntilComma({bool singleEquals = false}) {
      return _expression(
        singleEquals: singleEquals,
        consumeNewlines: true,
        until: () => scanner.peekChar() == $comma,
      );
    }
  
    /// Whether [expression] is allowed as an operand of a `/` expression that
    /// produces a potentially slash-separated number.
    bool _isSlashOperand(Expression expression) =>
        expression is NumberExpression ||
        expression is FunctionExpression ||
        (expression is BinaryOperationExpression && expression.allowsSlash);
  
    /// Consumes an expression that doesn't contain any top-level whitespace.
    Expression _singleExpression() => switch (scanner.peekChar()) {
          // Note: when adding a new case, make sure it's reflected in
          // [_lookingAtExpression] and [_expression].
          null => scanner.error("Expected expression."),
          $lparen => parentheses(),
          $slash => _unaryOperation(),
          $dot => _number(),
          $lbracket => _expression(bracketList: true),
          $dollar => _variable(),
          $ampersand => _selector(),
          $single_quote || $double_quote => interpolatedString(),
          $hash => _hashExpression(),
          $plus => _plusExpression(),
          $minus => _minusExpression(),
          $exclamation => _importantExpression(),
          $percent => _percentExpression(),
          // dart-lang/sdk#52740
          // ignore: non_constant_relational_pattern_expression
          $u || $U when scanner.peekChar(1) == $plus => _unicodeRange(),
          // ignore: non_constant_relational_pattern_expression
          >= $0 && <= $9 => _number(),
          // ignore: non_constant_relational_pattern_expression
          (>= $a && <= $z) ||
          // ignore: non_constant_relational_pattern_expression
          (>= $A && <= $Z) ||
          $_ ||
          $backslash ||
          >= 0x80 =>
            identifierLike(),
          _ => scanner.error("Expected expression."),
        };
  
    /// Consumes a parenthesized expression.
    @protected
    Expression parentheses() {
      var wasInParentheses = _inParentheses;
      _inParentheses = true;
      try {
        var start = scanner.state;
        scanner.expectChar($lparen);
        whitespace(consumeNewlines: true);
        var inside = scanner.state;
        if (!_lookingAtExpression()) {
          scanner.expectChar($rparen);
          return ListExpression(
            [],
            ListSeparator.undecided,
            scanner.spanFrom(start),
          );
        }
  
        var first = expressionUntilComma();
        if (scanner.scanChar($colon)) {
          whitespace(consumeNewlines: true);
          return _map(first, start);
        }
  
        if (!scanner.scanChar($comma)) {
          scanner.expectChar($rparen);
          return ParenthesizedExpression(first, scanner.spanFrom(start));
        }
        whitespace(consumeNewlines: true);
  
        var expressions = [first];
        while (true) {
          if (!_lookingAtExpression()) break;
          expressions.add(expressionUntilComma());
          if (!scanner.scanChar($comma)) break;
          whitespace(consumeNewlines: true);
        }
  
        var list = ListExpression(
          expressions,
          ListSeparator.comma,
          scanner.spanFrom(inside),
        );
        scanner.expectChar($rparen);
        return ParenthesizedExpression(list, scanner.spanFrom(start));
      } finally {
        _inParentheses = wasInParentheses;
      }
    }
  
    /// Consumes a map expression.
    ///
    /// This expects to be called after the first colon in the map, with [first]
    /// as the expression before the colon and [start] the point before the
    /// opening parenthesis.
    MapExpression _map(Expression first, LineScannerState start) {
      var pairs = [(first, expressionUntilComma())];
  
      while (scanner.scanChar($comma)) {
        whitespace(consumeNewlines: true);
        if (!_lookingAtExpression()) break;
  
        var key = expressionUntilComma();
        scanner.expectChar($colon);
        whitespace(consumeNewlines: true);
        var value = expressionUntilComma();
        pairs.add((key, value));
      }
  
      scanner.expectChar($rparen);
      return MapExpression(pairs, scanner.spanFrom(start));
    }
  
    /// Consumes an expression that starts with a `#`.
    Expression _hashExpression() {
      assert(scanner.peekChar() == $hash);
      if (scanner.peekChar(1) == $lbrace) return identifierLike();
  
      var start = scanner.state;
      scanner.expectChar($hash);
  
      if (scanner.peekChar()?.isDigit ?? false) {
        return ColorExpression(_hexColorContents(start), scanner.spanFrom(start));
      }
  
      var afterHash = scanner.state;
      var identifier = interpolatedIdentifier();
      if (_isHexColor(identifier)) {
        scanner.state = afterHash;
        return ColorExpression(_hexColorContents(start), scanner.spanFrom(start));
      }
  
      var buffer = InterpolationBuffer();
      buffer.writeCharCode($hash);
      buffer.addInterpolation(identifier);
      return StringExpression(buffer.interpolation(scanner.spanFrom(start)));
    }
  
    /// Consumes the contents of a hex color, after the `#`.
    SassColor _hexColorContents(LineScannerState start) {
      var digit1 = _hexDigit();
      var digit2 = _hexDigit();
      var digit3 = _hexDigit();
  
      int red;
      int green;
      int blue;
      double? alpha;
      if (!scanner.peekChar().isHex) {
        // #abc
        red = (digit1 << 4) + digit1;
        green = (digit2 << 4) + digit2;
        blue = (digit3 << 4) + digit3;
      } else {
        var digit4 = _hexDigit();
        if (!scanner.peekChar().isHex) {
          // #abcd
          red = (digit1 << 4) + digit1;
          green = (digit2 << 4) + digit2;
          blue = (digit3 << 4) + digit3;
          alpha = ((digit4 << 4) + digit4) / 0xff;
        } else {
          red = (digit1 << 4) + digit2;
          green = (digit3 << 4) + digit4;
          blue = (_hexDigit() << 4) + _hexDigit();
  
          if (scanner.peekChar().isHex) {
            alpha = ((_hexDigit() << 4) + _hexDigit()) / 0xff;
          }
        }
      }
  
      return SassColor.rgbInternal(
        red,
        green,
        blue,
        alpha ?? 1,
        // Don't emit four- or eight-digit hex colors as hex, since that's not
        // yet well-supported in browsers.
        alpha == null ? SpanColorFormat(scanner.spanFrom(start)) : null,
      );
    }
  
    /// Returns whether [interpolation] is a plain string that can be parsed as a
    /// hex color.
    bool _isHexColor(Interpolation interpolation) {
      var plain = interpolation.asPlain;
      if (plain case String(length: 3 || 4 || 6 || 8)) {
        return plain.codeUnits.every((char) => char.isHex);
      } else {
        return false;
      }
    }
  
    // Consumes a single hexadecimal digit.
    int _hexDigit() => (scanner.peekChar()?.isHex ?? false)
        ? asHex(scanner.readChar())
        : scanner.error("Expected hex digit.");
  
    /// Consumes an expression that starts with a `+`.
    Expression _plusExpression() {
      assert(scanner.peekChar() == $plus);
      var next = scanner.peekChar(1);
      return next.isDigit || next == $dot ? _number() : _unaryOperation();
    }
  
    /// Consumes an expression that starts with a `-`.
    Expression _minusExpression() {
      assert(scanner.peekChar() == $minus);
      if (scanner.peekChar(1) case int(isDigit: true) || $dot) return _number();
      if (_lookingAtInterpolatedIdentifier()) return identifierLike();
      return _unaryOperation();
    }
  
    /// Consumes an `!important` expression.
    Expression _importantExpression() {
      assert(scanner.peekChar() == $exclamation);
  
      var start = scanner.state;
      scanner.readChar();
      whitespace(consumeNewlines: true);
      expectIdentifier("important");
      return StringExpression.plain("!important", scanner.spanFrom(start));
    }
  
    /// Consumes a `%` expression.
    Expression _percentExpression() {
      assert(scanner.peekChar() == $percent);
  
      var start = scanner.state;
      scanner.readChar();
      return StringExpression.plain("%", scanner.spanFrom(start));
    }
  
    /// Consumes a unary operation expression.
    UnaryOperationExpression _unaryOperation() {
      var start = scanner.state;
      var operator = _unaryOperatorFor(scanner.readChar());
      if (operator == null) {
        scanner.error("Expected unary operator.", position: scanner.position - 1);
      } else if (plainCss && operator != UnaryOperator.divide) {
        scanner.error(
          "Operators aren't allowed in plain CSS.",
          position: scanner.position - 1,
          length: 1,
        );
      }
  
      whitespace(consumeNewlines: true);
      var operand = _singleExpression();
      return UnaryOperationExpression(operator, operand, scanner.spanFrom(start));
    }
  
    /// Returns the unary operator corresponding to [character], or `null` if
    /// the character is not a unary operator.
    UnaryOperator? _unaryOperatorFor(int character) => switch (character) {
          $plus => UnaryOperator.plus,
          $minus => UnaryOperator.minus,
          $slash => UnaryOperator.divide,
          _ => null,
        };
  
    /// Consumes a number expression.
    NumberExpression _number() {
      var start = scanner.state;
      var first = scanner.peekChar();
      if (first == $plus || first == $minus) scanner.readChar();
  
      if (scanner.peekChar() != $dot) _consumeNaturalNumber();
  
      // Don't complain about a dot after a number unless the number starts with a
      // dot. We don't allow a plain ".", but we need to allow "1." so that
      // "1..." will work as a rest argument.
      _tryDecimal(
        allowTrailingDot: scanner.position != start.position &&
            first != $plus &&
            first != $minus,
      );
      _tryExponent();
  
      // Use Dart's built-in double parsing so that we don't accumulate
      // floating-point errors for numbers with lots of digits.
      var number = double.parse(scanner.substring(start.position));
  
      String? unit;
      if (scanner.scanChar($percent)) {
        unit = "%";
      } else if (lookingAtIdentifier() &&
          // Disallow units beginning with `--`.
          (scanner.peekChar() != $dash || scanner.peekChar(1) != $dash)) {
        unit = identifier(unit: true);
      }
  
      return NumberExpression(number, scanner.spanFrom(start), unit: unit);
    }
  
    /// Consumes a natural number (that is, a non-negative integer).
    ///
    /// Doesn't support scientific notation.
    void _consumeNaturalNumber() {
      if (!scanner.readChar().isDigit) {
        scanner.error("Expected digit.", position: scanner.position - 1);
      }
  
      while (scanner.peekChar().isDigit) {
        scanner.readChar();
      }
    }
  
    /// Consumes the decimal component of a number if it exists.
    ///
    /// If [allowTrailingDot] is `false`, this will throw an error if there's a
    /// dot without any numbers following it. Otherwise, it will ignore the dot
    /// without consuming it.
    void _tryDecimal({bool allowTrailingDot = false}) {
      if (scanner.peekChar() != $dot) return;
  
      if (!scanner.peekChar(1).isDigit) {
        if (allowTrailingDot) return;
        scanner.error("Expected digit.", position: scanner.position + 1);
      }
  
      scanner.readChar();
      while (scanner.peekChar().isDigit) {
        scanner.readChar();
      }
    }
  
    /// Consumes the exponent component of a number if it exists.
    void _tryExponent() {
      var first = scanner.peekChar();
      if (first != $e && first != $E) return;
  
      var next = scanner.peekChar(1);
      if (!next.isDigit && next != $minus && next != $plus) return;
  
      scanner.readChar();
      if (next case $plus || $minus) scanner.readChar();
      if (!scanner.peekChar().isDigit) scanner.error("Expected digit.");
  
      while (scanner.peekChar().isDigit) {
        scanner.readChar();
      }
    }
  
    /// Consumes a unicode range expression.
    StringExpression _unicodeRange() {
      var start = scanner.state;
      expectIdentChar($u);
      scanner.expectChar($plus);
  
      var firstRangeLength = 0;
      while (scanCharIf((char) => char != null && char.isHex)) {
        firstRangeLength++;
      }
  
      var hasQuestionMark = false;
      while (scanner.scanChar($question)) {
        hasQuestionMark = true;
        firstRangeLength++;
      }
  
      if (firstRangeLength == 0) {
        scanner.error('Expected hex digit or "?".');
      } else if (firstRangeLength > 6) {
        error("Expected at most 6 digits.", scanner.spanFrom(start));
      } else if (hasQuestionMark) {
        return StringExpression.plain(
          scanner.substring(start.position),
          scanner.spanFrom(start),
        );
      }
  
      if (scanner.scanChar($minus)) {
        var secondRangeStart = scanner.state;
        var secondRangeLength = 0;
        while (scanCharIf((char) => char != null && char.isHex)) {
          secondRangeLength++;
        }
  
        if (secondRangeLength == 0) {
          scanner.error("Expected hex digit.");
        } else if (secondRangeLength > 6) {
          error("Expected at most 6 digits.", scanner.spanFrom(secondRangeStart));
        }
      }
  
      if (_lookingAtInterpolatedIdentifierBody()) {
        scanner.error("Expected end of identifier.");
      }
  
      return StringExpression.plain(
        scanner.substring(start.position),
        scanner.spanFrom(start),
      );
    }
  
    /// Consumes a variable expression.
    VariableExpression _variable() {
      var start = scanner.state;
      var name = variableName();
  
      if (plainCss) {
        error(
          "Sass variables aren't allowed in plain CSS.",
          scanner.spanFrom(start),
        );
      }
  
      return VariableExpression(name, scanner.spanFrom(start));
    }
  
    /// Consumes a selector expression.
    SelectorExpression _selector() {
      if (plainCss) {
        scanner.error(
          "The parent selector isn't allowed in plain CSS.",
          length: 1,
        );
      }
  
      var start = scanner.state;
      scanner.expectChar($ampersand);
  
      if (scanner.scanChar($ampersand)) {
        warnings.add((
          deprecation: null,
          message: 'In Sass, "&&" means two copies of the parent selector. You '
              'probably want to use "and" instead.',
          span: scanner.spanFrom(start),
        ));
        scanner.position--;
      }
  
      return SelectorExpression(scanner.spanFrom(start));
    }
  
    /// Consumes a quoted string expression.
    StringExpression interpolatedString() {
      // NOTE: this logic is largely duplicated in Parser.string. Most changes
      // here should be mirrored there.
  
      var start = scanner.state;
      var quote = scanner.readChar();
  
      if (quote != $single_quote && quote != $double_quote) {
        scanner.error("Expected string.", position: start.position);
      }
  
      var buffer = InterpolationBuffer();
      loop:
      while (true) {
        switch (scanner.peekChar()) {
          case var next when next == quote:
            scanner.readChar();
            break loop;
          case null || int(isNewline: true):
            scanner.error("Expected ${String.fromCharCode(quote)}.");
          case $backslash:
            var second = scanner.peekChar(1);
            if (second.isNewline) {
              scanner.readChar();
              scanner.readChar();
              if (second == $cr) scanner.scanChar($lf);
            } else {
              buffer.writeCharCode(escapeCharacter());
            }
          case $hash when scanner.peekChar(1) == $lbrace:
            var (expression, span) = singleInterpolation();
            buffer.add(expression, span);
          case _:
            buffer.writeCharCode(scanner.readChar());
        }
      }
  
      return StringExpression(
        buffer.interpolation(scanner.spanFrom(start)),
        quotes: true,
      );
    }
  
    /// Consumes an expression that starts like an identifier.
    @protected
    Expression identifierLike() {
      var start = scanner.state;
      var identifier = interpolatedIdentifier();
      var plain = identifier.asPlain;
      late String? lower;
      if (plain != null) {
        if (plain == "if" && scanner.peekChar() == $lparen) {
          var invocation = _argumentInvocation();
          return IfExpression(
            invocation,
            identifier.span.expand(invocation.span),
          );
        } else if (plain == "not") {
          whitespace(consumeNewlines: true);
          var expression = _singleExpression();
          return UnaryOperationExpression(
            UnaryOperator.not,
            expression,
            identifier.span.expand(expression.span),
          );
        }
  
        lower = plain.toLowerCase();
        if (scanner.peekChar() != $lparen) {
          switch (plain) {
            case "false":
              return BooleanExpression(false, identifier.span);
            case "null":
              return NullExpression(identifier.span);
            case "true":
              return BooleanExpression(true, identifier.span);
          }
  
          if (colorsByName[lower] case var color?) {
            color = SassColor.rgbInternal(
              color.red,
              color.green,
              color.blue,
              color.alpha,
              SpanColorFormat(identifier.span),
            );
            return ColorExpression(color, identifier.span);
          }
        }
  
        if (trySpecialFunction(lower, start) case var specialFunction?) {
          return specialFunction;
        }
      }
  
      switch (scanner.peekChar()) {
        case $dot when scanner.peekChar(1) == $dot:
          return StringExpression(identifier);
  
        case $dot:
          scanner.readChar();
          // TODO(dart-lang/sdk#52757): Make this a separate case.
          if (plain != null) return namespacedExpression(plain, start);
          error("Interpolation isn't allowed in namespaces.", identifier.span);
  
        case $lparen when plain != null:
          return FunctionExpression(
            plain,
            _argumentInvocation(allowEmptySecondArg: lower == 'var'),
            scanner.spanFrom(start),
          );
  
        case $lparen:
          return InterpolatedFunctionExpression(
            identifier,
            _argumentInvocation(),
            scanner.spanFrom(start),
          );
  
        case _:
          return StringExpression(identifier);
      }
    }
  
    /// Consumes an expression after a namespace.
    ///
    /// This assumes the scanner is positioned immediately after the `.`. The
    /// [start] should refer to the state at the beginning of the namespace.
    @protected
    Expression namespacedExpression(String namespace, LineScannerState start) {
      if (scanner.peekChar() == $dollar) {
        var name = variableName();
        _assertPublic(name, () => scanner.spanFrom(start));
        return VariableExpression(
          name,
          scanner.spanFrom(start),
          namespace: namespace,
        );
      }
  
      return FunctionExpression(
        _publicIdentifier(),
        _argumentInvocation(),
        scanner.spanFrom(start),
        namespace: namespace,
      );
    }
  
    /// If [name] is the name of a function with special syntax, consumes it.
    ///
    /// Otherwise, returns `null`. [start] is the location before the beginning of
    /// [name].
    @protected
    Expression? trySpecialFunction(String name, LineScannerState start) {
      InterpolationBuffer buffer;
      if (name == "type" && scanner.scanChar($lparen)) {
        buffer = InterpolationBuffer()
          ..write(name)
          ..writeCharCode($lparen);
      } else {
        var normalized = unvendor(name);
        switch (normalized) {
          case "calc" when normalized != name && scanner.scanChar($lparen):
          case "element" || "expression" when scanner.scanChar($lparen):
            buffer = InterpolationBuffer()
              ..write(name)
              ..writeCharCode($lparen);
  
          case "progid" when scanner.scanChar($colon):
            buffer = InterpolationBuffer()
              ..write(name)
              ..writeCharCode($colon);
            var next = scanner.peekChar();
            while (next != null && (next.isAlphabetic || next == $dot)) {
              buffer.writeCharCode(scanner.readChar());
              next = scanner.peekChar();
            }
            scanner.expectChar($lparen);
            buffer.writeCharCode($lparen);
  
          case "url":
            return _tryUrlContents(
              start,
            ).andThen((contents) => StringExpression(contents));
  
          case _:
            return null;
        }
      }
  
      buffer.addInterpolation(_interpolatedDeclarationValue(allowEmpty: true));
      scanner.expectChar($rparen);
      buffer.writeCharCode($rparen);
  
      return StringExpression(buffer.interpolation(scanner.spanFrom(start)));
    }
  
    /// Like [_urlContents], but returns `null` if the URL fails to parse.
    ///
    /// [start] is the position before the beginning of the name. [name] is the
    /// function's name; it defaults to `"url"`.
    Interpolation? _tryUrlContents(LineScannerState start, {String? name}) {
      // NOTE: this logic is largely duplicated in Parser.tryUrl. Most changes
      // here should be mirrored there.
  
      var beginningOfContents = scanner.state;
      if (!scanner.scanChar($lparen)) return null;
      whitespaceWithoutComments(consumeNewlines: true);
  
      // Match Ruby Sass's behavior: parse a raw URL() if possible, and if not
      // backtrack and re-parse as a function expression.
      var buffer = InterpolationBuffer()
        ..write(name ?? 'url')
        ..writeCharCode($lparen);
      loop:
      while (true) {
        switch (scanner.peekChar()) {
          case null:
            break loop;
          case $backslash:
            buffer.write(escape());
          case $hash when scanner.peekChar(1) == $lbrace:
            var (expression, span) = singleInterpolation();
            buffer.add(expression, span);
          case $exclamation ||
                $percent ||
                $ampersand ||
                $hash ||
                // dart-lang/sdk#52740
                // ignore: non_constant_relational_pattern_expression
                (>= $asterisk && <= $tilde) ||
                >= 0x80:
            buffer.writeCharCode(scanner.readChar());
          case int(isWhitespace: true):
            whitespaceWithoutComments(consumeNewlines: true);
            if (scanner.peekChar() != $rparen) break loop;
          case $rparen:
            buffer.writeCharCode(scanner.readChar());
            return buffer.interpolation(scanner.spanFrom(start));
          case _:
            break loop;
        }
      }
  
      scanner.state = beginningOfContents;
      return null;
    }
  
    /// Consumes a [url] token that's allowed to contain SassScript.
    @protected
    Expression dynamicUrl() {
      var start = scanner.state;
      expectIdentifier("url");
      if (_tryUrlContents(start) case var contents?) {
        return StringExpression(contents);
      }
  
      return InterpolatedFunctionExpression(
        Interpolation.plain("url", scanner.spanFrom(start)),
        _argumentInvocation(),
        scanner.spanFrom(start),
      );
    }
  
    /// Consumes tokens up to "{", "}", ";", or "!".
    ///
    /// This respects string and comment boundaries and supports interpolation.
    /// Once this interpolation is evaluated, it's expected to be re-parsed.
    ///
    /// If [omitComments] is true, comments will still be consumed, but they will
    /// not be included in the returned interpolation.
    ///
    /// Differences from [_interpolatedDeclarationValue] include:
    ///
    /// * This always stops at curly braces.
    ///
    /// * This does not interpret backslashes, since the text is expected to be
    ///   re-parsed.
    ///
    /// * This does not compress adjacent whitespace characters.
    @protected
    Interpolation almostAnyValue({bool omitComments = false}) {
      var start = scanner.state;
      var buffer = InterpolationBuffer();
  
      var brackets = <int>[];
  
      loop:
      while (true) {
        switch (scanner.peekChar()) {
          case $backslash:
            // Write a literal backslash because this text will be re-parsed.
            buffer.writeCharCode(scanner.readChar());
            buffer.writeCharCode(scanner.readChar());
  
          case $double_quote || $single_quote:
            buffer.addInterpolation(interpolatedString().asInterpolation());
  
          case $slash:
            switch (scanner.peekChar(1)) {
              case $asterisk when !omitComments:
                buffer.write(rawText(loudComment));
  
              case $asterisk:
                loudComment();
  
              case $slash when !omitComments:
                buffer.write(rawText(silentComment));
  
              case $slash:
                silentComment();
  
              case _:
                buffer.writeCharCode(scanner.readChar());
            }
  
          case $hash when scanner.peekChar(1) == $lbrace:
            // Add a full interpolated identifier to handle cases like
            // "#{...}--1", since "--1" isn't a valid identifier on its own.
            buffer.addInterpolation(interpolatedIdentifier());
  
          case $cr || $lf || $ff:
            if (indented && brackets.isEmpty) break loop;
            buffer.writeCharCode(scanner.readChar());
  
          case $exclamation || $semicolon || $lbrace || $rbrace:
            break loop;
  
          case $u || $U:
            var beforeUrl = scanner.state;
            var identifier = this.identifier();
            if (identifier != "url" &&
                // This isn't actually a standard CSS feature, but it was
                // supported by the old `@document` rule so we continue to support
                // it for backwards-compatibility.
                identifier != "url-prefix") {
              buffer.write(identifier);
              continue loop;
            }
  
            if (_tryUrlContents(beforeUrl, name: identifier) case var contents?) {
              buffer.addInterpolation(contents);
            } else {
              scanner.state = beforeUrl;
              buffer.writeCharCode(scanner.readChar());
            }
  
          case $lparen || $lbracket:
            var bracket = scanner.readChar();
            buffer.writeCharCode(bracket);
            brackets.add(opposite(bracket));
  
          case ($rparen || $rbracket) && var char?:
            if (brackets.isEmpty) {
              scanner.error('Unexpected "${String.fromCharCode(char)}".');
            }
            var bracket = brackets.removeLast();
            scanner.expectChar(bracket);
            buffer.writeCharCode(bracket);
  
          case null:
            break loop;
  
          case _ when lookingAtIdentifier():
            buffer.write(identifier());
  
          case _:
            buffer.writeCharCode(scanner.readChar());
        }
      }
  
      return buffer.interpolation(scanner.spanFrom(start));
    }
  
    /// Consumes tokens until it reaches a top-level `";"`, `")"`, `"]"`,
    /// or `"}"` and returns their contents as a string.
    ///
    /// If [allowEmpty] is `false` (the default), this requires at least one token.
    ///
    /// If [allowSemicolon] is `true`, this doesn't stop at semicolons and instead
    /// includes them in the interpolated output.
    ///
    /// If [allowColon] is `false`, this stops at top-level colons.
    ///
    /// If [allowOpenBrace] is `false`, this stops at opening curly braces.
    ///
    /// If [silentComments] is `true`, this will parse silent comments as
    /// comments. Otherwise, it will preserve two adjacent slashes and emit them
    /// to CSS.
    ///
    /// If [consumeNewlines] is `true`, the indented syntax will consume newlines
    /// as whitespace. It should only be set to `true` in positions when a
    /// statement can't end.
    ///
    /// Unlike [declarationValue], this allows interpolation.
    Interpolation _interpolatedDeclarationValue({
      bool allowEmpty = false,
      bool allowSemicolon = false,
      bool allowColon = true,
      bool allowOpenBrace = true,
      bool silentComments = true,
      bool consumeNewlines = false,
    }) {
      // NOTE: this logic is largely duplicated in Parser.declarationValue. Most
      // changes here should be mirrored there.
  
      var start = scanner.state;
      var buffer = InterpolationBuffer();
  
      var brackets = <int>[];
      var wroteNewline = false;
      loop:
      while (true) {
        switch (scanner.peekChar()) {
          case $backslash:
            buffer.write(escape(identifierStart: true));
            wroteNewline = false;
  
          case $double_quote || $single_quote:
            buffer.addInterpolation(interpolatedString().asInterpolation());
            wroteNewline = false;
  
          case $slash:
            switch (scanner.peekChar(1)) {
              case $asterisk:
                buffer.write(rawText(loudComment));
                wroteNewline = false;
  
              case $slash when silentComments:
                silentComment();
                wroteNewline = false;
  
              case _:
                buffer.writeCharCode(scanner.readChar());
                wroteNewline = false;
            }
  
          // Add a full interpolated identifier to handle cases like "#{...}--1",
          // since "--1" isn't a valid identifier on its own.
          case $hash when scanner.peekChar(1) == $lbrace:
            buffer.addInterpolation(interpolatedIdentifier());
            wroteNewline = false;
  
          case $space || $tab
              when !wroteNewline && scanner.peekChar(1).isWhitespace:
            // Collapse whitespace into a single character unless it's following a
            // newline, in which case we assume it's indentation.
            scanner.readChar();
  
          case $space || $tab:
            buffer.writeCharCode(scanner.readChar());
  
          case $lf || $cr || $ff
              when indented && !consumeNewlines && brackets.isEmpty:
            break loop;
  
          case $lf || $cr || $ff:
            // Collapse multiple newlines into one.
            if (!scanner.peekChar(-1).isNewline) buffer.writeln();
            scanner.readChar();
            wroteNewline = true;
  
          case $lbrace when !allowOpenBrace:
            break loop;
  
          case $lparen || $lbrace || $lbracket:
            var bracket = scanner.readChar();
            buffer.writeCharCode(bracket);
            brackets.add(opposite(bracket));
            wroteNewline = false;
  
          case $rparen || $rbrace || $rbracket:
            if (brackets.isEmpty) break loop;
            var bracket = brackets.removeLast();
            scanner.expectChar(bracket);
            buffer.writeCharCode(bracket);
            wroteNewline = false;
  
          case $semicolon:
            if (!allowSemicolon && brackets.isEmpty) break loop;
            buffer.writeCharCode(scanner.readChar());
            wroteNewline = false;
  
          case $colon:
            if (!allowColon && brackets.isEmpty) break loop;
            buffer.writeCharCode(scanner.readChar());
            wroteNewline = false;
  
          case $u || $U:
            var beforeUrl = scanner.state;
            var identifier = this.identifier();
            if (identifier != "url" &&
                // This isn't actually a standard CSS feature, but it was
                // supported by the old `@document` rule so we continue to support
                // it for backwards-compatibility.
                identifier != "url-prefix") {
              buffer.write(identifier);
              wroteNewline = false;
              continue loop;
            }
  
            if (_tryUrlContents(beforeUrl, name: identifier) case var contents?) {
              buffer.addInterpolation(contents);
            } else {
              scanner.state = beforeUrl;
              buffer.writeCharCode(scanner.readChar());
            }
            wroteNewline = false;
  
          case null:
            break loop;
  
          case _ when lookingAtIdentifier():
            buffer.write(identifier());
            wroteNewline = false;
  
          case _:
            buffer.writeCharCode(scanner.readChar());
            wroteNewline = false;
        }
      }
  
      if (brackets.isNotEmpty) scanner.expectChar(brackets.last);
      if (!allowEmpty && buffer.isEmpty) scanner.error("Expected token.");
      return buffer.interpolation(scanner.spanFrom(start));
    }
  
    /// Consumes an identifier that may contain interpolation.
    @protected
    Interpolation interpolatedIdentifier() {
      var start = scanner.state;
      var buffer = InterpolationBuffer();
  
      if (scanner.scanChar($dash)) {
        buffer.writeCharCode($dash);
  
        if (scanner.scanChar($dash)) {
          buffer.writeCharCode($dash);
          _interpolatedIdentifierBody(buffer);
          return buffer.interpolation(scanner.spanFrom(start));
        }
      }
  
      switch (scanner.peekChar()) {
        case null:
          scanner.error("Expected identifier.");
        case int(isNameStart: true):
          buffer.writeCharCode(scanner.readChar());
        case $backslash:
          buffer.write(escape(identifierStart: true));
        case $hash when scanner.peekChar(1) == $lbrace:
          var (expression, span) = singleInterpolation();
          buffer.add(expression, span);
        case _:
          scanner.error("Expected identifier.");
      }
  
      _interpolatedIdentifierBody(buffer);
      return buffer.interpolation(scanner.spanFrom(start));
    }
  
    /// Consumes a chunk of a possibly-interpolated CSS identifier after the name
    /// start, and adds the contents to the [buffer] buffer.
    void _interpolatedIdentifierBody(InterpolationBuffer buffer) {
      loop:
      while (true) {
        switch (scanner.peekChar()) {
          case null:
            break loop;
          case $underscore || $dash || int(isAlphanumeric: true) || >= 0x80:
            buffer.writeCharCode(scanner.readChar());
          case $backslash:
            buffer.write(escape());
          case $hash when scanner.peekChar(1) == $lbrace:
            var (expression, span) = singleInterpolation();
            buffer.add(expression, span);
          case _:
            break loop;
        }
      }
    }
  
    /// Consumes interpolation and returns it along with the span covering the
    /// `#{}`.
    @protected
    (Expression, FileSpan span) singleInterpolation() {
      var start = scanner.state;
      scanner.expect('#{');
      whitespace(consumeNewlines: true);
      var contents = _expression(consumeNewlines: true);
      scanner.expectChar($rbrace);
      var span = scanner.spanFrom(start);
  
      if (plainCss) {
        error("Interpolation isn't allowed in plain CSS.", span);
      }
  
      return (contents, span);
    }
  
    // ## Media Queries
  
    /// Consumes a list of media queries.
    Interpolation _mediaQueryList() {
      var start = scanner.state;
      var buffer = InterpolationBuffer();
      while (true) {
        whitespace(consumeNewlines: false);
        _mediaQuery(buffer);
        whitespace(consumeNewlines: false);
        if (!scanner.scanChar($comma)) break;
        buffer.writeCharCode($comma);
        buffer.writeCharCode($space);
      }
      return buffer.interpolation(scanner.spanFrom(start));
    }
  
    /// Consumes a single media query.
    void _mediaQuery(InterpolationBuffer buffer) {
      // This is somewhat duplicated in MediaQueryParser._mediaQuery.
      if (scanner.peekChar() == $lparen) {
        _mediaInParens(buffer);
        whitespace(consumeNewlines: false);
        if (scanIdentifier("and")) {
          buffer.write(" and ");
          expectWhitespace();
          _mediaLogicSequence(buffer, "and");
        } else if (scanIdentifier("or")) {
          buffer.write(" or ");
          expectWhitespace();
          _mediaLogicSequence(buffer, "or");
        }
  
        return;
      }
  
      var identifier1 = interpolatedIdentifier();
      if (equalsIgnoreCase(identifier1.asPlain, "not")) {
        // For example, "@media not (...) {"
        expectWhitespace();
  
        if (!_lookingAtInterpolatedIdentifier()) {
          buffer.write("not ");
          _mediaOrInterp(buffer);
          return;
        }
      }
  
      whitespace(consumeNewlines: false);
      buffer.addInterpolation(identifier1);
      if (!_lookingAtInterpolatedIdentifier()) {
        // For example, "@media screen {".
        return;
      }
  
      buffer.writeCharCode($space);
      var identifier2 = interpolatedIdentifier();
  
      if (equalsIgnoreCase(identifier2.asPlain, "and")) {
        expectWhitespace();
        // For example, "@media screen and ..."
        buffer.write(" and ");
      } else {
        whitespace(consumeNewlines: false);
        buffer.addInterpolation(identifier2);
        if (scanIdentifier("and")) {
          // For example, "@media only screen and ..."
          expectWhitespace();
          buffer.write(" and ");
        } else {
          // For example, "@media only screen {"
          return;
        }
      }
  
      // We've consumed either `IDENTIFIER "and"` or
      // `IDENTIFIER IDENTIFIER "and"`.
  
      if (scanIdentifier("not")) {
        // For example, "@media screen and not (...) {"
        expectWhitespace();
        buffer.write("not ");
        _mediaOrInterp(buffer);
        return;
      }
  
      _mediaLogicSequence(buffer, "and");
      return;
    }
  
    /// Consumes one or more `MediaOrInterp` expressions separated by [operator]
    /// and writes them to [buffer].
    void _mediaLogicSequence(InterpolationBuffer buffer, String operator) {
      while (true) {
        _mediaOrInterp(buffer);
        whitespace(consumeNewlines: false);
  
        if (!scanIdentifier(operator)) return;
        expectWhitespace(consumeNewlines: false);
  
        buffer.writeCharCode($space);
        buffer.write(operator);
        buffer.writeCharCode($space);
      }
    }
  
    /// Consumes a `MediaOrInterp` expression and writes it to [buffer].
    void _mediaOrInterp(InterpolationBuffer buffer) {
      if (scanner.peekChar() == $hash) {
        var (expression, span) = singleInterpolation();
        buffer.add(expression, span);
      } else {
        _mediaInParens(buffer);
      }
    }
  
    /// Consumes a `MediaInParens` expression and writes it to [buffer].
    void _mediaInParens(InterpolationBuffer buffer) {
      scanner.expectChar($lparen, name: "media condition in parentheses");
      buffer.writeCharCode($lparen);
      whitespace(consumeNewlines: true);
  
      if (scanner.peekChar() == $lparen) {
        _mediaInParens(buffer);
        whitespace(consumeNewlines: true);
        if (scanIdentifier("and")) {
          buffer.write(" and ");
          expectWhitespace(consumeNewlines: true);
          _mediaLogicSequence(buffer, "and");
        } else if (scanIdentifier("or")) {
          buffer.write(" or ");
          expectWhitespace(consumeNewlines: true);
          _mediaLogicSequence(buffer, "or");
        }
      } else if (scanIdentifier("not")) {
        buffer.write("not ");
        expectWhitespace(consumeNewlines: true);
        _mediaOrInterp(buffer);
      } else {
        var expressionBefore = _expressionUntilComparison();
        buffer.add(expressionBefore, expressionBefore.span);
        if (scanner.scanChar($colon)) {
          whitespace(consumeNewlines: true);
          buffer.writeCharCode($colon);
          buffer.writeCharCode($space);
          var expressionAfter = _expression(consumeNewlines: true);
          buffer.add(expressionAfter, expressionAfter.span);
        } else {
          var next = scanner.peekChar();
          if (next case $langle || $rangle || $equal) {
            buffer.writeCharCode($space);
            buffer.writeCharCode(scanner.readChar());
            if (next case $langle || $rangle when scanner.scanChar($equal)) {
              buffer.writeCharCode($equal);
            }
            buffer.writeCharCode($space);
  
            whitespace(consumeNewlines: true);
            var expressionMiddle = _expressionUntilComparison();
            buffer.add(expressionMiddle, expressionMiddle.span);
  
            // dart-lang/sdk#45356
            if (next case $langle || $rangle when scanner.scanChar(next!)) {
              buffer.writeCharCode($space);
              buffer.writeCharCode(next);
              if (scanner.scanChar($equal)) buffer.writeCharCode($equal);
              buffer.writeCharCode($space);
  
              whitespace(consumeNewlines: true);
              var expressionAfter = _expressionUntilComparison();
              buffer.add(expressionAfter, expressionAfter.span);
            }
          }
        }
      }
  
      scanner.expectChar($rparen);
      whitespace(consumeNewlines: false);
      buffer.writeCharCode($rparen);
    }
  
    /// Consumes an expression until it reaches a top-level `<`, `>`, or a `=`
    /// that's not `==`.
    Expression _expressionUntilComparison() => _expression(
          consumeNewlines: true,
          until: () => switch (scanner.peekChar()) {
            $equal => scanner.peekChar(1) != $equal,
            $langle || $rangle => true,
            _ => false,
          },
        );
  
    // ## Supports Conditions
  
    /// Consumes a `@supports` condition.
    ///
    /// If [inParentheses] is true, the indented syntax will consume newlines
    /// where a statement otherwise would end.
    SupportsCondition _supportsCondition({bool inParentheses = false}) {
      var start = scanner.state;
      if (scanIdentifier("not")) {
        whitespace(consumeNewlines: inParentheses);
        return SupportsNegation(
          _supportsConditionInParens(),
          scanner.spanFrom(start),
        );
      }
  
      var condition = _supportsConditionInParens();
      whitespace(consumeNewlines: inParentheses);
      String? operator;
      while (lookingAtIdentifier()) {
        if (operator != null) {
          expectIdentifier(operator);
        } else if (scanIdentifier("or")) {
          operator = "or";
        } else {
          expectIdentifier("and");
          operator = "and";
        }
  
        whitespace(consumeNewlines: inParentheses);
        var right = _supportsConditionInParens();
        condition = SupportsOperation(
          condition,
          right,
          operator,
          scanner.spanFrom(start),
        );
        whitespace(consumeNewlines: inParentheses);
      }
      return condition;
    }
  
    /// Consumes a parenthesized supports condition, or an interpolation.
    SupportsCondition _supportsConditionInParens() {
      var start = scanner.state;
  
      if (_lookingAtInterpolatedIdentifier()) {
        var identifier = interpolatedIdentifier();
        if (identifier.asPlain?.toLowerCase() == "not") {
          error('"not" is not a valid identifier here.', identifier.span);
        }
  
        if (scanner.scanChar($lparen)) {
          var arguments = _interpolatedDeclarationValue(
            allowEmpty: true,
            allowSemicolon: true,
            consumeNewlines: true,
          );
          scanner.expectChar($rparen);
          return SupportsFunction(identifier, arguments, scanner.spanFrom(start));
        } else if (identifier.contents case [Expression expression]) {
          return SupportsInterpolation(expression, scanner.spanFrom(start));
        } else {
          error("Expected @supports condition.", identifier.span);
        }
      }
  
      scanner.expectChar($lparen);
      whitespace(consumeNewlines: true);
      if (scanIdentifier("not")) {
        whitespace(consumeNewlines: true);
        var condition = _supportsConditionInParens();
        scanner.expectChar($rparen);
        return SupportsNegation(condition, scanner.spanFrom(start));
      } else if (scanner.peekChar() == $lparen) {
        var condition = _supportsCondition(inParentheses: true);
        scanner.expectChar($rparen);
        return condition.withSpan(scanner.spanFrom(start));
      }
  
      // Unfortunately, we may have to backtrack here. The grammar is:
      //
      //       Expression ":" Expression
      //     | InterpolatedIdentifier InterpolatedAnyValue?
      //
      // These aren't ambiguous because this `InterpolatedAnyValue` is forbidden
      // from containing a top-level colon, but we still have to parse the full
      // expression to figure out if there's a colon after it.
      //
      // We could avoid the overhead of a full expression parse by looking ahead
      // for a colon (outside of balanced brackets), but in practice we expect the
      // vast majority of real uses to be `Expression ":" Expression`, so it makes
      // sense to parse that case faster in exchange for less code complexity and
      // a slower backtracking case.
      Expression name;
      var nameStart = scanner.state;
      var wasInParentheses = _inParentheses;
      try {
        name = _expression(consumeNewlines: true);
        scanner.expectChar($colon);
      } on FormatException catch (_) {
        scanner.state = nameStart;
        _inParentheses = wasInParentheses;
  
        var identifier = interpolatedIdentifier();
        if (_trySupportsOperation(identifier, nameStart) case var operation?) {
          scanner.expectChar($rparen);
          return operation.withSpan(scanner.spanFrom(start));
        }
  
        // If parsing an expression fails, try to parse an
        // `InterpolatedAnyValue` instead. But if that value runs into a
        // top-level colon, then this is probably intended to be a declaration
        // after all, so we rethrow the declaration-parsing error.
        var contents = (InterpolationBuffer()
              ..addInterpolation(identifier)
              ..addInterpolation(
                _interpolatedDeclarationValue(
                  allowEmpty: true,
                  allowSemicolon: true,
                  allowColon: false,
                  consumeNewlines: true,
                ),
              ))
            .interpolation(scanner.spanFrom(nameStart));
        if (scanner.peekChar() == $colon) rethrow;
  
        scanner.expectChar($rparen);
        return SupportsAnything(contents, scanner.spanFrom(start));
      }
  
      var value = _supportsDeclarationValue(name);
      scanner.expectChar($rparen);
      return SupportsDeclaration(name, value, scanner.spanFrom(start));
    }
  
    /// Parses and returns the right-hand side of a declaration in a supports
    /// query.
    Expression _supportsDeclarationValue(Expression name) {
      if (name
          case StringExpression(
            hasQuotes: false,
            :var text,
          ) when text.initialPlain.startsWith("--")) {
        return StringExpression(_interpolatedDeclarationValue());
      } else {
        whitespace(consumeNewlines: true);
        return _expression(consumeNewlines: true);
      }
    }
  
    /// If [interpolation] is followed by `"and"` or `"or"`, parse it as a supports operation.
    ///
    /// Otherwise, return `null` without moving the scanner position.
    SupportsOperation? _trySupportsOperation(
      Interpolation interpolation,
      LineScannerState start,
    ) {
      if (interpolation.contents.length != 1) return null;
      var expression = interpolation.contents.first;
      if (expression is! Expression) return null;
  
      var beforeWhitespace = scanner.state;
      whitespace(consumeNewlines: true);
  
      SupportsOperation? operation;
      String? operator;
      while (lookingAtIdentifier()) {
        if (operator != null) {
          expectIdentifier(operator);
        } else if (scanIdentifier("and")) {
          operator = "and";
        } else if (scanIdentifier("or")) {
          operator = "or";
        } else {
          scanner.state = beforeWhitespace;
          return null;
        }
  
        whitespace(consumeNewlines: true);
        var right = _supportsConditionInParens();
        operation = SupportsOperation(
          operation ?? SupportsInterpolation(expression, interpolation.span),
          right,
          operator,
          scanner.spanFrom(start),
        );
        whitespace(consumeNewlines: true);
      }
  
      return operation;
    }
  
    // ## Characters
  
    /// Returns whether the scanner is immediately before an identifier that may
    /// contain interpolation.
    ///
    /// This is based on [the CSS algorithm][], but it assumes all backslashes
    /// start escapes and it considers interpolation to be valid in an identifier.
    ///
    /// [the CSS algorithm]: https://drafts.csswg.org/css-syntax-3/#would-start-an-identifier
    bool _lookingAtInterpolatedIdentifier() =>
        // See also [ScssParser._lookingAtIdentifier].
        switch (scanner.peekChar()) {
          null => false,
          int(isNameStart: true) || $backslash => true,
          $hash => scanner.peekChar(1) == $lbrace,
          $dash => switch (scanner.peekChar(1)) {
              null => false,
              $hash => scanner.peekChar(2) == $lbrace,
              int(isNameStart: true) || $backslash || $dash => true,
              _ => false,
            },
          _ => false,
        };
  
    /// Returns whether the scanner is immediately before a character that could
    /// start a `*prop: val`, `:prop: val`, `#prop: val`, or `.prop: val` hack.
    bool _lookingAtPotentialPropertyHack() => switch (scanner.peekChar()) {
          $colon || $asterisk || $dot => true,
          $hash => scanner.peekChar(1) != $lbrace,
          _ => false,
        };
  
    /// Returns whether the scanner is immediately before a sequence of characters
    /// that could be part of an CSS identifier body.
    ///
    /// The identifier body may include interpolation.
    bool _lookingAtInterpolatedIdentifierBody() => switch (scanner.peekChar()) {
          null => false,
          int(isName: true) || $backslash => true,
          $hash => scanner.peekChar(1) == $lbrace,
          _ => false,
        };
  
    /// Returns whether the scanner is immediately before a SassScript expression.
    bool _lookingAtExpression() => switch (scanner.peekChar()) {
          null => false,
          $dot => scanner.peekChar(1) != $dot,
          $exclamation => switch (scanner.peekChar(1)) {
              null || $i || $I || int(isWhitespace: true) => true,
              _ => false,
            },
          $lparen ||
          $slash ||
          $lbracket ||
          $single_quote ||
          $double_quote ||
          $hash ||
          $plus ||
          $minus ||
          $backslash ||
          $dollar ||
          $ampersand ||
          $percent ||
          int(isNameStart: true) ||
          int(isDigit: true) =>
            true,
          _ => false,
        };
  
    // ## Utilities
  
    /// Consumes a block of [child] statements and passes them, as well as the
    /// span from [start] to the end of the child block, to [create].
    T _withChildren<T>(
      Statement child(),
      LineScannerState start,
      T create(List<Statement> children, FileSpan span),
    ) {
      var result = create(children(child), scanner.spanFrom(start));
      whitespaceWithoutComments(consumeNewlines: false);
      return result;
    }
  
    /// Consumes a string that contains a valid URL.
    Uri _urlString() {
      var start = scanner.state;
      var url = string();
      try {
        return Uri.parse(url);
      } on FormatException catch (innerError, stackTrace) {
        error(
          "Invalid URL: ${innerError.message}",
          scanner.spanFrom(start),
          stackTrace,
        );
      }
    }
  
    /// Like [identifier], but rejects identifiers that begin with `_` or `-`.
    String _publicIdentifier() {
      var start = scanner.state;
      var result = identifier();
      _assertPublic(result, () => scanner.spanFrom(start));
      return result;
    }
  
    /// Throws an error if [identifier] isn't public.
    ///
    /// Calls [span] to provide the span for an error if one occurs.
    void _assertPublic(String identifier, FileSpan span()) {
      if (!isPrivate(identifier)) return;
      error(
        "Private members can't be accessed from outside their modules.",
        span(),
      );
    }
  
    /// Adds [expression] to [buffer], or if it's an unquoted string adds the
    /// interpolation it contains instead.
    void _addOrInject(InterpolationBuffer buffer, Expression expression) {
      if (expression is StringExpression && !expression.hasQuotes) {
        buffer.addInterpolation(expression.text);
      } else {
        buffer.add(expression, expression.span);
      }
    }
  
    // ## Abstract Methods
  
    /// Whether this is parsing the indented syntax.
    @protected
    bool get indented;
  
    /// Whether this is a plain CSS stylesheet.
    @protected
    bool get plainCss => false;
  
    /// The indentation level at the current scanner position.
    ///
    /// This value isn't used directly by [StylesheetParser]; it's just passed to
    /// [scanElse].
    @protected
    int get currentIndentation;
  
    /// Parses and returns a selector used in a style rule.
    @protected
    Interpolation styleRuleSelector();
  
    /// Asserts that the scanner is positioned before a statement separator, or at
    /// the end of a list of statements.
    ///
    /// If the [name] of the parent rule is passed, it's used for error reporting.
    ///
    /// This consumes whitespace, but nothing else, including comments.
    @protected
    void expectStatementSeparator([String? name]);
  
    /// Whether the scanner is positioned at the end of a statement.
    @protected
    bool atEndOfStatement();
  
    /// Whether the scanner is positioned before a block of children that can be
    /// parsed with [children].
    @protected
    bool lookingAtChildren();
  
    /// Tries to scan an `@else` rule after an `@if` block, and returns whether
    /// that succeeded.
    ///
    /// This should just scan the rule name, not anything afterwards.
    /// [ifIndentation] is the result of [currentIndentation] from before the
    /// corresponding `@if` was parsed.
    @protected
    bool scanElse(int ifIndentation);
  
    /// Consumes a block of child statements.
    ///
    /// Unlike most production consumers, this does *not* consume trailing
    /// whitespace. This is necessary to ensure that the source span for the
    /// parent rule doesn't cover whitespace after the rule.
    @protected
    List<Statement> children(Statement child());
  
    /// Consumes top-level statements.
    ///
    /// The [statement] callback may return `null`, indicating that a statement
    /// was consumed that shouldn't be added to the AST.
    @protected
    List<Statement> statements(Statement? statement());
  }
  