diff --git a/.github/config/tdesign_api.yaml b/.github/config/tdesign_api.yaml new file mode 100644 index 0000000..6b242db --- /dev/null +++ b/.github/config/tdesign_api.yaml @@ -0,0 +1,66 @@ +# CI 专用:API 文档合规性审计清单 +# 说明:此文件仅用于 tdesign-flutter-tools 仓库的 CI,不会写入 tdesign-component 源码。 +# 注意:不要放在 .github/workflows/ —— 该目录下所有 .yaml 都会被 GitHub 当作 workflow 解析。 +# 与 demo_tool/all_build.sh 中重点抽测的 5 个组件 --name 配置对齐。 + +version: 1 + +components: + button: + folder_name: button + source_folder: lib/src/components/button + classes: + - TButton + - TButtonStyle + + picker: + folder_name: picker + source_folder: lib/src/components/picker + classes: + - TPicker + - TPickerOption + - TPickerValue + - TPickerLoadEvent + - TPickerColumns + - TPickerLinked + - TPickerItems + - TPickerKeys + + popup: + folder_name: popup + source_folder: lib/src/components/popup + classes: + - TSlidePopupRoute + - TPopupBottomDisplayPanel + - TPopupBottomConfirmPanel + - TPopupCenterPanel + + dialog: + folder_name: dialog + source_folder: lib/src/components/dialog + classes: + - TAlertDialog + - TConfirmDialog + - TDialogButtonOptions + - TDialogButtonStyle + - TDialogScaffold + - TDialogTitle + - TDialogContent + - TDialogInfoWidget + - HorizontalNormalButtons + - HorizontalTextButtons + - TDialogButton + - TDialogImagePosition + - TImageDialog + - TInputDialog + + calendar: + folder_name: calendar + source_folder: lib/src/components/calendar + classes: + - TCalendar + - TCalendarPopup + - TCalendarStyle + - TCalendarDataSource + - TLunarInfo + - TCalendarDateType diff --git a/.github/workflows/api-compliance.yml b/.github/workflows/api-compliance.yml new file mode 100644 index 0000000..fc4c12b --- /dev/null +++ b/.github/workflows/api-compliance.yml @@ -0,0 +1,66 @@ +# 只读校验:tdesign_api.yaml 清单 + analyzer AST validate,不写回 tdesign-flutter 源码 +name: API 文档合规性校验 + +on: + pull_request: + branches: [develop, main] + types: [opened, synchronize, reopened] + push: + branches: [develop, main] + workflow_dispatch: + +concurrency: + group: api-compliance-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FLUTTER_VERSION: "3.32.0" + FLUTTER_REPO: tdesign-flutter + FLUTTER_BRANCH: develop + +jobs: + validate-api-docs: + name: YAML 清单 + AST 合规性 + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: 检出 tools 仓库 + uses: actions/checkout@v4 + + - name: 安装 Flutter / Dart + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: 解析 tools 依赖 + run: flutter pub get + + - name: 静态分析 tools + run: dart analyze --fatal-infos + + - name: 克隆 tdesign-flutter(测试与 validate 共用) + run: | + git clone --depth 1 --branch "${FLUTTER_BRANCH}" \ + https://github.com/Tencent/tdesign-flutter.git "${FLUTTER_REPO}" + + - name: 单元测试(完备性相关) + env: + TDESIGN_COMPONENT_ROOT: ${{ github.workspace }}/${{ env.FLUTTER_REPO }}/tdesign-component + run: | + dart test test/aux_types_test.dart \ + test/ctor_defaults_test.dart \ + test/duplicate_source_test.dart \ + test/factory_ctor_test.dart \ + test/enum_members_test.dart \ + test/positional_ctor_test.dart + + - name: 运行 AST 完备性检测(非零 exit code 即失败) + run: | + dart run bin/main.dart validate \ + --component-root "${{ github.workspace }}/${FLUTTER_REPO}/tdesign-component" \ + --config .github/config/tdesign_api.yaml diff --git a/.gitignore b/.gitignore index 867445c..a44f5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,13 @@ build/ .codebuddy/plans +# 本地 dart compile exe 产物(CI 构建 / README 编译命令) +/api_tool* +/demo_tool +/demo_tool_* +/demo_tool_*.exe +*.exe + # Android related **/android/**/gradle-wrapper.jar **/android/.gradle diff --git a/README.md b/README.md index 857907d..c722557 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,23 @@ /// 属性简介(必须) ``` +### 工具职责边界 + +工具只负责**通用 AST 解析规则**,不会对个别组件做特殊兼容,也不会修正源码里不合规的注释。 + +| 由工具负责(通用规则) | 由源码注释负责(需合规编写) | +| --- | --- | +| 从构造参数 AST 提取类型、默认值 | 字段/参数的 `///` 说明文案 | +| 构造参数与公开属性/静态成员分表展示 | 注释内容与字段语义一致(如 content 不应写「标题」) | +| 过滤 `this.xxx` 被误识别为默认值 | 错别字、遗漏注释、注释写在错误位置 | +| 解析 `abstract class` 实例方法、工厂构造及参数表 | `super.key` 等场景的类型展示 | +| 从父类字段解析 `super.xxx` 参数类型 | 无注释时说明列显示 `-`(符合预期) | +| 同文件内自动收录 public 的 enum / typedef | 也可在 `--name` 中显式指定枚举或别名名称 | +| folder 模式下检测跨文件重复 enum/typedef 并告警 | 文档保留重复条目以暴露源码问题,工具不做 silent dedupe | +| Markdown 表格转义、方法参数格式化 | 无注释时说明列显示 `-`(符合预期) | + +**原则:** 注释不合规导致的文档问题,应在组件源码中补全/修正 `///` 注释,而不是在工具里打补丁。 + ### 组件demo注释示例 ```dart @@ -28,6 +45,29 @@ /// demo示例介绍(可以为空) ``` +## 本地开发与测试 + +当 `tdesign-component/pubspec.yaml` 使用 path 依赖指向本仓库时,可在本地直接验证文档生成,无需发布到 git: + +```bash +# 1. 确保 component 的 pubspec 已配置: +# tdesign_flutter_tools: +# path: ../tdesign-flutter-tools + +# 2. 在 component 目录解析依赖(需要网络) +cd ../tdesign-component && dart pub get + +# 3. 运行本地测试脚本(picker / calendar / dialog) +./scripts/local_test.sh picker +``` + +若 `dart pub get` 因网络不可用失败,可临时将 `tdesign-component/.dart_tool/package_config.json` 中 +`tdesign_flutter_tools` 的 `rootUri` 指向本地路径,脚本会通过 `--packages` 跳过联网校验: + +```bash +./scripts/local_test.sh calendar +``` + ## 组件库工具使用方法 ### 初始化工具调用命令 diff --git a/bin/main.dart b/bin/main.dart index 90703f2..aa3e9d4 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; +import 'package:tdesign_flutter_tools/api_completeness.dart'; import 'package:tdesign_flutter_tools/model.dart'; import 'package:tdesign_flutter_tools/smart_create.dart'; import 'package:tdesign_flutter_tools/smart_update.dart'; @@ -78,6 +80,78 @@ class CreateCommand extends Command { } } +class ValidateCommand extends Command { + @override + String name = 'validate'; + + @override + String description = + '校验 API 文档完备性(使用 analyzer AST,与 generate 同一套解析规则)。'; + + ValidateCommand() { + argParser.addOption( + 'component-root', + help: 'tdesign-component 根目录路径', + defaultsTo: '../tdesign-flutter/tdesign-component', + ); + argParser.addOption( + 'config', + help: '审计清单 YAML/JSON 路径', + defaultsTo: '.github/config/tdesign_api.yaml', + ); + argParser.addMultiOption( + 'components', + help: '仅检测指定组件,如 button,picker(默认 5 组件全量)', + ); + argParser.addFlag('verbose', abbr: 'v', help: '打印 analyzer 解析过程'); + } + + @override + Future run() async { + final String raw = argResults!['component-root'] as String; + final String componentRoot = p.isAbsolute(raw) + ? p.normalize(raw) + : p.normalize(p.join(Directory.current.path, raw)); + if (!Directory(componentRoot).existsSync()) { + stderr.writeln('ERROR: component 目录不存在: $componentRoot'); + exitCode = 1; + return; + } + + final String configRaw = argResults!['config'] as String; + final String configPath = p.isAbsolute(configRaw) + ? p.normalize(configRaw) + : p.normalize(p.join(Directory.current.path, configRaw)); + + List configs; + try { + configs = await loadAuditConfigsFromFile(configPath); + } catch (e) { + stderr.writeln('ERROR: 无法加载配置 $configPath: $e'); + exitCode = 2; + return; + } + + final List only = + argResults!['components'] as List? ?? []; + if (only.isNotEmpty) { + final Set wanted = only.toSet(); + configs = configs + .where((ComponentAuditConfig c) => wanted.contains(c.componentKey)) + .toList(); + } + + final int errors = await runCompletenessAudit( + componentRoot: componentRoot, + configs: configs, + quiet: !(argResults!['verbose'] as bool? ?? false), + ); + if (errors > 0) { + exitCode = 1; + } + } +} + class UpdateCommand extends Command { @override String name = 'update'; @@ -120,6 +194,7 @@ void main(List arguments) { CommandRunner('tdesign_flutter_tools', 'TDesign Flutter component documentation tools.') ..addCommand(CreateCommand()) + ..addCommand(ValidateCommand()) ..addCommand(UpdateCommand()) ..run(arguments); } diff --git a/lib/api_completeness.dart b/lib/api_completeness.dart new file mode 100644 index 0000000..cd1c939 --- /dev/null +++ b/lib/api_completeness.dart @@ -0,0 +1,517 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'model.dart'; +import 'smart_create.dart'; + +/// 完备性检测条目 +class CompletenessIssue { + CompletenessIssue({ + required this.component, + required this.level, + required this.category, + required this.message, + }); + + final String component; + final String level; // ERROR | WARN | INFO + final String category; // scope | tool | source | ok + final String message; +} + +/// 单个组件的审计配置(与 demo_tool/all_build.sh 的 --name 对齐) +class ComponentAuditConfig { + ComponentAuditConfig({ + required this.componentKey, + required this.classNames, + required this.sourceFolder, + required this.folderName, + }); + + final String componentKey; + final List classNames; + /// 相对 component 根目录,如 lib/src/components/picker + final String sourceFolder; + final String folderName; +} + +/// 从 YAML/JSON 配置文件加载审计清单(components 节点) +Future> loadAuditConfigsFromFile(String path) async { + final File file = File(path); + if (!file.existsSync()) { + throw ArgumentError('配置文件不存在: $path'); + } + final String content = await file.readAsString(); + if (path.endsWith('.json')) { + final dynamic decoded = jsonDecode(content); + if (decoded is Map && decoded['components'] is Map) { + return _configsFromMap( + Map.from(decoded['components'] as Map)); + } + if (decoded is Map) { + return _configsFromMap(decoded); + } + throw ArgumentError('JSON 配置格式无效: $path'); + } + return _configsFromYaml(content); +} + +List _configsFromMap(Map components) { + final List configs = []; + for (final MapEntry entry in components.entries) { + if (entry.value is! Map) { + throw ArgumentError('组件 ${entry.key} 配置无效'); + } + final Map map = + Map.from(entry.value as Map); + final String? folderName = map['folder_name'] as String?; + final String? sourceFolder = map['source_folder'] as String?; + final dynamic classesRaw = map['classes']; + if (folderName == null || + sourceFolder == null || + classesRaw is! List || + classesRaw.isEmpty) { + throw ArgumentError('组件 ${entry.key} 缺少 folder_name / source_folder / classes'); + } + configs.add( + ComponentAuditConfig( + componentKey: entry.key, + folderName: folderName, + sourceFolder: sourceFolder, + classNames: classesRaw.map((dynamic e) => e.toString()).toList(), + ), + ); + } + return configs; +} + +/// 解析 CI 专用 YAML 子集(仅 components / folder_name / source_folder / classes) +List _configsFromYaml(String yaml) { + final Map> components = + >{}; + String? currentKey; + String? listKey; + + for (final String rawLine in yaml.split('\n')) { + final String line = rawLine.split('#').first.trimRight(); + final String trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + + final RegExpMatch? componentMatch = + RegExp(r'^(\w+):$').firstMatch(trimmed); + if (rawLine.startsWith(' ') && + !rawLine.startsWith(' ') && + componentMatch != null) { + currentKey = componentMatch.group(1); + components[currentKey!] = {'classes': []}; + listKey = null; + continue; + } + + if (currentKey == null) { + continue; + } + + if (trimmed.startsWith('- ')) { + if (listKey == 'classes') { + (components[currentKey]!['classes'] as List) + .add(trimmed.substring(2).trim()); + } + continue; + } + + final RegExpMatch? kvMatch = + RegExp(r'^(\w+):(?:\s*(.+))?$').firstMatch(trimmed); + if (kvMatch == null) { + continue; + } + final String key = kvMatch.group(1)!; + final String? value = kvMatch.group(2)?.trim(); + if (value == null || value.isEmpty) { + listKey = key; + if (key == 'classes') { + components[currentKey]![key] = []; + } + } else { + listKey = null; + components[currentKey]![key] = value; + } + } + + return _configsFromMap( + components.map((String k, Map v) => MapEntry(k, v)), + ); +} + +/// 默认审计配置路径(相对 tools 仓库根目录) +String defaultAuditConfigPath() => '.github/config/tdesign_api.yaml'; + +/// 从 Markdown API 文档解析 {类名: section 正文} +Map parseMarkdownSections(String markdown) { + final Map sections = {}; + for (final String part in markdown.split(RegExp(r'\n(?=### )'))) { + final RegExpMatch? match = RegExp(r'^### (\S+)\n').firstMatch(part); + if (match != null) { + sections[match.group(1)!] = part; + } + } + return sections; +} + +/// 读取「默认构造方法」表格中的参数名(不含公开属性 / 静态成员表) +Set markdownDefaultCtorParamNames(String section) { + const String header = '#### 默认构造方法'; + if (!section.contains(header)) { + return {}; + } + final String block = + section.split(header).skip(1).first.split(RegExp(r'\n#### ')).first; + final Set names = {}; + for (final String line in block.split('\n')) { + if (!line.startsWith('|') || line.startsWith('| ---')) { + continue; + } + final List cols = + line.trim().replaceFirst('|', '').replaceFirst(RegExp(r'\|$'), '').split('|'); + if (cols.isEmpty) { + continue; + } + final String name = cols.first.trim(); + if (name.isEmpty || name == '参数' || name == '名称' || name == '属性') { + continue; + } + names.add(name); + } + return names; +} + +/// 构造表中类型列为 `-` 的参数名 +List markdownCtorParamsWithEmptyType(String section) { + const String header = '#### 默认构造方法'; + if (!section.contains(header)) { + return []; + } + final String block = + section.split(header).skip(1).first.split(RegExp(r'\n#### ')).first; + final List bad = []; + for (final String line in block.split('\n')) { + if (!line.startsWith('|') || line.startsWith('| ---')) { + continue; + } + final List cols = + line.trim().replaceFirst('|', '').replaceFirst(RegExp(r'\|$'), '').split('|'); + if (cols.length < 2) { + continue; + } + final String name = cols[0].trim(); + final String type = cols[1].trim(); + if (name.isEmpty || name == '参数' || name == '名称' || name == '属性') { + continue; + } + if (type == '-') { + bad.add(name); + } + } + return bad; +} + +/// 跨文件重复 enum/typedef(返回 issue,不打印) +List duplicateAuxiliaryIssues( + String componentKey, + List parsed, +) { + final Map> locations = >{}; + for (final ParsedComponentInfoInfo item in parsed) { + final String? kind = item.componentInfo?.kind; + if (kind != 'enum' && kind != 'typedef') { + continue; + } + final String? name = item.componentInfo?.name; + if (name == null || name.isEmpty) { + continue; + } + final String file = item.componentInfo?.sourceFile ?? 'unknown'; + locations.putIfAbsent('$kind:$name', () => []).add(file); + } + + final List issues = []; + for (final MapEntry> entry in locations.entries) { + final Set uniqueFiles = entry.value.toSet(); + if (uniqueFiles.length <= 1) { + continue; + } + final List parts = entry.key.split(':'); + final String kindLabel = parts[0] == 'enum' ? 'enum' : 'typedef'; + final String typeName = parts.length > 1 ? parts[1] : entry.key; + issues.add( + CompletenessIssue( + component: componentKey, + level: 'ERROR', + category: 'source', + message: + '源码重复定义 $kindLabel `$typeName`: ${uniqueFiles.join(', ')}', + ), + ); + } + return issues; +} + +/// 用 analyzer AST 解析源码,对比已生成的 *_api.md +Future> auditComponent({ + required String componentRoot, + required ComponentAuditConfig config, + bool quiet = true, +}) async { + final List issues = []; + final String root = p.normalize(componentRoot); + final String apiPath = + p.join(root, 'example/assets/api/${config.folderName}_api.md'); + final File apiFile = File(apiPath); + + if (!apiFile.existsSync()) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'ERROR', + category: 'scope', + message: '缺少文档文件 ${p.basename(apiPath)}', + ), + ); + return issues; + } + + final Map sections = + parseMarkdownSections(await apiFile.readAsString()); + + final String basePath = root.endsWith(Platform.pathSeparator) + ? root + : '$root${Platform.pathSeparator}'; + + final List parsed = await SmartCreator( + isFileMode: false, + onlyApi: true, + nameList: config.classNames, + basePath: basePath, + path: config.sourceFolder, + folderName: config.folderName, + isGrammarParser: false, + ).parseOnly(quiet: quiet); + + final Map parsedByName = + { + for (final ParsedComponentInfoInfo item in parsed) + if (item.componentInfo?.name != null) item.componentInfo!.name!: item, + }; + + issues.addAll(duplicateAuxiliaryIssues(config.componentKey, parsed)); + + for (final String className in config.classNames) { + if (!sections.containsKey(className)) { + final ParsedComponentInfoInfo? info = parsedByName[className]; + if (info == null) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'ERROR', + category: 'scope', + message: '--name 中的 $className 未出现在文档且 AST 未解析到定义', + ), + ); + } else { + final String kind = info.componentInfo?.kind ?? 'class'; + issues.add( + CompletenessIssue( + component: config.componentKey, + level: kind == 'enum' || kind == 'typedef' ? 'WARN' : 'ERROR', + category: 'scope', + message: '--name 中的 $className 未出现在文档', + ), + ); + } + continue; + } + + final ParsedComponentInfoInfo? info = parsedByName[className]; + if (info == null) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'WARN', + category: 'source', + message: '$className 在配置中但 AST 未在当前目录解析到定义', + ), + ); + continue; + } + + final String kind = info.componentInfo?.kind ?? 'class'; + final String section = sections[className]!; + + if (kind == 'enum') { + if (!section.contains('#### 枚举值')) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'WARN', + category: 'tool', + message: 'enum $className 缺少枚举值表', + ), + ); + } + continue; + } + + if (kind == 'typedef') { + if (!section.contains('#### 类型定义')) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'WARN', + category: 'tool', + message: 'typedef $className 缺少类型定义', + ), + ); + } + continue; + } + + final Set srcCtorParams = + info.propertyList.map((PropertyInfo e) => e.name).where((n) => n.isNotEmpty).toSet(); + final bool hasInstanceMethods = + info.componentInfo?.instanceMethodList.isNotEmpty ?? false; + + if (srcCtorParams.isEmpty) { + if (hasInstanceMethods) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'INFO', + category: 'source', + message: '$className 无默认构造参数(如 abstract class),跳过构造参数对比', + ), + ); + } + continue; + } + + final Set docCtorParams = markdownDefaultCtorParamNames(section); + final Set missingInDoc = srcCtorParams.difference(docCtorParams); + final Set extraInDoc = docCtorParams.difference(srcCtorParams); + + if (missingInDoc.isNotEmpty) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'ERROR', + category: 'tool', + message: + '$className 文档缺少构造参数: ${missingInDoc.toList()..sort()}', + ), + ); + } + if (extraInDoc.isNotEmpty) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'WARN', + category: 'tool', + message: + '$className 文档多出非构造参数: ${extraInDoc.toList()..sort()}', + ), + ); + } + + final List emptyTypes = markdownCtorParamsWithEmptyType(section); + if (emptyTypes.isNotEmpty) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'WARN', + category: 'tool', + message: '$className 构造参数类型未解析(-): $emptyTypes', + ), + ); + } + } + + if (issues.where((CompletenessIssue i) => i.category != 'ok').isEmpty) { + issues.add( + CompletenessIssue( + component: config.componentKey, + level: 'INFO', + category: 'ok', + message: '未发现完备性问题', + ), + ); + } + + return issues; +} + +/// 批量审计并打印报告;返回 ERROR 数量 +Future runCompletenessAudit({ + required String componentRoot, + List? configs, + bool quiet = true, +}) async { + final List auditConfigs = configs ?? + await loadAuditConfigsFromFile( + p.join(Directory.current.path, defaultAuditConfigPath()), + ); + int errorCount = 0; + int warnCount = 0; + + stdout.writeln('=' * 60); + stdout.writeln('API 文档完备性检测(analyzer AST,5 组件)'); + stdout.writeln('=' * 60); + + for (final ComponentAuditConfig config in auditConfigs) { + final List issues = await auditComponent( + componentRoot: componentRoot, + config: config, + quiet: quiet, + ); + final String apiPath = + p.join(componentRoot, 'example/assets/api/${config.folderName}_api.md'); + Map sections = {}; + if (File(apiPath).existsSync()) { + sections = parseMarkdownSections(await File(apiPath).readAsString()); + } + + stdout.writeln('\n## ${config.componentKey}'); + stdout.writeln( + ' 文档条目 (${sections.length}): ${sections.keys.take(8).join(', ')}${sections.length > 8 ? '...' : ''}', + ); + + final bool hasOk = + issues.any((CompletenessIssue i) => i.category == 'ok'); + if (hasOk) { + stdout.writeln(' ✅ 未发现完备性问题'); + } + for (final CompletenessIssue issue in issues) { + if (issue.category == 'ok') { + continue; + } + final String icon = switch (issue.level) { + 'ERROR' => '❌', + 'WARN' => '⚠️', + _ => 'ℹ️', + }; + stdout.writeln(' $icon [${issue.category}] ${issue.message}'); + if (issue.level == 'ERROR') { + errorCount++; + } else if (issue.level == 'WARN') { + warnCount++; + } + } + } + + stdout.writeln('\n${'=' * 60}'); + stdout.writeln('汇总: ERROR=$errorCount, WARN=$warnCount'); + stdout.writeln('=' * 60); + return errorCount; +} diff --git a/lib/component_rule.dart b/lib/component_rule.dart index a28b92f..3c0e289 100644 --- a/lib/component_rule.dart +++ b/lib/component_rule.dart @@ -3,8 +3,6 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/visitor.dart'; -import 'package:collection/collection.dart'; - import 'model.dart'; import 'util.dart'; @@ -20,7 +18,6 @@ class ComponentRule { this.nameList, this.basePath, this.folderName, - this.isMerge, this.sourceFileName}); final ParsedUnitResult? parsedUnitResult; //词法分析 @@ -30,7 +27,6 @@ class ComponentRule { final String? folderName; final String? sourceFileName; final int? startTime; - final bool? isMerge; final bool? isGrammarParser; List analyse() { @@ -42,7 +38,6 @@ class ComponentRule { basePath: basePath, onParsedComponentInfoInfo: (ParsedComponentInfoInfo info) { parsedComponentInfoList.add(info); - // print('添加解析结果:${info.componentInfo.name}'); }, sourceFileName: sourceFileName); resolvedUnitResult!.libraryElement.accept(visitor); @@ -53,18 +48,12 @@ class ComponentRule { folderName: folderName, onParsedComponentInfoInfo: (ParsedComponentInfoInfo info) { parsedComponentInfoList.add(info); - Debug.red('添加解析结果:${info.componentInfo!.name}'); }, sourceFileName: sourceFileName); parsedUnitResult!.unit.accept(visitor); } - // int endTime1 = DateTime.now().microsecondsSinceEpoch; - // print('语法分析完毕! 用时: ${((endTime1 - startTime1) / 1000).floor()}ms'); - // analysisResult.unit.accept(visitor); - int endTime = DateTime.now().microsecondsSinceEpoch; print('analyse 执行用时: ${((endTime - startTime1) / 1000).floor()}ms'); - // print('${nameList.join(',')} 生成完毕! 用时: ${((endTime - startTime) / 1000).floor()}ms'); print('$sourceFileName 生成完毕'); return parsedComponentInfoList; } @@ -80,195 +69,455 @@ class ComponentAstVisitor extends RecursiveAstVisitor { final OnParsedComponentInfoInfo? onParsedComponentInfoInfo; ComponentInfo? componentInfo; List propertyList = []; - Map fieldMap = {}; + List extraPropertyList = []; + List staticMemberList = []; + Map fieldMap = {}; + final Set _constructorParamNames = {}; + /// 当前文件内所有类的字段快照,用于解析 super.xxx 类型 + final Map> _allClassFieldMaps = {}; + /// 各类默认构造的形式参数默认值(用于 super.xxx 未显式写默认值时) + final Map> _allClassConstructorDefaults = {}; + bool _targetFoundInUnit = false; + final List _pendingEnums = []; + final List _pendingTypedefs = []; + final Set _emittedAuxiliaryNames = {}; + // 当前正在解析的类名(文件内任意类) + String? _currentClassName; + String? _currentClassSuperName; + // 当前正在解析的目标类名(null 表示不在目标类内) + String? _currentTargetClassName; + // 当前正在解析的类是否是 abstract class(用于收集实例方法) + bool _currentClassIsAbstract = false; + + bool get _isInTargetClass => _currentTargetClassName != null; + + void _emitParsedInfo(ParsedComponentInfoInfo info) { + info.componentInfo?.sourceFile = sourceFileName; + onParsedComponentInfoInfo?.call(info); + } + + ParsedComponentInfoInfo _emptyParsedInfo(ComponentInfo componentInfo) { + return ParsedComponentInfoInfo() + ..componentInfo = componentInfo + ..propertyList = [] + ..extraPropertyList = [] + ..staticMemberList = [] + ..fieldMap = {}; + } + + void _emitEnum(EnumDeclaration node) { + final String name = node.name.lexeme; + if (_emittedAuxiliaryNames.contains(name)) { + return; + } + _emittedAuxiliaryNames.add(name); + final ComponentInfo componentInfo = ComponentInfo() + ..name = name + ..kind = 'enum'; + if (node.documentationComment != null) { + componentInfo.introduction = removeDocumentationComment( + node.documentationComment!.tokens.join('\n'), + ); + } + for (final EnumConstantDeclaration constant in node.constants) { + final EnumMemberInfo member = EnumMemberInfo() + ..name = constant.name.lexeme; + if (constant.documentationComment != null) { + member.introduction = removeDocumentationComment( + constant.documentationComment!.tokens.join('\n'), + ); + } + componentInfo.enumMembers.add(member); + } + componentInfo.enumValues = + componentInfo.enumMembers.map((EnumMemberInfo m) => m.name).toList(); + _emitParsedInfo(_emptyParsedInfo(componentInfo)); + } + + void _emitTypedef(GenericTypeAlias node) { + final String name = node.name.lexeme; + if (_emittedAuxiliaryNames.contains(name)) { + return; + } + _emittedAuxiliaryNames.add(name); + final ComponentInfo componentInfo = ComponentInfo() + ..name = name + ..kind = 'typedef' + ..typedefDefinition = 'typedef ${node.name.lexeme} = ${node.type.toSource()};'; + if (node.documentationComment != null) { + componentInfo.introduction = removeDocumentationComment( + node.documentationComment!.tokens.join('\n'), + ); + } + _emitParsedInfo(_emptyParsedInfo(componentInfo)); + } + + void _flushPendingAuxiliaryTypes() { + if (!_targetFoundInUnit) { + return; + } + for (final EnumDeclaration node in _pendingEnums) { + _emitEnum(node); + } + for (final GenericTypeAlias node in _pendingTypedefs) { + _emitTypedef(node); + } + _pendingEnums.clear(); + _pendingTypedefs.clear(); + } + + void _resetClassState() { + componentInfo = null; + propertyList = []; + extraPropertyList = []; + staticMemberList = []; + fieldMap = {}; + _constructorParamNames.clear(); + _currentTargetClassName = null; + _currentClassIsAbstract = false; + _currentClassSuperName = null; + } + + PropertyInfo _copyPropertyInfo(PropertyInfo source) { + return PropertyInfo() + ..name = source.name + ..type = source.type + ..isRequired = source.isRequired + ..isNamed = source.isNamed + ..introduction = source.introduction + ..defaultValue = source.defaultValue; + } + + String? _parseSuperClassName(ClassDeclaration node) { + final ExtendsClause? extendsClause = node.extendsClause; + if (extendsClause == null) { + return null; + } + final TypeAnnotation superClass = extendsClause.superclass; + if (superClass is NamedType) { + return superClass.name2.lexeme; + } + return superClass.toString(); + } + + void _saveClassFieldMap(String className) { + final Map snapshot = {}; + for (final MapEntry entry in fieldMap.entries) { + snapshot[entry.key] = _copyPropertyInfo(entry.value); + } + _allClassFieldMaps[className] = snapshot; + } + + PropertyInfo _buildPropertyFromParameter(FormalParameter param) { + final PropertyInfo item = PropertyInfo(); + item.name = formalParameterName(param); + item.isRequired = + param.isRequired || param.toSource().toString().startsWith('@required'); + item.isNamed = param.isNamed; + item.type = extractFormalParameterType( + param, + superClassFieldMaps: _allClassFieldMaps[_currentClassSuperName], + ); + String? rawDefault = extractFormalParameterDefaultValue(param); + if ((rawDefault == null || rawDefault.trim().isEmpty) && + param is SuperFormalParameter && + _currentClassSuperName != null) { + rawDefault = _allClassConstructorDefaults[_currentClassSuperName]?[item.name]; + } + item.defaultValue = formatDefaultValueForDoc( + rawDefault, + paramName: item.name, + isRequired: item.isRequired, + ); + return item; + } + + void _splitFieldsBeyondConstructor() { + for (final MapEntry entry in fieldMap.entries) { + if (entry.key.startsWith('_')) { + continue; + } + if (_constructorParamNames.contains(entry.key)) { + continue; + } + final PropertyInfo field = _copyPropertyInfo(entry.value)..name = entry.key; + if (entry.value.isStatic) { + staticMemberList.add(field); + } else { + extraPropertyList.add(field); + } + } + extraPropertyList.sort((PropertyInfo a, PropertyInfo b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase())); + staticMemberList.sort((PropertyInfo a, PropertyInfo b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase())); + } + + void _fillPropertyFromFieldMap(PropertyInfo item) { + final PropertyInfo? field = fieldMap[item.name]; + if (field == null) { + return; + } + if (item.type.isEmpty && field.type.isNotEmpty) { + item.type = field.type; + } + if (item.introduction.isEmpty && field.introduction.isNotEmpty) { + item.introduction = field.introduction; + } + } + + void _finalizeConstructorMethodParams() { + if (componentInfo == null) { + return; + } + for (final StaticMethodInfo method in componentInfo!.constructorMethodList) { + for (final PropertyInfo param in method.params) { + _fillPropertyFromFieldMap(param); + if ((param.defaultValue == '-' || param.defaultValue.isEmpty) && + _currentClassSuperName != null) { + final String? parentDefault = + _allClassConstructorDefaults[_currentClassSuperName]?[param.name]; + if (parentDefault != null && + parentDefault.isNotEmpty && + parentDefault != '-') { + param.defaultValue = parentDefault; + } + } + if (param.type.isEmpty) { + param.type = '-'; + } + } + } + } + + void _sortPropertyListPreservingPositional() { + final List positional = + propertyList.where((PropertyInfo p) => !p.isNamed).toList(); + final List named = propertyList + .where((PropertyInfo p) => p.isNamed) + .toList() + ..sort((PropertyInfo a, PropertyInfo b) => + a.name.toLowerCase().compareTo(b.name.toLowerCase())); + propertyList + ..clear() + ..addAll(positional) + ..addAll(named); + } @override void visitConstructorDeclaration(ConstructorDeclaration node) { + if (_currentClassName == null) { + node.visitChildren(this); + return; + } node.visitChildren(this); - // 无命名构造函数 - // List childEntities = node.childEntities.toList(); - // bool isNormalConstructor = childEntities.isNotEmpty && nameList!.contains(childEntities[0].toString()); - // bool isConstConstructor = childEntities.length >= 2 && childEntities[0].toString() == 'const' && nameList!.contains(childEntities[1].toString()); if (node.name == null) { + final Map ctorDefaults = {}; for (final FormalParameter param in node.parameters.parameters) { - List strList = []; - strList.add('identifier:' + (param.name?.lexeme.toString() ?? "")); - strList.add('isNamed:' + param.isNamed.toString()); - strList.add('isOptional:' + param.isOptional.toString()); - strList.add('isOptionalNamed:' + param.isOptionalNamed.toString()); - strList.add('isOptionalPositional:' + param.isOptionalPositional.toString()); - strList.add('isPositional:' + param.isPositional.toString()); - strList.add('isRequired:' + param.isRequired.toString()); - strList.add('isRequiredNamed:' + param.isRequiredNamed.toString()); - strList.add('isRequiredPositional:' + param.isRequiredPositional.toString()); - strList.add('requiredKeyword:' + param.requiredKeyword.toString()); - strList.add('declaredElement:' + param.declaredElement.toString()); - strList.add('parent:' + param.toSource().toString()); - strList.add('beginToken:' + param.beginToken.toString()); - strList.add('endToken:' + param.endToken.toString()); - String tmp = ''; - if (param.childEntities.isNotEmpty) { - tmp = param.childEntities.map((e) => e.toString()).toList().join('|'); - } - strList.add('childEntities:' + tmp); - Debug.yellow('构造参数[$folderName]: ${strList.join(', ')}'); - PropertyInfo item = PropertyInfo(); - item.name = param.name?.lexeme.toString() ?? ""; - item.isRequired = param.isRequired || param.toSource().toString().startsWith('@required'); - item.isNamed = param.isNamed; - if (param.childEntities.length == 3) { - item.defaultValue = param.childEntities.toList().last.toString(); + final PropertyInfo built = _buildPropertyFromParameter(param); + final String? raw = extractFormalParameterDefaultValue(param); + if (raw != null && raw.trim().isNotEmpty) { + ctorDefaults[built.name] = formatDefaultValueForDoc( + raw, + paramName: built.name, + isRequired: built.isRequired, + ); } - if (tmp.startsWith('Key') && param.beginToken.toString() == 'Key') { - item.type = 'Key'; + if (_isInTargetClass) { + propertyList.add(built); + if (built.name.isNotEmpty) { + _constructorParamNames.add(built.name); + } } - propertyList.add(item); } - } else { + _allClassConstructorDefaults[_currentClassName!] = ctorDefaults; + } else if (_isInTargetClass) { // 记录工厂构造方法 - StaticMethodInfo staticMethodInfo = new StaticMethodInfo(); + final StaticMethodInfo staticMethodInfo = StaticMethodInfo(); staticMethodInfo.name = node.name.toString(); - staticMethodInfo.introduction = removeDocumentationComment(node.documentationComment?.tokens.join("\n") ?? ""); - // staticMethodInfo.returnType = node.returnType.type.toString(); - node.parameters.parameters.forEach((element) { - PropertyInfo info = PropertyInfo(); - info.name = element.name?.lexeme.toString() ?? "Null"; - // if (element is SimpleFormalParameter) { - // info.type = element.type.toString(); - // } else if(element is DefaultFormalParameter && element.parameter is FieldFormalParameter){ - // info.type = (element.parameter as FieldFormalParameter).parameters.toString(); - // } else if(element is FieldFormalParameter) { - // info.name = element.toString(); - // } - - info.isRequired = - element.isRequiredNamed || element.isRequiredPositional; - info.isNamed = element.isNamed; - - staticMethodInfo.params.add(info); - }); + staticMethodInfo.introduction = removeDocumentationComment( + node.documentationComment?.tokens.join('\n') ?? ''); + for (final FormalParameter element in node.parameters.parameters) { + staticMethodInfo.params.add(_buildPropertyFromParameter(element)); + } componentInfo ??= ComponentInfo(); componentInfo!.constructorMethodList.add(staticMethodInfo); } - String tmp = ''; - if (node.childEntities.isNotEmpty) { - tmp = node.childEntities.map((e) => e.toString()).toList().join('|'); - } - List strList = []; - strList.add(node.firstTokenAfterCommentAndMetadata.toString()); - strList.add(node.parameters.parameters.map((e) => e.name?.lexeme.toString() ?? "").toList().join('|')); - strList.add(node.childEntities.length.toString()); - strList.add(tmp); - strList.add(node.beginToken.toString()); - strList.add(node.name.toString()); - // strList.add(node.toSource()); - Debug.green('构造函数[$folderName]: ${strList.join(', ')}'); } @override void visitFieldDeclaration(FieldDeclaration node) { + if (_currentClassName == null) { + node.visitChildren(this); + return; + } node.visitChildren(this); - String tmp = ''; - if (node.childEntities.isNotEmpty) { - tmp = node.childEntities.map((e) => e.toString()).toList().join('|'); - } - List strList = []; - strList.add(node.childEntities.length.toString()); - strList.add(tmp); - strList.add(node.beginToken.toString()); - strList.add(node.toSource()); - strList.add('type:' + node.fields.type.toString()); - strList.add('fields:' + node.fields.variables.join(',').toString()); - Debug.blue('成员变量[$folderName]: ${strList.join(', ')}'); String fieldName = node.fields.variables.join(','); - if(fieldName.contains("=")){ - fieldName = fieldName.split("=")[0].trim(); + if (fieldName.contains('=')) { + fieldName = fieldName.split('=')[0].trim(); } - PropertyInfo? item = propertyList.firstWhereOrNull((element) => element.name == fieldName); - if (item == null) { - item = PropertyInfo(); - fieldMap[fieldName] = item; + if (fieldName.startsWith('_')) { + return; } - item.type = node.fields.type.toString(); - if (node.beginToken.toString().startsWith('///')) { + PropertyInfo? item = fieldMap[fieldName]; + item ??= PropertyInfo()..name = fieldName; + fieldMap[fieldName] = item; + item.isStatic = node.staticKeyword != null; + item.type = node.fields.type?.toString() ?? ''; + if (node.documentationComment != null) { + item.introduction = removeDocumentationComment( + node.documentationComment!.tokens.join('\n'), + ); + } else if (node.beginToken.toString().startsWith('///')) { item.introduction = removeDocumentationComment(node.beginToken.toString()); } } @override - void visitComment(Comment node) { - node.visitChildren(this); - String token = node.tokens.map((e) => e.toString()).toList().join('|'); - String tmp = ''; - if (node.childEntities.isNotEmpty) { - tmp = node.childEntities.map((e) => e.toString()).toList().join('|'); - } - List strList = []; - strList.add(node.isDocumentation.toString()); - strList.add(token); - strList.add(node.runtimeType.toString()); - strList.add(node.childEntities.length.toString()); - strList.add(tmp); - Debug('注释[$folderName]: ${strList.join(', ')}'); + void visitCompilationUnit(CompilationUnit node) { + _targetFoundInUnit = false; + _pendingEnums.clear(); + _pendingTypedefs.clear(); + _emittedAuxiliaryNames.clear(); + super.visitCompilationUnit(node); + _flushPendingAuxiliaryTypes(); + } + + @override + void visitEnumDeclaration(EnumDeclaration node) { + final String enumName = node.name.lexeme; + if (enumName.startsWith('_')) { + return; + } + if (nameList!.contains(enumName)) { + _emitEnum(node); + } else { + _pendingEnums.add(node); + } + } + + @override + void visitGenericTypeAlias(GenericTypeAlias node) { + final String aliasName = node.name.lexeme; + if (aliasName.startsWith('_')) { + return; + } + if (nameList!.contains(aliasName)) { + _emitTypedef(node); + } else { + _pendingTypedefs.add(node); + } } @override void visitClassDeclaration(ClassDeclaration node) { + final String className = node.name.toString(); + final bool isTarget = nameList!.contains(className); + if (isTarget) { + _targetFoundInUnit = true; + } + final String? previousClassName = _currentClassName; + final String? previousSuperClassName = _currentClassSuperName; + final Map previousFieldMap = fieldMap; + final String? previousTargetClassName = _currentTargetClassName; + final bool previousClassIsAbstract = _currentClassIsAbstract; + + _currentClassName = className; + fieldMap = {}; + _currentClassSuperName = _parseSuperClassName(node); + + if (isTarget) { + _currentTargetClassName = className; + _currentClassIsAbstract = node.abstractKeyword != null; + propertyList = []; + extraPropertyList = []; + staticMemberList = []; + _constructorParamNames.clear(); + } + node.visitChildren(this); - if (nameList!.contains(node.name.toString())) { + _saveClassFieldMap(className); + + if (isTarget) { componentInfo ??= ComponentInfo(); - List strList = []; - strList.add(node.name.toString()); - strList.add(node.beginToken.toString()); - if (node.name.toString() == 'TECheckBox') { - // strList.add(node.getField('checked')!.parent!.parent!.beginToken.toString()); - strList.add(node.getProperty('checked')!.parent!.parent!.beginToken.toString()); - } - Debug.red('类[$folderName]: ${strList.join(', ')}'); - componentInfo!.name = node.name.toString(); + componentInfo!.name = className; if (node.documentationComment != null) { componentInfo!.introduction = removeDocumentationComment( - node.documentationComment!.tokens.join("\n")); + node.documentationComment!.tokens.join('\n')); } - if (onParsedComponentInfoInfo != null) { - // 按照属性名称的首字母排序 - propertyList.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - - onParsedComponentInfoInfo!(ParsedComponentInfoInfo() - ..componentInfo = componentInfo - ..propertyList = propertyList - ..fieldMap = fieldMap); + _splitFieldsBeyondConstructor(); + for (final PropertyInfo item in propertyList) { + _fillPropertyFromFieldMap(item); + if ((item.defaultValue == '-' || item.defaultValue.isEmpty) && + _currentClassSuperName != null) { + final String? parentDefault = + _allClassConstructorDefaults[_currentClassSuperName]?[item.name]; + if (parentDefault != null && + parentDefault.isNotEmpty && + parentDefault != '-') { + item.defaultValue = parentDefault; + } + } + if (item.type.isEmpty) { + item.type = '-'; + } + } + for (final PropertyInfo item in extraPropertyList) { + if (item.type.isEmpty) { + item.type = '-'; + } } + for (final PropertyInfo item in staticMemberList) { + if (item.type.isEmpty) { + item.type = '-'; + } + } + _finalizeConstructorMethodParams(); + _sortPropertyListPreservingPositional(); + _emitParsedInfo(ParsedComponentInfoInfo() + ..componentInfo = componentInfo + ..propertyList = propertyList + ..extraPropertyList = extraPropertyList + ..staticMemberList = staticMemberList + ..fieldMap = fieldMap); + _resetClassState(); } - componentInfo = null; - propertyList = []; + + _currentClassName = previousClassName; + _currentClassSuperName = previousSuperClassName; + fieldMap = previousFieldMap; + _currentTargetClassName = previousTargetClassName; + _currentClassIsAbstract = previousClassIsAbstract; } @override void visitMethodDeclaration(MethodDeclaration node) { + if (!_isInTargetClass) { + super.visitMethodDeclaration(node); + return; + } super.visitMethodDeclaration(node); - if(node.isStatic && !node.name.toString().startsWith("_")){ - StaticMethodInfo staticMethodInfo = new StaticMethodInfo(); - staticMethodInfo.name = node.name.toString(); - staticMethodInfo.introduction = removeDocumentationComment(node.documentationComment?.tokens.join("\n") ?? ""); - staticMethodInfo.returnType = node.returnType?.type.toString(); - node.parameters?.parameters.forEach((element) { - PropertyInfo info = PropertyInfo(); - info.name = element.name?.lexeme.toString() ?? "Null"; - if (element is SimpleFormalParameter) { - info.type = element.type.toString(); - } else if(element is DefaultFormalParameter && element.parameter is SimpleFormalParameter){ - info.type = (element.parameter as SimpleFormalParameter).type.toString(); - } + final String methodName = node.name.toString(); + // 私有方法不收录 + if (methodName.startsWith('_')) { + return; + } - info.isRequired = - element.isRequiredNamed || element.isRequiredPositional; - info.isNamed = element.isNamed; + StaticMethodInfo methodInfo = StaticMethodInfo(); + methodInfo.name = methodName; + methodInfo.introduction = removeDocumentationComment( + node.documentationComment?.tokens.join('\n') ?? ''); + methodInfo.returnType = node.returnType?.toSource(); + node.parameters?.parameters.forEach((FormalParameter element) { + methodInfo.params.add(_buildPropertyFromParameter(element)); + }); - staticMethodInfo.params.add(info); - }); - componentInfo ??= ComponentInfo(); - componentInfo!.staticMethodList.add(staticMethodInfo); + componentInfo ??= ComponentInfo(); + if (node.isStatic) { + componentInfo!.staticMethodList.add(methodInfo); + } else if (_currentClassIsAbstract) { + // abstract class 的实例方法(含 abstract 方法和带默认实现的可覆写方法) + componentInfo!.instanceMethodList.add(methodInfo); } } } @@ -292,7 +541,10 @@ class ComponentVisitor extends RecursiveElementVisitor { if (onParsedComponentInfoInfo != null) { onParsedComponentInfoInfo!(ParsedComponentInfoInfo() ..componentInfo = componentInfo - ..propertyList = propertyList); + ..propertyList = propertyList + ..extraPropertyList = [] + ..staticMemberList = [] + ..fieldMap = {}); } } } @@ -301,21 +553,10 @@ class ComponentVisitor extends RecursiveElementVisitor { ComponentInfo parseBaseInfo(ClassElement element) { ComponentInfo componentInfo = ComponentInfo(); componentInfo.name = element.displayName; - List comments = []; if (element.documentationComment != null) { - comments = element.documentationComment!.split('///'); - } - for (final String item in comments) { - if (item.trim().isNotEmpty) { - // print('注解:$item'); - if (componentInfo.introduction!.isNotEmpty) { - componentInfo.introduction = '${componentInfo.introduction} \n'; - } - componentInfo.introduction = '${componentInfo.introduction}${item.trim()}'; - } + componentInfo.introduction = removeDocumentationComment( + element.documentationComment!); } - // print('\n组件基本信息:'); - // print('$componentInfo'); return componentInfo; } @@ -338,11 +579,6 @@ class ComponentVisitor extends RecursiveElementVisitor { } propertyList.add(item); } - // print('\n组件属性信息:'); - // for (final item in propertyList) { - // print(item); - // } - // 按照属性名称的首字母排序 propertyList.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); @@ -350,16 +586,16 @@ class ComponentVisitor extends RecursiveElementVisitor { } String getDefaultValue(ParameterElement param) { - bool paramIsString = param.type.getDisplayString(withNullability: false) == 'String'; + final bool paramIsString = + param.type.getDisplayString(withNullability: false) == 'String'; String defaultValue = param.defaultValueCode!; - // if (defaultValue == '\'\'') { - // defaultValue = '""'; - // } + if (defaultValue == param.name || defaultValue == 'this.${param.name}') { + return '-'; + } if (defaultValue.startsWith("'")) { defaultValue = defaultValue.substring(1, defaultValue.length - 1); } - // print('paramIsString=$paramIsString, defaultValue=$defaultValue'); - return paramIsString ? defaultValue : '$defaultValue'; + return paramIsString ? defaultValue : defaultValue; } String? getDescription(String name, List fields) { diff --git a/lib/model.dart b/lib/model.dart index 25d9810..e469423 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -62,6 +62,24 @@ class ComponentInfo { // 其他构造方法信息 List constructorMethodList = []; + // 实例方法信息(用于 abstract class 的接口方法文档) + List instanceMethodList = []; + + /// API 条目类型:class | enum | typedef + String kind = 'class'; + + /// 枚举成员名称(仅 kind == enum) + List enumValues = []; + + /// 枚举成员详情(仅 kind == enum) + List enumMembers = []; + + /// typedef 定义源码(仅 kind == typedef) + String typedefDefinition = ''; + + /// 解析来源文件(用于 folder 模式下检测跨文件重复定义) + String? sourceFile; + Map toJson() { return { 'name': name, @@ -134,13 +152,13 @@ class DemoInfo { } } -class EnumInfo { - String? type; // 枚举的类型 例如 TestEnumStyle - String? name; // 枚举实例的变量名称,例如代码: TestEnumStyle style; 其中 style 就是 name - List? values; //枚举的值,例如 TestEnumStyle 的 values = style1, style2 +//组件属性信息 +/// 枚举成员(名称 + 文档注释) +class EnumMemberInfo { + String name = ''; + String introduction = ''; } -//组件属性信息 class PropertyInfo { //属性的名称 String name = ''; @@ -160,6 +178,9 @@ class PropertyInfo { // 默认值 String defaultValue = '-'; + // 是否为静态成员 + bool isStatic = false; + @override String toString() { return '$name | $type | $isRequired | $isNamed | $defaultValue | $introduction'; @@ -168,8 +189,13 @@ class PropertyInfo { class ParsedComponentInfoInfo { ComponentInfo? componentInfo; + /// 默认构造方法的形式参数 late List propertyList; - late Map fieldMap; + /// 未出现在默认构造中、但对外可见的实例字段 + late List extraPropertyList; + /// 静态成员(含 static const 等) + late List staticMemberList; + late Map fieldMap; } // 用户执行的命令 diff --git a/lib/smart_create.dart b/lib/smart_create.dart index f852e66..5f4bcdc 100644 --- a/lib/smart_create.dart +++ b/lib/smart_create.dart @@ -23,7 +23,6 @@ class SmartCreator { this.folderName, this.output, this.isFileMode, - this.isMerge = false, this.onlyApi = false, this.isGrammarParser = false, }); @@ -34,7 +33,6 @@ class SmartCreator { final String? folderName; // 文件夹名称 final String? output; // 输出文件夹名称 final bool? isFileMode; // 是否是单文件模式 - final bool isMerge; // 是否合并在一个文件夹中 final bool? onlyApi; final bool? isGrammarParser; //是否使用语法分析器 final CommandInfo? commandInfo; @@ -97,6 +95,60 @@ class SmartCreator { return analyseFile(analysisContextCollection, files, startTime); } + /// 仅解析源码(不写入 md),供完备性检测等场景复用与 generate 相同的 AST 规则。 + Future> parseOnly({bool quiet = false}) async { + final List files = _collectSourceFiles(); + if (files.isEmpty) { + return []; + } + final int startTime = DateTime.now().microsecondsSinceEpoch; + if (!quiet) { + final String? sdkPath = await _detectSdkPath(); + if (sdkPath != null && sdkPath.isNotEmpty) { + AnsiPen pen = AnsiPen()..green(bold: true); + print(pen('Detected Dart SDK: $sdkPath')); + } + } + final String? sdkPath = await _detectSdkPath(); + final AnalysisContextCollection analysisContextCollection = + (sdkPath != null && sdkPath.isNotEmpty) + ? AnalysisContextCollection( + includedPaths: files, + excludedPaths: [], + resourceProvider: PhysicalResourceProvider.INSTANCE, + sdkPath: sdkPath, + ) + : AnalysisContextCollection( + includedPaths: files, + excludedPaths: [], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + return _parseComponents(analysisContextCollection, files, startTime, + quiet: quiet); + } + + List _collectSourceFiles() { + final List files = []; + if (isFileMode!) { + String filePath = join(basePath!, path); + filePath = normalize(filePath); + if (File(filePath).existsSync()) { + files.add(filePath); + } + } else { + final String fullPath = normalize(join(basePath!, path)); + final Directory comDir = Directory(fullPath); + if (comDir.existsSync()) { + for (final FileSystemEntity item in comDir.listSync()) { + if (item.path.endsWith('.dart')) { + files.add(item.path); + } + } + } + } + return files; + } + // Attempt to detect Dart SDK path using multiple strategies: // 1) DART_SDK env // 2) FLUTTER_ROOT/FLUTTER_HOME -> bin/cache/dart-sdk @@ -173,26 +225,35 @@ class SmartCreator { return null; } - Future analyseFile(AnalysisContextCollection analysisContextCollection, List paths, int startTime) async { - // print('${DateTime.now().toLocal()} analyseFile'); - List parsedComponentInfoList = []; + Future> _parseComponents( + AnalysisContextCollection analysisContextCollection, + List paths, + int startTime, { + bool quiet = false, + }) async { + final List parsedComponentInfoList = + []; for (final String filePath in paths) { - print('\n\n${DateTime.now().toLocal()} 开始分析 ${basename(filePath)}'); - String normalizedPath = normalize(filePath); + if (!quiet) { + print('\n\n${DateTime.now().toLocal()} 开始分析 ${basename(filePath)}'); + } + final String normalizedPath = normalize(filePath); ParsedUnitResult? unit; ResolvedUnitResult? unit2; if (isGrammarParser!) { - // 在新版本的analyzer中,getResolvedUnit方法返回的是SomeResolvedUnitResult - var result = await analysisContextCollection.contextFor(normalizedPath).currentSession.getResolvedUnit(normalizedPath); - // 将SomeResolvedUnitResult转换为ResolvedUnitResult + final result = await analysisContextCollection + .contextFor(normalizedPath) + .currentSession + .getResolvedUnit(normalizedPath); unit2 = result as ResolvedUnitResult?; } else { - // 在新版本的analyzer中,getParsedUnit方法返回的是SomeParsedUnitResult - var result = analysisContextCollection.contextFor(normalizedPath).currentSession.getParsedUnit(normalizedPath); - // 将SomeParsedUnitResult转换为ParsedUnitResult + final result = analysisContextCollection + .contextFor(normalizedPath) + .currentSession + .getParsedUnit(normalizedPath); unit = result as ParsedUnitResult?; } - ComponentRule issuesInFile = ComponentRule( + final ComponentRule issuesInFile = ComponentRule( parsedUnitResult: unit, resolvedUnitResult: unit2, isGrammarParser: isGrammarParser, @@ -200,25 +261,46 @@ class SmartCreator { basePath: basePath, folderName: folderName, startTime: startTime, - isMerge: isMerge, sourceFileName: basename(filePath), ); - int endTime = DateTime.now().microsecondsSinceEpoch; - print('${isGrammarParser! ? "语法分析" : "词法分析"}执行用时: ${((endTime - startTime) / 1000).floor()}ms'); - // print('${DateTime.now().toLocal()} 开始解析 ${basename(filePath)}'); + if (!quiet) { + final int endTime = DateTime.now().microsecondsSinceEpoch; + print( + '${isGrammarParser! ? "语法分析" : "词法分析"}执行用时: ${((endTime - startTime) / 1000).floor()}ms'); + } + parsedComponentInfoList.addAll(issuesInFile.analyse()); + } + reportDuplicateAuxiliaryDefinitions(parsedComponentInfoList); - List items = issuesInFile.analyse(); - parsedComponentInfoList.addAll(items); + int kindOrder(ParsedComponentInfoInfo info) { + switch (info.componentInfo?.kind) { + case 'enum': + return 1; + case 'typedef': + return 2; + default: + return 0; + } } - // 按照 nameList 的顺序对解析结果进行排序,确保输出顺序与用户输入顺序一致 - parsedComponentInfoList.sort((a, b) { + + parsedComponentInfoList.sort((ParsedComponentInfoInfo a, + ParsedComponentInfoInfo b) { + final int kindCmp = kindOrder(a).compareTo(kindOrder(b)); + if (kindCmp != 0) { + return kindCmp; + } int indexA = nameList!.indexOf(a.componentInfo!.name!); int indexB = nameList!.indexOf(b.componentInfo!.name!); - // 找不到的元素排到最后 if (indexA == -1) indexA = nameList!.length; if (indexB == -1) indexB = nameList!.length; return indexA.compareTo(indexB); }); + return parsedComponentInfoList; + } + + Future analyseFile(AnalysisContextCollection analysisContextCollection, List paths, int startTime) async { + final List parsedComponentInfoList = + await _parseComponents(analysisContextCollection, paths, startTime); await generateApiInfoFile(parsedComponentInfoList); if (!onlyApi! && parsedComponentInfoList.isNotEmpty) { await generateBaseInfoFile(parsedComponentInfoList.first.componentInfo!, commandInfo!); @@ -241,47 +323,135 @@ class SmartCreator { File file = File(path); await file.create(recursive: false); String fileContent = ''' -## API'''; +## API +'''; StringBuffer sb = StringBuffer(fileContent); for (final apiInfo in parsedComponentInfoList) { - if (parsedComponentInfoList.length > 0) { - sb.write('\n'); - if (parsedComponentInfoList.indexOf(apiInfo) >= 1) { - sb.write('''```\n```\n\n'''); + if (parsedComponentInfoList.indexOf(apiInfo) >= 1) { + sb.write('\n\n'); + } + sb.write('### ${apiInfo.componentInfo!.name}'); + final introduction = apiInfo.componentInfo!.introduction ?? ''; + final String kind = apiInfo.componentInfo?.kind ?? 'class'; + + if (kind == 'enum') { + if (introduction.isNotEmpty) { + sb.write('\n#### 简介\n'); + sb.write(introduction); } - sb.write('### ${apiInfo.componentInfo!.name}'); - if (commandInfo?.isGetComments ?? false) { + final List enumMembers = + apiInfo.componentInfo!.enumMembers; + if (enumMembers.isNotEmpty) { + sb.write('\n#### 枚举值\n'); + sb.write('''\n +| 名称 | 说明 | +| --- | --- |\n'''); + for (final EnumMemberInfo member in enumMembers) { + final String doc = + member.introduction.isEmpty ? '-' : member.introduction; + sb.write( + '| ${sanitizeTableCell(member.name)} | ${sanitizeTableCell(doc)} |\n'); + } + } else if (apiInfo.componentInfo!.enumValues.isNotEmpty) { + sb.write('\n#### 枚举值\n'); + sb.write('''\n +| 名称 | 说明 | +| --- | --- |\n'''); + for (final String value in apiInfo.componentInfo!.enumValues) { + sb.write('| ${sanitizeTableCell(value)} | - |\n'); + } + } + continue; + } + + if (kind == 'typedef') { + if (introduction.isNotEmpty) { sb.write('\n#### 简介\n'); - sb.write('${apiInfo.componentInfo!.introduction}'); + sb.write(introduction); + } + if (apiInfo.componentInfo!.typedefDefinition.isNotEmpty) { + sb.write('\n#### 类型定义\n\n'); + sb.write('```dart\n${apiInfo.componentInfo!.typedefDefinition}\n```\n'); } + continue; } - if (apiInfo.propertyList.isNotEmpty) { - // 填充introduction - apiInfo.propertyList.forEach((element) { - if(element.introduction.isEmpty){ - element.type = apiInfo.fieldMap[element.name]?.type ?? ''; - element.introduction = apiInfo.fieldMap[element.name]?.introduction ?? ''; - } - }); - sb.write('\n#### 默认构造方法'); + // 无任何可渲染内容(如 sealed 基类)或有实例方法(如 abstract class),都强制显示简介 + final hasNoContent = apiInfo.propertyList.isEmpty && + apiInfo.extraPropertyList.isEmpty && + apiInfo.staticMemberList.isEmpty && + (apiInfo.componentInfo?.constructorMethodList.isEmpty ?? true) && + (apiInfo.componentInfo?.staticMethodList.isEmpty ?? true) && + (apiInfo.componentInfo?.instanceMethodList.isEmpty ?? true); + final hasInstanceMethods = + apiInfo.componentInfo?.instanceMethodList.isNotEmpty ?? false; + if (((commandInfo?.isGetComments ?? false) || + hasNoContent || + hasInstanceMethods) && + introduction.isNotEmpty) { + sb.write('\n#### 简介\n'); + sb.write(introduction); + } + void writePropertyTable( + List items, { + required String header, + String nameColumn = '参数', + }) { + if (items.isEmpty) { + return; + } + sb.write('\n#### $header'); sb.write('''\n -| 参数 | 类型 | 默认值 | 说明 | +| $nameColumn | 类型 | 默认值 | 说明 | | --- | --- | --- | --- |\n'''); - for (final item in apiInfo.propertyList) { - sb.write('''| ${item.name} | ${item.type} | ${item.defaultValue} | ${item.introduction} |\n'''); + for (final PropertyInfo item in items) { + sb.write( + '''| ${sanitizeTableCell(item.name)} | ${sanitizeTableCell(item.type.isEmpty ? '-' : item.type)} | ${sanitizeTableCell(item.defaultValue)} | ${sanitizeTableCell(item.introduction)} |\n'''); } } - if(apiInfo.componentInfo?.constructorMethodList.isNotEmpty ?? false){ - sb.write("\n\n"); - sb.write("#### 工厂构造方法"); - sb.write('''\n -| 名称 | 说明 | -| --- | --- |\n'''); - // 按照方法名称的首字母排序 - apiInfo.componentInfo!.constructorMethodList.sort((a, b) => a.name!.toLowerCase().compareTo(b.name!.toLowerCase())); - for (final item in apiInfo.componentInfo!.constructorMethodList) { - sb.write('''| ${apiInfo.componentInfo!.name}.${item.name} | ${item.introduction} |\n'''); + + if (apiInfo.propertyList.isNotEmpty) { + // 用 fieldMap 补全构造参数缺失的类型和说明 + for (final PropertyInfo element in apiInfo.propertyList) { + final PropertyInfo? field = apiInfo.fieldMap[element.name]; + if (field == null) { + continue; + } + if (element.type.isEmpty || element.type == '-') { + element.type = field.type.isNotEmpty ? field.type : element.type; + } + if (element.introduction.isEmpty) { + element.introduction = field.introduction; + } + } + writePropertyTable(apiInfo.propertyList, header: '默认构造方法'); + } + writePropertyTable(apiInfo.extraPropertyList, + header: '公开属性', nameColumn: '属性'); + writePropertyTable(apiInfo.staticMemberList, + header: '静态成员', nameColumn: '名称'); + if (apiInfo.componentInfo?.constructorMethodList.isNotEmpty ?? false) { + sb.write('\n\n'); + sb.write('#### 工厂构造方法'); + apiInfo.componentInfo!.constructorMethodList.sort((StaticMethodInfo a, + StaticMethodInfo b) => + a.name!.toLowerCase().compareTo(b.name!.toLowerCase())); + for (final StaticMethodInfo item + in apiInfo.componentInfo!.constructorMethodList) { + sb.write( + '\n\n##### ${apiInfo.componentInfo!.name}.${sanitizeTableCell(item.name)}'); + if (item.introduction != null && item.introduction!.isNotEmpty) { + sb.write('\n\n${item.introduction}'); + } + if (item.params.isNotEmpty) { + sb.write('''\n +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- |\n'''); + for (final PropertyInfo param in item.params) { + sb.write( + '''| ${sanitizeTableCell(param.name)} | ${sanitizeTableCell(param.type.isEmpty ? '-' : param.type)} | ${sanitizeTableCell(param.defaultValue)} | ${sanitizeTableCell(param.introduction)} |\n'''); + } + } } } if(apiInfo.componentInfo?.staticMethodList.isNotEmpty ?? false){ @@ -293,11 +463,21 @@ class SmartCreator { // 按照方法名称的首字母排序 apiInfo.componentInfo!.staticMethodList.sort((a, b) => a.name!.toLowerCase().compareTo(b.name!.toLowerCase())); for (final item in apiInfo.componentInfo!.staticMethodList) { - StringBuffer paramsSb = StringBuffer(); - item.params.forEach((element) { - paramsSb.write(" ${element.isRequired ? "required " : ""}${element.type} ${element.name},"); - }); - sb.write('''| ${item.name} | ${item.returnType == "null" ? "" : item.returnType} | ${paramsSb.toString()} | ${item.introduction?.replaceAll("\n", " ")} |\n'''); + sb.write( + '''| ${sanitizeTableCell(item.name)} | ${sanitizeTableCell(item.returnType == "null" ? "" : item.returnType)} | ${sanitizeTableCell(formatMethodParams(item.params))} | ${sanitizeTableCell(item.introduction)} |\n'''); + } + } + if(apiInfo.componentInfo?.instanceMethodList.isNotEmpty ?? false){ + sb.write("\n\n"); + sb.write("#### 方法"); + sb.write('''\n +| 名称 | 返回类型 | 参数 | 说明 | +| --- | --- | --- | --- |\n'''); + for (final item in apiInfo.componentInfo!.instanceMethodList) { + final returnType = + item.returnType == "null" ? "" : (item.returnType ?? ""); + sb.write( + '| ${sanitizeTableCell(item.name)} | ${sanitizeTableCell(returnType)} | ${sanitizeTableCell(formatMethodParams(item.params))} | ${sanitizeTableCell(item.introduction)} |\n'); } } } diff --git a/lib/smart_update.dart b/lib/smart_update.dart index f71dd1c..e5f14b1 100644 --- a/lib/smart_update.dart +++ b/lib/smart_update.dart @@ -125,17 +125,11 @@ class SmartUpdater { } } } - int endTime = DateTime.now().microsecondsSinceEpoch; - Debug('${comDirPath.split("/").last} 组件示例分析完毕 ${demoList.map((e) => e.name).toList().join(" | ")} 用时: ${((endTime - startTime) / 1000).floor()}ms'); - // for (final item in demoList) { - // print('$item'); - // } return demoList; } // 迁移demo的code文件 Future migrateDemoCodeFile(List demoList) async { - int startTime = DateTime.now().microsecondsSinceEpoch; for (final demoInfo in demoList) { String destName = CamelToUnderline(demoInfo.name!); String fullPath = join(basePath!, 'example/assets/code/$destName.code'); @@ -151,14 +145,11 @@ class SmartUpdater { } File destFile = File(fullPath); await destFile.writeAsString(linesNew.join('\n')); - int endTime = DateTime.now().microsecondsSinceEpoch; - Debug('${demoInfo.name} 示例代码迁移成功! 用时: ${((endTime - startTime) / 1000).floor()}ms'); } } // 迁移组件文件 Future migrateComFiles(String comDirPath) async { - int startTime = DateTime.now().microsecondsSinceEpoch; Directory comDir = Directory(comDirPath); List files = comDir.listSync(); for (final item in files) { @@ -168,17 +159,13 @@ class SmartUpdater { File comMarkdownFile = item as File; String relativePath = 'example/assets/doc/$filename'; await comMarkdownFile.copy(join(basePath!, relativePath)); - Debug('$relativePath 组件介绍文档更新成功'); } else if (filename.endsWith('.png')) { // 迁移组件封面图 File comPreviewFile = item as File; String relativePath = 'example/assets/preview/$filename'; await comPreviewFile.copy(join(basePath!, relativePath)); - Debug('$relativePath 组件封面图更新成功'); } } - int endTime = DateTime.now().microsecondsSinceEpoch; - Debug('组件文档迁移完毕! 用时: ${((endTime - startTime) / 1000).floor()}ms'); } Future getComponentInfo(String comDirPath) async { @@ -274,7 +261,6 @@ class SmartUpdater { List importList = []; List componentGroupList = []; Set displayGroupList = componentConfig.componentList!.map((e) => e.group).toSet(); - Debug('全部分类:$displayGroupList'); for (final groupType in displayGroupList) { List componentInfoList = []; if (componentConfig.componentList!.isNotEmpty) { diff --git a/lib/util.dart b/lib/util.dart index db84c1c..81e617f 100644 --- a/lib/util.dart +++ b/lib/util.dart @@ -1,7 +1,8 @@ -import 'package:analyzer/dart/element/type.dart'; -// import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/ast/ast.dart'; import 'package:ansicolor/ansicolor.dart'; +import 'model.dart'; + // 驼峰转下划线 String CamelToUnderline(String input) { RegExp exp = RegExp(r'(?<=[a-z])[A-Z]'); @@ -14,72 +15,159 @@ String removeDocumentationComment(String element) { return element.replaceAll(regex, '').trim(); } -// 是否是demo文件 -bool isValidDemoFile(String fileName) { - final regex = RegExp(r'demo[0-9]+.dart'); - return regex.hasMatch(fileName); -} - -// 是否是demo类 -bool isValidDemoClass(String className) { - final regex = RegExp(r'.*Demo[0-9]+$'); - return regex.hasMatch(className); +/// 获取形式参数名称(兼容 DefaultFormalParameter 包裹 FieldFormalParameter) +String formalParameterName(FormalParameter param) { + if (param is DefaultFormalParameter) { + return formalParameterName(param.parameter); + } + return param.name?.lexeme ?? ''; } -bool isEnum(DartType targetType) => targetType is InterfaceType && targetType.element.kind.name == 'ENUM'; - -bool hasType(List superTypes, String type) => superTypes.any((superType) => superType.getDisplayString(withNullability: false) == type); - -bool isWidget(List superTypes) => hasType(superTypes, 'Widget'); - -class Debug { - bool isEnable = false; - - Debug(String msg) { - if (isEnable) { - print(msg); +/// 从构造/方法参数 AST 提取类型字符串 +String extractFormalParameterType( + FormalParameter param, { + Map? superClassFieldMaps, +}) { + FormalParameter target = param; + if (param is DefaultFormalParameter) { + target = param.parameter; + } + if (target is SimpleFormalParameter) { + return target.type?.toString() ?? ''; + } + if (target is FieldFormalParameter) { + return target.type?.toString() ?? ''; + } + if (target is SuperFormalParameter) { + final String? explicitType = target.type?.toString(); + if (explicitType != null && explicitType.isNotEmpty) { + return explicitType; + } + final String? superParamName = formalParameterName(target); + if (superParamName != null && + superClassFieldMaps != null && + superClassFieldMaps.containsKey(superParamName)) { + final String parentType = superClassFieldMaps[superParamName]!.type; + if (parentType.isNotEmpty) { + return parentType; + } + } + // `super.key` 未写显式类型时,文档统一展示为 Key? + if (superParamName == 'key') { + return 'Key?'; } + return ''; } + return ''; +} - Debug.green(String msg) { - if (isEnable) { - AnsiPen pen = AnsiPen()..green(bold: true); - print(pen(msg)); - } +/// 从构造/方法参数 AST 提取默认值源码 +String? extractFormalParameterDefaultValue(FormalParameter param) { + if (param is DefaultFormalParameter) { + return param.defaultValue?.toSource(); } + return null; +} - Debug.red(String msg) { - if (isEnable) { - AnsiPen pen = AnsiPen()..red(bold: true); - print(pen(msg)); +/// 规范化 API 文档中的默认值展示 +String formatDefaultValueForDoc( + String? raw, { + required String paramName, + required bool isRequired, +}) { + if (raw == null || raw.trim().isEmpty) { + return '-'; + } + var value = raw.trim(); + // `this.fieldName` 会被误识别为默认值 + if (value == paramName || value == 'this.$paramName') { + return '-'; + } + if ((value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"'))) { + if (value.length >= 2) { + value = value.substring(1, value.length - 1); } } + return value; +} - Debug.yellow(String msg) { - if (isEnable) { - AnsiPen pen = AnsiPen()..yellow(bold: true); - print(pen(msg)); - } +/// 清理 Markdown 表格单元格,避免破坏表格结构 +String sanitizeTableCell(String? text) { + if (text == null || text.isEmpty) { + return '-'; } + return text + .replaceAll('|', '\\|') + .replaceAll('\n', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); +} - Debug.black(String msg) { - if (isEnable) { - AnsiPen pen = AnsiPen()..black(bold: true); - print(pen(msg)); +/// 格式化方法参数列表,便于写入 Markdown 表格 +String formatMethodParams(List params) { + if (params.isEmpty) { + return '-'; + } + final buffer = StringBuffer(); + for (final PropertyInfo element in params) { + final isRequired = element.isRequired; + final type = element.type; + final name = element.name; + if (type.isNotEmpty) { + buffer.write('${isRequired ? 'required ' : ''}$type $name'); + } else { + buffer.write('${isRequired ? 'required ' : ''}$name'); } + buffer.write(', '); } + final result = buffer.toString().trim(); + if (result.endsWith(',')) { + return result.substring(0, result.length - 1).trim(); + } + return result.isEmpty ? '-' : result; +} - Debug.white(String msg) { - if (isEnable) { - AnsiPen pen = AnsiPen()..white(bold: true); - print(pen(msg)); +/// 检测 folder 模式下跨文件重复的 enum/typedef 定义(源码规范问题,不去重掩盖) +void reportDuplicateAuxiliaryDefinitions(List items) { + final Map> locations = >{}; + for (final ParsedComponentInfoInfo item in items) { + final String? kind = item.componentInfo?.kind; + if (kind != 'enum' && kind != 'typedef') { + continue; + } + final String? name = item.componentInfo?.name; + if (name == null || name.isEmpty) { + continue; } + final String file = item.componentInfo?.sourceFile ?? 'unknown'; + locations.putIfAbsent('$kind:$name', () => []).add(file); } - - Debug.blue(String msg) { - if (isEnable) { - AnsiPen pen = AnsiPen()..blue(bold: true); - print(pen(msg)); + final AnsiPen pen = AnsiPen()..yellow(bold: true); + for (final MapEntry> entry in locations.entries) { + if (entry.value.length <= 1) { + continue; } + final List parts = entry.key.split(':'); + final String kindLabel = parts[0] == 'enum' ? 'enum' : 'typedef'; + final String typeName = parts.length > 1 ? parts[1] : entry.key; + final String files = entry.value.toSet().join(', '); + print(pen( + 'Warning: 源码重复定义 $kindLabel `$typeName`,出现在: $files', + )); + print(pen(' 建议: 将 `$typeName` 收敛到单一文件定义,避免文档重复与维护漂移')); } } + +// 是否是demo文件 +bool isValidDemoFile(String fileName) { + final regex = RegExp(r'demo[0-9]+.dart'); + return regex.hasMatch(fileName); +} + +// 是否是demo类 +bool isValidDemoClass(String className) { + final regex = RegExp(r'.*Demo[0-9]+$'); + return regex.hasMatch(className); +} + diff --git a/pubspec.lock b/pubspec.lock index a4d7263..db09dc8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + coverage: + dependency: transitive + description: + name: coverage + sha256: "802bd084fb82e55df091ec8ad1553a7331b61c08251eef19a508b6f3f3a9858d" + url: "https://pub.dev" + source: hosted + version: "1.13.1" crypto: dependency: transitive description: @@ -123,14 +139,54 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" glob: dependency: transitive description: name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "0.7.2" leak_tracker: dependency: transitive description: @@ -155,6 +211,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" markdown: dependency: "direct main" description: @@ -187,6 +251,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -203,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" pub_semver: dependency: transitive description: @@ -211,11 +299,59 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -256,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e" + url: "https://pub.dev" + source: hosted + version: "1.25.15" test_api: dependency: transitive description: @@ -264,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + test_core: + dependency: transitive + description: + name: test_core + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + url: "https://pub.dev" + source: hosted + version: "0.6.8" typed_data: dependency: transitive description: @@ -289,21 +441,53 @@ packages: source: hosted version: "15.0.0" watcher: - dependency: "direct main" + dependency: transitive description: name: watcher sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" url: "https://pub.dev" source: hosted version: "1.1.2" - yaml: + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: "direct main" description: name: yaml - sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.1.3" sdks: dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7f0065b..7312f25 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,13 +14,13 @@ dependencies: markdown: ^7.1.1 ansicolor: ^2.0.2 analyzer: ^6.2.0 - watcher: ^1.1.0 collection: ^1.18.0 dev_dependencies: flutter_test: sdk: flutter + test: ^1.25.15 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/scripts/local_test.sh b/scripts/local_test.sh new file mode 100755 index 0000000..54d402f --- /dev/null +++ b/scripts/local_test.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# 本地测试 API 文档生成工具 +# 在 tdesign-component 目录下,通过 path 依赖指向 ../tdesign-flutter-tools +# 用法: ./scripts/local_test.sh [picker|calendar|dialog] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOLS_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +COMPONENT_DIR="$(cd "${TOOLS_DIR}/../tdesign-flutter/tdesign-component" && pwd)" +TARGET="${1:-picker}" + +cd "${COMPONENT_DIR}" + +echo "==> 工作目录: ${COMPONENT_DIR}" +echo "==> 本地 tools: ${TOOLS_DIR}" +echo "==> 生成组件: ${TARGET}" + +run_generate() { + local folder="$1" + local names="$2" + local folder_name="$3" + + # 使用已有 package_config,避免 dart run 触发 pub.dev 联网校验 + dart --packages="${COMPONENT_DIR}/.dart_tool/package_config.json" \ + run "${TOOLS_DIR}/bin/main.dart" generate \ + --folder "${folder}" \ + --name "${names}" \ + --folder-name "${folder_name}" \ + --output "example/assets/api/" \ + --only-api +} + +case "${TARGET}" in + picker) + run_generate \ + "lib/src/components/picker" \ + "TPicker,TPickerOption,TPickerValue,TPickerLoadEvent,TPickerColumns,TPickerLinked,TPickerItems,TPickerKeys" \ + "picker" + ;; + calendar) + run_generate \ + "lib/src/components/calendar" \ + "TCalendar,TCalendarPopup,TCalendarStyle,TCalendarDataSource,TLunarInfo,TCalendarDateType" \ + "calendar" + ;; + dialog) + run_generate \ + "lib/src/components/dialog" \ + "TAlertDialog,TConfirmDialog,TDialogButtonOptions,TDialogScaffold,TDialogTitle,TDialogContent,TDialogInfoWidget,HorizontalNormalButtons,HorizontalTextButtons,TDialogButton,TImageDialog,TInputDialog" \ + "dialog" + ;; + *) + echo "未知组件: ${TARGET},可选: picker | calendar | dialog" + exit 1 + ;; +esac + +echo "==> 完成,输出目录: ${COMPONENT_DIR}/example/assets/api/${TARGET}_api.md" diff --git a/test/aux_types_test.dart b/test/aux_types_test.dart new file mode 100644 index 0000000..202d2e0 --- /dev/null +++ b/test/aux_types_test.dart @@ -0,0 +1,54 @@ +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:tdesign_flutter_tools/component_rule.dart'; +import 'package:test/test.dart'; + +import 'support/component_paths.dart'; + +List _analyse(List names, String relPath) { + final String path = componentSourcePath(relPath); + final col = AnalysisContextCollection( + includedPaths: [path], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + final parsed = col.contextFor(path).currentSession.getParsedUnit(path) + as ParsedUnitResult; + return ComponentRule( + parsedUnitResult: parsed, + isGrammarParser: false, + nameList: names, + folderName: 'popup', + sourceFileName: relPath.split('/').last, + ).analyse(); +} + +void main() { + test('auto includes SlideTransitionFrom when parsing TSlidePopupRoute file', () { + final list = _analyse( + ['TSlidePopupRoute'], + 'lib/src/components/popup/t_popup_route.dart', + ); + final names = list.map((e) => e.componentInfo!.name).toList(); + expect(names, contains('SlideTransitionFrom')); + final enumInfo = + list.firstWhere((e) => e.componentInfo!.name == 'SlideTransitionFrom'); + expect(enumInfo.componentInfo!.kind, 'enum'); + expect(enumInfo.componentInfo!.enumValues, + containsAll(['top', 'right', 'left', 'bottom', 'center'])); + }); + + test('auto includes PopupClick when parsing popup panel file', () { + final list = _analyse( + ['TPopupBottomDisplayPanel'], + 'lib/src/components/popup/t_popup_panel.dart', + ); + final names = list.map((e) => e.componentInfo!.name).toList(); + expect(names, contains('PopupClick')); + final typedefInfo = + list.firstWhere((e) => e.componentInfo!.name == 'PopupClick'); + expect(typedefInfo.componentInfo!.kind, 'typedef'); + expect(typedefInfo.componentInfo!.typedefDefinition, + contains('Function()')); + }); +} diff --git a/test/ctor_defaults_test.dart b/test/ctor_defaults_test.dart new file mode 100644 index 0000000..33ac770 --- /dev/null +++ b/test/ctor_defaults_test.dart @@ -0,0 +1,54 @@ +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:tdesign_flutter_tools/component_rule.dart'; +import 'package:test/test.dart'; + +import 'support/component_paths.dart'; + +List _analyse(List names) { + const String relPath = 'lib/src/components/popup/t_popup_panel.dart'; + final String path = componentSourcePath(relPath); + final col = AnalysisContextCollection( + includedPaths: [path], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + final parsed = col.contextFor(path).currentSession.getParsedUnit(path) + as ParsedUnitResult; + final rule = ComponentRule( + parsedUnitResult: parsed, + isGrammarParser: false, + nameList: names, + folderName: 'popup', + sourceFileName: 't_popup_panel.dart', + ); + return rule.analyse(); +} + +void main() { + test('TPopupBasePanel ctor captures field defaults', () { + final list = _analyse(['TPopupBasePanel']); + final draggable = + list.first.propertyList.firstWhere((p) => p.name == 'draggable'); + expect(draggable.defaultValue, 'false'); + }); + + test('TPopupBottomDisplayPanel inherits super param defaults when base parsed first', () { + final list = _analyse(['TPopupBasePanel', 'TPopupBottomDisplayPanel']); + final panel = list.firstWhere((e) => e.componentInfo!.name == 'TPopupBottomDisplayPanel'); + final draggable = + panel.propertyList.firstWhere((p) => p.name == 'draggable'); + expect(draggable.defaultValue, 'false'); + }); + + test('TPopupBottomDisplayPanel inherits super param defaults', () { + final list = _analyse(['TPopupBottomDisplayPanel']); + expect(list, isNotEmpty); + final props = list.first.propertyList; + final draggable = props.firstWhere((p) => p.name == 'draggable'); + expect(draggable.type, 'bool'); + expect(draggable.defaultValue, 'false'); + final maxHeight = props.firstWhere((p) => p.name == 'maxHeightRatio'); + expect(maxHeight.defaultValue, '0.9'); + }); +} diff --git a/test/duplicate_source_test.dart b/test/duplicate_source_test.dart new file mode 100644 index 0000000..7c3e2f8 --- /dev/null +++ b/test/duplicate_source_test.dart @@ -0,0 +1,38 @@ +import 'package:tdesign_flutter_tools/model.dart'; +import 'package:tdesign_flutter_tools/util.dart'; +import 'package:test/test.dart'; + +void main() { + test('reportDuplicateAuxiliaryDefinitions warns on cross-file enum dup', () { + final items = [ + ParsedComponentInfoInfo() + ..componentInfo = (ComponentInfo() + ..name = 'CalendarTrigger' + ..kind = 'enum' + ..sourceFile = 't_calendar.dart') + ..propertyList = [] + ..extraPropertyList = [] + ..staticMemberList = [] + ..fieldMap = {}, + ParsedComponentInfoInfo() + ..componentInfo = (ComponentInfo() + ..name = 'CalendarTrigger' + ..kind = 'enum' + ..sourceFile = 't_calendar_popup.dart') + ..propertyList = [] + ..extraPropertyList = [] + ..staticMemberList = [] + ..fieldMap = {}, + ]; + expect( + () => reportDuplicateAuxiliaryDefinitions(items), + prints( + allOf( + contains('CalendarTrigger'), + contains('t_calendar.dart'), + contains('t_calendar_popup.dart'), + ), + ), + ); + }); +} diff --git a/test/enum_members_test.dart b/test/enum_members_test.dart new file mode 100644 index 0000000..3c197fc --- /dev/null +++ b/test/enum_members_test.dart @@ -0,0 +1,44 @@ +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:tdesign_flutter_tools/component_rule.dart'; +import 'package:tdesign_flutter_tools/model.dart'; +import 'package:test/test.dart'; + +import 'support/component_paths.dart'; + +List _analyse(List names, String relPath) { + final String path = componentSourcePath(relPath); + final col = AnalysisContextCollection( + includedPaths: [path], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + final parsed = + col.contextFor(path).currentSession.getParsedUnit(path) as ParsedUnitResult; + return ComponentRule( + parsedUnitResult: parsed, + isGrammarParser: false, + nameList: names, + sourceFileName: relPath.split('/').last, + ).analyse(); +} + +void main() { + test('TCalendarDateType enum members include /// documentation', () { + final list = _analyse( + ['TCalendarDateType'], + 'lib/src/components/calendar/t_lunar_date.dart', + ); + final info = list.first; + expect(info.componentInfo!.kind, 'enum'); + expect(info.componentInfo!.enumMembers.length, 2); + + final solar = info.componentInfo!.enumMembers + .firstWhere((EnumMemberInfo m) => m.name == 'solar'); + final lunar = info.componentInfo!.enumMembers + .firstWhere((EnumMemberInfo m) => m.name == 'lunar'); + + expect(solar.introduction, contains('阳历')); + expect(lunar.introduction, contains('阴历')); + }); +} diff --git a/test/factory_ctor_test.dart b/test/factory_ctor_test.dart new file mode 100644 index 0000000..1f6140b --- /dev/null +++ b/test/factory_ctor_test.dart @@ -0,0 +1,55 @@ +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:tdesign_flutter_tools/component_rule.dart'; +import 'package:tdesign_flutter_tools/model.dart'; +import 'package:test/test.dart'; + +import 'support/component_paths.dart'; + +List _analyse(List names, String relPath) { + final String path = componentSourcePath(relPath); + final col = AnalysisContextCollection( + includedPaths: [path], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + final parsed = + col.contextFor(path).currentSession.getParsedUnit(path) as ParsedUnitResult; + return ComponentRule( + parsedUnitResult: parsed, + isGrammarParser: false, + nameList: names, + sourceFileName: relPath.split('/').last, + ).analyse(); +} + +PropertyInfo? _factoryParam( + ParsedComponentInfoInfo info, + String factoryName, + String paramName, +) { + final StaticMethodInfo method = info.componentInfo!.constructorMethodList + .firstWhere((StaticMethodInfo m) => m.name == factoryName); + for (final PropertyInfo param in method.params) { + if (param.name == paramName) { + return param; + } + } + return null; +} + +void main() { + test('TAlertDialog.vertical factory params get types from fieldMap', () { + final list = _analyse( + ['TAlertDialog'], + 'lib/src/components/dialog/t_alert_dialog.dart', + ); + final info = list.first; + final backgroundColor = _factoryParam(info, 'vertical', 'backgroundColor'); + final title = _factoryParam(info, 'vertical', 'title'); + expect(backgroundColor, isNotNull); + expect(backgroundColor!.type, 'Color?'); + expect(title, isNotNull); + expect(title!.type, 'String?'); + }); +} diff --git a/test/positional_ctor_test.dart b/test/positional_ctor_test.dart new file mode 100644 index 0000000..e102387 --- /dev/null +++ b/test/positional_ctor_test.dart @@ -0,0 +1,44 @@ +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/file_system/physical_file_system.dart'; +import 'package:tdesign_flutter_tools/component_rule.dart'; +import 'package:tdesign_flutter_tools/model.dart'; +import 'package:test/test.dart'; + +import 'support/component_paths.dart'; + +List _analyse(List names, String relPath) { + final String path = componentSourcePath(relPath); + final col = AnalysisContextCollection( + includedPaths: [path], + resourceProvider: PhysicalResourceProvider.INSTANCE, + ); + final parsed = + col.contextFor(path).currentSession.getParsedUnit(path) as ParsedUnitResult; + return ComponentRule( + parsedUnitResult: parsed, + isGrammarParser: false, + nameList: names, + sourceFileName: relPath.split('/').last, + ).analyse(); +} + +void main() { + test('TCalendarPopup default ctor includes positional context param', () { + final list = _analyse( + ['TCalendarPopup'], + 'lib/src/components/calendar/t_calendar_popup.dart', + ); + final info = list.first; + expect(info.propertyList.map((PropertyInfo p) => p.name), contains('context')); + + final contextParam = info.propertyList + .firstWhere((PropertyInfo p) => p.name == 'context'); + expect(contextParam.isNamed, isFalse); + expect(contextParam.type, 'BuildContext'); + + final firstParam = info.propertyList.first; + expect(firstParam.name, 'context'); + expect(firstParam.isNamed, isFalse); + }); +} diff --git a/test/support/component_paths.dart b/test/support/component_paths.dart new file mode 100644 index 0000000..f016889 --- /dev/null +++ b/test/support/component_paths.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// tdesign-component 根目录。 +/// CI 通过环境变量 `TDESIGN_COMPONENT_ROOT` 注入;本地默认可用 ../tdesign-flutter/tdesign-component。 +String get tdesignComponentRoot { + final String? fromEnv = Platform.environment['TDESIGN_COMPONENT_ROOT']; + if (fromEnv != null && fromEnv.isNotEmpty) { + return p.normalize(fromEnv); + } + + final List candidates = [ + p.normalize( + p.join(Directory.current.path, '../tdesign-flutter/tdesign-component'), + ), + ]; + for (final String candidate in candidates) { + if (Directory(candidate).existsSync()) { + return candidate; + } + } + + throw StateError( + '未找到 tdesign-component。请 clone tdesign-flutter 或设置 TDESIGN_COMPONENT_ROOT。', + ); +} + +String componentSourcePath(String relativePath) { + return p.join(tdesignComponentRoot, relativePath); +}