// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:analysis_server/lsp_protocol/protocol.dart' as lsp;
import 'package:analysis_server/lsp_protocol/protocol.dart' hide Declaration;
import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/collections.dart';
import 'package:analysis_server/src/computer/computer_documentation.dart';
import 'package:analysis_server/src/computer/computer_signature.dart' as server;
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/constants.dart' as lsp;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/dartdoc.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/lsp_analysis_server.dart' as lsp;
import 'package:analysis_server/src/lsp/snippets.dart';
import 'package:analysis_server/src/lsp/source_edits.dart';
import 'package:analysis_server/src/protocol_server.dart'
    as server
    hide AnalysisError;
import 'package:analysis_server/src/services/completion/dart/dart_completion_suggestion.dart';
import 'package:analysis_server/src/services/completion/dart/feature_computer.dart';
import 'package:analysis_server/src/services/snippets/snippet.dart';
import 'package:analysis_server/src/utilities/extensions/list.dart';
import 'package:analysis_server/src/utilities/extensions/string.dart';
import 'package:analyzer/dart/analysis/results.dart' as server;
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/diagnostic/diagnostic.dart' as server;
import 'package:analyzer/error/error.dart' as server;
import 'package:analyzer/source/line_info.dart' as server;
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source_range.dart' as server;
import 'package:analyzer/src/dart/analysis/search.dart'
    as server
    show DeclarationKind;
import 'package:analyzer/src/dart/element/element.dart';
import 'package:analyzer/src/diagnostic/diagnostic.dart' as diag;
import 'package:analyzer_plugin/protocol/protocol_common.dart' as plugin;
import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;

const languageSourceName = 'dart';

/// A regex used for splitting the display text in a completion so that
/// filterText only includes the symbol name and not any additional text (such
/// as parens, ` => `). Match `=>` but not `==` (which may appear in overrides).
final completionFilterTextSplitPattern = RegExp(r'=>|[\(]');

/// A regex to extract the type name from the parameter string of a setter
/// completion item.
final completionSetterTypePattern = RegExp(r'^\((\S+)\s+\S+\)$');

final diagnosticTagsForErrorCode = <String, List<lsp.DiagnosticTag>>{
  _diagnosticCode(diag.deadCode): [lsp.DiagnosticTag.Unnecessary],
  _diagnosticCode(diag.deprecatedMemberUse): [lsp.DiagnosticTag.Deprecated],
  'deprecated_member_use_from_same_package': [lsp.DiagnosticTag.Deprecated],
  'deprecated_member_use_from_same_package_with_message': [
    lsp.DiagnosticTag.Deprecated,
  ],
  _diagnosticCode(diag.deprecatedMemberUseWithMessage): [
    lsp.DiagnosticTag.Deprecated,
  ],
};

/// The value to subtract relevance from to get the correct sortText for a
/// completion item.
final sortTextMaxValue = int.parse('9' * maximumRelevance.toString().length);

/// Pattern for docComplete text on completion items that can be upgraded to
/// the "detail" field so that it can be shown more prominently by clients.
///
/// This is typically used for labels like _latest compatible_ and _latest_ in
/// the pubspec version items. These go into docComplete so that they appear
/// reasonably for non-LSP clients where there is no equivalent of the detail
/// field.
final upgradableDocCompletePattern = RegExp(r'^_([\w ]{0,20})_$');

lsp.Either2<lsp.MarkupContent, String> asMarkupContentOrString(
  Set<lsp.MarkupKind>? preferredFormats,
  String content,
) {
  return preferredFormats != null
      ? lsp.Either2<lsp.MarkupContent, String>.t1(
          _asMarkup(preferredFormats, content),
        )
      : lsp.Either2<lsp.MarkupContent, String>.t2(content);
}

({String text, lsp.InsertTextFormat format}) buildInsertText({
  required bool supportsSnippets,
  required bool commitCharactersEnabled,
  required bool completeFunctionCalls,
  required String? requiredArgumentListString,
  required List<int>? requiredArgumentListTextRanges,
  required bool hasOptionalParameters,
  required String completion,
  required int selectionOffset,
  required int selectionLength,
}) {
  var insertText = completion;
  var insertTextFormat = lsp.InsertTextFormat.PlainText;

  // SuggestionBuilder already does the equiv of completeFunctionCalls for
  // some methods (for example Flutter's setState). If the completion already
  // includes any `(` then disable our own insertion as the special-cased code
  // will likely provide better code.
  if (completion.contains('(')) {
    completeFunctionCalls = false;
  }

  // If the client supports snippets, we can support completeFunctionCalls or
  // setting a selection.
  if (supportsSnippets) {
    // completeFunctionCalls should only work if commit characters are disabled
    // otherwise the editor may insert parens that we're also inserting.
    if (!commitCharactersEnabled && completeFunctionCalls) {
      insertTextFormat = lsp.InsertTextFormat.Snippet;
      var hasRequiredParameters =
          requiredArgumentListTextRanges?.isNotEmpty ?? false;
      var functionCallSuffix =
          hasRequiredParameters && requiredArgumentListString != null
          ? buildSnippetStringWithTabStops(
              requiredArgumentListString,
              requiredArgumentListTextRanges,
            )
          // Optional params still gets a final tab stop in the parens.
          : hasOptionalParameters
          ? SnippetBuilder.finalTabStop
          // And no parameters at all we skip the tabstop in the parens.
          : '';
      insertText =
          '${SnippetBuilder.escapeSnippetPlainText(insertText)}($functionCallSuffix)';
    } else if (selectionOffset != 0 &&
        // We don't need a tab stop if the selection is the end of the string.
        selectionOffset != completion.length) {
      insertTextFormat = lsp.InsertTextFormat.Snippet;
      insertText = buildSnippetStringWithTabStops(completion, [
        selectionOffset,
        selectionLength,
      ]);
    }
  }

  return (text: insertText, format: insertTextFormat);
}

/// Creates a [lsp.WorkspaceEdit] from simple [server.SourceFileEdit]s.
///
/// [clientCapabilities] should be for the client that will handle this edit,
/// which is not necessarily the client that triggered the request that called
/// this function (for example a DTD client may call a request that triggers an
/// edit that will be sent to the editor).
///
/// If [annotateChanges] is set, change annotations will be produced and
/// marked as needing confirmation from the user (depending on the value).
///
/// Note: This code will fetch the version of each document being modified so
/// it's important to call this immediately after computing edits to ensure
/// the document is not modified before the version number is read.
lsp.WorkspaceEdit createPlainWorkspaceEdit(
  AnalysisServer analysisServer,
  LspClientCapabilities clientCapabilities,
  List<server.SourceFileEdit> edits, {
  ChangeAnnotations annotateChanges = ChangeAnnotations.none,
  String? filePath,
  LineInfo? lineInfo,
}) {
  return toWorkspaceEdit(
    annotateChanges: annotateChanges,
    clientCapabilities,
    edits.map((e) {
      // If we don't expet to create the file use the passed line info if any
      // and it matches the given file.
      // If we expect to create the file, `server.getLineInfo()` won't
      // provide a LineInfo so create one from empty contents.
      LineInfo pickedLineInfo;
      if (e.fileStamp == -1) {
        pickedLineInfo = LineInfo.fromContent('');
      } else {
        if (filePath != null && lineInfo != null && filePath == e.file) {
          pickedLineInfo = lineInfo;
        } else {
          pickedLineInfo = analysisServer.getLineInfo(e.file)!;
        }
      }
      return FileEditInformation(
        analysisServer.getVersionedDocumentIdentifier(e.file),
        pickedLineInfo,
        e.edits,
        // `fileStamp == 1` is used by the server to indicate the file needs
        // creating.
        newFile: e.fileStamp == -1,
      );
    }).toList(),
  );
}

/// Create a [WorkspaceEdit] that renames [oldPath] to [newPath].
WorkspaceEdit createRenameEdit(
  ClientUriConverter uriConverter,
  String oldPath,
  String newPath,
) {
  var changes =
      <Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>>[];

  var rename = RenameFile(
    oldUri: uriConverter.toClientUri(oldPath),
    newUri: uriConverter.toClientUri(newPath),
  );

  var renameUnion =
      Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>.t3(rename);

  changes.add(renameUnion);

  var edit = WorkspaceEdit(documentChanges: changes);
  return edit;
}

