template.dart 15.1 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:file/file.dart';
6 7
import 'package:package_config/package_config.dart';
import 'package:package_config/package_config_types.dart';
8

9
import 'base/common.dart';
10
import 'base/file_system.dart';
11 12
import 'base/logger.dart';
import 'base/template.dart';
13
import 'cache.dart';
14
import 'dart/package_map.dart';
15

16 17 18 19 20 21
/// The Kotlin keywords which are not Java keywords.
/// They are escaped in Kotlin files.
///
/// https://kotlinlang.org/docs/keyword-reference.html
const List<String> kReservedKotlinKeywords = <String>['when', 'in'];

22
/// Expands templates in a directory to a destination. All files that must
23 24 25
/// undergo template expansion should end with the '.tmpl' extension. All files
/// that should be replaced with the corresponding image from
/// flutter_template_images should end with the '.img.tmpl' extension. All other
26 27
/// files are ignored. In case the contents of entire directories must be copied
/// as is, the directory itself can end with '.tmpl' extension. Files within
28 29 30
/// such a directory may also contain the '.tmpl' or '.img.tmpl' extensions and
/// will be considered for expansion. In case certain files need to be copied
/// but without template expansion (data files, etc.), the '.copy.tmpl'
31 32 33
/// extension may be used. Furthermore, templates may contain additional
/// test files intended to run on the CI. Test files must end in `.test.tmpl`
/// and are only included when the --implementation-tests flag is enabled.
34
///
35 36 37
/// Folders with platform/language-specific content must be named
/// '<platform>-<language>.tmpl'.
///
38 39
/// Files in the destination will contain none of the '.tmpl', '.copy.tmpl',
/// 'img.tmpl', or '-<language>.tmpl' extensions.
40
class Template {
41
  factory Template(Directory templateSource, Directory? imageSourceDir, {
42 43 44
    required FileSystem fileSystem,
    required Logger logger,
    required TemplateRenderer templateRenderer,
45
    Set<Uri>? templateManifest,
46 47 48
  }) {
    return Template._(
      <Directory>[templateSource],
49
      imageSourceDir != null ? <Directory>[imageSourceDir] : <Directory>[],
50 51 52 53 54 55 56 57 58
      fileSystem: fileSystem,
      logger: logger,
      templateRenderer: templateRenderer,
      templateManifest: templateManifest,
    );
  }

  Template._(
    List<Directory> templateSources, this.imageSourceDirectories, {
59 60 61 62
    required FileSystem fileSystem,
    required Logger logger,
    required TemplateRenderer templateRenderer,
    required Set<Uri>? templateManifest,
63
  }) : _fileSystem = fileSystem,
64 65
       _logger = logger,
       _templateRenderer = templateRenderer,
66
       _templateManifest = templateManifest ?? <Uri>{} {
67 68 69 70
    for (final Directory sourceDirectory in templateSources) {
      if (!sourceDirectory.existsSync()) {
        throwToolExit('Template source directory does not exist: ${sourceDirectory.absolute.path}');
      }
71 72
    }

73 74 75 76 77 78
    final Map<FileSystemEntity, Directory> templateFiles = <FileSystemEntity, Directory>{
      for (final Directory sourceDirectory in templateSources)
        for (final FileSystemEntity entity in sourceDirectory.listSync(recursive: true))
          entity: sourceDirectory,
    };
    for (final FileSystemEntity entity in templateFiles.keys.whereType<File>()) {
79
      if (_templateManifest.isNotEmpty && !_templateManifest.contains(Uri.file(entity.absolute.path))) {
80
        _logger.printTrace('Skipping ${entity.absolute.path}, missing from the template manifest.');
81 82 83
        // Skip stale files in the flutter_tools directory.
        continue;
      }
84

85
      final String relativePath = fileSystem.path.relative(entity.path,
86
          from: templateFiles[entity]!.absolute.path);
87
      if (relativePath.contains(templateExtension)) {
88 89 90
        // If '.tmpl' appears anywhere within the path of this entity, it is
        // is a candidate for rendering. This catches cases where the folder
        // itself is a template.
91
        _templateFilePaths[relativePath] = fileSystem.path.absolute(entity.path);
92 93 94 95
      }
    }
  }

96
  static Future<Template> fromName(String name, {
97 98 99 100
    required FileSystem fileSystem,
    required Set<Uri>? templateManifest,
    required Logger logger,
    required TemplateRenderer templateRenderer,
101
  }) async {
Ian Hickson's avatar
Ian Hickson committed
102
    // All named templates are placed in the 'templates' directory
103
    final Directory templateDir = _templateDirectoryInPackage(name, fileSystem);
104
    final Directory imageDir = await templateImageDirectory(name, fileSystem, logger);
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
    return Template._(
      <Directory>[templateDir],
      <Directory>[imageDir],
      fileSystem: fileSystem,
      logger: logger,
      templateRenderer: templateRenderer,
      templateManifest: templateManifest,
    );
  }

