blob: 377b00ee641b8ab225b5fb64348b94a55524306e [file]
// Copyright (c) 2026, 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 'dart:convert';
import 'package:analysis_server/lsp_protocol/protocol.dart'
show InitializeParams;
import 'package:analysis_server/src/session_logger/log_entry.dart';
import 'package:collection/collection.dart';
import 'package:language_server_protocol/json_parsing.dart';
import 'package:path/path.dart' as path;
/// A utility for normalizing paths in log entries.
class LogNormalizer {
final path.Context pathContext;
/// A map from the path-to-be-replaced to the replacement string.
///
/// Paths are canonicalized to lowercase and the replacement regex is
/// case-insensitive.
final Map<String, String> _replacements =
CanonicalizedMap<String, String, String>((key) => key.toLowerCase());
/// A cached regex for all current replacements to allow them to occur in a
/// single pass.
RegExp? _replacementPattern;
LogNormalizer(this.pathContext);
/// Extracts paths and URIs from an LSP initialize request and adds
/// replacements for each.
void addLspWorkspaceReplacements(Message message) {
var params = message.params;
if (params == null ||
!InitializeParams.canParse(params, nullLspJsonReporter)) {
return;
}
var initializeParams = InitializeParams.fromJson(params);
if (initializeParams.rootPath case var rootPath?) {
addPathReplacement(rootPath, '{{rootPath}}');
}
if (initializeParams.rootUri case var rootUri?) {
if (rootUri.isScheme('file')) {
addUriReplacement(rootUri, '{{rootUri}}');
}
}
if (initializeParams.workspaceFolders case var workspaceFolders?) {
for (var i = 0; i < workspaceFolders.length; i++) {
var uri = workspaceFolders[i].uri;
if (uri.isScheme('file')) {
addUriReplacement(uri, '{{workspaceFolder-$i}}');
}
}
}
}
/// Adds a replacement for the given [inputPath] in both path and URI form.
///
/// Replacements assume the path (or URI) appear as quoted strings in the
/// JSON (and therefore must be preceeded by a quote and followed by a
/// path/uri separator or another quote).
void addPathReplacement(String inputPath, String replacement) {
if (inputPath.isEmpty) return;
var uri = Uri.file(inputPath);
var uriString = uri.toString();
// VS Code always encodes colons in the path part so we need to handle
// file:///c:/foo and
// file:///c%3A/foo
var uriStringWithEncodedColons = uri
.replace(path: uri.path.replaceAll(':', '%3A'))
.toString();
void addWithQuotesAndTrailingSlash(String input, String separator) {
_replacements['"$input"'] = '"$replacement"';
_replacements['"$input$separator'] = '"$replacement$separator';
}
// All replacements must be in the map because we need to look up the values
// during replacement so if the regex matches variations, we need to be able
// to find them here.
// TODO(dantup): If a client json encodes slightly differently to Dart, the
// encoded version here might not be a match. Perhaps we should instead
// build RegExp's here that handle encoded/unencoded versions of each
// character that might be different (and fold the special case for colon
// from above into it).
// Paths
var separator = pathContext.separator;
addWithQuotesAndTrailingSlash(inputPath, separator);
addWithQuotesAndTrailingSlash(_jsonEncode(inputPath), separator);
// URIs
separator = '/';
addWithQuotesAndTrailingSlash(uriString, separator);
addWithQuotesAndTrailingSlash(_jsonEncode(uriString), separator);
addWithQuotesAndTrailingSlash(uriStringWithEncodedColons, separator);
addWithQuotesAndTrailingSlash(
_jsonEncode(uriStringWithEncodedColons),
separator,
);
// Reset the cached pattern so it's built on the next call to normalize.
_replacementPattern = null;
}
/// Adds a replacement for the given [inputUri] in both path and URI form, but raw
/// and JSON encoded.
void addUriReplacement(Uri uri, String replacement) {
if (uri.isScheme('file')) {
addPathReplacement(uri.toFilePath(), replacement);
}
}
/// Returns the given [json] string after replacing any known paths with
/// placeholders.
String normalize(String json) {
if (_replacements.isEmpty) return json;
// Build a single regex that matches any of the replacements.
var replacementPattern = _replacementPattern ??= RegExp(
(_replacements.keys.toList()
// Sort keys longest-to-shortest so that we always replace the
// largest part of the path if there are nested workspace folders.
..sort((a, b) => b.length.compareTo(a.length)))
.map(RegExp.escape)
.join('|'),
caseSensitive: false,
);
// Replace with the respective value from the replacement map.
return json.replaceAllMapped(
replacementPattern,
(match) => _replacements[match[0]]!,
);
}
/// Returns a JSON-encoded version of [inputString] without the surrounding
/// quotes.
String _jsonEncode(String inputString) {
var encoded = json.encode(inputString);
return encoded.substring(1, encoded.length - 1);
}
}