blob: fd93ddbfae9eda941651d392f3baf80eabee59ad [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';
/// A utility for normalizing paths in log entries.
class LogNormalizer {
/// 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 lazily built reverse map for [_replacements].
Map<String, String>? _denormalizedReplacements;
/// A cached regex for all current replacements to allow them to occur in a
/// single pass.
RegExp? _replacementPattern;
/// A cached regex for all current denormalizations to allow them to occur in
/// a single pass.
RegExp? _denormalizationPattern;
/// 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);
// Record rootPath and rootUri separately even though we handle both URIs
// and paths for each replacement, because we don't know for certain that
// the client provided us the same in each.
if (initializeParams.rootPath case var rootPath?) {
addReplacementsForPath(rootPath, 'rootPath');
}
if (initializeParams.rootUri case var rootUri?) {
addReplacementsForUri(rootUri, 'rootUri');
}
if (initializeParams.workspaceFolders case var workspaceFolders?) {
for (var i = 0; i < workspaceFolders.length; i++) {
var uri = workspaceFolders[i].uri;
addReplacementsForUri(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).
///
/// The replacement will be formatted as `{{$name}}` for URIs and
/// `{{$name:filePath}}` for file paths to allow for reversing this
/// replacement.
void addReplacementsForPath(String inputPath, String name) {
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,
List<String> separators,
String replacement,
) {
_replacements['"$input"'] = '"$replacement"';
for (var separator in separators) {
_replacements['"$input$separator'] = '"$replacement$separator';
}
}
// Compute replacement strings.
var pathReplacement = '{{$name:filePath}}';
var uriReplacement = '{{$name}}';
// 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 pathSeparators = ['/', r'\'];
addWithQuotesAndTrailingSlash(
_jsonEncode(inputPath),
pathSeparators,
pathReplacement,
);
addWithQuotesAndTrailingSlash(inputPath, pathSeparators, pathReplacement);
// URIs
var uriSeparators = ['/'];
addWithQuotesAndTrailingSlash(
_jsonEncode(uriString),
uriSeparators,
uriReplacement,
);
addWithQuotesAndTrailingSlash(uriString, uriSeparators, uriReplacement);
addWithQuotesAndTrailingSlash(
_jsonEncode(uriStringWithEncodedColons),
uriSeparators,
uriReplacement,
);
addWithQuotesAndTrailingSlash(
uriStringWithEncodedColons,
uriSeparators,
uriReplacement,
);
// Reset the cached pattern so it's built on the next call to normalize.
_replacementPattern = null;
_denormalizedReplacements = null;
_denormalizationPattern = null;
}
/// A convenience method for calling [addReplacementsForPath] when you have a
/// [Uri].
///
/// Only if the URI is a 'file://' URI will it be recorded.
void addReplacementsForUri(Uri uri, String replacement) {
if (uri.isScheme('file')) {
addReplacementsForPath(uri.toFilePath(), replacement);
}
}
/// Restores [normalizedContent] to full paths/URIs by performing the opposite
/// replacements.
///
/// Where normalization collapsed multiple equivalent strings into one (for
/// example different casing or URI encoding), they will all be restored to
/// a single canonical version.
String denormalize(String normalizedContent) {
if (_replacements.isEmpty) return normalizedContent;
var denormalizedReplacements = _denormalizedReplacements ??=
_buildDenormalizedReplacements();
var denormalizationPattern = _denormalizationPattern ??= RegExp(
denormalizedReplacements.keys.map(RegExp.escape).join('|'),
);
return normalizedContent.replaceAllMapped(
denormalizationPattern,
(match) => denormalizedReplacements[match[0]]!,
);
}
/// 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]]!,
);
}
Map<String, String> _buildDenormalizedReplacements() {
var denormalizedReplacements = <String, String>{};
for (var MapEntry(:key, :value) in _replacements.entries) {
// Multiple original strings can normalize to the same placeholder.
// Use the first value as the canonical form.
denormalizedReplacements.putIfAbsent(value, () => key);
}
return denormalizedReplacements;
}
/// 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);
}
}