/// Creates a [lsp.WorkspaceEdit] from a [server.SourceChange].
///
/// Can return experimental [lsp.SnippetTextEdit]s if the following are true:
/// - the client has indicated support for in the experimental section of their
///   client capabilities, and
/// - [allowSnippets] is true, and
/// - [change] contains only a single edit to the single file [filePath]
/// - [lineInfo] is provided (which should be for the single edited file)
///
/// Note: This code will fetch the version of each document being modified so
/// it's important to call this immediately after computing edits to ensure
/// the document is not modified before the version number is read.
lsp.WorkspaceEdit createWorkspaceEdit(
  AnalysisServer analysisServer,
  LspClientCapabilities clientCapabilities,
  server.SourceChange change, {
  ChangeAnnotations annotateChanges = ChangeAnnotations.none,
  // The caller must specify whether snippets are valid here for where they're
  // sending this edit. Right now, support is limited to CodeActions.
  bool allowSnippets = false,
  String? filePath,
  LineInfo? lineInfo,
}) {
  assert(
    annotateChanges == ChangeAnnotations.none || !allowSnippets,
    'annotateChanges is not supported with snippets',
  );
  // In order to return snippets, we must ensure we are only modifying a single
  // existing file with a single edit and that there is either a selection or a
  // linked edit group (otherwise there's no value in snippets).
  if (!allowSnippets ||
      !clientCapabilities.experimentalSnippetTextEdit ||
      !clientCapabilities.documentChanges ||
      filePath == null ||
      lineInfo == null ||
      change.edits.length != 1 ||
      change.edits.single.fileStamp == -1 || // new file
      change.edits.single.file != filePath ||
      change.edits.single.edits.length != 1 ||
      (change.selection == null && change.linkedEditGroups.isEmpty)) {
    return createPlainWorkspaceEdit(
      analysisServer,
      clientCapabilities,
      change.edits,
      annotateChanges: annotateChanges,
      filePath: filePath,
      lineInfo: lineInfo,
    );
  }

  var fileEdit = change.edits.single;
  var snippetEdits = toSnippetTextEdits(
    fileEdit.file,
    fileEdit,
    change.linkedEditGroups,
    lineInfo,
    selectionOffset: change.selection?.offset,
    selectionLength: change.selectionLength,
  );

  // Compile the edits into a TextDocumentEdit for this file.
  var textDocumentEdit = lsp.TextDocumentEdit(
    textDocument: analysisServer.getVersionedDocumentIdentifier(fileEdit.file),
    edits: snippetEdits
        .map(
          (e) =>
              Either3<
                lsp.AnnotatedTextEdit,
                lsp.SnippetTextEdit,
                lsp.TextEdit
              >.t2(e),
        )
        .toList(),
  );

  // Convert to the union that documentChanges require.
  var textDocumentEditsAsUnion =
      Either4<
        lsp.CreateFile,
        lsp.DeleteFile,
        lsp.RenameFile,
        lsp.TextDocumentEdit
      >.t4(textDocumentEdit);

  /// Add the textDocumentEdit to a WorkspaceEdit.
  return lsp.WorkspaceEdit(documentChanges: [textDocumentEditsAsUnion]);
}

lsp.SymbolKind declarationKindToSymbolKind(
  Set<lsp.SymbolKind> supportedSymbolKinds,
  server.DeclarationKind? kind,
) {
  bool isSupported(lsp.SymbolKind kind) => supportedSymbolKinds.contains(kind);

  List<lsp.SymbolKind> getKindPreferences() {
    switch (kind) {
      case server.DeclarationKind.CLASS:
      case server.DeclarationKind.CLASS_TYPE_ALIAS:
        return const [lsp.SymbolKind.Class];
      case server.DeclarationKind.CONSTRUCTOR:
        return const [lsp.SymbolKind.Constructor];
      case server.DeclarationKind.ENUM:
        return const [lsp.SymbolKind.Enum];
      case server.DeclarationKind.ENUM_CONSTANT:
        return const [lsp.SymbolKind.EnumMember, lsp.SymbolKind.Enum];
      case server.DeclarationKind.EXTENSION:
        return const [lsp.SymbolKind.Class];
      case server.DeclarationKind.EXTENSION_TYPE:
        return const [lsp.SymbolKind.Class];
      case server.DeclarationKind.FIELD:
        return const [lsp.SymbolKind.Field];
      case server.DeclarationKind.FUNCTION:
        return const [lsp.SymbolKind.Function];
      case server.DeclarationKind.FUNCTION_TYPE_ALIAS:
        return const [lsp.SymbolKind.Class];
      case server.DeclarationKind.GETTER:
        return const [lsp.SymbolKind.Property];
      case server.DeclarationKind.METHOD:
        return const [lsp.SymbolKind.Method];
      case server.DeclarationKind.MIXIN:
        return const [lsp.SymbolKind.Class];
      case server.DeclarationKind.SETTER:
        return const [lsp.SymbolKind.Property];
      case server.DeclarationKind.TYPE_ALIAS:
        return const [lsp.SymbolKind.Class];
      case server.DeclarationKind.VARIABLE:
        return const [lsp.SymbolKind.Variable];
      default:
        // Assert that we only get here if kind=null. If it's anything else
        // then we're missing a mapping from above.
        assert(kind == null, 'Unexpected declaration kind $kind');
        return const [];
    }
  }

  // LSP requires we specify *some* kind, so in the case where the above code doesn't
  // match we'll just have to send a value to avoid a crash.
  return getKindPreferences().firstWhere(
    isSupported,
    orElse: () => lsp.SymbolKind.Obj,
  );
}

lsp.CompletionItemKind? elementKindToCompletionItemKind(
  Set<lsp.CompletionItemKind> supportedCompletionKinds,
  server.ElementKind kind,
) {
  bool isSupported(lsp.CompletionItemKind kind) =>
      supportedCompletionKinds.contains(kind);

  List<lsp.CompletionItemKind> getKindPreferences() {
    switch (kind) {
      case server.ElementKind.CLASS:
      case server.ElementKind.CLASS_TYPE_ALIAS:
        return const [lsp.CompletionItemKind.Class];
      case server.ElementKind.COMPILATION_UNIT:
        return const [
          lsp.CompletionItemKind.File,
          lsp.CompletionItemKind.Module,
        ];
      case server.ElementKind.CONSTRUCTOR:
      case server.ElementKind.CONSTRUCTOR_INVOCATION:
        return const [lsp.CompletionItemKind.Constructor];
      case server.ElementKind.ENUM:
        return const [lsp.CompletionItemKind.Enum];
      case server.ElementKind.ENUM_CONSTANT:
        return const [
          lsp.CompletionItemKind.EnumMember,
          lsp.CompletionItemKind.Enum,
        ];
      case server.ElementKind.FIELD:
        return const [lsp.CompletionItemKind.Field];
      case server.ElementKind.FILE:
        return const [lsp.CompletionItemKind.File];
      case server.ElementKind.FUNCTION:
        return const [lsp.CompletionItemKind.Function];
      case server.ElementKind.FUNCTION_TYPE_ALIAS:
        return const [lsp.CompletionItemKind.Class];
      case server.ElementKind.GETTER:
        return const [lsp.CompletionItemKind.Property];
      case server.ElementKind.LABEL:
        // There isn't really a good CompletionItemKind for labels so we'll
        // just use the Text option.
        return const [lsp.CompletionItemKind.Text];
      case server.ElementKind.LIBRARY:
        return const [lsp.CompletionItemKind.Module];
      case server.ElementKind.LOCAL_VARIABLE:
        return const [lsp.CompletionItemKind.Variable];
      case server.ElementKind.METHOD:
        return const [lsp.CompletionItemKind.Method];
      case server.ElementKind.MIXIN:
        return const [lsp.CompletionItemKind.Class];
      case server.ElementKind.PARAMETER:
      case server.ElementKind.PREFIX:
        return const [lsp.CompletionItemKind.Variable];
      case server.ElementKind.SETTER:
        return const [lsp.CompletionItemKind.Property];
      case server.ElementKind.TOP_LEVEL_VARIABLE:
        return const [lsp.CompletionItemKind.Variable];
      case server.ElementKind.TYPE_PARAMETER:
        return const [
          lsp.CompletionItemKind.TypeParameter,
          lsp.CompletionItemKind.Variable,
        ];
      case server.ElementKind.UNIT_TEST_GROUP:
      case server.ElementKind.UNIT_TEST_TEST:
        return const [lsp.CompletionItemKind.Method];
      default:
        return const [];
    }
  }

  return getKindPreferences().firstWhereOrNull(isSupported);
}