  static Future<Template> merged(List<String> names, Directory directory, {
    required FileSystem fileSystem,
    required Set<Uri> templateManifest,
    required Logger logger,
    required TemplateRenderer templateRenderer,
  }) async {
    // All named templates are placed in the 'templates' directory
    return Template._(
      <Directory>[
        for (final String name in names)
125
          _templateDirectoryInPackage(name, fileSystem),
126 127 128
      ],
      <Directory>[
        for (final String name in names)
129 130
          if ((await templateImageDirectory(name, fileSystem, logger)).existsSync())
            await templateImageDirectory(name, fileSystem, logger),
131
      ],
132
      fileSystem: fileSystem,
133 134
      logger: logger,
      templateRenderer: templateRenderer,
135 136
      templateManifest: templateManifest,
    );
Ian Hickson's avatar
Ian Hickson committed
137 138
  }

139
  final FileSystem _fileSystem;
140
  final Logger _logger;
141
  final Set<Uri> _templateManifest;
142 143
  final TemplateRenderer _templateRenderer;

144 145
  static const String templateExtension = '.tmpl';
  static const String copyTemplateExtension = '.copy.tmpl';
146
  static const String imageTemplateExtension = '.img.tmpl';
147
  static const String testTemplateExtension = '.test.tmpl';
148
  final Pattern _kTemplateLanguageVariant = RegExp(r'(\w+)-(\w+)\.tmpl.*');
149
  final List<Directory> imageSourceDirectories;
150

151
  final Map<String /* relative */, String /* absolute source */> _templateFilePaths = <String, String>{};
152

153 154 155
  /// Render the template into [directory].
  ///
  /// May throw a [ToolExit] if the directory is not writable.
156 157
  int render(
    Directory destination,
158
    Map<String, Object?> context, {
159
    bool overwriteExisting = true,
160
    bool printStatusWhenWriting = true,
161
  }) {
162 163 164
    try {
      destination.createSync(recursive: true);
    } on FileSystemException catch (err) {
165
      _logger.printError(err.toString());
166 167
      throwToolExit('Failed to flutter create at ${destination.path}.');
    }
Devon Carew's avatar
Devon Carew committed
168
    int fileCount = 0;
169
    final bool implementationTests = (context['implementationTests'] as bool?) ?? false;
170

171 172 173 174 175
    /// Returns the resolved destination path corresponding to the specified
    /// raw destination path, after performing language filtering and template
    /// expansion on the path itself.
    ///
    /// Returns null if the given raw destination path has been filtered.
176 177
    String? renderPath(String relativeDestinationPath) {
      final Match? match = _kTemplateLanguageVariant.matchAsPrefix(relativeDestinationPath);
178
      if (match != null) {
179 180
        final String platform = match.group(1)!;
        final String? language = context['${platform}Language'] as String?;
181
        if (language != match.group(2)) {
182
          return null;
183
        }
184 185
        relativeDestinationPath = relativeDestinationPath.replaceAll('$platform-$language.tmpl', platform);
      }
186

187
      final bool android = (context['android'] as bool?) ?? false;
188 189 190 191
      if (relativeDestinationPath.contains('android') && !android) {
        return null;
      }

192
      final bool ios = (context['ios'] as bool?) ?? false;
193 194 195
      if (relativeDestinationPath.contains('ios') && !ios) {
        return null;
      }
196

197
      // Only build a web project if explicitly asked.
198
      final bool web = (context['web'] as bool?) ?? false;
199 200 201
      if (relativeDestinationPath.contains('web') && !web) {
        return null;
      }
202
      // Only build a Linux project if explicitly asked.
203
      final bool linux = (context['linux'] as bool?) ?? false;
204 205 206
      if (relativeDestinationPath.startsWith('linux.tmpl') && !linux) {
        return null;
      }
207
      // Only build a macOS project if explicitly asked.
208
      final bool macOS = (context['macos'] as bool?) ?? false;
209 210 211
      if (relativeDestinationPath.startsWith('macos.tmpl') && !macOS) {
        return null;
      }
212
      // Only build a Windows project if explicitly asked.
213
      final bool windows = (context['windows'] as bool?) ?? false;
214 215 216
      if (relativeDestinationPath.startsWith('windows.tmpl') && !windows) {
        return null;
      }
217

218 219 220 221
      final String? projectName = context['projectName'] as String?;
      final String? androidIdentifier = context['androidIdentifier'] as String?;
      final String? pluginClass = context['pluginClass'] as String?;
      final String? pluginClassSnakeCase = context['pluginClassSnakeCase'] as String?;
222
      final String destinationDirPath = destination.absolute.path;
223 224
      final String pathSeparator = _fileSystem.path.separator;
      String finalDestinationPath = _fileSystem.path
225
        .join(destinationDirPath, relativeDestinationPath)
226
        .replaceAll(copyTemplateExtension, '')
227
        .replaceAll(imageTemplateExtension, '')
228
        .replaceAll(testTemplateExtension, '')
229
        .replaceAll(templateExtension, '');
230

231
      if (android != null && android && androidIdentifier != null) {
232 233 234
        finalDestinationPath = finalDestinationPath
            .replaceAll('androidIdentifier', androidIdentifier.replaceAll('.', pathSeparator));
      }
235
      if (projectName != null) {
236
        finalDestinationPath = finalDestinationPath.replaceAll('projectName', projectName);
237
      }
238 239 240 241
      // This must be before the pluginClass replacement step.
      if (pluginClassSnakeCase != null) {
        finalDestinationPath = finalDestinationPath.replaceAll('pluginClassSnakeCase', pluginClassSnakeCase);
      }
242
      if (pluginClass != null) {
243
        finalDestinationPath = finalDestinationPath.replaceAll('pluginClass', pluginClass);
244
      }
245 246 247 248
      return finalDestinationPath;
    }

    _templateFilePaths.forEach((String relativeDestinationPath, String absoluteSourcePath) {
249
      final bool withRootModule = context['withRootModule'] as bool? ?? false;
250
      if (!withRootModule && absoluteSourcePath.contains('flutter_root')) {
251
        return;
252
      }
253

254 255 256 257
      if (!implementationTests && absoluteSourcePath.contains(testTemplateExtension)) {
        return;
      }

258
      final String? finalDestinationPath = renderPath(relativeDestinationPath);
259
      if (finalDestinationPath == null) {
260
        return;
261
      }
262 263
      final File finalDestinationFile = _fileSystem.file(finalDestinationPath);
      final String relativePathForLogging = _fileSystem.path.relative(finalDestinationFile.path);
264 265 266 267 268

      // Step 1: Check if the file needs to be overwritten.

      if (finalDestinationFile.existsSync()) {
        if (overwriteExisting) {
269
          finalDestinationFile.deleteSync(recursive: true);
270
          if (printStatusWhenWriting) {
271
            _logger.printStatus('  $relativePathForLogging (overwritten)');
272
          }
273 274
        } else {
          // The file exists but we cannot overwrite it, move on.
275
          if (printStatusWhenWriting) {
276
            _logger.printTrace('  $relativePathForLogging (existing - skipped)');
277
          }
278 279 280
          return;
        }
      } else {
281
        if (printStatusWhenWriting) {
282
          _logger.printStatus('  $relativePathForLogging (created)');
283
        }
284 285
      }

286
      fileCount += 1;
Devon Carew's avatar
Devon Carew committed
287

288
      finalDestinationFile.createSync(recursive: true);
289
      final File sourceFile = _fileSystem.file(absoluteSourcePath);
290

291
      // Step 2: If the absolute paths ends with a '.copy.tmpl', this file does
292 293
      //         not need mustache rendering but needs to be directly copied.

294 295
      if (sourceFile.path.endsWith(copyTemplateExtension)) {
        sourceFile.copySync(finalDestinationFile.path);
296 297 298 299

        return;
      }

300 301 302 303
      // Step 3: If the absolute paths ends with a '.img.tmpl', this file needs
      //         to be copied from the template image package.

      if (sourceFile.path.endsWith(imageTemplateExtension)) {
304 305 306
        final List<File> potentials = <File>[
          for (final Directory imageSourceDir in imageSourceDirectories)
            _fileSystem.file(_fileSystem.path
307
                .join(imageSourceDir.path, relativeDestinationPath.replaceAll(imageTemplateExtension, ''))),
308 309 310 311 312 313 314 315 316
        ];

        if (potentials.any((File file) => file.existsSync())) {
          final File imageSourceFile = potentials.firstWhere((File file) => file.existsSync());

          imageSourceFile.copySync(finalDestinationFile.path);
        } else {
          throwToolExit('Image File not found ${finalDestinationFile.path}');
        }
317 318 319 320 321

        return;
      }

      // Step 4: If the absolute path ends with a '.tmpl', this file needs
322 323
      //         rendering via mustache.

324
      if (sourceFile.path.endsWith(templateExtension)) {
325
        final String templateContents = sourceFile.readAsStringSync();
326 327 328 329 330
        final String? androidIdentifier = context['androidIdentifier'] as String?;
        if (finalDestinationFile.path.endsWith('.kt') && androidIdentifier != null) {
          context['androidIdentifier'] = _escapeKotlinKeywords(androidIdentifier);
        }

331
        final String renderedContents = _templateRenderer.renderString(templateContents, context);
332 333 334 335 336 337

        finalDestinationFile.writeAsStringSync(renderedContents);

        return;
      }

338
      // Step 5: This file does not end in .tmpl but is in a directory that
339
      //         does. Directly copy the file to the destination.
340
      sourceFile.copySync(finalDestinationFile.path);
341
    });
Devon Carew's avatar
Devon Carew committed
342 343

    return fileCount;
344 345 346
  }
}

