| // 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); |
| |
| // |
| } |