lsp.SymbolKind elementKindToSymbolKind(
  Set<lsp.SymbolKind> supportedSymbolKinds,
  server.ElementKind? kind,
) {
  bool isSupported(lsp.SymbolKind kind) => supportedSymbolKinds.contains(kind);

  List<lsp.SymbolKind> getKindPreferences() {
    switch (kind) {
      case server.ElementKind.CLASS:
      case server.ElementKind.CLASS_TYPE_ALIAS:
        return const [lsp.SymbolKind.Class];
      case server.ElementKind.COMPILATION_UNIT:
        return const [lsp.SymbolKind.File];
      case server.ElementKind.CONSTRUCTOR:
      case server.ElementKind.CONSTRUCTOR_INVOCATION:
        return const [lsp.SymbolKind.Constructor];
      case server.ElementKind.ENUM:
        return const [lsp.SymbolKind.Enum];
      case server.ElementKind.ENUM_CONSTANT:
        return const [lsp.SymbolKind.EnumMember, lsp.SymbolKind.Enum];
      case server.ElementKind.EXTENSION:
        return const [lsp.SymbolKind.Namespace];
      case server.ElementKind.EXTENSION_TYPE:
        return const [lsp.SymbolKind.Namespace];
      case server.ElementKind.FIELD:
        return const [lsp.SymbolKind.Field];
      case server.ElementKind.FILE:
        return const [lsp.SymbolKind.File];
      case server.ElementKind.FUNCTION:
      case server.ElementKind.FUNCTION_INVOCATION:
        return const [lsp.SymbolKind.Function];
      case server.ElementKind.FUNCTION_TYPE_ALIAS:
        return const [lsp.SymbolKind.Class];
      case server.ElementKind.GETTER:
        return const [lsp.SymbolKind.Property];
      case server.ElementKind.LABEL:
        // There isn't really a good SymbolKind for labels so we'll
        // just use the Null option.
        return const [lsp.SymbolKind.Null];
      case server.ElementKind.LIBRARY:
        return const [lsp.SymbolKind.Namespace];
      case server.ElementKind.LOCAL_VARIABLE:
        return const [lsp.SymbolKind.Variable];
      case server.ElementKind.METHOD:
        return const [lsp.SymbolKind.Method];
      case server.ElementKind.MIXIN:
        return const [lsp.SymbolKind.Class];
      case server.ElementKind.PARAMETER:
      case server.ElementKind.PREFIX:
        return const [lsp.SymbolKind.Variable];
      case server.ElementKind.SETTER:
        return const [lsp.SymbolKind.Property];
      case server.ElementKind.TOP_LEVEL_VARIABLE:
        return const [lsp.SymbolKind.Variable];
      case server.ElementKind.TYPE_PARAMETER:
        return const [lsp.SymbolKind.TypeParameter, lsp.SymbolKind.Variable];
      case server.ElementKind.UNIT_TEST_GROUP:
      case server.ElementKind.UNIT_TEST_TEST:
        return const [lsp.SymbolKind.Method];
      default:
        // Assert that we only get here if kind=null. If it's anything else
        // then we're missing a mapping from above.
        assert(kind == null, 'Unexpected element kind $kind');
        return const [];
    }
  }

  // LSP requires we specify *some* kind, so in the case where the above code doesn't
  // match we'll just have to send a value to avoid a crash.
  return getKindPreferences().firstWhere(
    isSupported,
    orElse: () => lsp.SymbolKind.Obj,
  );
}

lsp.Location? fragmentToLocation(
  ClientUriConverter uriConverter,
  Fragment? fragment,
) {
  if (fragment == null) {
    return null;
  }

  var libraryFragment = fragment.libraryFragment!;
  var sourcePath = libraryFragment.source.fullName;

  int? nameOffset;
  int? nameLength;
  if (fragment case PropertyAccessorFragmentImpl(
    :var isOriginDeclaration,
  ) when !isOriginDeclaration) {
    var element = fragment.element.nonSynthetic;
    nameOffset = element.firstFragment.nameOffset;
    nameLength = element.firstFragment.name?.length;
  } else {
    nameOffset = fragment.nameOffset;
    nameLength = fragment.name?.length;
  }

  // For unnamed constructors, use the type name as the target location.
  if (nameOffset == null && fragment is ConstructorFragment) {
    nameOffset = fragment.typeNameOffset;
    nameLength = fragment.typeName?.length;
  }

  if (nameOffset == null || nameLength == null) {
    // This is some kind of synthetic fragment we can't navigate to.
    return null;
  }

  return lsp.Location(
    uri: uriConverter.toClientUri(sourcePath),
    range: toRange(libraryFragment.lineInfo, nameOffset, nameLength),
  );
}

/// Returns additional details to be shown against a completion.
CompletionDetail getCompletionDetail(
  server.CompletionSuggestion suggestion, {
  required bool supportsDeprecated,
}) {
  var element = suggestion.element;
  var parameters = element?.parameters;
  // Prefer the element return type (because it may be more specific
  // for overrides) and fall back to the parameter type or return type from the
  // suggestion (handles records).
  var returnType =
      element?.returnType ?? suggestion.parameterType ?? suggestion.returnType;

  // Extract the type from setters to be shown in the place a return type
  // would usually be shown.
  if (returnType == null &&
      element?.kind == server.ElementKind.SETTER &&
      parameters != null) {
    returnType = completionSetterTypePattern.firstMatch(parameters)?[1];
    parameters = null;
  }

  var truncatedParameters = switch (parameters) {
    null || '' => '',
    '()' => '()',
    _ => '(…)',
  };
  var fullSignature = switch ((parameters, returnType)) {
    (null, _) => returnType ?? '',
    (var parameters?, null) => parameters,
    (var parameters?, '') => parameters,
    (var parameters?, _) => '$parameters → $returnType',
  };
  var truncatedSignature = switch ((parameters, returnType)) {
    (null, null) => '',
    // Include a leading space when no parameters so return type isn't right
    // against the completion label.
    (null, var returnType?) => ' $returnType',
    (_, null) || (_, '') => truncatedParameters,
    (_, var returnType?) => '$truncatedParameters → $returnType',
  };

  // Use the full signature in the details popup.
  var detail = fullSignature;
  if (suggestion.isDeprecated && !supportsDeprecated) {
    // If the item is deprecated and we don't support the native deprecated flag
    // then include it in the details.
    detail = '$detail\n\n(Deprecated)'.trim();
  }

  var libraryUri = suggestion.libraryUri;
  var autoImportUri = (suggestion.isNotImported ?? false) && libraryUri != null
      ? Uri.parse(libraryUri)
      : null;

  return (
    detail: detail,
    truncatedParams: truncatedParameters,
    truncatedSignature: truncatedSignature,
    autoImportUri: autoImportUri,
  );
}

/// Gets a library URI formatted for display in code completion as the target
/// library that a symbol comes from.
///
/// File URIs will be made relative to [completionFilePath]. Other URIs will be
/// returned as-is.
String? getCompletionDisplayUriString({
  required ClientUriConverter uriConverter,
  required path.Context pathContext,
  required Uri? elementLibraryUri,
  required String completionFilePath,
}) {
  if (elementLibraryUri == null) {
    return null;
  }

  return elementLibraryUri.isScheme('file')
      // Compute the relative path and then put into a URI so the display
      // always uses forward slashes (as a URI) regardless of platform.
      ? uriConverter
            .toClientUri(
              pathContext.relative(
                uriConverter.fromClientUri(elementLibraryUri),
                from: pathContext.dirname(completionFilePath),
              ),
            )
            .toString()
      : elementLibraryUri.toString();
}

List<lsp.DiagnosticTag>? getDiagnosticTags(
  Set<lsp.DiagnosticTag>? supportedTags,
  plugin.AnalysisError error,
) {
  if (supportedTags == null) {
    return null;
  }

  var tags = diagnosticTagsForErrorCode[error.code]
      ?.where(supportedTags.contains)
      .toList();

  return tags != null && tags.isNotEmpty ? tags : null;
}

bool isDartDocument(lsp.TextDocumentIdentifier doc) => isDartUri(doc.uri);

bool isDartUri(Uri uri) => uri.path.endsWith('.dart');

/// Converts a [server.Location] to an [lsp.Range] by translating the
/// offset/length using a `LineInfo`.
///
/// This function ignores any line/column info on the
/// [server.Location] assuming it is either not available not unreliable.
lsp.Range locationOffsetLenToRange(
  server.LineInfo lineInfo,
  server.Location location,
) => toRange(lineInfo, location.offset, location.length);

/// Converts a [server.Location] to an [lsp.Range] if all line and column
/// values are available.
///
/// Returns null if any values are -1 or null.
lsp.Range? locationToRange(server.Location location) {
  var startLine = location.startLine;
  var startColumn = location.startColumn;
  var endLine = location.endLine ?? -1;
  var endColumn = location.endColumn ?? -1;
  if (startLine == -1 ||
      startColumn == -1 ||
      endLine == -1 ||
      endColumn == -1) {
    return null;
  }
  // LSP positions are 0-based but Location is 1-based.
  return Range(
    start: Position(line: startLine - 1, character: startColumn - 1),
    end: Position(line: endLine - 1, character: endColumn - 1),
  );
}