347
Directory _templateDirectoryInPackage(String name, FileSystem fileSystem) {
348
  final String templatesDir = fileSystem.path.join(Cache.flutterRoot!,
349
      'packages', 'flutter_tools', 'templates');
350 351 352
  return fileSystem.directory(fileSystem.path.join(templatesDir, name));
}

353 354 355 356
/// Returns the directory containing the 'name' template directory in
/// flutter_template_images, to resolve image placeholder against.
/// if 'name' is null, return the parent template directory.
Future<Directory> templateImageDirectory(String? name, FileSystem fileSystem, Logger logger) async {
357
  final String toolPackagePath = fileSystem.path.join(
358
      Cache.flutterRoot!, 'packages', 'flutter_tools');
359
  final String packageFilePath = fileSystem.path.join(toolPackagePath, '.dart_tool', 'package_config.json');
360
  final PackageConfig packageConfig = await loadPackageConfigWithLogging(
361
    fileSystem.file(packageFilePath),
362
    logger: logger,
363
  );
364
  final Uri? imagePackageLibDir = packageConfig['flutter_template_images']?.packageUriRoot;
365
  final Directory templateDirectory = fileSystem.directory(imagePackageLibDir)
366
      .parent
367 368
      .childDirectory('templates');
  return name == null ? templateDirectory : templateDirectory.childDirectory(name);
369
}
370 371 372 373 374 375 376 377

String _escapeKotlinKeywords(String androidIdentifier) {
  final List<String> segments = androidIdentifier.split('.');
  final List<String> correctedSegments = segments.map(
    (String segment) => kReservedKotlinKeywords.contains(segment) ? '`$segment`' : segment
  ).toList();
  return correctedSegments.join('.');
}