blob: 9a3f6866e03e995f23fd4cfe4535384dbbd9340f [file] [log] [blame]
// Copyright 2014 The Flutter Authors. 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 'dart:io';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart' show immutable;
import 'package:path/path.dart' as path;
import 'common.dart';
import 'json_get.dart';
import 'layout_types.dart';
/// Signature for function that asynchonously returns a value.
typedef AsyncGetter<T> = Future<T> Function();
/// The filename of the local cache for the GraphQL response.
const String _githubCacheFileName = 'github-response.json';
/// The file of the remote repo to query.
const String _githubTargetFolder = 'src/vs/workbench/services/keybinding/browser/keyboardLayouts';
/// The full query string for GraphQL.
const String _githubQuery = '''
{
repository(owner: "microsoft", name: "vscode") {
defaultBranchRef {
target {
... on Commit {
history(first: 1) {
nodes {
oid
file(path: "$_githubTargetFolder") {
extension lineCount object {
... on Tree {
entries {
name object {
... on Blob {
text
}
}
}
}
}
}
}
}
}
}
}
}
}
''';
/// All goals in the form of KeyboardEvent.key.
final List<String> _kGoalKeys = kLayoutGoals.keys.toList();
/// A map from the key of `kLayoutGoals` (KeyboardEvent.key) to an
/// auto-incremental index.
final Map<String, int> _kGoalToIndex = Map<String, int>.fromEntries(
_kGoalKeys.asMap().entries.map(
(MapEntry<int, String> entry) => MapEntry<String, int>(entry.value, entry.key)),
);
/// Retrieve a string using the procedure defined by `ifNotExist` based on the
/// cache file at `cachePath`.
///
/// If `forceRefresh` is false, this function tries to read the cache file, calls
/// `ifNotExist` when necessary, and writes the result to the cache.
///
/// If `forceRefresh` is true, this function never read the cache file, always
/// calls `ifNotExist` when necessary, and still writes the result to the cache.
///
/// Exceptions from `ifNotExist` will be thrown, while exceptions related to
/// caching are only printed.
Future<String> _tryCached(String cachePath, bool forceRefresh, AsyncGetter<String> ifNotExist) async {
final File cacheFile = File(cachePath);
if (!forceRefresh && cacheFile.existsSync()) {
try {
final String result = cacheFile.readAsStringSync();
print('Using GitHub cache.');
return result;
} catch (exception) {
print('Error reading GitHub cache, rebuilding. Details: $exception');
}
}
final String result = await ifNotExist();
IOSink? sink;
try {
print('Requesting from GitHub...');
Directory(path.dirname(cachePath)).createSync(recursive: true);
sink = cacheFile.openWrite();
cacheFile.writeAsStringSync(result);
} catch (exception) {
print('Error writing GitHub cache. Details: $exception');
} finally {
sink?.close();
}
return result;
}
/// Make a GraphQL request, cache it, and return the result.
///
/// If `forceRefresh` is false, this function tries to read the cache file at
/// `cachePath`. Regardless of `forceRefresh`, the response is always recorded
/// in the cache file.
Future<Map<String, dynamic>> _fetchGithub(String githubToken, bool forceRefresh, String cachePath) async {
final String response = await _tryCached(cachePath, forceRefresh, () async {
final String condensedQuery = _githubQuery
.replaceAll(RegExp(r'\{ +'), '{')
.replaceAll(RegExp(r' +\}'), '}');
final http.Response response = await http.post(
Uri.parse('https://api.github.com/graphql'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': 'bearer $githubToken',
},
body: jsonEncode(<String, String>{
'query': condensedQuery,
}),
);
if (response.statusCode != 200) {
throw Exception('Request to GitHub failed with status code ${response.statusCode}: ${response.reasonPhrase}');
}
return response.body;
});
return jsonDecode(response) as Map<String, dynamic>;
}
@immutable
class _GitHubFile {
const _GitHubFile({required this.name, required this.content});
final String name;
final String content;
}
_GitHubFile _jsonGetGithubFile(JsonContext<JsonArray> files, int index) {
final JsonContext<JsonObject> file = jsonGetIndex<JsonObject>(files, index);
return _GitHubFile(
name: jsonGetKey<String>(file, 'name').current,
content: jsonGetPath<String>(file, 'object.text').current,
);
}
/// Parses a literal JavaScript string that represents a character, which might
/// have been escaped or empty.
String _parsePrintable(String rawString, int isDeadKey) {
// Parse a char represented in unicode hex, such as \u001b.
final RegExp hexParser = RegExp(r'^\\u([0-9a-fA-F]+)$');
if (isDeadKey != 0) {
return LayoutEntry.kDeadKey;
}
if (rawString.isEmpty) {
return '';
}
final RegExpMatch? hexMatch = hexParser.firstMatch(rawString);
if (hexMatch != null) {
final int codeUnit = int.parse(hexMatch.group(1)!, radix: 16);
return String.fromCharCode(codeUnit);
}
return const <String, String>{
r'\\': r'\',
r'\r': '\r',
r'\b': '\b',
r'\t': '\t',
r"\'": "'",
}[rawString] ?? rawString;
}
LayoutPlatform _platformFromGithubString(String origin) {
switch (origin) {
case 'win':
return LayoutPlatform.win;
case 'linux':
return LayoutPlatform.linux;
case 'darwin':
return LayoutPlatform.darwin;
default:
throw ArgumentError('Unexpected platform "$origin".');
}
}
/// Parses a single layout file.
Layout _parseLayoutFromGithubFile(_GitHubFile file) {
final Map<String, LayoutEntry> entries = <String, LayoutEntry>{};
// Parse a line that looks like the following, and get its key as well as
// the content within the square bracket.
//
// F19: [],
// KeyZ: ['y', 'Y', '', '', 0, 'VK_Y'],
final RegExp lineParser = RegExp(r'^[ \t]*(.+?): \[(.*)\],$');
// Parse each child of the content within the square bracket.
final RegExp listParser = RegExp(r"^'(.*?)', '(.*?)', '(.*?)', '(.*?)', (\d)(?:, '(.+)')?$");
file.content.split('\n').forEach((String line) {
final RegExpMatch? lineMatch = lineParser.firstMatch(line);
if (lineMatch == null) {
return;
}
// KeyboardKey.code, such as "KeyZ".
final String eventCode = lineMatch.group(1)!;
// Only record goals.
if (!_kGoalToIndex.containsKey(eventCode)) {
return;
}
// Comma-separated definition as a string, such as "'y', 'Y', '', '', 0, 'VK_Y'".
final String definition = lineMatch.group(2)!;
if (definition.isEmpty) {
return;
}
// Group 1-4 are single strings for an entry, such as "y", "", "\u001b".
// Group 5 is the dead mask.
final RegExpMatch? listMatch = listParser.firstMatch(definition);
assert(listMatch != null, 'Unable to match $definition');
final int deadMask = int.parse(listMatch!.group(5)!, radix: 10);
entries[eventCode] = LayoutEntry(
<String>[
_parsePrintable(listMatch.group(1)!, deadMask & 0x1),
_parsePrintable(listMatch.group(2)!, deadMask & 0x2),
_parsePrintable(listMatch.group(3)!, deadMask & 0x4),
_parsePrintable(listMatch.group(4)!, deadMask & 0x8),
],
);
});
for (final String goalKey in _kGoalKeys) {
entries.putIfAbsent(goalKey, () => LayoutEntry.empty);
}
// Parse the file name, which looks like "en-belgian.win.ts".
final RegExp fileNameParser = RegExp(r'^([^.]+)\.([^.]+)\.ts$');
late final Layout layout;
try {
final RegExpMatch? match = fileNameParser.firstMatch(file.name);
final String layoutName = match!.group(1)!;
final LayoutPlatform platform = _platformFromGithubString(match.group(2)!);
layout = Layout(layoutName, platform, entries);
} catch (exception) {
throw ArgumentError('Unrecognizable file name ${file.name}.');
}
return layout;
}
/// Sort layouts by language first, then by platform.
int _sortLayout(Layout a, Layout b) {
int result = a.language.compareTo(b.language);
if (result == 0) {
result = a.platform.index.compareTo(b.platform.index);
}
return result;
}
/// The overall results returned from the GitHub request.
class GithubResult {
/// Create a [GithubResult].
const GithubResult(this.layouts, this.url);
/// All layouts, sorted.
final List<Layout> layouts;
/// The URL that points to the source folder of the VSCode GitHub repo,
/// containing the correct commit hash.
final String url;
}
/// Fetch necessary information from the VSCode GitHub repo.
///
/// The GraphQL request is made using the token `githubToken` (which requires
/// no extra access). The response is cached in files under directory
/// `cacheRoot`.
///
/// If `force` is false, this function tries to read the cache. Regardless of
/// `force`, the response is always recorded in the cache.
Future<GithubResult> fetchFromGithub({
required String githubToken,
required bool force,
required String cacheRoot,
}) async {
// Fetch files from GitHub.
final Map<String, dynamic> githubBody = await _fetchGithub(
githubToken,
force,
path.join(cacheRoot, _githubCacheFileName),
);
// Parse the result from GitHub.
final JsonContext<JsonObject> commitJson = jsonGetPath<JsonObject>(
JsonContext.root(githubBody),
'data.repository.defaultBranchRef.target.history.nodes.0',
);
final String commitId = jsonGetKey<String>(commitJson, 'oid').current;
final JsonContext<JsonArray> fileListJson = jsonGetPath<JsonArray>(
commitJson,
'file.object.entries',
);
final Iterable<_GitHubFile> files = Iterable<_GitHubFile>.generate(
fileListJson.current.length,
(int index) => _jsonGetGithubFile(fileListJson, index),
).where(
// Exclude controlling files, which contain no layout information.
(_GitHubFile file) => !file.name.startsWith('layout.contribution.')
&& !file.name.startsWith('_.contribution'),
);
// Layouts must be sorted to ensure that the output file has a fixed order.
final List<Layout> layouts = files.map(_parseLayoutFromGithubFile)
.toList()
..sort(_sortLayout);
final String url = 'https://github.com/microsoft/vscode/tree/$commitId/$_githubTargetFolder';
return GithubResult(layouts, url);
//
}