/// Merges two [WorkspaceEdit]s into a single one.
///
/// Will throw if given [WorkspaceEdit]s that do not use documentChanges.
WorkspaceEdit mergeWorkspaceEdits(List<WorkspaceEdit> edits) {
  // TODO(dantup): This method (and much other code here) should be
  // significantly tidied up when nonfunction-type-aliases is available here.
  var changes =
      <Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>>[];

  for (var edit in edits) {
    changes.addAll(edit.documentChanges!);
  }

  return WorkspaceEdit(documentChanges: changes);
}

lsp.Location navigationTargetToLocation(
  Uri targetFileUri,
  server.NavigationTarget target,
  server.LineInfo targetLineInfo,
) {
  return lsp.Location(
    uri: targetFileUri,
    range: toRange(targetLineInfo, target.offset, target.length),
  );
}

lsp.LocationLink? navigationTargetToLocationLink(
  server.NavigationRegion region,
  server.LineInfo regionLineInfo,
  Uri targetFileUri,
  server.NavigationTarget target,
  server.LineInfo targetLineInfo,
) {
  var nameRange = toRange(targetLineInfo, target.offset, target.length);
  var codeOffset = target.codeOffset;
  var codeLength = target.codeLength;
  var codeRange = codeOffset != null && codeLength != null
      ? toRange(targetLineInfo, codeOffset, codeLength)
      : nameRange;

  return lsp.LocationLink(
    originSelectionRange: toRange(regionLineInfo, region.offset, region.length),
    targetUri: targetFileUri,
    targetRange: codeRange,
    targetSelectionRange: nameRange,
  );
}

lsp.Diagnostic pluginToDiagnostic(
  ClientUriConverter uriConverter,
  server.LineInfo? Function(String) getLineInfo,
  plugin.AnalysisError error, {
  required Set<lsp.DiagnosticTag>? supportedTags,
  required bool clientSupportsCodeDescription,
}) {
  List<lsp.DiagnosticRelatedInformation>? relatedInformation;
  var contextMessages = error.contextMessages;
  if (contextMessages != null && contextMessages.isNotEmpty) {
    relatedInformation = contextMessages
        .map(
          (message) => pluginToDiagnosticRelatedInformation(
            uriConverter,
            getLineInfo,
            message,
          ),
        )
        .nonNulls
        .toList();
  }

  var message = error.message;
  if (error.correction != null) {
    message = '$message\n${error.correction}';
  }

  var range =
      locationToRange(error.location) ??
      locationOffsetLenToRange(
        // TODO(dantup): This null assertion is not sound and can lead to
        //   errors (for example during a large rename where files may be
        //   removed as diagnostics are being mapped). To remove this,
        //   error.location should be updated to require line/col information
        //   (which involves breaking changes).
        getLineInfo(error.location.file)!,
        error.location,
      );
  var documentationUrl = error.url;
  return lsp.Diagnostic(
    range: range,
    severity: pluginToDiagnosticSeverity(error.severity),
    code: error.code,
    source: languageSourceName,
    message: message,
    tags: getDiagnosticTags(supportedTags, error),
    relatedInformation: relatedInformation,
    // Only include codeDescription if the client explicitly supports it
    // (a minor optimization to avoid unnecessary payload/(de)serialization).
    codeDescription: clientSupportsCodeDescription && documentationUrl != null
        ? CodeDescription(href: Uri.parse(documentationUrl))
        : null,
  );
}

lsp.DiagnosticRelatedInformation? pluginToDiagnosticRelatedInformation(
  ClientUriConverter uriConverter,
  server.LineInfo? Function(String) getLineInfo,
  plugin.DiagnosticMessage message,
) {
  var file = message.location.file;
  var uri = uriConverter.toClientUri(file);
  var lineInfo = getLineInfo(file);
  // We shouldn't get context messages for something we can't get a LineInfo for
  // but if we did, it's better to omit the context than fail to send the errors.
  if (lineInfo == null) {
    return null;
  }
  return lsp.DiagnosticRelatedInformation(
    location: lsp.Location(
      uri: uri,
      // TODO(dantup): Switch to using line/col information from the context
      // message once confirmed that AnalyzerConverter is not using the wrong
      // LineInfo.
      range: toRange(
        lineInfo,
        message.location.offset,
        message.location.length,
      ),
    ),
    message: message.message,
  );
}

lsp.DiagnosticSeverity pluginToDiagnosticSeverity(
  plugin.AnalysisErrorSeverity severity,
) {
  return switch (severity) {
    plugin.AnalysisErrorSeverity.ERROR => lsp.DiagnosticSeverity.Error,
    plugin.AnalysisErrorSeverity.WARNING => lsp.DiagnosticSeverity.Warning,
    plugin.AnalysisErrorSeverity.INFO => lsp.DiagnosticSeverity.Information,
  };
}

/// Records a change annotation for [edit] in [uri] into the [changeAnnotations]
/// map based on the value of [annotateChanges].
ChangeAnnotation? recordEditAnnotation(
  Uri uri,
  server.SourceEdit edit, {
  required ChangeAnnotations annotateChanges,
  required Map<ChangeAnnotationIdentifier, ChangeAnnotation>? changeAnnotations,
}) {
  assert(
    (annotateChanges == ChangeAnnotations.none) == (changeAnnotations == null),
  );
  if (changeAnnotations == null) {
    return null;
  }

  // Always try to provide good descriptions when producing annotated
  // changes but use a fallback rather than failing if they're not
  // available.
  // When running with asserts, assert there is a description to
  // highlight where we're not passing them.
  assert(edit.description != null);
  var label = edit.description ?? edit.id ?? uri.pathSegments.last;
  return changeAnnotations[label] ??= ChangeAnnotation(
    label: label,
    needsConfirmation: annotateChanges == ChangeAnnotations.requireConfirmation,
  );
}

/// Converts a numeric relevance to a sortable string.
///
/// The servers relevance value is a number with highest being best. LSP uses a
/// a string sort on the `sortText` field. Subtracting the relevance from a
/// large number will produce text that will sort correctly.
///
/// Relevance can be 0, so it's important to subtract from a number like 999
/// and not 1000 or the 0 relevance items will sort at the top instead of the
/// bottom.
///
/// 1000000 -> 9999999 - 1000000 -> 8 999 999
///     555 -> 9999999 -     555 -> 9 999 444
///      10 -> 9999999 -      10 -> 9 999 989
///       1 -> 9999999 -       1 -> 9 999 998
///       0 -> 9999999 -       0 -> 9 999 999
String relevanceToSortText(int relevance) =>
    (sortTextMaxValue - relevance).toString();

/// Creates a SnippetTextEdit for a set of edits using Linked Edit Groups.
///
/// Edit groups offsets are based on the entire content being modified after all
/// edits, so [editOffset] must to take into account both the offset of the edit
/// _and_ any delta from edits prior to this one in the file.
///
/// [selectionOffset] is also absolute and assumes `edit.replacement` will be
/// inserted at [editOffset].
lsp.SnippetTextEdit snippetTextEditFromEditGroups(
  String filePath,
  server.LineInfo lineInfo,
  server.SourceEdit edit, {
  required List<server.LinkedEditGroup> editGroups,
  required int editOffset,
  required int? selectionOffset,
  required int? selectionLength,
}) {
  return lsp.SnippetTextEdit(
    insertTextFormat: lsp.InsertTextFormat.Snippet,
    range: toRange(lineInfo, edit.offset, edit.length),
    newText: buildSnippetStringForEditGroups(
      edit.replacement,
      filePath: filePath,
      editGroups: editGroups,
      editOffset: editOffset,
      selectionOffset: selectionOffset,
      selectionLength: selectionLength,
    ),
  );
}

/// Creates a SnippetTextEdit for an edit with a selection placeholder.
///
/// [selectionOffsetRelative] is relative to (and therefore must be within) the
/// edit.
lsp.SnippetTextEdit snippetTextEditWithSelection(
  server.LineInfo lineInfo,
  server.SourceEdit edit, {
  required int selectionOffsetRelative,
  int? selectionLength,
}) {
  return lsp.SnippetTextEdit(
    insertTextFormat: lsp.InsertTextFormat.Snippet,
    range: toRange(lineInfo, edit.offset, edit.length),
    newText: buildSnippetStringWithTabStops(edit.replacement, [
      selectionOffsetRelative,
      selectionLength ?? 0,
    ]),
  );
}

