// 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 'package:meta/meta.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../convert.dart'; import '../plugins.dart'; import '../project.dart'; import 'visual_studio_project.dart'; // Constants corresponding to specific reference types in a solution file. // These values are defined by the .sln format. const String _kSolutionTypeGuidFolder = '2150E333-8FDC-42A3-9474-1A3956D46DE8'; const String _kSolutionTypeGuidVcxproj = '8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942'; // The GUID for the folder above, managed by this class. This is an arbitrary // value that was randomly generated, but it should not be changed since that // would cause issues for existing Flutter projects. const String _kFlutterPluginSolutionFolderGuid = '5C2E738A-1DD3-445A-AAC8-EEB9648DD07C'; // The FlutterBuild project GUID. This is an arbitrary // value that was randomly generated, but it should not be changed since that // would cause issues for existing Flutter projects. const String _kFlutterBuildProjectGuid = '6419BF13-6ECD-4CD2-9E85-E566A1F03F8F'; /// Extracts and stores the plugin name and vcxproj GUID for [plugin]. class _PluginProjectInfo { _PluginProjectInfo(Plugin plugin, { @required FileSystem fileSystem, }) { name = plugin.name; final File projectFile = fileSystem.directory(plugin.path).childDirectory('windows').childFile('plugin.vcxproj'); try { guid = VisualStudioProject(projectFile, fileSystem: fileSystem).guid; } on FileSystemException { throwToolExit('Unable to find a plugin.vcxproj for plugin "$name"'); } if (guid == null) { throwToolExit('Unable to find a plugin.vcxproj ID for plugin "$name"'); } } // The name of the plugin, which is also the name of the symlink folder. String name; // The GUID of the plugin's project. String guid; } // TODO(stuartmorgan): Consider replacing this class with a real parser. See // https://github.com/flutter/flutter/issues/51430. class VisualStudioSolutionUtils { const VisualStudioSolutionUtils({ @required WindowsProject project, @required FileSystem fileSystem, }) : _project = project, _fileSystem = fileSystem; final WindowsProject _project; final FileSystem _fileSystem; /// Updates the solution file for [project] to have the project references and /// dependencies to include [plugins], removing any previous plugins from the /// solution. Future<void> updatePlugins(List<Plugin> plugins) async { final String solutionContent = await _project.solutionFile.readAsString(); // Map of GUID to name for the current plugin list. final Map<String, String> currentPluginInfo = _getWindowsPluginNamesByGuid(plugins); // Find any plugins referenced in the project that are no longer used, and // any that are new. // // While the simplest approach to updating the solution would be to remove all // entries associated with plugins, and then add all the current plugins in // one block, Visual Studio has its own (unknown, likely data-structure-hash // based) order that it will use each time it writes out the file due to any // solution-level changes made in the UI. To avoid thrashing, and resulting // confusion (e.g., in review diffs), this update attempts to instead preserve // the ordering that is already there, so that once Visual Studio has // reordered the plugins, that order will be stable. final Set<String> existingPlugins = _findPreviousPluginGuids(solutionContent); final Set<String> currentPlugins = currentPluginInfo.keys.toSet(); final Set<String> removedPlugins = existingPlugins.difference(currentPlugins); final Set<String> addedPlugins = currentPlugins.difference(existingPlugins); final RegExp projectStartPattern = RegExp(r'^Project\("{' + _kSolutionTypeGuidVcxproj + r'}"\)\s*=\s*".*",\s*"(.*)",\s*"{([A-Fa-f0-9\-]*)}"\s*$'); final RegExp pluginsFolderProjectStartPattern = RegExp(r'^Project\("{' + _kSolutionTypeGuidFolder + r'}"\)\s*=.*"{' + _kFlutterPluginSolutionFolderGuid + r'}"\s*$'); final RegExp projectEndPattern = RegExp(r'^EndProject\s*$'); final RegExp globalStartPattern = RegExp(r'^Global\s*$'); final RegExp globalEndPattern = RegExp(r'^EndGlobal\s*$'); final RegExp projectDependenciesStartPattern = RegExp(r'^\s*ProjectSection\(ProjectDependencies\)\s*=\s*postProject\s*$'); final RegExp globalSectionProjectConfigurationStartPattern = RegExp(r'^\s*GlobalSection\(ProjectConfigurationPlatforms\)\s*=\s*postSolution\s*$'); final RegExp globalSectionNestedProjectsStartPattern = RegExp(r'^\s*GlobalSection\(NestedProjects\)\s*=\s*preSolution\s*$'); final StringBuffer newSolutionContent = StringBuffer(); // readAsString drops the BOM; re-add it. newSolutionContent.writeCharCode(unicodeBomCharacterRune); final Iterator<String> lineIterator = solutionContent.split('\n').iterator; bool foundFlutterPluginsFolder = false; bool foundNestedProjectsSection = false; bool foundRunnerProject = false; while (lineIterator.moveNext()) { final Match projectStartMatch = projectStartPattern.firstMatch(lineIterator.current); if (projectStartMatch != null) { final String guid = projectStartMatch.group(2); if (currentPlugins.contains(guid)) { // Write an up-to-date version at this location (in case, e.g., the name // has changed). _writePluginProjectEntry(guid, currentPluginInfo[guid], newSolutionContent); // Drop the old copy. _skipUntil(lineIterator, projectEndPattern); continue; } else if (removedPlugins.contains(guid)) { // Drop the stale plugin project. _skipUntil(lineIterator, projectEndPattern); continue; } else if (projectStartMatch.group(1) == _project.vcprojFile.basename) { foundRunnerProject = true; // Update the Runner project's dependencies on the plugins. // Skip to the dependencies section, or if there isn't one the end of // the project. while (!projectDependenciesStartPattern.hasMatch(lineIterator.current) && !projectEndPattern.hasMatch(lineIterator.current)) { newSolutionContent.writeln(lineIterator.current); lineIterator.moveNext(); } // Add/update the dependencies section. if (projectDependenciesStartPattern.hasMatch(lineIterator.current)) { newSolutionContent.writeln(lineIterator.current); _processSectionPluginReferences(removedPlugins, addedPlugins, lineIterator, _writeProjectDependency, newSolutionContent); } else { _writeDependenciesSection(currentPlugins, newSolutionContent); } } } if (pluginsFolderProjectStartPattern.hasMatch(lineIterator.current)) { foundFlutterPluginsFolder = true; } if (globalStartPattern.hasMatch(lineIterator.current)) { // The Global section is the end of the project list. Add any new plugins // here, since the location VS will use is unknown. They will likely be // reordered the next time VS writes the file. for (final String guid in addedPlugins) { _writePluginProjectEntry(guid, currentPluginInfo[guid], newSolutionContent); } // Also add the plugins folder if there wasn't already one. if (!foundFlutterPluginsFolder) { _writePluginFolderProjectEntry(newSolutionContent); } } // Update the ProjectConfiguration section once it is reached. if (globalSectionProjectConfigurationStartPattern.hasMatch(lineIterator.current)) { newSolutionContent.writeln(lineIterator.current); _processSectionPluginReferences(removedPlugins, addedPlugins, lineIterator, _writePluginConfigurationEntries, newSolutionContent); } // Update the NestedProjects section once it is reached. if (globalSectionNestedProjectsStartPattern.hasMatch(lineIterator.current)) { newSolutionContent.writeln(lineIterator.current); _processSectionPluginReferences(removedPlugins, addedPlugins, lineIterator, _writePluginNestingEntry, newSolutionContent); foundNestedProjectsSection = true; } // If there wasn't a NestedProjects global section, add one at the end. if (!foundNestedProjectsSection && globalEndPattern.hasMatch(lineIterator.current)) { newSolutionContent.writeln('\tGlobalSection(NestedProjects) = preSolution\r'); for (final String guid in currentPlugins) { _writePluginNestingEntry(guid, newSolutionContent); } newSolutionContent.writeln('\tEndGlobalSection\r'); } // Re-output anything that hasn't been explicitly skipped above. newSolutionContent.writeln(lineIterator.current); } if (!foundRunnerProject) { throwToolExit( 'Could not add plugins to Windows project:\n' 'Unable to find a "${_project.vcprojFile.basename}" project in ${_project.solutionFile.path}'); } await _project.solutionFile.writeAsString(newSolutionContent.toString().trimRight()); } /// Advances [iterator] it reaches an element that matches [pattern]. /// /// Note that the current element at the time of calling is *not* checked. void _skipUntil(Iterator<String> iterator, RegExp pattern) { while (iterator.moveNext()) { if (pattern.hasMatch(iterator.current)) { return; } } } /// Writes the main project entry for the plugin with the given [guid] and /// [name]. void _writePluginProjectEntry(String guid, String name, StringBuffer output) { output.write(''' Project("{$_kSolutionTypeGuidVcxproj}") = "$name", "Flutter\\ephemeral\\.plugin_symlinks\\$name\\windows\\plugin.vcxproj", "{$guid}"\r \tProjectSection(ProjectDependencies) = postProject\r \t\t{$_kFlutterBuildProjectGuid} = {$_kFlutterBuildProjectGuid}\r \tEndProjectSection\r EndProject\r '''); } /// Writes the main project entry for the Flutter Plugins solution folder. void _writePluginFolderProjectEntry(StringBuffer output) { const String folderName = 'Flutter Plugins'; output.write(''' Project("{$_kSolutionTypeGuidFolder}") = "$folderName", "$folderName", "{$_kFlutterPluginSolutionFolderGuid}"\r EndProject\r '''); } /// Writes a project dependencies section, depending on all the GUIDs in /// [dependencies]. void _writeDependenciesSection(Iterable<String> dependencies, StringBuffer output) { output.writeln('ProjectSection(ProjectDependencies) = postProject\r'); for (final String guid in dependencies) { _writeProjectDependency(guid, output); } output.writeln('EndProjectSection\r'); } /// Returns the GUIDs of all the Flutter plugin projects in the given solution. Set<String> _findPreviousPluginGuids(String solutionContent) { // Find the plugin folder's known GUID in ProjectDependencies lines. // Each line in that section has the form: // {project GUID} = {solution folder GUID} final RegExp pluginFolderChildrenPattern = RegExp( r'^\s*{([A-Fa-f0-9\-]*)}\s*=\s*{' + _kFlutterPluginSolutionFolderGuid + r'}\s*$', multiLine: true, ); return pluginFolderChildrenPattern .allMatches(solutionContent) .map((Match match) => match.group(1)).toSet(); } /// Returns a mapping of plugin project GUID to name for all the Windows plugins /// in [plugins]. Map<String, String> _getWindowsPluginNamesByGuid(List<Plugin> plugins) { final Map<String, String> currentPluginInfo = <String, String>{}; for (final Plugin plugin in plugins) { if (plugin.platforms.containsKey(_project.pluginConfigKey)) { final _PluginProjectInfo info = _PluginProjectInfo(plugin, fileSystem: _fileSystem); if (currentPluginInfo.containsKey(info.guid)) { throwToolExit('The plugins "${currentPluginInfo[info.guid]}" and "${info.name}" ' 'have the same ProjectGuid, which prevents them from being used together.\n\n' 'Please contact the plugin authors to resolve this, and/or remove one of the ' 'plugins from your project.'); } currentPluginInfo[info.guid] = info.name; } } return currentPluginInfo; } /// Walks a GlobalSection or ProjectSection, removing entries related to removed /// plugins and adding entries for new plugins at the end using /// [newEntryWriter], which takes the guid of the plugin to write entries for. /// /// The caller is responsible for printing the section start line, which should /// be [lineIterator.current] when this is called, and the section end line, /// which will be [lineIterator.current] on return. void _processSectionPluginReferences( Set<String> removedPlugins, Set<String> addedPlugins, Iterator<String> lineIterator, Function(String, StringBuffer) newEntryWriter, StringBuffer output, ) { // Extracts the guid of the project that a line refers to. Currently all // sections this function is used for start with "{project guid}", even though // the rest of the line varies by section, so the pattern can currently be // shared rather than parameterized. final RegExp entryPattern = RegExp(r'^\s*{([A-Fa-f0-9\-]*)}'); final RegExp sectionEndPattern = RegExp(r'^\s*End\w*Section\s*$'); while (lineIterator.moveNext()) { if (sectionEndPattern.hasMatch(lineIterator.current)) { // The end of the section; add entries for new plugins, then exit. for (final String guid in addedPlugins) { newEntryWriter(guid, output); } return; } // Otherwise it's the sectino body. Drop any lines associated with old // plugins, but pass everything else through as output. final Match entryMatch = entryPattern.firstMatch(lineIterator.current); if (entryMatch != null && removedPlugins.contains(entryMatch.group(1))) { continue; } output.writeln(lineIterator.current); } } /// Writes all the configuration entries for the plugin project with the given /// [guid]. /// /// Should be called within the context of writing /// GlobalSection(ProjectConfigurationPlatforms). void _writePluginConfigurationEntries(String guid, StringBuffer output) { final List<String> configurations = <String>['Debug', 'Profile', 'Release']; final List<String> entryTypes = <String>['ActiveCfg', 'Build.0']; for (final String configuration in configurations) { for (final String entryType in entryTypes) { output.writeln('\t\t{$guid}.$configuration|x64.$entryType = $configuration|x64\r'); } } } /// Writes the entries to nest the plugin projects with the given [guid] under /// the Flutter Plugins solution folder. /// /// Should be called within the context of writing /// GlobalSection(NestedProjects). void _writePluginNestingEntry(String guid, StringBuffer output) { output.writeln('\t\t{$guid} = {$_kFlutterPluginSolutionFolderGuid}\r'); } /// Writes the entrie to make a project depend on another project with the /// given [guid]. /// /// Should be called within the context of writing /// ProjectSection(ProjectDependencies). void _writeProjectDependency(String guid, StringBuffer output) { output.writeln('\t\t{$guid} = {$guid}\r'); } }