1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'dart:io' show ProcessResult, exitCode, stderr; |
6 | |
7 | import 'package:args/args.dart' ; |
8 | import 'package:file/file.dart' ; |
9 | import 'package:file/local.dart' ; |
10 | import 'package:path/path.dart' as path; |
11 | import 'package:platform/platform.dart' ; |
12 | import 'package:process/process.dart' ; |
13 | import 'package:snippets/snippets.dart'; |
14 | |
15 | const String _kElementOption = 'element' ; |
16 | const String _kHelpOption = 'help' ; |
17 | const String _kInputOption = 'input' ; |
18 | const String _kLibraryOption = 'library' ; |
19 | const String _kOutputDirectoryOption = 'output-directory' ; |
20 | const String _kOutputOption = 'output' ; |
21 | const String _kPackageOption = 'package' ; |
22 | const String _kSerialOption = 'serial' ; |
23 | const String _kTypeOption = 'type' ; |
24 | |
25 | class GitStatusFailed implements Exception { |
26 | GitStatusFailed(this.gitResult); |
27 | |
28 | final ProcessResult gitResult; |
29 | |
30 | @override |
31 | String toString() { |
32 | return 'git status exited with a non-zero exit code: ' |
33 | ' ${gitResult.exitCode}:\n ${gitResult.stderr}\n ${gitResult.stdout}' ; |
34 | } |
35 | } |
36 | |
37 | /// A singleton filesystem that can be set by tests to a memory filesystem. |
38 | FileSystem filesystem = const LocalFileSystem(); |
39 | |
40 | /// A singleton snippet generator that can be set by tests to a mock, so that |
41 | /// we can test the command line parsing. |
42 | SnippetGenerator snippetGenerator = SnippetGenerator(); |
43 | |
44 | /// A singleton platform that can be set by tests for use in testing command line |
45 | /// parsing. |
46 | Platform platform = const LocalPlatform(); |
47 | |
48 | /// A singleton process manager that can be set by tests for use in testing. |
49 | ProcessManager processManager = const LocalProcessManager(); |
50 | |
51 | /// Get the name of the channel these docs are from. |
52 | /// |
53 | /// First check env variable LUCI_BRANCH, then refer to the currently |
54 | /// checked out git branch. |
55 | String getChannelName({ |
56 | Platform platform = const LocalPlatform(), |
57 | ProcessManager processManager = const LocalProcessManager(), |
58 | }) { |
59 | switch (platform.environment['LUCI_BRANCH' ]?.trim()) { |
60 | // Backward compatibility: Still support running on "master", but pretend it is "main". |
61 | case 'master' || 'main' : |
62 | return 'main' ; |
63 | case 'stable' : |
64 | return 'stable' ; |
65 | } |
66 | |
67 | final RegExp gitBranchRegexp = RegExp(r'^## (?<branch>.*)' ); |
68 | final ProcessResult gitResult = processManager.runSync( |
69 | <String>['git' , 'status' , '-b' , '--porcelain' ], |
70 | // Use the FLUTTER_ROOT, if defined. |
71 | workingDirectory: |
72 | platform.environment['FLUTTER_ROOT' ]?.trim() ?? filesystem.currentDirectory.path, |
73 | // Adding extra debugging output to help debug why git status inexplicably fails |
74 | // (random non-zero error code) about 2% of the time. |
75 | environment: <String, String>{'GIT_TRACE' : '2' , 'GIT_TRACE_SETUP' : '2' }, |
76 | ); |
77 | if (gitResult.exitCode != 0) { |
78 | throw GitStatusFailed(gitResult); |
79 | } |
80 | |
81 | final RegExpMatch? gitBranchMatch = gitBranchRegexp.firstMatch( |
82 | (gitResult.stdout as String).trim().split('\n' ).first, |
83 | ); |
84 | return gitBranchMatch == null |
85 | ? '<unknown>' |
86 | : gitBranchMatch.namedGroup('branch' )!.split('...' ).first; |
87 | } |
88 | |
89 | const List<String> sampleTypes = <String>['snippet' , 'sample' , 'dartpad' ]; |
90 | |
91 | // This is a hack to workaround the fact that git status inexplicably fails |
92 | // (with random non-zero error code) about 2% of the time. |
93 | String getChannelNameWithRetries({ |
94 | Platform platform = const LocalPlatform(), |
95 | ProcessManager processManager = const LocalProcessManager(), |
96 | }) { |
97 | int retryCount = 0; |
98 | |
99 | while (retryCount < 2) { |
100 | try { |
101 | return getChannelName(platform: platform, processManager: processManager); |
102 | } on GitStatusFailed catch (e) { |
103 | retryCount += 1; |
104 | stderr.write('git status failed, retrying ( $retryCount)\nError report:\n $e' ); |
105 | } |
106 | } |
107 | |
108 | return getChannelName(platform: platform, processManager: processManager); |
109 | } |
110 | |
111 | /// Generates snippet dartdoc output for a given input, and creates any sample |
112 | /// applications needed by the snippet. |
113 | void main(List<String> argList) { |
114 | final Map<String, String> environment = platform.environment; |
115 | final ArgParser parser = ArgParser(); |
116 | |
117 | parser.addOption( |
118 | _kTypeOption, |
119 | defaultsTo: 'dartpad' , |
120 | allowed: sampleTypes, |
121 | allowedHelp: <String, String>{ |
122 | 'dartpad' : 'Produce a code sample application for using in Dartpad.' , |
123 | 'sample' : 'Produce a code sample application.' , |
124 | 'snippet' : 'Produce a nicely formatted piece of sample code.' , |
125 | }, |
126 | help: 'The type of snippet to produce.' , |
127 | ); |
128 | parser.addOption( |
129 | _kOutputOption, |
130 | help: |
131 | 'The output name for the generated sample application. Overrides ' |
132 | 'the naming generated by the -- $_kPackageOption/-- $_kLibraryOption/-- $_kElementOption ' |
133 | 'arguments. Metadata will be written alongside in a .json file. ' |
134 | 'The basename of this argument is used as the ID. If this is a ' |
135 | 'relative path, will be placed under the -- $_kOutputDirectoryOption location.' , |
136 | ); |
137 | parser.addOption( |
138 | _kOutputDirectoryOption, |
139 | defaultsTo: '.' , |
140 | help: 'The output path for the generated sample application.' , |
141 | ); |
142 | parser.addOption( |
143 | _kInputOption, |
144 | defaultsTo: environment['INPUT' ], |
145 | help: 'The input file containing the sample code to inject.' , |
146 | ); |
147 | parser.addOption( |
148 | _kPackageOption, |
149 | defaultsTo: environment['PACKAGE_NAME' ], |
150 | help: 'The name of the package that this sample belongs to.' , |
151 | ); |
152 | parser.addOption( |
153 | _kLibraryOption, |
154 | defaultsTo: environment['LIBRARY_NAME' ], |
155 | help: 'The name of the library that this sample belongs to.' , |
156 | ); |
157 | parser.addOption( |
158 | _kElementOption, |
159 | defaultsTo: environment['ELEMENT_NAME' ], |
160 | help: 'The name of the element that this sample belongs to.' , |
161 | ); |
162 | parser.addOption( |
163 | _kSerialOption, |
164 | defaultsTo: environment['INVOCATION_INDEX' ], |
165 | help: 'A unique serial number for this snippet tool invocation.' , |
166 | ); |
167 | parser.addFlag( |
168 | _kHelpOption, |
169 | negatable: false, |
170 | help: 'Prints help documentation for this command' , |
171 | ); |
172 | |
173 | final ArgResults args = parser.parse(argList); |
174 | |
175 | if (args[_kHelpOption]! as bool) { |
176 | stderr.writeln(parser.usage); |
177 | exitCode = 0; |
178 | return; |
179 | } |
180 | |
181 | final String sampleType = args[_kTypeOption]! as String; |
182 | |
183 | if (args[_kInputOption] == null) { |
184 | stderr.writeln(parser.usage); |
185 | errorExit( |
186 | 'The -- $_kInputOption option must be specified, either on the command ' |
187 | 'line, or in the INPUT environment variable.' , |
188 | ); |
189 | return; |
190 | } |
191 | |
192 | final File input = filesystem.file(args['input' ]! as String); |
193 | if (!input.existsSync()) { |
194 | errorExit('The input file ${input.path} does not exist.' ); |
195 | return; |
196 | } |
197 | |
198 | final String packageName = args[_kPackageOption] as String? ?? '' ; |
199 | final String libraryName = args[_kLibraryOption] as String? ?? '' ; |
200 | final String elementName = args[_kElementOption] as String? ?? '' ; |
201 | final String serial = args[_kSerialOption] as String? ?? '' ; |
202 | late String id; |
203 | File? output; |
204 | final Directory outputDirectory = |
205 | filesystem.directory(args[_kOutputDirectoryOption]! as String).absolute; |
206 | |
207 | if (args[_kOutputOption] != null) { |
208 | id = path.basenameWithoutExtension(args[_kOutputOption]! as String); |
209 | final File outputPath = filesystem.file(args[_kOutputOption]! as String); |
210 | if (outputPath.isAbsolute) { |
211 | output = outputPath; |
212 | } else { |
213 | output = filesystem.file(path.join(outputDirectory.path, outputPath.path)); |
214 | } |
215 | } else { |
216 | final List<String> idParts = <String>[]; |
217 | if (packageName.isNotEmpty && packageName != 'flutter' ) { |
218 | idParts.add(packageName.replaceAll(RegExp(r'\W' ), '_' ).toLowerCase()); |
219 | } |
220 | if (libraryName.isNotEmpty) { |
221 | idParts.add(libraryName.replaceAll(RegExp(r'\W' ), '_' ).toLowerCase()); |
222 | } |
223 | if (elementName.isNotEmpty) { |
224 | idParts.add(elementName); |
225 | } |
226 | if (serial.isNotEmpty) { |
227 | idParts.add(serial); |
228 | } |
229 | if (idParts.isEmpty) { |
230 | errorExit( |
231 | 'Unable to determine ID. At least one of -- $_kPackageOption, ' |
232 | '-- $_kLibraryOption, -- $_kElementOption, - $_kSerialOption, or the environment variables ' |
233 | 'PACKAGE_NAME, LIBRARY_NAME, ELEMENT_NAME, or INVOCATION_INDEX must be non-empty.' , |
234 | ); |
235 | return; |
236 | } |
237 | id = idParts.join('.' ); |
238 | output = outputDirectory.childFile(' $id.dart' ); |
239 | } |
240 | output.parent.createSync(recursive: true); |
241 | |
242 | final int? sourceLine = |
243 | environment['SOURCE_LINE' ] != null ? int.tryParse(environment['SOURCE_LINE' ]!) : null; |
244 | final String sourcePath = environment['SOURCE_PATH' ] ?? 'unknown.dart' ; |
245 | final SnippetDartdocParser sampleParser = SnippetDartdocParser(filesystem); |
246 | final SourceElement element = sampleParser.parseFromDartdocToolFile( |
247 | input, |
248 | startLine: sourceLine, |
249 | element: elementName, |
250 | sourceFile: filesystem.file(sourcePath), |
251 | type: sampleType, |
252 | ); |
253 | final Map<String, Object?> metadata = <String, Object?>{ |
254 | 'channel' : getChannelNameWithRetries(platform: platform, processManager: processManager), |
255 | 'serial' : serial, |
256 | 'id' : id, |
257 | 'package' : packageName, |
258 | 'library' : libraryName, |
259 | 'element' : elementName, |
260 | }; |
261 | |
262 | for (final CodeSample sample in element.samples) { |
263 | sample.metadata.addAll(metadata); |
264 | snippetGenerator.generateCode(sample, output: output); |
265 | print(snippetGenerator.generateHtml(sample)); |
266 | } |
267 | |
268 | exitCode = 0; |
269 | } |
270 | |