lsp.CompletionItem snippetToCompletionItem(
  lsp.LspAnalysisServer server,
  LspClientCapabilities capabilities,
  String file,
  LineInfo lineInfo,
  Position position,
  Snippet snippet,
  CompletionItemDefaults? defaults,
) {
  assert(capabilities.completionSnippets);

  var formats = capabilities.completionDocumentationFormats;
  var documentation = snippet.documentation;
  var supportsAsIsInsertMode = capabilities.completionInsertTextModes.contains(
    InsertTextMode.asIs,
  );
  var changes = snippet.change;

  // We must only get one change for this file to be able to apply snippets.
  var thisFilesChange = changes.edits.singleWhere((e) => e.file == file);
  var otherFilesChanges = changes.edits.where((e) => e.file != file).toList();

  // If this completion involves editing other files, we'll need to build
  // a command that the client will call to apply those edits later, because
  // LSP Completions can only provide simple edits for the current file.
  Command? command;
  if (otherFilesChanges.isNotEmpty) {
    var workspaceEdit = createPlainWorkspaceEdit(
      server,
      capabilities,
      otherFilesChanges,
    );
    command = Command(
      title: 'Add import',
      command: Commands.sendWorkspaceEdit,
      arguments: [
        {'edit': workspaceEdit},
      ],
    );
  }

  /// Convert the changes to TextEdits using snippet tokens for linked edit
  /// groups.
  var mainFileEdits = toSnippetTextEdits(
    file,
    thisFilesChange,
    changes.linkedEditGroups,
    lineInfo,
    selectionOffset: changes.selection?.file == file
        ? changes.selection?.offset
        : null,
    selectionLength: changes.selection?.file == file
        ? changes.selectionLength
        : null,
  );

  // For LSP, we need to provide the main edit and other edits separately. The
  // main edit must include the location that completion was invoked. If we find
  // more than one, take the first one since imports are usually added as later
  // edits (so when applied sequentially they will be inserted at the start of
  // the file after the other edits).
  var mainEdit = mainFileEdits.firstWhere(
    (edit) => edit.range.start.line == position.line,
  );
  var nonMainEdits = mainFileEdits.where((edit) => edit != mainEdit).toList();

  // Capture any default combined range. If there are different insert/replace
  // ranges just take `null` because snippets always use the same ranges and
  // if defaults are different ours can't possibly be redundant.
  var defaultRange = defaults?.editRange?.map(
    (ranges) => null,
    (range) => range,
  );
  var hasDefaultEditRange = mainEdit.range == defaultRange;

  return lsp.CompletionItem(
    label: snippet.label,
    filterText: snippet.prefix.orNullIfSameAs(snippet.label),
    kind: lsp.CompletionItemKind.Snippet,
    command: command,
    documentation: documentation != null
        ? asMarkupContentOrString(formats, documentation)
        : null,
    // Force snippets to be sorted at the bottom of the list.
    // TODO(dantup): Consider if we can rank these better. Client-side
    //   snippets have always been forced to the bottom partly because they
    //   show up in more places than wanted.
    sortText: 'zzz${snippet.prefix}',
    insertTextFormat: lsp.InsertTextFormat.Snippet,
    insertTextMode: supportsAsIsInsertMode ? InsertTextMode.asIs : null,
    // Set textEdit or textEditText depending on whether we need to specify
    // a range or not.
    textEdit: hasDefaultEditRange
        ? null
        : Either2<InsertReplaceEdit, TextEdit>.t2(mainEdit),
    textEditText: hasDefaultEditRange
        ? mainEdit.newText.orNullIfSameAs(snippet.label)
        : null,
    additionalTextEdits: nonMainEdits.nullIfEmpty,
  );
}

/// Sorts a list of [server.SourceEdit]s for mapping to LSP types.
///
/// Server works with edits that can be applied sequentially to a [String]. This
/// means inserts at the same offset are in the reverse order. For LSP, all
/// offsets relate to the original document and inserts with the same offset
/// appear in the order they will appear in the final document.
List<server.SourceEdit> sortSourceEditsForLsp(List<server.SourceEdit> edits) {
  // Since for LSP the ordering of items without the same offset do not matter,
  // we can simply reverse the entire list.
  return edits.reversed.toList();
}

lsp.CompletionItemKind? suggestionKindToCompletionItemKind(
  Set<lsp.CompletionItemKind> supportedCompletionKinds,
  server.CompletionSuggestionKind kind,
  String label,
) {
  bool isSupported(lsp.CompletionItemKind kind) =>
      supportedCompletionKinds.contains(kind);

  List<lsp.CompletionItemKind> getKindPreferences() {
    switch (kind) {
      case server.CompletionSuggestionKind.ARGUMENT_LIST:
        return const [lsp.CompletionItemKind.Variable];
      case server.CompletionSuggestionKind.IMPORT:
        // For package/relative URIs, we can send File/Folder kinds for better icons.
        if (!label.startsWith('dart:')) {
          return label.endsWith('.dart')
              ? const [
                  lsp.CompletionItemKind.File,
                  lsp.CompletionItemKind.Module,
                ]
              : const [
                  lsp.CompletionItemKind.Folder,
                  lsp.CompletionItemKind.Module,
                ];
        }
        return const [lsp.CompletionItemKind.Module];
      case server.CompletionSuggestionKind.IDENTIFIER:
        return const [lsp.CompletionItemKind.Variable];
      case server.CompletionSuggestionKind.INVOCATION:
        return const [lsp.CompletionItemKind.Method];
      case server.CompletionSuggestionKind.KEYWORD:
        return const [lsp.CompletionItemKind.Keyword];
      case server.CompletionSuggestionKind.NAMED_ARGUMENT:
        return const [lsp.CompletionItemKind.Variable];
      case server.CompletionSuggestionKind.OPTIONAL_ARGUMENT:
        return const [lsp.CompletionItemKind.Variable];
      case server.CompletionSuggestionKind.PARAMETER:
        return const [lsp.CompletionItemKind.Value];
      case server.CompletionSuggestionKind.PACKAGE_NAME:
        return const [lsp.CompletionItemKind.Module];
      default:
        return const [];
    }
  }

  return getKindPreferences().firstWhereOrNull(isSupported);
}

lsp.ClosingLabel toClosingLabel(
  server.LineInfo lineInfo,
  server.ClosingLabel label,
) => lsp.ClosingLabel(
  range: toRange(lineInfo, label.offset, label.length),
  label: label.label,
);

/// Converts [id] to a [CodeActionKind] using [fallbackOrPrefix] as a fallback
/// or a prefix if the ID is not already a fix/refactor.
lsp.CodeActionKind toCodeActionKind(
  String? id,
  lsp.CodeActionKind fallbackOrPrefix,
) {
  if (id == null) {
    return fallbackOrPrefix;
  }
  // Dart fixes and assists start with "dart.assist." and "dart.fix." but in LSP
  // we want to use the predefined prefixes for CodeActions.
  var newId = id
      .replaceAll('dart.assist', lsp.CodeActionKind.Refactor.toString())
      .replaceAll('dart.fix', lsp.CodeActionKind.QuickFix.toString())
      .replaceAll(
        'analysisOptions.assist',
        lsp.CodeActionKind.Refactor.toString(),
      )
      .replaceAll('analysisOptions.fix', lsp.CodeActionKind.QuickFix.toString())
      .replaceAll('pubspec.assist', lsp.CodeActionKind.Refactor.toString())
      .replaceAll('pubspec.fix', lsp.CodeActionKind.QuickFix.toString());

  // If the ID does not start with either of the kinds above, prefix it as
  // it will be an unqualified ID from a plugin.
  if (!newId.startsWith(lsp.CodeActionKind.Refactor.toString()) &&
      !newId.startsWith(lsp.CodeActionKind.QuickFix.toString())) {
    newId = '$fallbackOrPrefix.$newId';
  }

  return lsp.CodeActionKind(newId);
}

