| // Copyright 2024 The Flutter Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'dart:math'; | |
| import 'package:path/path.dart' as p; | |
| import 'package:yaml/yaml.dart'; | |
| /// This class contains config file related business logic for | |
| /// [license_utils.dart]. | |
| /// | |
| /// The [LicenseConfig] reads in config data from a YAML file into an | |
| /// object that can be used to update license headers. | |
| /// | |
| /// The 'repo wide check' test in [license_utils_test.dart] runs this logic to | |
| /// ensure that proper license headers are enforced. | |
| /// | |
| /// It is also designed so that it be used by the dt command or | |
| /// from a standalone script/application in the future. | |
| /// | |
| /// Sample config file: | |
| /// ```yaml | |
| /// # sequence of license text strings that should be matched against at the top of a file and removed. <value>, which normally represents a date, will be stored. | |
| /// remove_licenses: | |
| /// - | | |
| /// // This is some <value1> multiline license | |
| /// // text that should be removed from the file. | |
| /// - | | |
| /// /* This is other <value2> multiline license | |
| /// text that should be removed from the file. */ | |
| /// - | | |
| /// # This is more <value3> multiline license | |
| /// # text that should be removed from the file. | |
| /// - | | |
| /// // This is some multiline license text to | |
| /// // remove that does not contain a stored value. | |
| /// # sequence of license text strings that should be added to the top of a file. {value} will be replaced. | |
| /// add_licenses: | |
| /// - | | |
| /// // This is some <value1> multiline license | |
| /// // text that should be added to the file. | |
| /// - | | |
| /// # This is other <value3> multiline license | |
| /// # text that should be added to the file. | |
| /// - | | |
| /// // This is some multiline license text to | |
| /// // add that does not contain a stored value. | |
| /// # defines which files should have license text added or updated. | |
| /// update_paths: | |
| /// # path(s) to recursively check for files to remove/add license | |
| /// include: | |
| /// - /repo_root | |
| /// # path(s) to recursively check for files to ignore | |
| /// exclude: | |
| /// # exclude everything in the /repo_root/sub_dir1 directory | |
| /// - /repo_root/sub_dir1/ | |
| /// # exclude the given files | |
| /// - /repo_root/sub_dir2/exclude1.ext1 | |
| /// - /repo_root/sub_dir2/sub_dir3/exclude2.ext2 | |
| /// file_types: | |
| /// # extension | |
| /// ext1: | |
| /// # one or more indices of remove_licenses to remove | |
| /// remove: | |
| /// - 0 | |
| /// - 1 | |
| /// # index of add_licenses to add | |
| /// add: 0 | |
| /// ext2: | |
| /// remove: | |
| /// - 2 | |
| /// add: 1 | |
| /// ``` | |
| class LicenseConfig { | |
| /// Builds a [LicenseConfig] from the provided values. | |
| LicenseConfig({ | |
| required this.removeLicenses, | |
| required this.addLicenses, | |
| required this.includePaths, | |
| required this.excludePaths, | |
| required this.fileTypes, | |
| }); | |
| /// Reads the contents of the yaml [file] and parses it into a [LicenseConfig] | |
| /// object. | |
| factory LicenseConfig.fromYamlFile(File file) { | |
| final yamlString = file.readAsStringSync(); | |
| final yamlDoc = loadYamlDocument(yamlString); | |
| final yaml = yamlDoc.contents as YamlMap; | |
| final updatePaths = yaml['update_paths']; | |
| return LicenseConfig( | |
| removeLicenses: yaml['remove_licenses'], | |
| addLicenses: yaml['add_licenses'], | |
| includePaths: updatePaths['include'], | |
| excludePaths: updatePaths['exclude'], | |
| fileTypes: updatePaths['file_types'], | |
| ); | |
| } | |
| /// Sequence of license text strings that should be matched against at the top | |
| /// of a file and removed. | |
| final YamlList removeLicenses; | |
| /// Sequence of license text strings that should be added to the top of a | |
| /// file. | |
| final YamlList addLicenses; | |
| /// Path(s) to recursively check for file to remove/add license text | |
| final YamlList includePaths; | |
| /// Path(s) to recursively check for files to ignore | |
| final YamlList excludePaths; | |
| /// Contains the extension (without a '.') and the associated indices | |
| /// of [removeLicenses] to remove and index of [addLicenses] to add for the | |
| /// file type. | |
| final YamlMap fileTypes; | |
| /// Returns the list of indices for the given [ext] of [removeLicenses] | |
| /// containing the license text to remove. | |
| YamlList getRemoveIndicesForExtension(String ext) { | |
| final fileType = fileTypes[_removeDotFromExtension(ext)]; | |
| return fileType['remove'] as YamlList; | |
| } | |
| /// Returns the index for the given [ext] of [addLicenses] containing the | |
| /// license text to add. | |
| int getAddIndexForExtension(String ext) { | |
| final fileType = fileTypes[_removeDotFromExtension(ext)]; | |
| return fileType['add']; | |
| } | |
| /// Returns whether the file should be excluded according to the config. | |
| bool shouldExclude(File file) { | |
| var included = false; | |
| for (final includePath in includePaths) { | |
| if (p.equals(includePath, file.path) || | |
| p.isWithin(includePath, file.path)) { | |
| included = true; | |
| break; | |
| } | |
| } | |
| var excluded = false; | |
| for (final excludePath in excludePaths) { | |
| if (p.equals(excludePath, file.path) || | |
| p.isWithin(excludePath, file.path)) { | |
| excluded = true; | |
| break; | |
| } | |
| } | |
| return !included || excluded; | |
| } | |
| /// The config expects an extension without a starting dot, so remove | |
| /// any that might exist (e.g. in the extension that is returned by | |
| /// [p.extension]). | |
| String _removeDotFromExtension(String ext) { | |
| if (ext.startsWith('.') && ext.length > 1) { | |
| return ext.substring(1); | |
| } | |
| return ext; | |
| } | |
| } | |
| /// This class contains license update related business logic for | |
| /// [license_utils.dart]. | |
| /// | |
| /// The [LicenseHeader] uses config data from [LicenseConfig] to update | |
| /// license text in configured files. | |
| class LicenseHeader { | |
| /// Processes the [file] for replacement information. | |
| /// | |
| /// If the [file] contains the given [existingLicenseText] within the first | |
| /// number of [byteCount] bytes, return a Record containing: | |
| /// - existingHeader: existing license header text | |
| /// - replacementHeader: replacement license header text | |
| /// | |
| /// If the file can't be read or no match is found, throws an exception. | |
| /// | |
| /// Note on stored values | |
| /// --------------------- | |
| /// Any instance of <value> in the replacement license text will be | |
| /// replaced with either: | |
| /// 1. the stored value parsed from the existing header | |
| /// 2. [defaultStoredValue], if no value is parsed | |
| /// 3. current year, if no [defaultStoredValue] is provided | |
| /// | |
| /// For now, only up to one stored value is supported. | |
| Future<({String existingHeader, String replacementHeader})> | |
| getReplacementInfo({ | |
| required File file, | |
| required String existingLicenseText, | |
| required String replacementLicenseText, | |
| required int byteCount, | |
| String? defaultStoredValue, | |
| }) async { | |
| final stream = file | |
| .openRead(0, byteCount) | |
| .transform(utf8.decoder) | |
| .handleError( | |
| (e) => throw StateError( | |
| 'License header expected, but error reading file - $e', | |
| ), | |
| ); | |
| await for (final content in stream) { | |
| // Return just the license headers for the simple case with no stored | |
| // value requested (i.e. content matches licenseText verbatim) | |
| if (content.contains(existingLicenseText)) { | |
| final storedName = _parseStoredName(replacementLicenseText); | |
| replacementLicenseText = replacementLicenseText.replaceAll( | |
| '<$storedName>', | |
| defaultStoredValue ?? DateTime.now().year.toString(), | |
| ); | |
| return ( | |
| existingHeader: existingLicenseText, | |
| replacementHeader: replacementLicenseText, | |
| ); | |
| } | |
| // Return a non-empty map for the case where there is a stored value | |
| // requested (i.e. when there is a '<value>' defined in the license text) | |
| final storedName = _parseStoredName(existingLicenseText); | |
| if (storedName.isNotEmpty) { | |
| return _processHeaders( | |
| storedName: storedName, | |
| existingLicenseText: existingLicenseText, | |
| replacementLicenseText: replacementLicenseText, | |
| content: content, | |
| ); | |
| } | |
| } | |
| throw StateError('License header expected in ${file.path}, but not found!'); | |
| } | |
| /// Returns a copy of the given [file] with the [existingHeader] replaced by | |
| /// the [replacementHeader]. | |
| /// | |
| /// Reads and writes the entire file contents all at once, so performance may | |
| /// degrade for large files. | |
| File rewriteLicenseHeader({ | |
| required File file, | |
| required String existingHeader, | |
| required String replacementHeader, | |
| }) { | |
| final rewrittenFile = File('${file.path}.tmp'); | |
| final contents = file.readAsStringSync(); | |
| final replacementContents = contents.replaceFirst( | |
| existingHeader, | |
| replacementHeader, | |
| ); | |
| rewrittenFile.writeAsStringSync(replacementContents, flush: true); | |
| return rewrittenFile; | |
| } | |
| /// Bulk update license headers for files in the [directory] as configured | |
| /// in the [config] and return a processed paths Record containing: | |
| /// - list of included paths | |
| /// - list of updated paths | |
| /// | |
| /// If [dryRun] is set, return the processed paths Record, but no files will | |
| /// be actually be updated. | |
| Future<({List<String> includedPaths, List<String> updatedPaths})> bulkUpdate({ | |
| required Directory directory, | |
| required LicenseConfig config, | |
| bool dryRun = false, | |
| }) async { | |
| final includedPathsList = <String>[]; | |
| final updatedPathsList = <String>[]; | |
| final files = directory | |
| .listSync(recursive: true) | |
| .whereType<File>() | |
| .toList(); | |
| for (final file in files) { | |
| if (!config.shouldExclude(file)) { | |
| includedPathsList.add(file.path); | |
| final extension = p.extension(file.path); | |
| final removeIndices = config.getRemoveIndicesForExtension(extension); | |
| for (final removeIndex in removeIndices) { | |
| final existingLicenseText = config.removeLicenses[removeIndex]; | |
| final addIndex = config.getAddIndexForExtension(extension); | |
| final replacementLicenseText = config.addLicenses[addIndex]; | |
| final fileLength = file.lengthSync(); | |
| const bufferSize = 20; | |
| // Assume that the license text will be near the start of the file, | |
| // but add in some buffer. | |
| final byteCount = min( | |
| bufferSize + existingLicenseText.length, | |
| fileLength, | |
| ); | |
| final replacementInfo = await getReplacementInfo( | |
| file: file, | |
| existingLicenseText: existingLicenseText, | |
| replacementLicenseText: replacementLicenseText, | |
| byteCount: byteCount as int, | |
| ); | |
| if (replacementInfo.existingHeader.isNotEmpty && | |
| replacementInfo.replacementHeader.isNotEmpty) { | |
| if (dryRun) { | |
| updatedPathsList.add(file.path); | |
| } else { | |
| final rewrittenFile = rewriteLicenseHeader( | |
| file: file, | |
| existingHeader: replacementInfo.existingHeader, | |
| replacementHeader: replacementInfo.replacementHeader, | |
| ); | |
| if (rewrittenFile.lengthSync() > 0) { | |
| file.writeAsStringSync( | |
| rewrittenFile.readAsStringSync(), | |
| mode: FileMode.writeOnly, | |
| flush: true, | |
| ); | |
| updatedPathsList.add(file.path); | |
| } | |
| rewrittenFile.deleteSync(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return (includedPaths: includedPathsList, updatedPaths: updatedPathsList); | |
| } | |
| ({String existingHeader, String replacementHeader}) _processHeaders({ | |
| required String storedName, | |
| required String existingLicenseText, | |
| required String replacementLicenseText, | |
| required String content, | |
| }) { | |
| final matchStr = RegExp.escape(existingLicenseText); | |
| final storedNameIndex = matchStr.indexOf('<$storedName>'); | |
| if (storedNameIndex != -1) { | |
| final beforeStoredName = matchStr.substring(0, storedNameIndex); | |
| final afterStoredName = matchStr | |
| .substring(storedNameIndex + storedName.length + 2) | |
| .trimRight(); | |
| final storedMatcher = RegExp( | |
| r'' + | |
| beforeStoredName + | |
| r'((?<' + | |
| storedName + | |
| r'>\S+))' + | |
| afterStoredName, | |
| ); | |
| if (storedMatcher.hasMatch(content)) { | |
| final match = storedMatcher.firstMatch(content); | |
| final existingHeaderValue = match?.group(0); | |
| final storedValue = match?.namedGroup(storedName); | |
| final replacementHeaderValue = replacementLicenseText.replaceAll( | |
| '<$storedName>', | |
| storedValue ?? DateTime.now().year.toString(), | |
| ); | |
| return ( | |
| existingHeader: existingHeaderValue ?? '', | |
| replacementHeader: replacementHeaderValue, | |
| ); | |
| } | |
| } | |
| return const (existingHeader: '', replacementHeader: ''); | |
| } | |
| // TODO(mossmana) Add support for multiple stored names | |
| String _parseStoredName(String licenseText) { | |
| final storedMatch = RegExp(r'<(\S+)>').firstMatch(licenseText); | |
| final storedName = storedMatch?.group(1); | |
| return storedName ?? ''; | |
| } | |
| } |