diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 333d2c68b5..b7e624b4e2 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -612,17 +612,17 @@ abstract class FlutterCommand extends Command { valueHelp: 'foo=bar', splitCommas: false, ); - useDartDefineConfigJsonFileOption(); + useDartDefineFromFileOption(); } - void useDartDefineConfigJsonFileOption() { + void useDartDefineFromFileOption() { argParser.addMultiOption( FlutterOptions.kDartDefineFromFileOption, - help: 'The path of a json format file where flutter define a global constant pool. ' - 'Json entry will be available as constants from the String.fromEnvironment, bool.fromEnvironment, ' - 'and int.fromEnvironment constructors; the key and field are json values.\n' + help: + 'The path of a .json or .env file containing key-value pairs that will be available as environment variables.\n' + 'These can be accessed using the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment constructors.\n' 'Multiple defines can be passed by repeating "--${FlutterOptions.kDartDefineFromFileOption}" multiple times.', - valueHelp: 'use-define-config.json', + valueHelp: 'use-define-config.json|.env', splitCommas: false, ); } @@ -1341,18 +1341,29 @@ abstract class FlutterCommand extends Command { final Map dartDefineConfigJsonMap = {}; if (argParser.options.containsKey(FlutterOptions.kDartDefineFromFileOption)) { - final List configJsonPaths = stringsArg( + final List configFilePaths = stringsArg( FlutterOptions.kDartDefineFromFileOption, ); - for (final String path in configJsonPaths) { + for (final String path in configFilePaths) { if (!globals.fs.isFileSync(path)) { throwToolExit('Json config define file "--${FlutterOptions .kDartDefineFromFileOption}=$path" is not a file, ' 'please fix first!'); } - final String configJsonRaw = globals.fs.file(path).readAsStringSync(); + final String configRaw = globals.fs.file(path).readAsStringSync(); + + // Determine whether the file content is JSON or .env format. + String configJsonRaw; + if (configRaw.trim().startsWith('{')) { + configJsonRaw = configRaw; + } else { + + // Convert env file to JSON. + configJsonRaw = convertEnvFileToJsonRaw(configRaw); + } + try { // Fix json convert Object value :type '_InternalLinkedHashMap' is not a subtype of type 'Map' in type cast (json.decode(configJsonRaw) as Map) @@ -1370,6 +1381,88 @@ abstract class FlutterCommand extends Command { return dartDefineConfigJsonMap; } + /// Parse a property line from an env file. + /// Supposed property structure should be: + /// key=value + /// + /// Where: key is a string without spaces and value is a string. + /// Value can also contain '=' char. + /// + /// Returns a record of key and value as strings. + MapEntry _parseProperty(String line) { + final RegExp blockRegExp = RegExp(r'^\s*([a-zA-Z_]+[a-zA-Z0-9_]*)\s*=\s*"""\s*(.*)$'); + if (blockRegExp.hasMatch(line)) { + throwToolExit('Multi-line value is not supported: $line'); + } + + final RegExp propertyRegExp = RegExp(r'^\s*([a-zA-Z_]+[a-zA-Z0-9_]*)\s*=\s*(.*)?$'); + final Match? match = propertyRegExp.firstMatch(line); + if (match == null) { + throwToolExit('Unable to parse file provided for ' + '--${FlutterOptions.kDartDefineFromFileOption}.\n' + 'Invalid property line: $line'); + } + + final String key = match.group(1)!; + final String value = match.group(2) ?? ''; + + // Remove wrapping quotes and trailing line comment. + final RegExp doubleQuoteValueRegExp = RegExp(r'^"(.*)"\s*(\#\s*.*)?$'); + final Match? doubleQuoteValue = doubleQuoteValueRegExp.firstMatch(value); + if (doubleQuoteValue != null) { + return MapEntry(key, doubleQuoteValue.group(1)!); + } + + final RegExp quoteValueRegExp = RegExp(r"^'(.*)'\s*(\#\s*.*)?$"); + final Match? quoteValue = quoteValueRegExp.firstMatch(value); + if (quoteValue != null) { + return MapEntry(key, quoteValue.group(1)!); + } + + final RegExp backQuoteValueRegExp = RegExp(r'^`(.*)`\s*(\#\s*.*)?$'); + final Match? backQuoteValue = backQuoteValueRegExp.firstMatch(value); + if (backQuoteValue != null) { + return MapEntry(key, backQuoteValue.group(1)!); + } + + final RegExp noQuoteValueRegExp = RegExp(r'^([^#\n\s]*)\s*(?:\s*#\s*(.*))?$'); + final Match? noQuoteValue = noQuoteValueRegExp.firstMatch(value); + if (noQuoteValue != null) { + return MapEntry(key, noQuoteValue.group(1)!); + } + + return MapEntry(key, value); + } + + /// Converts an .env file string to its equivalent JSON string. + /// + /// For example, the .env file string + /// key=value # comment + /// complexKey="foo#bar=baz" + /// would be converted to a JSON string equivalent to: + /// { + /// "key": "value", + /// "complexKey": "foo#bar=baz" + /// } + /// + /// Multiline values are not supported. + String convertEnvFileToJsonRaw(String configRaw) { + final List lines = configRaw + .split('\n') + .map((String line) => line.trim()) + .where((String line) => line.isNotEmpty) + .where((String line) => !line.startsWith('#')) // Remove comment lines. + .toList(); + + final Map propertyMap = {}; + for (final String line in lines) { + final MapEntry property = _parseProperty(line); + propertyMap[property.key] = property.value; + } + + return jsonEncode(propertyMap); + } + /// Updates dart-defines based on [webRenderer]. @visibleForTesting static List updateDartDefines(List dartDefines, WebRendererMode webRenderer) { diff --git a/packages/flutter_tools/test/commands.shard/permeable/build_bundle_test.dart b/packages/flutter_tools/test/commands.shard/permeable/build_bundle_test.dart index f0d9714beb..c4a501897f 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/build_bundle_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/build_bundle_test.dart @@ -518,7 +518,8 @@ void main() { "kDouble": 1.1, "name": "denghaizhu", "title": "this is title from config json file", - "nullValue": null + "nullValue": null, + "containEqual": "sfadsfv=432f" } ''' ); @@ -549,6 +550,7 @@ void main() { 'name=denghaizhu', 'title=this is title from config json file', 'nullValue=null', + 'containEqual=sfadsfv=432f', 'body=this is body from config json file', ]), ); @@ -557,6 +559,155 @@ void main() { ProcessManager: () => FakeProcessManager.any(), }); + testUsingContext('--dart-define-from-file correctly parses a valid env file', () async { + globals.fs + .file(globals.fs.path.join('lib', 'main.dart')) + .createSync(recursive: true); + globals.fs.file('pubspec.yaml').createSync(); + globals.fs.file('.packages').createSync(); + await globals.fs.file('.env').writeAsString(''' + # comment + kInt=1 + kDouble=1.1 # should be double + + name=piotrfleury + title=this is title from config env file + empty= + + doubleQuotes="double quotes 'value'#=" # double quotes + singleQuotes='single quotes "value"#=' # single quotes + backQuotes=`back quotes "value" '#=` # back quotes + + hashString="some-#-hash-string-value" + + # Play around with spaces around the equals sign. + spaceBeforeEqual =value + spaceAroundEqual = value + spaceAfterEqual= value + + '''); + await globals.fs.file('.env2').writeAsString(''' + # second comment + + body=this is body from config env file + '''); + final CommandRunner runner = + createTestCommandRunner(BuildBundleCommand( + logger: BufferLogger.test(), + )); + + await runner.run([ + 'bundle', + '--no-pub', + '--dart-define-from-file=.env', + '--dart-define-from-file=.env2', + ]); + }, overrides: { + BuildSystem: () => TestBuildSystem.all(BuildResult(success: true), + (Target target, Environment environment) { + expect( + _decodeDartDefines(environment), + containsAllInOrder(const [ + 'kInt=1', + 'kDouble=1.1', + 'name=piotrfleury', + 'title=this is title from config env file', + 'empty=', + "doubleQuotes=double quotes 'value'#=", + 'singleQuotes=single quotes "value"#=', + 'backQuotes=back quotes "value" \'#=', + 'hashString=some-#-hash-string-value', + 'spaceBeforeEqual=value', + 'spaceAroundEqual=value', + 'spaceAfterEqual=value', + 'body=this is body from config env file' + ]), + ); + }), + FileSystem: fsFactory, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('--dart-define-from-file option env file throws a ToolExit when .env file contains a multiline value', () async { + globals.fs + .file(globals.fs.path.join('lib', 'main.dart')) + .createSync(recursive: true); + globals.fs.file('pubspec.yaml').createSync(); + globals.fs.file('.packages').createSync(); + await globals.fs.file('.env').writeAsString(''' + # single line value + name=piotrfleury + + # multi-line value + multiline = """ Welcome to .env demo + a simple counter app with .env file support + for more info, check out the README.md file + Thanks! """ # This is the welcome message that will be displayed on the counter app + + '''); + final CommandRunner runner = + createTestCommandRunner(BuildBundleCommand( + logger: BufferLogger.test(), + )); + + expect(() => runner.run([ + 'bundle', + '--no-pub', + '--dart-define-from-file=.env', + ]), throwsToolExit(message: 'Multi-line value is not supported: multiline = """ Welcome to .env demo')); + }, overrides: { + BuildSystem: () => TestBuildSystem.all(BuildResult(success: true)), + FileSystem: fsFactory, + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('--dart-define-from-file option works with mixed file formats', + () async { + globals.fs + .file(globals.fs.path.join('lib', 'main.dart')) + .createSync(recursive: true); + globals.fs.file('pubspec.yaml').createSync(); + globals.fs.file('.packages').createSync(); + await globals.fs.file('.env').writeAsString(''' + kInt=1 + kDouble=1.1 + name=piotrfleury + title=this is title from config env file + '''); + await globals.fs.file('config.json').writeAsString(''' + { + "body": "this is body from config json file" + } + '''); + final CommandRunner runner = + createTestCommandRunner(BuildBundleCommand( + logger: BufferLogger.test(), + )); + + await runner.run([ + 'bundle', + '--no-pub', + '--dart-define-from-file=.env', + '--dart-define-from-file=config.json', + ]); + }, overrides: { + BuildSystem: () => TestBuildSystem.all(BuildResult(success: true), + (Target target, Environment environment) { + expect( + _decodeDartDefines(environment), + containsAllInOrder(const [ + 'kInt=1', + 'kDouble=1.1', + 'name=piotrfleury', + 'title=this is title from config env file', + 'body=this is body from config json file', + ]), + ); + }), + FileSystem: fsFactory, + ProcessManager: () => FakeProcessManager.any(), + }); + testUsingContext('test --dart-define-from-file option if conflict', () async { globals.fs.file(globals.fs.path.join('lib', 'main.dart')).createSync(recursive: true); globals.fs.file('pubspec.yaml').createSync();