lsp.CompletionItem toCompletionItem(
  LspClientCapabilities capabilities,
  server.LineInfo lineInfo,
  server.CompletionSuggestion suggestion, {
  required ClientUriConverter uriConverter,
  required path.Context pathContext,
  required String completionFilePath,
  bool hasDefaultEditRange = false,
  bool hasDefaultTextMode = false,
  required Range replacementRange,
  required Range insertionRange,
  required DocumentationPreference includeDocumentation,
  required bool commitCharactersEnabled,
  required bool completeFunctionCalls,
  CompletionItemResolutionInfo? resolutionData,
}) {
  // isCallable is used to suffix the label with parens so it's clear the item
  // is callable.
  //
  // isInvocation means the location at which it's used is an invocation (and
  // therefore it is appropriate to include the parens/parameters in the
  // inserted text).
  //
  // In the case of show combinators, the parens will still be shown to indicate
  // functions but they should not be included in the completions.
  var elementKind = suggestion.element?.kind;
  var isCallable =
      elementKind == server.ElementKind.CONSTRUCTOR ||
      elementKind == server.ElementKind.FUNCTION ||
      elementKind == server.ElementKind.METHOD;
  var isInvocation =
      suggestion.kind == server.CompletionSuggestionKind.INVOCATION;
  if (!isCallable || !isInvocation) {
    completeFunctionCalls = false;
  }

  var supportsCompletionDeprecatedFlag = capabilities.completionDeprecatedFlag;
  var supportsDeprecatedTag = capabilities.completionItemTags.contains(
    lsp.CompletionItemTag.Deprecated,
  );
  var formats = capabilities.completionDocumentationFormats;
  var supportsSnippets = capabilities.completionSnippets;
  var supportsInsertReplace = capabilities.insertReplaceCompletionRanges;
  var supportsAsIsInsertMode = capabilities.completionInsertTextModes.contains(
    InsertTextMode.asIs,
  );
  var useLabelDetails = capabilities.completionLabelDetails;

  var label = suggestion.displayText ?? suggestion.completion;
  assert(label.isNotEmpty);

  // Displayed labels may have additional info appended (for example '(...)' on
  // callables and ` => ` on getters) that should not be included in filterText,
  // so strip anything from the first paren/space.
  //
  // Only do this if label doesn't start with the pattern, because if it does
  // (for example for a closure `(a, b) {}`) we'll end up with an empty string
  // but we should instead use the whole label.

  // TODO(dantup): Consider including more of these raw fields in the original
  //  suggestion to avoid needing to manipulate them in this way here.
  var filterText = !label.startsWith(completionFilterTextSplitPattern)
      ? label.split(completionFilterTextSplitPattern).first.trim()
      : label;

  // If we're using label details, we also don't want the label to include any
  // additional symbols as noted above, because they will appear in the extra
  // details fields.
  if (useLabelDetails) {
    label = filterText;
  }

  // Trim any trailing comma from the (displayed) label.
  if (label.endsWith(',')) {
    label = label.substring(0, label.length - 1);
  }

  var element = suggestion.element;
  var colorPreviewHex =
      capabilities.completionItemKinds.contains(CompletionItemKind.Color) &&
          suggestion is DartCompletionSuggestion
      ? suggestion.colorHex
      : null;
  var completionKind = colorPreviewHex != null
      ? CompletionItemKind.Color
      : element != null
      ? elementKindToCompletionItemKind(
          capabilities.completionItemKinds,
          element.kind,
        )
      : suggestionKindToCompletionItemKind(
          capabilities.completionItemKinds,
          suggestion.kind,
          label,
        );

  var labelDetails = getCompletionDetail(
    suggestion,
    supportsDeprecated:
        supportsCompletionDeprecatedFlag || supportsDeprecatedTag,
  );

  // For legacy display, include short params on the end of labels as long as
  // the item doesn't have custom display text (which may already include
  // params).
  if (!useLabelDetails && suggestion.displayText == null) {
    label += labelDetails.truncatedParams;
  }

  var insertTextInfo = buildInsertText(
    supportsSnippets: supportsSnippets,
    commitCharactersEnabled: commitCharactersEnabled,
    completeFunctionCalls: completeFunctionCalls,
    requiredArgumentListString: suggestion.defaultArgumentListString,
    requiredArgumentListTextRanges: suggestion.defaultArgumentListTextRanges,
    hasOptionalParameters: suggestion.parameterNames?.isNotEmpty ?? false,
    completion: suggestion.completion,
    selectionOffset: suggestion.selectionOffset,
    selectionLength: suggestion.selectionLength,
  );
  var insertText = insertTextInfo.text;
  var insertTextFormat = insertTextInfo.format;
  var isMultilineCompletion = insertText.contains('\n');

  var rawDoc = includeDocumentation == DocumentationPreference.full
      ? suggestion.docComplete
      : includeDocumentation == DocumentationPreference.summary
      ? suggestion.docSummary
      : null;
  var cleanedDoc = cleanDartdoc(rawDoc);

  // To improve the display of some items (like pubspec version numbers),
  // short labels in the format `_foo_` in docComplete are "upgraded" to the
  // detail field.
  var labelMatch = cleanedDoc != null
      ? upgradableDocCompletePattern.firstMatch(cleanedDoc)
      : null;
  if (labelMatch != null) {
    cleanedDoc = null;
    labelDetails = (
      detail: labelMatch[1]!,
      truncatedParams: labelDetails.truncatedParams,
      truncatedSignature: labelDetails.truncatedSignature,
      autoImportUri: labelDetails.autoImportUri,
    );
  }

  // Append hex colours to the end of the docs, this will allow editors that
  // use a regex to find a color at the start/end like VS Code to show a color
  // preview.
  if (colorPreviewHex != null) {
    cleanedDoc = '${cleanedDoc ?? ''}\n\n$colorPreviewHex'.trim();
  }

  // Because we potentially send thousands of these items, we should minimise
  // the generated JSON as much as possible - for example using nulls in place
  // of empty lists/false where possible.
  return lsp.CompletionItem(
    label: label,
    kind: completionKind,
    tags: nullIfEmpty([
      if (supportsDeprecatedTag && suggestion.isDeprecated)
        lsp.CompletionItemTag.Deprecated,
    ]),
    data: resolutionData,
    detail: labelDetails.detail.nullIfEmpty,
    labelDetails: useLabelDetails
        ? CompletionItemLabelDetails(
            detail: labelDetails.truncatedSignature.nullIfEmpty,
            description: getCompletionDisplayUriString(
              uriConverter: uriConverter,
              pathContext: pathContext,
              elementLibraryUri: labelDetails.autoImportUri,
              completionFilePath: completionFilePath,
            ),
          ).nullIfEmpty
        : null,
    documentation: cleanedDoc != null
        ? asMarkupContentOrString(formats, cleanedDoc)
        : null,
    deprecated: supportsCompletionDeprecatedFlag && suggestion.isDeprecated
        ? true
        : null,
    sortText: relevanceToSortText(suggestion.relevance),
    filterText: filterText.orNullIfSameAs(
      label,
    ), // filterText uses label if not set
    insertTextFormat: insertTextFormat != lsp.InsertTextFormat.PlainText
        ? insertTextFormat
        : null, // Defaults to PlainText if not supplied
    insertTextMode:
        !hasDefaultTextMode && supportsAsIsInsertMode && isMultilineCompletion
        ? InsertTextMode.asIs
        : null,
    // When using defaults for edit range, don't use textEdit.
    textEdit: hasDefaultEditRange
        ? null
        : supportsInsertReplace && insertionRange != replacementRange
        ? Either2<InsertReplaceEdit, TextEdit>.t1(
            InsertReplaceEdit(
              insert: insertionRange,
              replace: replacementRange,
              newText: insertText,
            ),
          )
        : Either2<InsertReplaceEdit, TextEdit>.t2(
            TextEdit(range: replacementRange, newText: insertText),
          ),
    // When using defaults for edit range, use textEditText.
    textEditText: hasDefaultEditRange ? insertText.orNullIfSameAs(label) : null,
  );
}

lsp.Diagnostic toDiagnostic(
  ClientUriConverter uriConverter,
  server.ResolvedUnitResult result,
  server.Diagnostic diagnostic, {
  required Set<lsp.DiagnosticTag> supportedTags,
  required bool clientSupportsCodeDescription,
}) {
  return pluginToDiagnostic(
    uriConverter,
    (_) => result.lineInfo,
    server.newAnalysisError_fromEngine(result, diagnostic),
    supportedTags: supportedTags,
    clientSupportsCodeDescription: clientSupportsCodeDescription,
  );
}

lsp.Element toElement(server.LineInfo lineInfo, server.Element element) {
  var location = element.location;
  return lsp.Element(
    range: location != null
        ? toRange(lineInfo, location.offset, location.length)
        : null,
    name: toElementName(element),
    kind: element.kind.name,
    parameters: element.parameters,
    typeParameters: element.typeParameters,
    returnType: element.returnType,
  );
}

String toElementName(server.Element element) {
  return element.name.isNotEmpty
      ? element.name
      : (element.kind == server.ElementKind.EXTENSION
            ? (element.extendedType?.isNotEmpty ?? false)
                  ? 'extension on ${element.extendedType}'
                  : '<unnamed extension>'
            : '<unnamed>');
}

lsp.FlutterOutline toFlutterOutline(
  server.LineInfo lineInfo,
  server.FlutterOutline outline,
) {
  var attributes = outline.attributes;
  var dartElement = outline.dartElement;
  var children = outline.children;

  return lsp.FlutterOutline(
    kind: outline.kind.name,
    label: outline.label,
    className: outline.className,
    variableName: outline.variableName,
    attributes: attributes
        ?.map((attribute) => toFlutterOutlineAttribute(lineInfo, attribute))
        .toList(),
    dartElement: dartElement != null ? toElement(lineInfo, dartElement) : null,
    range: toRange(lineInfo, outline.offset, outline.length),
    codeRange: toRange(lineInfo, outline.codeOffset, outline.codeLength),
    children: children?.map((c) => toFlutterOutline(lineInfo, c)).toList(),
  );
}

lsp.FlutterOutlineAttribute toFlutterOutlineAttribute(
  server.LineInfo lineInfo,
  server.FlutterOutlineAttribute attribute,
) {
  var valueLocation = attribute.valueLocation;
  return lsp.FlutterOutlineAttribute(
    name: attribute.name,
    label: attribute.label,
    valueRange: valueLocation != null
        ? toRange(lineInfo, valueLocation.offset, valueLocation.length)
        : null,
  );
}

lsp.FoldingRangeKind? toFoldingRangeKind(server.FoldingKind kind) {
  switch (kind) {
    case server.FoldingKind.COMMENT:
    case server.FoldingKind.DOCUMENTATION_COMMENT:
    case server.FoldingKind.FILE_HEADER:
      return lsp.FoldingRangeKind.Comment;
    case server.FoldingKind.DIRECTIVES:
      return lsp.FoldingRangeKind.Imports;
    default:
      // null (actually undefined in LSP, the toJson() takes care of that) is
      // valid, and actually the value used for the majority of folds
      // (class/functions/etc.).
      return null;
  }
}

lsp.Location toLocation(
  ClientUriConverter uriConverter,
  server.Location location,
  server.LineInfo lineInfo,
) => lsp.Location(
  uri: uriConverter.toClientUri(location.file),
  range: toRange(lineInfo, location.offset, location.length),
);

ErrorOr<int> toOffset(
  server.LineInfo lineInfo,
  lsp.Position pos, {
  bool failureIsCritical = false,
}) {
  // line is zero-based so cannot equal lineCount
  if (pos.line >= lineInfo.lineCount) {
    return ErrorOr<int>.error(
      lsp.ResponseError(
        code: failureIsCritical
            ? lsp.ServerErrorCodes.clientServerInconsistentState
            : lsp.ServerErrorCodes.invalidFileLineCol,
        message: 'Invalid line number',
        data: pos.line.toString(),
      ),
    );
  }
  // TODO(dantup): Is there any way to validate the character? We could ensure
  // it's less than the offset of the next line, but that would only work for
  // all lines except the last one.
  return ErrorOr<int>.success(
    lineInfo.getOffsetOfLine(pos.line) + pos.character,
  );
}

ErrorOr<int> toOffsetFromOffsetLineAndColumn(
  int? offsetOfLine,
  int column,
  int originalLine, {
  bool failureIsCritical = false,
}) {
  if (offsetOfLine == null) {
    return ErrorOr<int>.error(
      lsp.ResponseError(
        code: failureIsCritical
            ? lsp.ServerErrorCodes.clientServerInconsistentState
            : lsp.ServerErrorCodes.invalidFileLineCol,
        message: 'Invalid line number',
        data: originalLine.toString(),
      ),
    );
  }
  return ErrorOr<int>.success(offsetOfLine + column);
}

lsp.Outline toOutline(server.LineInfo lineInfo, server.Outline outline) {
  var children = outline.children;
  return lsp.Outline(
    element: toElement(lineInfo, outline.element),
    range: toRange(lineInfo, outline.offset, outline.length),
    codeRange: toRange(lineInfo, outline.codeOffset, outline.codeLength),
    children: children?.map((c) => toOutline(lineInfo, c)).toList(),
  );
}

lsp.Position toPosition(server.CharacterLocation location) {
  // LSP is zero-based, but analysis server is 1-based.
  return lsp.Position(
    line: location.lineNumber - 1,
    character: location.columnNumber - 1,
  );
}

lsp.Range toRange(server.LineInfo lineInfo, int offset, int length) {
  assert(offset >= 0);
  assert(length >= 0);
  var start = lineInfo.getLocation(offset);
  var end = lineInfo.getLocation(offset + length);

  return lsp.Range(start: toPosition(start), end: toPosition(end));
}

lsp.SignatureHelp toSignatureHelp(
  Set<lsp.MarkupKind>? preferredFormats,
  server.SignatureInformation signature,
) {
  // For now, we only support returning one (though we may wish to use named
  // args. etc. to provide one for each possible "next" option when the cursor
  // is at the end ready to provide another argument).

  /// Gets the label for an individual parameter in the form
  ///     String s = 'foo'
  String getParamLabel(FormalParameterElement p) {
    var defaultCodeSuffix = p.defaultValueCode != null
        ? ' = ${p.defaultValueCode}'
        : '';

    var prefix = p.isRequiredNamed ? 'required ' : '';

    return '$prefix${p.type} ${p.displayName}$defaultCodeSuffix';
  }

  /// Gets the full signature label in the form
  ///     foo(String s, int i, bool a = true)
  String getSignatureLabel(server.SignatureInformation resp) {
    var positionalRequired = signature.parameters
        .where((p) => p.isRequiredPositional)
        .toList();
    var positionalOptional = signature.parameters
        .where((p) => p.isOptionalPositional)
        .toList();
    var named = signature.parameters.where((p) => p.isNamed).toList();
    var params = [
      if (positionalRequired.isNotEmpty)
        positionalRequired.map(getParamLabel).join(', '),
      if (positionalOptional.isNotEmpty)
        '[${positionalOptional.map(getParamLabel).join(', ')}]',
      if (named.isNotEmpty) '{${named.map(getParamLabel).join(', ')}}',
    ];
    return '${resp.name}(${params.join(", ")})';
  }

  lsp.ParameterInformation toParameterInfo(FormalParameterElement param) {
    // LSP 3.14.0 supports providing label offsets (to avoid clients having
    // to guess based on substrings). We should check the
    // signatureHelp.signatureInformation.parameterInformation.labelOffsetSupport
    // capability when deciding to send that.
    return lsp.ParameterInformation(label: getParamLabel(param));
  }

  var cleanedDoc = cleanDartdoc(signature.dartdoc);

  return lsp.SignatureHelp(
    signatures: [
      lsp.SignatureInformation(
        label: getSignatureLabel(signature),
        documentation: cleanedDoc != null
            ? asMarkupContentOrString(preferredFormats, cleanedDoc)
            : null,
        parameters: signature.parameters.map(toParameterInfo).toList(),
      ),
    ],
    activeSignature: 0, // activeSignature
    // We must provide a unsigned integer here but it's possible there isn't
    // a valid value (because the user might be in the 10th argument of an
    // invocation that only takes 1). The LSP spec allows us to send an
    // out-of-bounds value so send the first out-of-bound value (`.length`). The
    // spec says this may be treated as 0, however VS Code will not highlight
    // any parameter in this case (which is preferred and hopefully other
    // clients may copy).
    activeParameter:
        signature.activeParameterIndex ?? signature.parameters.length,
  );
}

List<lsp.SnippetTextEdit> toSnippetTextEdits(
  String filePath,
  server.SourceFileEdit change,
  List<server.LinkedEditGroup> editGroups,
  LineInfo lineInfo, {
  required int? selectionOffset,
  required int? selectionLength,
}) {
  var snippetEdits = <lsp.SnippetTextEdit>[];

  // Edit groups offsets are based on the document after the edits are applied.
  // This means we must compute an offset delta for each edit that takes into
  // account all edits that might be made before it in the document (which are
  // after it in the edits). To do this, reverse the list when computing the
  // offsets, but reverse them back to the original list order when returning so
  // that we do not apply them incorrectly in tests (where we will apply them
  // in-sequence).

  var offsetDelta = 0;
  for (var edit in change.edits.reversed) {
    snippetEdits.add(
      snippetTextEditFromEditGroups(
        filePath,
        lineInfo,
        edit,
        editGroups: editGroups,
        editOffset: edit.offset + offsetDelta,
        selectionOffset: selectionOffset,
        selectionLength: selectionLength,
      ),
    );

    offsetDelta += edit.replacement.length - edit.length;
  }

  return snippetEdits.reversed.toList();
}

ErrorOr<server.SourceRange> toSourceRange(
  server.LineInfo lineInfo,
  Range range,
) {
  // If there is a range, convert to offsets because that's what
  // the tokens are computed using initially.
  var start = toOffset(lineInfo, range.start);
  var end = toOffset(lineInfo, range.end);

  return (start, end).mapResultsSync(
    (start, end) => success(server.SourceRange(start, end - start)),
  );
}

ErrorOr<server.SourceRange?> toSourceRangeNullable(
  server.LineInfo lineInfo,
  Range? range,
) => range != null ? toSourceRange(lineInfo, range) : success(null);

/// Creates an [lsp.TextDocumentEdit] for [fileEdit].
///
/// If [changeAnnotations] is not `null`, change annotations will be appended
/// for each edit produced and marked as requiring user confirmation.
lsp.TextDocumentEdit toTextDocumentEdit(
  LspClientCapabilities capabilities,
  FileEditInformation fileEdit, {
  ChangeAnnotations annotateChanges = ChangeAnnotations.none,
  Map<ChangeAnnotationIdentifier, ChangeAnnotation>? changeAnnotations,
}) {
  assert(
    (annotateChanges == ChangeAnnotations.none) == (changeAnnotations == null),
  );
  return lsp.TextDocumentEdit(
    textDocument: fileEdit.doc,
    edits: sortSourceEditsForLsp(fileEdit.edits).map((edit) {
      var annotation = recordEditAnnotation(
        fileEdit.doc.uri,
        edit,
        annotateChanges: annotateChanges,
        changeAnnotations: changeAnnotations,
      );
      return toTextDocumentEditEdit(
        capabilities,
        fileEdit.lineInfo,
        edit,
        selectionOffsetRelative: fileEdit.selectionOffsetRelative,
        selectionLength: fileEdit.selectionLength,
        annotationIdentifier: annotation?.label,
      );
    }).toList(),
  );
}

Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>
toTextDocumentEditEdit(
  LspClientCapabilities capabilities,
  server.LineInfo lineInfo,
  server.SourceEdit edit, {
  int? selectionOffsetRelative,
  int? selectionLength,
  lsp.ChangeAnnotationIdentifier? annotationIdentifier,
}) {
  if (annotationIdentifier != null) {
    return Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>.t1(
      lsp.AnnotatedTextEdit(
        annotationId: annotationIdentifier,
        range: toRange(lineInfo, edit.offset, edit.length),
        newText: edit.replacement,
      ),
    );
  }
  if (!capabilities.experimentalSnippetTextEdit ||
      selectionOffsetRelative == null) {
    return Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>.t3(
      toTextEdit(lineInfo, edit),
    );
  }
  return Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>.t2(
    snippetTextEditWithSelection(
      lineInfo,
      edit,
      selectionOffsetRelative: selectionOffsetRelative,
      selectionLength: selectionLength,
    ),
  );
}

lsp.TextEdit toTextEdit(
  server.LineInfo lineInfo,
  server.SourceEdit edit, {
  ChangeAnnotation? annotation,
}) {
  return annotation != null
      ? lsp.AnnotatedTextEdit(
          range: toRange(lineInfo, edit.offset, edit.length),
          newText: edit.replacement,
          annotationId: annotation.label,
        )
      : lsp.TextEdit(
          range: toRange(lineInfo, edit.offset, edit.length),
          newText: edit.replacement,
        );
}

/// Creates an [lsp.WorkspaceEdit] for [edits].
///
/// [clientCapabilities] should be for the client that will handle this edit,
/// which is not necessarily the client that triggered the request that called
/// this function (for example a DTD client may call a request that triggers an
/// edit that will be sent to the editor).
///
/// If [annotateChanges] is set, change annotations will be produced and
/// marked as needing confirmation from the user (depending on the value).
lsp.WorkspaceEdit toWorkspaceEdit(
  LspClientCapabilities clientCapabilities,
  List<FileEditInformation> edits, {
  ChangeAnnotations annotateChanges = ChangeAnnotations.none,
}) {
  var supportsDocumentChanges = clientCapabilities.documentChanges;
  var changeAnnotations = annotateChanges != ChangeAnnotations.none
      ? <lsp.ChangeAnnotationIdentifier, ChangeAnnotation>{}
      : null;

  if (supportsDocumentChanges) {
    var supportsCreate = clientCapabilities.createResourceOperations;
    var changes =
        <
          Either4<
            lsp.CreateFile,
            lsp.DeleteFile,
            lsp.RenameFile,
            lsp.TextDocumentEdit
          >
        >[];

    // Convert each SourceEdit to either a TextDocumentEdit or a
    // CreateFile + a TextDocumentEdit depending on whether it's a new
    // file.
    for (var fileEdit in edits) {
      if (supportsCreate && fileEdit.newFile) {
        var create = lsp.CreateFile(uri: fileEdit.doc.uri);
        var createUnion =
            Either4<
              lsp.CreateFile,
              lsp.DeleteFile,
              lsp.RenameFile,
              lsp.TextDocumentEdit
            >.t1(create);
        changes.add(createUnion);
      }

      var textDocEdit = toTextDocumentEdit(
        clientCapabilities,
        fileEdit,
        annotateChanges: annotateChanges,
        changeAnnotations: changeAnnotations,
      );
      var textDocEditUnion =
          Either4<
            lsp.CreateFile,
            lsp.DeleteFile,
            lsp.RenameFile,
            lsp.TextDocumentEdit
          >.t4(textDocEdit);
      changes.add(textDocEditUnion);
    }

    return lsp.WorkspaceEdit(
      documentChanges: changes,
      changeAnnotations: changeAnnotations,
    );
  } else {
    return lsp.WorkspaceEdit(
      changes: toWorkspaceEditChanges(
        edits,
        annotateChanges: annotateChanges,
        changeAnnotations: changeAnnotations,
      ),
      changeAnnotations: changeAnnotations,
    );
  }
}

Map<Uri, List<lsp.TextEdit>> toWorkspaceEditChanges(
  List<FileEditInformation> edits, {
  ChangeAnnotations annotateChanges = ChangeAnnotations.none,
  Map<ChangeAnnotationIdentifier, ChangeAnnotation>? changeAnnotations,
}) {
  MapEntry<Uri, List<lsp.TextEdit>> createEdit(FileEditInformation file) {
    var edits = sortSourceEditsForLsp(file.edits).map((edit) {
      var annotation = recordEditAnnotation(
        file.doc.uri,
        edit,
        annotateChanges: annotateChanges,
        changeAnnotations: changeAnnotations,
      );
      return toTextEdit(file.lineInfo, edit, annotation: annotation);
    }).toList();
    return MapEntry(file.doc.uri, edits);
  }

  return Map<Uri, List<lsp.TextEdit>>.fromEntries(edits.map(createEdit));
}

lsp.MarkupContent _asMarkup(
  Set<lsp.MarkupKind> preferredFormats,
  String content,
) {
  if (preferredFormats.isEmpty) {
    preferredFormats.add(lsp.MarkupKind.Markdown);
  }

  var supportsMarkdown = preferredFormats.contains(lsp.MarkupKind.Markdown);
  var supportsPlain = preferredFormats.contains(lsp.MarkupKind.PlainText);
  // Since our PlainText version is actually just Markdown, only advertise it
  // as PlainText if the client explicitly supports PlainText and not Markdown.
  var format = supportsPlain && !supportsMarkdown
      ? lsp.MarkupKind.PlainText
      : lsp.MarkupKind.Markdown;

  return lsp.MarkupContent(kind: format, value: content);
}

String _diagnosticCode(server.DiagnosticCode code) => code.lowerCaseName;

/// Additional details about a completion that may be formatted differently
/// depending on the client capabilities.
typedef CompletionDetail = ({
  /// Additional details to go in the details popup.
  ///
  /// This is usually a full signature (with full parameters) and may also
  /// include whether the item is deprecated if the client did not support the
  /// native deprecated tag.
  String detail,

  /// Truncated parameters. Similar to [truncatedSignature] but does not
  /// include return types. Used in clients that cannot format signatures
  /// differently and is appended immediately after the completion label. The
  /// return type is omitted to reduce noise because this text is not subtle.
  String truncatedParams,

  /// A signature with truncated params. Used for showing immediately after
  /// the completion label when it can be formatted differently.
  ///
  /// () → String
  String truncatedSignature,

  /// The URI that will be auto-imported if this item is selected in a
  /// user-friendly string format (for example a relative path if for a `file:/`
  /// URI).
  Uri? autoImportUri,
});

extension CompletionLabelExtension on CompletionItemLabelDetails {
  /// Returns `null` if no fields are set, otherwise `this`.
  CompletionItemLabelDetails? get nullIfEmpty =>
      detail != null || description != null ? this : null;
}
