diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..942eb0e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Tools Regression + +on: + pull_request: + branches: [main, develop] + push: + branches: [main] + workflow_dispatch: + +jobs: + test: + name: test (${{ matrix.label }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - label: '3.32.0' + flutter-version: '3.32.0' + - label: latest + flutter-version: '' + env: + TDESIGN_COMPONENT_ROOT: ${{ github.workspace }}/tdesign-flutter/tdesign-component + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ matrix.flutter-version }} + cache: true + - run: flutter pub get + - run: dart analyze --fatal-infos + - run: git clone --depth 1 --branch develop https://github.com/Tencent/tdesign-flutter.git tdesign-flutter + - name: Run complete tools suite including CLI roundtrip + run: DART_SDK="$FLUTTER_ROOT/bin/cache/dart-sdk" dart test --reporter expanded diff --git a/README.md b/README.md index f9bc8e6..bbd942f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ 3. **参数说明写在「方法注释」或「字段注释」** - 顶层函数 / 静态方法 / 工厂 / 构造:推荐在该方法的 `///` 里写 `[paramName] 说明`。 - 构造参数:也可写在同名字段的 `///` 上。 + - 参数声明前的行内 `///` 也会读取,包含带默认值的命名参数。 - 无注释时表格「说明」列为 `-`,属预期,应在源码补全,**不要**在工具里打补丁。 4. **不要把静态方法参数表只写在类简介里** @@ -31,12 +32,19 @@ 7. **顶层函数需在 `--name` 中显式列出** 工具会为任意公开顶层函数生成独立 API 区块,包含返回类型、参数类型、默认值和 dartdoc;私有函数、getter、setter 以及未登记函数不会被收录。 + 维护组件清单时加 `--strict-names`,只输出 `--name` 中登记的声明,避免同文件辅助枚举或 typedef 混入其他组件文档。文件夹扫描会递归读取 `.dart` 源文件。 + + 公开普通实例方法、字段访问器、命名/工厂/无参构造与命名 extension 均会展示;Flutter 构建钩子、未自行补充文档的标准继承 override、`@internal` 和 `@visibleForTesting` 成员不收录。默认值保留声明值,Theme / Token 回退由源码注释说明;构造及方法参数另列是否必填。泛型在 Markdown 表格中转义,保留网页与 Demo 中的完整类型。类/extension 与可调用声明另外输出源码签名,包含泛型约束、位置/命名参数、默认值及 const/factory 标记;每个构造或方法独立列出完整参数。自定义 copyWith/lerp/Token 查询运算符会展示,即使源码尚未补注释也不会静默遗漏。参数说明仅从源码注释、构造/复制字段或 AST 可证明的透传获取;Widget key 的兜底说明仅适用于 Key/Key?,不会套用到 Token 的 String/Object 键。 8. **不对个别组件做特殊兼容** 工具只保留单一 AST / dartdoc 解析路线;注释位置或格式不对,应在 `tdesign-component` 修正。 -9. **CI 抽测清单见 `.github/config/tdesign_api.yaml`** - 本地 `validate` 与 CI 使用同一配置;`ERROR` 需为 0,`WARN` 多为 enum 成员缺注释等源码问题。 + 解析(`component_rule.dart`)、框架成员筛选(`public_member_policy.dart`)、签名格式化(`api_signature.dart`)与 Markdown 渲染(`api_markdown.dart`)分别维护。渲染不修改解析模型;构造/工厂/external 按 AST 标记处理。仅过滤能由所属基类识别的框架钩子,业务 `build` 和普通接口 `override` 不会因名字或缺注释被隐藏。函数类型参数保留完整类型;无法从源码推断的父类/字段类型显示 `-`,不伪造 `dynamic`。代码单元格保留字符串空白并转义 HTML 实体。 + + 默认构造参数使用五级子标题,`validate` 同时兼容既有的四级参数标题。CI 在 Flutter 3.32.0 与 latest 下运行完整工具单测,包含真实 CLI generate → validate 正例及删参数反例。 + +9. **生成与验收共用消费仓库 `tool/components.json`** + `validate --component-root ...` 默认验收完整 manifest,支持 file/folder、声明及顶层函数。仍可用 `--config` 指定原有 YAML/JSON 抽测清单;`ERROR` 需为 0,`WARN` 多为 enum 成员缺注释等源码问题。 ## 快速开始 @@ -70,12 +78,12 @@ cd $TOOLS dart run bin/main.dart validate \ --component-root $COMPONENT \ - --config .github/config/tdesign_api.yaml + --config $COMPONENT/tool/components.json # 仅测部分组件 dart run bin/main.dart validate \ --component-root $COMPONENT \ - --config .github/config/tdesign_api.yaml \ + --config $COMPONENT/tool/components.json \ --components button,popup ``` @@ -183,7 +191,7 @@ dart run bin/main.dart generate \ ```bash dart run bin/main.dart validate \ --component-root \ - --config .github/config/tdesign_api.yaml \ + --config $COMPONENT/tool/components.json \ [--components button,popup] ``` diff --git a/bin/main.dart b/bin/main.dart index 98c01d1..1e67435 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -28,6 +28,11 @@ class CreateCommand extends Command { help: '[可选]生成的组件示例文件夹名称,默认文件夹名称是第一项name的下划线表示', ); argParser.addOption('output', help: '文件输出路径'); + argParser.addFlag( + 'strict-names', + defaultsTo: false, + help: '只输出 --name 显式声明的公开类型和函数', + ); argParser.addFlag('only-api', defaultsTo: false, help: '是否只生成api文件'); argParser.addFlag( 'get-comments', @@ -50,6 +55,7 @@ class CreateCommand extends Command { bool onlyApi = argResults!['only-api'] ?? false; commandInfo.isOnlyApi = onlyApi; commandInfo.widgetNames = argResults!['name'].toString(); + commandInfo.strictNames = argResults!['strict-names'] ?? false; commandInfo.isGetComments = argResults!['get-comments'] ?? false; return commandInfo; } @@ -101,12 +107,11 @@ class ValidateCommand extends Command { ); argParser.addOption( 'config', - help: '审计清单 YAML/JSON 路径', - defaultsTo: '.github/config/tdesign_api.yaml', + help: '审计清单 YAML/JSON 路径;默认读取 component-root/tool/components.json', ); argParser.addMultiOption( 'components', - help: '仅检测指定组件,如 button,picker(默认 5 组件全量)', + help: '仅检测指定组件,如 button,picker(默认清单全量)', ); argParser.addFlag('verbose', abbr: 'v', help: '打印 analyzer 解析过程'); } @@ -124,7 +129,9 @@ class ValidateCommand extends Command { return; } - final String configRaw = argResults!['config'] as String; + final String configRaw = + argResults!['config'] as String? ?? + defaultAuditConfigPath(componentRoot: componentRoot); final String configPath = p.isAbsolute(configRaw) ? p.normalize(configRaw) diff --git a/lib/api_completeness.dart b/lib/api_completeness.dart index e99acf9..39eafcd 100644 --- a/lib/api_completeness.dart +++ b/lib/api_completeness.dart @@ -28,6 +28,7 @@ class ComponentAuditConfig { required this.classNames, required this.sourceFolder, required this.folderName, + this.sourceIsFile = false, }); final String componentKey; @@ -36,6 +37,7 @@ class ComponentAuditConfig { /// 相对 component 根目录,如 lib/src/components/picker final String sourceFolder; final String folderName; + final bool sourceIsFile; } /// 从 YAML/JSON 配置文件加载审计清单(components 节点) @@ -47,6 +49,30 @@ Future> loadAuditConfigsFromFile(String path) async { final String content = await file.readAsString(); if (path.endsWith('.json')) { final dynamic decoded = jsonDecode(content); + if (decoded is Map && decoded['components'] is List) { + if (decoded['schemaVersion'] != 1) + throw ArgumentError('Unsupported manifest schema: $path'); + return (decoded['components'] as List).map((dynamic item) { + final component = Map.from(item as Map); + final source = Map.from(component['source'] as Map); + final api = Map.from(component['api'] as Map); + final names = [ + ...(api['names'] as List).cast(), + ...?(api['functions'] as List?)?.cast(), + ]; + if (names.isEmpty || !const {'file', 'folder'}.contains(source['type'])) + throw ArgumentError( + 'Invalid component manifest entry: ${component['slug']}', + ); + return ComponentAuditConfig( + componentKey: component['slug'] as String, + classNames: names, + sourceFolder: source['path'] as String, + sourceIsFile: source['type'] == 'file', + folderName: component['slug'] as String, + ); + }).toList(); + } if (decoded is Map && decoded['components'] is Map) { return _configsFromMap( Map.from(decoded['components'] as Map), @@ -153,8 +179,10 @@ List _configsFromYaml(String yaml) { ); } -/// 默认审计配置路径(相对 tools 仓库根目录) -String defaultAuditConfigPath() => '.github/config/tdesign_api.yaml'; +/// Reuse the consuming component's manifest instead of a separate audit list. +String defaultAuditConfigPath({ + String componentRoot = '../tdesign-flutter/tdesign-component', +}) => p.join(componentRoot, 'tool', 'components.json'); /// 从 Markdown API 文档解析 {类名: section 正文} Map parseMarkdownSections(String markdown) { @@ -168,14 +196,28 @@ Map parseMarkdownSections(String markdown) { return sections; } +/// Accept current child headings and the former sibling parameter heading. +/// Headings inside declaration code fences do not delimit the section. +String _defaultConstructorBlock(String section) { + final lines = section.split('\n'); + final start = lines.indexWhere((line) => line.trim() == '#### 默认构造方法'); + if (start < 0) return ''; + final result = []; + var inCode = false; + for (final line in lines.skip(start + 1)) { + if (line.trimLeft().startsWith('```')) inCode = !inCode; + if (!inCode) { + final heading = RegExp(r'^(#{1,4})\s+(.+)$').firstMatch(line); + if (heading != null && line.trim() != '#### 参数') break; + } + result.add(line); + } + return result.join('\n'); +} + /// 读取「默认构造方法」表格中的参数名(不含公开属性 / 静态成员表) 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 String block = _defaultConstructorBlock(section); final Set names = {}; for (final String line in block.split('\n')) { if (!line.startsWith('|') || line.startsWith('| ---')) { @@ -200,12 +242,7 @@ Set markdownDefaultCtorParamNames(String section) { /// 构造表中类型列为 `-` 的参数名 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 String block = _defaultConstructorBlock(section); final List bad = []; for (final String line in block.split('\n')) { if (!line.startsWith('|') || line.startsWith('| ---')) { @@ -449,7 +486,7 @@ Future> auditComponent({ : '$root${Platform.pathSeparator}'; final List parsed = await SmartCreator( - isFileMode: false, + isFileMode: config.sourceIsFile, onlyApi: true, nameList: config.classNames, basePath: basePath, @@ -630,13 +667,13 @@ Future runCompletenessAudit({ final List auditConfigs = configs ?? await loadAuditConfigsFromFile( - p.join(Directory.current.path, defaultAuditConfigPath()), + defaultAuditConfigPath(componentRoot: componentRoot), ); int errorCount = 0; int warnCount = 0; stdout.writeln('=' * 60); - stdout.writeln('API 文档完备性检测(analyzer AST,5 组件)'); + stdout.writeln('API 文档完备性检测(analyzer AST,${auditConfigs.length} 组件)'); stdout.writeln('=' * 60); for (final ComponentAuditConfig config in auditConfigs) { diff --git a/lib/api_markdown.dart b/lib/api_markdown.dart new file mode 100644 index 0000000..8c2c9bb --- /dev/null +++ b/lib/api_markdown.dart @@ -0,0 +1,356 @@ +import 'api_signature.dart'; +import 'documentation.dart'; +import 'model.dart'; +import 'util.dart'; + +/// Render parsed API models without I/O or mutations of the input models. +String renderApiMarkdown( + List parsedComponentInfoList, { + required List names, + bool strictNames = false, + bool includeIntroduction = false, +}) { + String fileContent = ''' +## API + +默认值列展示源码声明的默认值;`-` 表示未显式声明。运行时的 Theme / Token 回退见说明,参数是否必填见「必填」列。 + +'''; + StringBuffer sb = StringBuffer(fileContent); + final List documentedInfos = + strictNames + ? parsedComponentInfoList + .where((info) => names.contains(info.componentInfo?.name)) + .toList() + : parsedComponentInfoList; + for (final apiInfo in documentedInfos) { + if (documentedInfos.indexOf(apiInfo) >= 1) { + sb.write('\n\n'); + } + sb.write('### ${apiInfo.componentInfo!.name}'); + final introduction = apiInfo.componentInfo!.introduction ?? ''; + final String introForSummary = stripIntroductionForApiSummary(introduction); + final bool showIntro = includeIntroduction; + final String kind = apiInfo.componentInfo?.kind ?? 'class'; + + if (kind == 'enum') { + if (showIntro && introForSummary.isNotEmpty) { + sb.write('\n#### 简介\n'); + sb.write(introForSummary); + } + final List enumMembers = + apiInfo.componentInfo!.enumMembers; + final bool isSimpleEnum = + apiInfo.componentInfo!.isSimpleEnum && !showIntro; + if (enumMembers.isNotEmpty) { + sb.write('\n#### 枚举值\n'); + if (isSimpleEnum) { + sb.write('''\n +| 名称 | +| --- |\n'''); + for (final EnumMemberInfo member in enumMembers) { + sb.write('| ${sanitizeTableCell(member.name)} |\n'); + } + } else { + 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'); + if (isSimpleEnum) { + sb.write('''\n +| 名称 | +| --- |\n'''); + for (final String value in apiInfo.componentInfo!.enumValues) { + sb.write('| ${sanitizeTableCell(value)} |\n'); + } + } else { + sb.write('''\n +| 名称 | 说明 | +| --- | --- |\n'''); + for (final String value in apiInfo.componentInfo!.enumValues) { + sb.write('| ${sanitizeTableCell(value)} | - |\n'); + } + } + } + continue; + } + + if (kind == 'typedef') { + if (showIntro && introForSummary.isNotEmpty) { + sb.write('\n#### 简介\n'); + sb.write(introForSummary); + } + if (apiInfo.componentInfo!.typedefDefinition.isNotEmpty) { + sb.write('\n#### 类型定义\n\n'); + sb.write('```dart\n${apiInfo.componentInfo!.typedefDefinition}\n```\n'); + } + continue; + } + + if (kind == 'function') { + final StaticMethodInfo? function = + apiInfo.componentInfo!.topLevelFunction; + if (function == null) { + continue; + } + sb.write('\n#### 顶层函数'); + if (function.introduction?.isNotEmpty ?? false) { + sb.write('\n\n${function.introduction}'); + } + final String returnType = function.returnType ?? 'dynamic'; + sb.write('\n\n返回类型:`$returnType`'); + if (function.signature.isNotEmpty) + sb.write('\n\n```dart\n${function.signature}\n```\n'); + if (function.params.isNotEmpty) { + sb.write( + '\n\n#### 参数\n\n' + '| 参数 | 类型 | 默认值 | 说明 | 必填 |\n' + '| --- | --- | --- | --- | --- |\n', + ); + for (final PropertyInfo parameter in function.params) { + sb.write( + '| ${sanitizeTableCell(parameter.name)} | ${sanitizeApiType(parameter.type.isEmpty ? '-' : parameter.type)} | ${sanitizeApiType(parameter.defaultValue)} | ${sanitizeTableCell(parameter.introduction.isEmpty ? '-' : parameter.introduction)} | ${parameter.isRequired ? '是' : '否'} |\n', + ); + } + } + continue; + } + + if (showIntro && introForSummary.isNotEmpty) { + sb.write('\n#### 简介\n'); + sb.write(introForSummary); + } + if (apiInfo.componentInfo!.declaration.isNotEmpty) + sb.write( + '\n\n#### 声明\n\n```dart\n${apiInfo.componentInfo!.declaration}\n```\n', + ); + void writeSignature( + String signature, { + ApiCallableKind kind = ApiCallableKind.method, + bool isExternal = false, + }) { + if (signature.isEmpty) return; + final code = formatApiSignature( + signature, + ownerDeclaration: apiInfo.componentInfo!.declaration, + kind: kind, + isExternal: isExternal, + ); + sb.write('\n\n```dart\n$code\n```\n'); + } + + StaticMethodInfo? currentMethod; + + void writePropertyTable( + List items, { + required String header, + String nameColumn = '参数', + int headingLevel = 4, + bool showRequired = false, + }) { + if (items.isEmpty) { + return; + } + sb.write('\n${'#' * headingLevel} $header'); + sb.write('''\n +| $nameColumn | 类型 | 默认值 | 说明 |${showRequired ? ' 必填 |' : ''} +| --- | --- | --- | --- |${showRequired ? ' --- |' : ''}\n'''); + for (final PropertyInfo item in items) { + sb.write( + '''| ${sanitizeTableCell(item.name)} | ${sanitizeApiType(item.type.isEmpty ? '-' : item.type)} | ${sanitizeApiType(item.defaultValue)} | ${sanitizeTableCell(item.introduction.isEmpty ? '-' : item.introduction)} |${showRequired ? ' ${item.isRequired ? '是' : '否'} |' : ''}\n''', + ); + } + } + + PropertyInfo? resolveForwardedParamInfo( + StaticMethodInfo method, + PropertyInfo param, + ) { + final String? targetName = method.forwardedTargetName; + final String? targetParamName = method.forwardedParamMap[param.name]; + if (targetName == null || + targetName.isEmpty || + targetParamName == null || + targetParamName.isEmpty) { + return null; + } + ParsedComponentInfoInfo? targetInfo; + for (final ParsedComponentInfoInfo item in parsedComponentInfoList) { + if (item.componentInfo?.kind == 'class' && + item.componentInfo?.name == targetName) { + targetInfo = item; + break; + } + } + if (targetInfo == null) { + return null; + } + final String? constructorName = method.forwardedConstructorName; + if (constructorName != null && constructorName.isNotEmpty) { + for (final StaticMethodInfo ctor + in targetInfo.componentInfo!.constructorMethodList) { + if (ctor.name != constructorName) { + continue; + } + for (final PropertyInfo item in ctor.params) { + if (item.name == targetParamName) { + return item; + } + } + return null; + } + return null; + } + for (final PropertyInfo item in targetInfo.propertyList) { + if (item.name == targetParamName) { + return item; + } + } + return targetInfo.fieldMap[targetParamName]; + } + + void writeMethodParamTable(List params) { + if (params.isEmpty) { + return; + } + sb.write('''\n +| 参数 | 类型 | 默认值 | 说明 | 必填 | +| --- | --- | --- | --- | --- |\n'''); + for (final PropertyInfo param in params) { + PropertyInfo? forwardedParam; + if (currentMethod != null) { + forwardedParam = resolveForwardedParamInfo(currentMethod!, param); + } + final String type = + ((param.type.isEmpty || param.type == '-') && + forwardedParam != null && + forwardedParam.type.isNotEmpty && + forwardedParam.type != '-') + ? forwardedParam.type + : param.type; + final String introduction = + param.introduction.isEmpty && forwardedParam != null + ? forwardedParam.introduction + : param.introduction; + sb.write( + '''| ${sanitizeTableCell(param.name)} | ${sanitizeApiType(type.isEmpty ? '-' : type)} | ${sanitizeApiType(param.defaultValue)} | ${sanitizeTableCell(introduction.isEmpty ? '-' : introduction)} | ${param.isRequired ? '是' : '否'} |\n''', + ); + } + } + + void writeMethodDetails( + List methods, { + required String header, + bool includeReturnType = false, + }) { + if (methods.isEmpty) { + return; + } + sb.write('\n\n#### $header'); + final orderedMethods = List.of(methods); + orderedMethods.sort( + (StaticMethodInfo a, StaticMethodInfo b) => + a.name!.toLowerCase().compareTo(b.name!.toLowerCase()), + ); + for (final StaticMethodInfo item in orderedMethods) { + currentMethod = item; + sb.write( + '\n\n##### ${apiInfo.componentInfo!.name}.${sanitizeTableCell(item.name)}', + ); + writeSignature( + item.signature, + kind: item.callableKind, + isExternal: item.isExternal, + ); + if (item.introduction != null && item.introduction!.isNotEmpty) { + sb.write('\n\n${item.introduction}'); + } + final String returnType = + item.returnType == 'null' ? '' : (item.returnType ?? ''); + if (includeReturnType && returnType.isNotEmpty) { + sb.write('\n\n返回类型:`$returnType`'); + } + writeMethodParamTable(item.params); + } + currentMethod = null; + } + + // 对外 API 优先:命令式入口 → 命名工厂 → 默认构造 → 字段/成员 → 实例方法 + if (apiInfo.componentInfo?.staticMethodList.isNotEmpty ?? false) { + writeMethodDetails( + apiInfo.componentInfo!.staticMethodList, + header: '静态方法', + includeReturnType: true, + ); + } + final List publicNamedConstructors = + apiInfo.componentInfo!.constructorMethodList + .where( + (StaticMethodInfo method) => + !isLibraryPrivateNamedConstructor(method.name), + ) + .toList(); + if (publicNamedConstructors.isNotEmpty) { + writeMethodDetails( + publicNamedConstructors.where((method) => method.isFactory).toList(), + header: '工厂构造方法', + ); + } + writeMethodDetails( + publicNamedConstructors.where((method) => !method.isFactory).toList(), + header: '命名构造方法', + ); + if (apiInfo.componentInfo!.hasDefaultConstructor && + apiInfo.propertyList.isEmpty) { + sb.write('\n#### 默认构造方法\n'); + writeSignature( + apiInfo.componentInfo!.defaultConstructorSignature, + kind: apiInfo.componentInfo!.defaultConstructorKind, + isExternal: apiInfo.componentInfo!.defaultConstructorIsExternal, + ); + final String docs = apiInfo.componentInfo!.defaultConstructorIntroduction; + if (docs.isNotEmpty) sb.write('\n$docs\n'); + } + if (apiInfo.propertyList.isNotEmpty) { + sb.write('\n#### 默认构造方法\n'); + writeSignature( + apiInfo.componentInfo!.defaultConstructorSignature, + kind: apiInfo.componentInfo!.defaultConstructorKind, + isExternal: apiInfo.componentInfo!.defaultConstructorIsExternal, + ); + final docs = apiInfo.componentInfo!.defaultConstructorIntroduction; + if (docs.isNotEmpty) sb.write('\n$docs\n'); + writePropertyTable( + apiInfo.propertyList, + header: '参数', + headingLevel: 5, + showRequired: true, + ); + } + writePropertyTable( + apiInfo.extraPropertyList, + header: '公开属性(字段与访问器)', + nameColumn: '属性', + ); + writePropertyTable( + apiInfo.staticMemberList, + header: '静态成员', + nameColumn: '名称', + ); + writeMethodDetails( + apiInfo.componentInfo!.instanceMethodList, + header: '实例方法', + includeReturnType: true, + ); + } + return sb.toString(); +} diff --git a/lib/api_signature.dart b/lib/api_signature.dart new file mode 100644 index 0000000..b4ee9a1 --- /dev/null +++ b/lib/api_signature.dart @@ -0,0 +1,60 @@ +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:dart_style/dart_style.dart'; + +/// Callable syntax, independent of the rendered section title. +enum ApiCallableKind { method, constructor, factoryConstructor, function } + +/// Format a declaration without changing tokens inside string literals. +String formatApiSignature( + String signature, { + String ownerDeclaration = '', + ApiCallableKind kind = ApiCallableKind.method, + bool isExternal = false, +}) { + final suffix = + isExternal || kind == ApiCallableKind.constructor + ? ';' + : kind == ApiCallableKind.factoryConstructor + ? ' = _DocumentationConstructor;' + : ' => throw UnimplementedError();'; + final wrapped = + ownerDeclaration.isEmpty + ? '$signature$suffix' + : '$ownerDeclaration { $signature$suffix }'; + final formatted = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ).format(wrapped); + final owner = parseString(content: formatted).unit.declarations.single; + final AnnotatedNode callable = + owner is ClassDeclaration + ? owner.members.single + : owner is ExtensionDeclaration + ? owner.members.single + : owner; + final end = + callable is ConstructorDeclaration + ? callable.parameters.end + : callable is MethodDeclaration + ? callable.parameters!.end + : (callable as FunctionDeclaration) + .functionExpression + .parameters! + .end; + + // Only whitespace BETWEEN tokens belongs to the wrapper. Literal contents, + // including multiline/interpolated strings, are copied without modification. + final buffer = StringBuffer(); + Token? token = callable.firstTokenAfterCommentAndMetadata; + var previousEnd = token.offset; + while (token != null && token.offset < end && !token.isEof) { + buffer.write( + formatted.substring(previousEnd, token.offset).replaceAll('\n ', '\n'), + ); + buffer.write(token.lexeme); + previousEnd = token.end; + token = token.next; + } + return buffer.toString(); +} diff --git a/lib/component_rule.dart b/lib/component_rule.dart index 84b9f21..68d8b7f 100644 --- a/lib/component_rule.dart +++ b/lib/component_rule.dart @@ -3,12 +3,33 @@ import 'dart:io'; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/ast/token.dart'; import 'documentation.dart'; +import 'api_signature.dart'; import 'model.dart'; +import 'public_member_policy.dart'; import 'util.dart'; typedef OnParsedComponentInfoInfo = void Function(ParsedComponentInfoInfo info); +/// Preserve declaration tokens (including generics and parameter kinds), without +/// dartdoc/annotations or a callable implementation body. +String declarationSignature(AnnotatedNode node, int end) { + Token? token = node.firstTokenAfterCommentAndMetadata; + final start = token.offset; + final buffer = StringBuffer(); + Token? previous; + while (token != null && token.offset < end && !token.isEof) { + if (token.offset >= start) { + if (previous != null && previous.end < token.offset) buffer.write(' '); + buffer.write(token.lexeme); + previous = token; + } + token = token.next; + } + return buffer.toString(); +} + // ignore_for_file: always_specify_types class ComponentRule { ComponentRule({ @@ -167,8 +188,7 @@ class ComponentAstVisitor extends RecursiveAstVisitor { ComponentInfo() ..name = name ..kind = 'typedef' - ..typedefDefinition = - 'typedef ${node.name.lexeme} = ${node.type.toSource()};'; + ..typedefDefinition = node.toSource(); if (node.documentationComment != null) { componentInfo.introduction = formatDocumentationForMarkdown( node.documentationComment!.tokens.join('\n'), @@ -279,7 +299,14 @@ class ComponentAstVisitor extends RecursiveAstVisitor { ); return parsed.narrative; } - return ''; + final List comments = []; + Token? token = param.beginToken.precedingComments; + while (token != null) { + if (token.lexeme.startsWith('///') || token.lexeme.startsWith('/**')) + comments.add(token.lexeme); + token = token.next; + } + return parseDocumentation(comments.join('\n')).narrative; } void _captureExplicitForwarding( @@ -586,8 +613,8 @@ class ComponentAstVisitor extends RecursiveAstVisitor { ); } - void _fillPropertyFromFieldMap(PropertyInfo item) { - final PropertyInfo? field = fieldMap[item.name]; + void _fillPropertyFromFieldMap(PropertyInfo item, {bool useField = true}) { + final PropertyInfo? field = useField ? fieldMap[item.name] : null; if (field != null) { if (item.type.isEmpty && field.type.isNotEmpty) { item.type = field.type; @@ -597,7 +624,7 @@ class ComponentAstVisitor extends RecursiveAstVisitor { } } if (item.introduction.isEmpty) { - item.introduction = fallbackParameterIntroduction(item.name); + item.introduction = fallbackParameterIntroduction(item.name, item.type); } } @@ -625,7 +652,10 @@ class ComponentAstVisitor extends RecursiveAstVisitor { }) { for (final StaticMethodInfo method in methods) { for (final PropertyInfo param in method.params) { - _fillPropertyFromFieldMap(param); + _fillPropertyFromFieldMap( + param, + useField: inheritParentDefault || method.name == 'copyWith', + ); if ((param.defaultValue == '-' || param.defaultValue.isEmpty) && inheritParentDefault && _currentClassSuperName != null) { @@ -666,6 +696,24 @@ class ComponentAstVisitor extends RecursiveAstVisitor { } node.visitChildren(this); if (node.name == null) { + if (_isInTargetClass) { + componentInfo ??= ComponentInfo(); + componentInfo!.hasDefaultConstructor = true; + componentInfo!.defaultConstructorKind = + node.factoryKeyword == null + ? ApiCallableKind.constructor + : ApiCallableKind.factoryConstructor; + componentInfo!.defaultConstructorIsExternal = + node.externalKeyword != null; + componentInfo!.defaultConstructorSignature = declarationSignature( + node, + node.parameters.end, + ); + componentInfo! + .defaultConstructorIntroduction = formatDocumentationForMarkdown( + node.documentationComment?.tokens.join('\n') ?? '', + ); + } final ParsedDocumentation constructorDocs = parseDocumentation( node.documentationComment?.tokens.join('\n') ?? '', parameterNames: node.parameters.parameters @@ -702,6 +750,16 @@ class ComponentAstVisitor extends RecursiveAstVisitor { // 记录命名/工厂构造方法 final StaticMethodInfo staticMethodInfo = StaticMethodInfo(); staticMethodInfo.name = constructorName; + staticMethodInfo.isFactory = node.factoryKeyword != null; + staticMethodInfo.callableKind = + staticMethodInfo.isFactory + ? ApiCallableKind.factoryConstructor + : ApiCallableKind.constructor; + staticMethodInfo.isExternal = node.externalKeyword != null; + staticMethodInfo.signature = declarationSignature( + node, + node.parameters.end, + ); staticMethodInfo.introduction = node.documentationComment?.tokens.join('\n') ?? ''; for (final FormalParameter element in node.parameters.parameters) { @@ -721,26 +779,43 @@ class ComponentAstVisitor extends RecursiveAstVisitor { return; } node.visitChildren(this); - String fieldName = node.fields.variables.join(','); - if (fieldName.contains('=')) { - fieldName = fieldName.split('=')[0].trim(); - } - if (fieldName.startsWith('_')) { + if (node.metadata.any( + (annotation) => + annotation.name.name == 'internal' || + annotation.name.name == 'visibleForTesting', + )) return; - } - 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 = formatDocumentationForMarkdown( - node.documentationComment!.tokens.join('\n'), - ); - } else if (node.beginToken.toString().startsWith('///')) { - item.introduction = formatDocumentationForMarkdown( - node.beginToken.toString(), - ); + for (final variable in node.fields.variables) { + String fieldName = variable.name.lexeme; + if (fieldName.contains('=')) { + fieldName = fieldName.split('=')[0].trim(); + } + if (fieldName.startsWith('_')) { + continue; + } + PropertyInfo? item = fieldMap[fieldName]; + item ??= PropertyInfo()..name = fieldName; + fieldMap[fieldName] = item; + item.isStatic = node.staticKeyword != null; + item.type = + node.fields.type?.toString() ?? + inferExpressionType(variable.initializer); + if (variable.initializer != null) { + item.defaultValue = formatDefaultValueForDoc( + variable.initializer!.toSource(), + paramName: fieldName, + isRequired: false, + ); + } + if (node.documentationComment != null) { + item.introduction = formatDocumentationForMarkdown( + node.documentationComment!.tokens.join('\n'), + ); + } else if (node.beginToken.toString().startsWith('///')) { + item.introduction = formatDocumentationForMarkdown( + node.beginToken.toString(), + ); + } } } @@ -801,6 +876,9 @@ class ComponentAstVisitor extends RecursiveAstVisitor { final StaticMethodInfo functionInfo = StaticMethodInfo() ..name = functionName + ..callableKind = ApiCallableKind.function + ..isExternal = node.externalKeyword != null + ..signature = declarationSignature(node, parameters.end) ..returnType = node.returnType?.toSource() ?? 'dynamic' ..introduction = node.documentationComment?.tokens.join('\n') ?? ''; for (final FormalParameter parameter in parameters.parameters) { @@ -812,7 +890,10 @@ class ComponentAstVisitor extends RecursiveAstVisitor { parameter.type = '-'; } if (parameter.introduction.isEmpty) { - parameter.introduction = fallbackParameterIntroduction(parameter.name); + parameter.introduction = fallbackParameterIntroduction( + parameter.name, + parameter.type, + ); } } @@ -857,6 +938,15 @@ class ComponentAstVisitor extends RecursiveAstVisitor { if (isTarget) { componentInfo ??= ComponentInfo(); componentInfo!.name = className; + componentInfo!.declaration = declarationSignature( + node, + node.leftBracket.offset, + ); + if (node.abstractKeyword == null && + !node.members.any((member) => member is ConstructorDeclaration)) { + componentInfo!.hasDefaultConstructor = true; + componentInfo!.defaultConstructorSignature = '$className()'; + } if (node.documentationComment != null) { componentInfo!.introduction = formatDocumentationForMarkdown( node.documentationComment!.tokens.join('\n'), @@ -910,6 +1000,47 @@ class ComponentAstVisitor extends RecursiveAstVisitor { _currentClassIsAbstract = previousClassIsAbstract; } + @override + void visitExtensionDeclaration(ExtensionDeclaration node) { + final String? name = node.name?.lexeme; + if (name == null || !nameList!.contains(name)) return; + _resetClassState(); + _currentClassName = name; + _currentTargetClassName = name; + componentInfo = + ComponentInfo() + ..name = name + ..kind = 'extension' + ..declaration = declarationSignature(node, node.leftBracket.offset) + ..introduction = formatDocumentationForMarkdown( + node.documentationComment?.tokens.join('\n') ?? '', + ); + node.visitChildren(this); + _splitFieldsBeyondConstructor(); + _finalizeCallableMethodParams(); + _emitParsedInfo( + ParsedComponentInfoInfo() + ..componentInfo = componentInfo + ..propertyList = propertyList + ..extraPropertyList = extraPropertyList + ..staticMemberList = staticMemberList + ..fieldMap = fieldMap, + ); + _resetClassState(); + _currentClassName = null; + } + + String inferExpressionType(Expression? expression) { + if (expression is NullLiteral) return 'Null'; + if (expression is IntegerLiteral) return 'int'; + if (expression is DoubleLiteral) return 'double'; + if (expression is BooleanLiteral) return 'bool'; + if (expression is StringLiteral) return 'String'; + if (expression is InstanceCreationExpression) + return expression.constructorName.type.toSource(); + return '-'; + } + @override void visitMethodDeclaration(MethodDeclaration node) { if (!_isInTargetClass) { @@ -919,12 +1050,44 @@ class ComponentAstVisitor extends RecursiveAstVisitor { super.visitMethodDeclaration(node); final String methodName = node.name.toString(); // 私有方法不收录 - if (methodName.startsWith('_')) { + if (methodName.startsWith('_') || + node.metadata.any( + (annotation) => + annotation.name.name == 'internal' || + annotation.name.name == 'visibleForTesting', + ) || + isFrameworkHook(node)) { return; } + if (node.isGetter || node.isSetter) { + final PropertyInfo property = fieldMap.putIfAbsent( + methodName, + () => PropertyInfo()..name = methodName, + ); + property.isStatic = node.isStatic; + final String type = + node.isGetter + ? node.returnType?.toSource() ?? 'dynamic' + : (node.parameters == null || node.parameters!.parameters.isEmpty) + ? 'dynamic' + : _buildPropertyFromParameter( + node.parameters!.parameters.first, + ).type; + if (node.isGetter || property.type.isEmpty) property.type = type; + final String docs = formatDocumentationForMarkdown( + node.documentationComment?.tokens.join('\n') ?? '', + ); + if (docs.isNotEmpty) property.introduction = docs; + return; + } StaticMethodInfo methodInfo = StaticMethodInfo(); methodInfo.name = methodName; + methodInfo.isExternal = node.externalKeyword != null; + methodInfo.signature = declarationSignature( + node, + node.parameters?.end ?? node.body.offset, + ); methodInfo.introduction = node.documentationComment?.tokens.join('\n') ?? ''; methodInfo.returnType = node.returnType?.toSource(); @@ -937,8 +1100,8 @@ class ComponentAstVisitor extends RecursiveAstVisitor { componentInfo ??= ComponentInfo(); if (node.isStatic) { componentInfo!.staticMethodList.add(methodInfo); - } else if (_currentClassIsAbstract) { - // abstract class 的实例方法(含 abstract 方法和带默认实现的可覆写方法) + } else { + // Concrete controllers and handles expose instance methods too. componentInfo!.instanceMethodList.add(methodInfo); } } diff --git a/lib/documentation.dart b/lib/documentation.dart index 43a848a..11e74cc 100644 --- a/lib/documentation.dart +++ b/lib/documentation.dart @@ -278,11 +278,14 @@ String stripIntroductionForApiSummary(String text) { return text; } final RegExp exampleWithFence = RegExp( - r'(^|\n)\s*\*\*示例\*\*\s*\n(?:\s*\n)*\s*```[\s\S]*?```', + r'(^|\n)\s*\*\*示例\*\*[::]?\s*\n(?:\s*\n)*\s*```[\s\S]*?```', multiLine: true, ); var cleaned = text.replaceAll(exampleWithFence, '\n'); - cleaned = cleaned.replaceAll(RegExp(r'(^|\n)\s*\*\*示例\*\*\s*(?=\n|$)'), '\n'); + cleaned = cleaned.replaceAll( + RegExp(r'(^|\n)\s*\*\*示例\*\*[::]?\s*(?=\n|$)'), + '\n', + ); cleaned = cleaned.replaceAll( RegExp(r'(^|\n)```[\s\S]*?```', multiLine: true), '\n', diff --git a/lib/model.dart b/lib/model.dart index 5b8daa2..dbdd6db 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -1,3 +1,5 @@ +import 'api_signature.dart'; + //组件信息 // import 'package:component_info/component_info.dart'; @@ -67,7 +69,14 @@ class ComponentInfo { // 其他构造方法信息 List constructorMethodList = []; - // 实例方法信息(用于 abstract class 的接口方法文档) + bool hasDefaultConstructor = false; + String defaultConstructorIntroduction = ''; + String declaration = ''; + String defaultConstructorSignature = ''; + ApiCallableKind defaultConstructorKind = ApiCallableKind.constructor; + bool defaultConstructorIsExternal = false; + + // Public instance method documentation. List instanceMethodList = []; /// API 条目类型:class | enum | typedef | function @@ -214,6 +223,7 @@ class ParsedComponentInfoInfo { // 用户执行的命令 class CommandInfo { + bool strictNames = false; String? file; String? folder; String? widgetNames; @@ -263,6 +273,10 @@ class CommandInfo { } class StaticMethodInfo { + String signature = ''; + ApiCallableKind callableKind = ApiCallableKind.method; + bool isExternal = false; + bool isFactory = false; // 方法名称 String? name; diff --git a/lib/public_member_policy.dart b/lib/public_member_policy.dart new file mode 100644 index 0000000..c76187f --- /dev/null +++ b/lib/public_member_policy.dart @@ -0,0 +1,196 @@ +import 'package:analyzer/dart/ast/ast.dart'; + +/// Framework exclusions require an owner type, never just a method name or +/// an @override annotation. Unknown/custom interfaces remain public APIs. +bool isFrameworkHook(MethodDeclaration method) { + final name = method.name.lexeme; + final hasDocs = method.documentationComment != null; + if (!hasDocs && const {'hashCode', '==', 'toString'}.contains(name)) { + return true; + } + final owner = method.parent; + if (owner is! ClassDeclaration) return false; + final unit = owner.parent; + final localClasses = { + if (unit is CompilationUnit) + for (final node in unit.declarations.whereType()) + node.name.lexeme: node, + }; + final seen = {}; + final pending = [ + if (owner.extendsClause != null) + owner.extendsClause!.superclass.name2.lexeme, + ...?owner.implementsClause?.interfaces.map((type) => type.name2.lexeme), + ...?owner.withClause?.mixinTypes.map((type) => type.name2.lexeme), + ]; + while (pending.isNotEmpty) { + final parent = pending.removeLast(); + if (!seen.add(parent)) continue; + final local = localClasses[parent]; + if (local != null) { + if (local.extendsClause != null) + pending.add(local.extendsClause!.superclass.name2.lexeme); + pending.addAll( + local.implementsClause?.interfaces.map((type) => type.name2.lexeme) ?? + [], + ); + pending.addAll( + local.withClause?.mixinTypes.map((type) => type.name2.lexeme) ?? [], + ); + continue; + } + if (const { + 'Widget', + 'StatelessWidget', + 'StatefulWidget', + 'State', + 'Tab', + 'RenderObjectWidget', + 'SingleChildRenderObjectWidget', + 'MultiChildRenderObjectWidget', + 'LeafRenderObjectWidget', + }.contains(parent) && + const { + 'build', + 'createState', + 'createElement', + 'debugFillProperties', + }.contains(name)) { + return true; + } + if (hasDocs) continue; + if (const {'PreferredSizeWidget', 'Tab'}.contains(parent) && + name == 'preferredSize') + return true; + if (const { + 'RenderObjectWidget', + 'SingleChildRenderObjectWidget', + 'MultiChildRenderObjectWidget', + 'LeafRenderObjectWidget', + }.contains(parent) && + const { + 'createRenderObject', + 'updateRenderObject', + 'didUnmountRenderObject', + }.contains(name)) + return true; + if (const { + 'Element', + 'ComponentElement', + 'RenderObjectElement', + 'SingleChildRenderObjectElement', + 'MultiChildRenderObjectElement', + }.contains(parent) && + const { + 'widget', + 'renderObject', + 'slot', + 'visitChildren', + 'forgetChild', + 'mount', + 'update', + 'unmount', + 'activate', + 'deactivate', + 'insertRenderObjectChild', + 'moveRenderObjectChild', + 'removeRenderObjectChild', + 'attachRenderObject', + 'detachRenderObject', + 'performRebuild', + 'debugFillProperties', + }.contains(name)) + return true; + if (parent == 'Decoration' && + const { + 'createBoxPainter', + 'hitTest', + 'isComplex', + 'debugFillProperties', + }.contains(name)) + return true; + if (parent == 'State' && + const { + 'initState', + 'dispose', + 'setState', + 'didChangeDependencies', + 'didUpdateWidget', + 'deactivate', + 'activate', + 'reassemble', + }.contains(name)) + return true; + if (parent == 'ChangeNotifier' && + const { + 'addListener', + 'removeListener', + 'notifyListeners', + 'dispose', + 'hasListeners', + }.contains(name)) + return true; + if (parent == 'LocalizationsDelegate' && + const {'isSupported', 'load', 'shouldReload'}.contains(name)) + return true; + if (parent == 'ThemeExtension' && name == 'type') return true; + if (const {'Map', 'MapBase', 'DelegatingMap'}.contains(parent) && + const { + '[]=', + 'keys', + 'values', + 'length', + 'isEmpty', + 'isNotEmpty', + 'containsKey', + 'containsValue', + 'clear', + 'remove', + 'addAll', + 'addEntries', + 'cast', + 'forEach', + 'map', + 'putIfAbsent', + 'removeWhere', + 'update', + 'updateAll', + }.contains(name)) + return true; + if (const {'Iterable', 'IterableBase'}.contains(parent) && + const { + 'iterator', + 'length', + 'isEmpty', + 'isNotEmpty', + 'first', + 'last', + 'single', + 'cast', + 'contains', + 'elementAt', + 'toList', + 'toSet', + 'map', + 'where', + 'expand', + 'fold', + 'reduce', + 'forEach', + 'any', + 'every', + 'join', + 'skip', + 'take', + 'skipWhile', + 'takeWhile', + 'firstWhere', + 'lastWhere', + 'singleWhere', + 'followedBy', + 'whereType', + }.contains(name)) + return true; + } + return false; +} diff --git a/lib/smart_create.dart b/lib/smart_create.dart index 44b2a96..c0bfb77 100644 --- a/lib/smart_create.dart +++ b/lib/smart_create.dart @@ -9,8 +9,8 @@ import 'package:ansicolor/ansicolor.dart'; import 'package:path/path.dart'; import 'package:analyzer/dart/analysis/results.dart'; +import 'api_markdown.dart'; import 'component_rule.dart'; -import 'documentation.dart'; import 'model.dart'; import 'util.dart'; @@ -37,33 +37,9 @@ class SmartCreator { final CommandInfo? commandInfo; Future run() async { - List files = []; - if (isFileMode!) { - String filePath = join(basePath!, path); - filePath = normalize(filePath); - files.add(filePath); - File file = File(files[0]); - if (!file.existsSync()) { - AnsiPen pen = AnsiPen()..red(bold: true); - print(pen('源文件路径不对: ${files[0]}')); - return; - } - } else { - String fullPath = join(basePath!, path); - fullPath = normalize(fullPath); - Directory comDir = Directory(fullPath); - if (comDir.existsSync()) { - List filesInDir = comDir.listSync(); - for (final item in filesInDir) { - files.add(item.path); - } - // Directory.listSync() 不保证目录顺序,排序以确保生成的 *_api.md 内容顺序固定、可复现。 - files.sort(); - } else { - AnsiPen pen = AnsiPen()..red(bold: true); - print(pen('输入的文件夹路径不对: $fullPath')); - } - } + final List files = _collectSourceFiles(); + if (files.isEmpty) + throw ArgumentError('No Dart sources found: ${join(basePath!, path)}'); int startTime = DateTime.now().microsecondsSinceEpoch; // print('${DateTime.now().toLocal()} AnalysisContextCollection');] var sb = StringBuffer(); @@ -149,8 +125,11 @@ class SmartCreator { 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')) { + for (final FileSystemEntity item in comDir.listSync( + recursive: true, + followLinks: false, + )) { + if (item is File && item.path.endsWith('.dart')) { files.add(item.path); } } @@ -342,369 +321,13 @@ class SmartCreator { String path = join(basePath!, relativePath); File file = File(path); await file.create(recursive: false); - String fileContent = ''' -## API -'''; - StringBuffer sb = StringBuffer(fileContent); - for (final apiInfo in parsedComponentInfoList) { - if (parsedComponentInfoList.indexOf(apiInfo) >= 1) { - sb.write('\n\n'); - } - sb.write('### ${apiInfo.componentInfo!.name}'); - final introduction = apiInfo.componentInfo!.introduction ?? ''; - final String introForSummary = stripIntroductionForApiSummary( - introduction, - ); - final bool showIntro = commandInfo?.isGetComments ?? false; - final String kind = apiInfo.componentInfo?.kind ?? 'class'; - - if (kind == 'enum') { - if (showIntro && introForSummary.isNotEmpty) { - sb.write('\n#### 简介\n'); - sb.write(introForSummary); - } - final List enumMembers = - apiInfo.componentInfo!.enumMembers; - final bool isSimpleEnum = apiInfo.componentInfo!.isSimpleEnum; - if (enumMembers.isNotEmpty) { - sb.write('\n#### 枚举值\n'); - if (isSimpleEnum) { - sb.write('''\n -| 名称 | -| --- |\n'''); - for (final EnumMemberInfo member in enumMembers) { - sb.write('| ${sanitizeTableCell(member.name)} |\n'); - } - } else { - 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'); - if (isSimpleEnum) { - sb.write('''\n -| 名称 | -| --- |\n'''); - for (final String value in apiInfo.componentInfo!.enumValues) { - sb.write('| ${sanitizeTableCell(value)} |\n'); - } - } else { - sb.write('''\n -| 名称 | 说明 | -| --- | --- |\n'''); - for (final String value in apiInfo.componentInfo!.enumValues) { - sb.write('| ${sanitizeTableCell(value)} | - |\n'); - } - } - } - continue; - } - - if (kind == 'typedef') { - if (showIntro && introForSummary.isNotEmpty) { - sb.write('\n#### 简介\n'); - sb.write(introForSummary); - } - if (apiInfo.componentInfo!.typedefDefinition.isNotEmpty) { - sb.write('\n#### 类型定义\n\n'); - sb.write( - '```dart\n${apiInfo.componentInfo!.typedefDefinition}\n```\n', - ); - } - continue; - } - - if (kind == 'function') { - final StaticMethodInfo? function = - apiInfo.componentInfo!.topLevelFunction; - if (function == null) { - continue; - } - sb.write('\n#### 顶层函数'); - if (function.introduction?.isNotEmpty ?? false) { - sb.write('\n\n${function.introduction}'); - } - final String returnType = function.returnType ?? 'dynamic'; - sb.write('\n\n返回类型:`$returnType`'); - if (function.params.isNotEmpty) { - sb.write( - '\n\n#### 参数\n\n' - '| 参数 | 类型 | 默认值 | 说明 |\n' - '| --- | --- | --- | --- |\n', - ); - for (final PropertyInfo parameter in function.params) { - sb.write( - '| ${sanitizeTableCell(parameter.name)} | ${sanitizeTableCell(parameter.type.isEmpty ? '-' : parameter.type)} | ${sanitizeTableCell(parameter.defaultValue)} | ${sanitizeTableCell(parameter.introduction.isEmpty ? '-' : parameter.introduction)} |\n', - ); - } - } - continue; - } - - if (showIntro && introForSummary.isNotEmpty) { - sb.write('\n#### 简介\n'); - sb.write(introForSummary); - } - StaticMethodInfo? currentMethod; - - void writePropertyTable( - List items, { - required String header, - String nameColumn = '参数', - }) { - if (items.isEmpty) { - return; - } - sb.write('\n#### $header'); - sb.write('''\n -| $nameColumn | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- |\n'''); - for (final PropertyInfo item in items) { - sb.write( - '''| ${sanitizeTableCell(item.name)} | ${sanitizeTableCell(item.type.isEmpty ? '-' : item.type)} | ${sanitizeTableCell(item.defaultValue)} | ${sanitizeTableCell(item.introduction.isEmpty ? '-' : item.introduction)} |\n''', - ); - } - } - - PropertyInfo? resolveForwardedParamInfo( - StaticMethodInfo method, - PropertyInfo param, - ) { - final String? targetName = method.forwardedTargetName; - final String? targetParamName = method.forwardedParamMap[param.name]; - if (targetName == null || - targetName.isEmpty || - targetParamName == null || - targetParamName.isEmpty) { - return null; - } - ParsedComponentInfoInfo? targetInfo; - for (final ParsedComponentInfoInfo item in parsedComponentInfoList) { - if (item.componentInfo?.kind == 'class' && - item.componentInfo?.name == targetName) { - targetInfo = item; - break; - } - } - if (targetInfo == null) { - return null; - } - final String? constructorName = method.forwardedConstructorName; - if (constructorName != null && constructorName.isNotEmpty) { - for (final StaticMethodInfo ctor - in targetInfo.componentInfo!.constructorMethodList) { - if (ctor.name != constructorName) { - continue; - } - for (final PropertyInfo item in ctor.params) { - if (item.name == targetParamName) { - return item; - } - } - return null; - } - return null; - } - for (final PropertyInfo item in targetInfo.propertyList) { - if (item.name == targetParamName) { - return item; - } - } - return targetInfo.fieldMap[targetParamName]; - } - - void writeMethodParamTable(List params) { - if (params.isEmpty) { - return; - } - sb.write('''\n -| 参数 | 类型 | 默认值 | 说明 | -| --- | --- | --- | --- |\n'''); - for (final PropertyInfo param in params) { - PropertyInfo? forwardedParam; - if (currentMethod != null) { - forwardedParam = resolveForwardedParamInfo(currentMethod!, param); - } - final String type = - ((param.type.isEmpty || param.type == '-') && - forwardedParam != null && - forwardedParam.type.isNotEmpty && - forwardedParam.type != '-') - ? forwardedParam.type - : param.type; - final String introduction = - param.introduction.isEmpty && forwardedParam != null - ? forwardedParam.introduction - : param.introduction; - sb.write( - '''| ${sanitizeTableCell(param.name)} | ${sanitizeTableCell(type.isEmpty ? '-' : type)} | ${sanitizeTableCell(param.defaultValue)} | ${sanitizeTableCell(introduction.isEmpty ? '-' : introduction)} |\n''', - ); - } - } - - void writeMethodDetails( - List methods, { - required String header, - bool includeReturnType = false, - bool compactCommonForwardedParams = false, - }) { - if (methods.isEmpty) { - return; - } - sb.write('\n\n#### $header'); - methods.sort( - (StaticMethodInfo a, StaticMethodInfo b) => - a.name!.toLowerCase().compareTo(b.name!.toLowerCase()), - ); - final Set commonForwardedParams = {}; - if (compactCommonForwardedParams && methods.length > 1) { - Set? common; - for (final StaticMethodInfo method in methods) { - final bool hasForwardedInfo = - method.forwardedTargetName == apiInfo.componentInfo!.name && - (method.forwardedConstructorName == null || - method.forwardedConstructorName!.isEmpty); - if (!hasForwardedInfo) { - common = {}; - break; - } - final Set forwardedCurrent = - method.params - .where( - (PropertyInfo param) => - method.forwardedParamMap[param.name] == param.name, - ) - .map((PropertyInfo p) => p.name) - .toSet(); - common = - common == null - ? forwardedCurrent - : common.intersection(forwardedCurrent); - if (common.isEmpty) { - break; - } - } - if (common != null && common.isNotEmpty) { - commonForwardedParams.addAll(common); - final List commonParamRows = - methods.first.params - .where( - (PropertyInfo param) => - commonForwardedParams.contains(param.name), - ) - .toList() - ..sort( - (PropertyInfo a, PropertyInfo b) => - a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - currentMethod = methods.first; - sb.write('\n\n##### 通用参数'); - sb.write('\n\n以下参数由各命名工厂统一透传,含义一致:'); - writeMethodParamTable(commonParamRows); - } - } - for (final StaticMethodInfo item in methods) { - currentMethod = item; - sb.write( - '\n\n##### ${apiInfo.componentInfo!.name}.${sanitizeTableCell(item.name)}', - ); - if (item.introduction != null && item.introduction!.isNotEmpty) { - sb.write('\n\n${item.introduction}'); - } - final String returnType = - item.returnType == 'null' ? '' : (item.returnType ?? ''); - if (includeReturnType && returnType.isNotEmpty) { - sb.write('\n\n返回类型:`$returnType`'); - } - final List params = - commonForwardedParams.isEmpty - ? item.params - : item.params - .where( - (PropertyInfo param) => - !commonForwardedParams.contains(param.name), - ) - .toList(); - if (commonForwardedParams.isNotEmpty) { - sb.write('\n\n其余参数见「通用参数」。'); - } - writeMethodParamTable(params); - } - currentMethod = null; - } - - // 对外 API 优先:命令式入口 → 命名工厂 → 默认构造 → 字段/成员 → 实例方法 - if (apiInfo.componentInfo?.staticMethodList.isNotEmpty ?? false) { - writeMethodDetails( - apiInfo.componentInfo!.staticMethodList, - header: '静态方法', - includeReturnType: true, - ); - } - final List publicNamedConstructors = - apiInfo.componentInfo!.constructorMethodList - .where( - (StaticMethodInfo method) => - !isLibraryPrivateNamedConstructor(method.name), - ) - .toList(); - if (publicNamedConstructors.isNotEmpty) { - writeMethodDetails( - publicNamedConstructors, - header: '工厂构造方法', - compactCommonForwardedParams: true, - ); - } - 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?.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 == null || item.introduction!.isEmpty ? '-' : item.introduction)} |\n', - ); - } - } - } - await file.writeAsString(sb.toString(), encoding: utf8); + final markdown = renderApiMarkdown( + parsedComponentInfoList, + names: nameList!, + strictNames: commandInfo?.strictNames ?? false, + includeIntroduction: commandInfo?.isGetComments ?? false, + ); + await file.writeAsString(markdown, encoding: utf8); int endTime = DateTime.now().microsecondsSinceEpoch; AnsiPen pen = AnsiPen()..green(bold: true); print( diff --git a/lib/util.dart b/lib/util.dart index 6d3b6c8..bb80a5e 100644 --- a/lib/util.dart +++ b/lib/util.dart @@ -26,7 +26,8 @@ String formalParameterName(FormalParameter param) { } /// Dart 库级私有命名构造(如 `ClassName._`),不应对外 API 文档展示。 -bool isLibraryPrivateNamedConstructor(String? name) => name == '_'; +bool isLibraryPrivateNamedConstructor(String? name) => + name?.startsWith('_') ?? false; /// 从构造/方法参数 AST 提取类型字符串 String extractFormalParameterType( @@ -38,7 +39,10 @@ String extractFormalParameterType( target = param.parameter; } if (target is SimpleFormalParameter) { - return target.type?.toString() ?? ''; + return target.type?.toString() ?? 'dynamic'; + } + if (target is FunctionTypedFormalParameter) { + return '${target.returnType?.toSource() ?? 'dynamic'} Function${target.typeParameters?.toSource() ?? ''}${target.parameters.toSource()}${target.question == null ? '' : '?'}'; } if (target is FieldFormalParameter) { return target.type?.toString() ?? ''; @@ -103,6 +107,19 @@ String sanitizeTableCell(String? text) { .trim(); } +/// Preserve Dart generics when Markdown interprets angle brackets as HTML. +String sanitizeApiType(String type) => + type.isEmpty + ? '-' + : type + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('|', r'\|') + .replaceAll('\r', ' ') + .replaceAll('\n', ' ') + .replaceAll('\t', ' '); + /// 格式化方法参数列表,便于写入 Markdown 表格 String formatMethodParams(List params) { if (params.isEmpty) { @@ -128,9 +145,10 @@ String formatMethodParams(List params) { } /// 通用参数说明兜底(仅在源码未提供注释时生效) -String fallbackParameterIntroduction(String name) { +String fallbackParameterIntroduction(String name, [String type = 'Key?']) { switch (name) { case 'key': + if (type != 'Key' && type != 'Key?') return ''; return '组件标识,用于区分或保留组件状态。'; default: return ''; diff --git a/test/api_contract_regression_test.dart b/test/api_contract_regression_test.dart new file mode 100644 index 0000000..ba8d84d --- /dev/null +++ b/test/api_contract_regression_test.dart @@ -0,0 +1,414 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:tdesign_flutter_tools/api_completeness.dart'; +import 'package:tdesign_flutter_tools/api_markdown.dart'; +import 'package:tdesign_flutter_tools/api_signature.dart'; +import 'package:tdesign_flutter_tools/component_rule.dart'; +import 'package:tdesign_flutter_tools/model.dart'; +import 'package:tdesign_flutter_tools/smart_create.dart'; +import 'package:tdesign_flutter_tools/util.dart'; +import 'package:test/test.dart'; + +List parse(String source, List names) { + final infos = []; + parseString(content: source).unit.accept( + ComponentAstVisitor(nameList: names, onParsedComponentInfoInfo: infos.add), + ); + return infos; +} + +List tokens(String source) { + final result = []; + Token? token = + parseString(content: source, throwIfDiagnostics: false).unit.beginToken; + while (token != null && !token.isEof) { + if (!token.isSynthetic && token.lexeme != ',') result.add(token.lexeme); + token = token.next; + } + return result; +} + +Future generate(String source, List names) async { + final temp = await Directory.systemTemp.createTemp('api_contract_'); + try { + await SmartCreator( + nameList: names, + basePath: temp.path, + output: '', + folderName: 'contract', + commandInfo: + CommandInfo() + ..strictNames = true + ..isGetComments = true, + ).generateApiInfoFile(parse(source, names)); + return File('${temp.path}/contract_api.md').readAsStringSync(); + } finally { + await temp.delete(recursive: true); + } +} + +void main() { + test( + 'CLI generation passes validate and a removed parameter fails', + () async { + final temp = await Directory.systemTemp.createTemp('api_cli_contract_'); + try { + final source = File('${temp.path}/lib/fixture.dart'); + await source.parent.create(recursive: true); + await source.writeAsString(''' +class Fixture { + /// Create a requested count. + Fixture({this.count = 1}); + /// Number of items. + final int count; +} +'''); + final output = Directory('${temp.path}/example/assets/api'); + await output.create(recursive: true); + final config = File('${temp.path}/config.json'); + await config.writeAsString(''' +{"components":{"fixture":{"classes":["Fixture"],"source_folder":"lib","folder_name":"fixture"}}} +'''); + final cli = File('bin/main.dart').absolute.path; + final environment = { + 'DART_SDK': + Directory( + File(Platform.resolvedExecutable).parent.parent.path, + ).path, + }; + final generated = await Process.run(Platform.resolvedExecutable, [ + cli, + 'generate', + '--folder', + source.parent.path, + '--name', + 'Fixture', + '--folder-name', + 'fixture', + '--output', + '${output.path}/', + '--only-api', + '--get-comments', + '--strict-names', + ], environment: environment); + expect( + generated.exitCode, + 0, + reason: '${generated.stdout}\n${generated.stderr}', + ); + final args = [ + cli, + 'validate', + '--component-root', + temp.path, + '--config', + config.path, + ]; + final valid = await Process.run( + Platform.resolvedExecutable, + args, + environment: environment, + ); + expect(valid.exitCode, 0, reason: '${valid.stdout}\n${valid.stderr}'); + final manifest = File('${temp.path}/tool/components.json'); + await manifest.parent.create(recursive: true); + await manifest.writeAsString(''' +{"schemaVersion":1,"components":[{"slug":"fixture","source":{"type":"file","path":"lib/fixture.dart"},"api":{"names":["Fixture"]}}]} +'''); + final fromManifest = await Process.run(Platform.resolvedExecutable, [ + cli, + 'validate', + '--component-root', + temp.path, + ], environment: environment); + expect( + fromManifest.exitCode, + 0, + reason: '${fromManifest.stdout}\n${fromManifest.stderr}', + ); + expect(fromManifest.stdout, contains('1 组件')); + final markdownFile = File('${output.path}/fixture_api.md'); + final markdown = await markdownFile.readAsString(); + expect(markdown, contains('##### 参数')); + expect(markdown, contains('Create a requested count.')); + await markdownFile.writeAsString( + markdown.replaceAll(RegExp(r'^\| count \|.*\n', multiLine: true), ''), + ); + final invalid = await Process.run( + Platform.resolvedExecutable, + args, + environment: environment, + ); + expect(invalid.exitCode, 1); + expect(invalid.stdout, contains('缺少构造参数: [count]')); + } finally { + await temp.delete(recursive: true); + } + }, + // Four separate CLI launches compile on a cold CI runner. + timeout: const Timeout(Duration(minutes: 2)), + ); + + for (final heading in ['', '#### 参数\n', '##### 参数\n']) { + test('constructor reader accepts parameter heading $heading', () { + final section = + ''' +### Fixture +#### 默认构造方法 +```dart +Fixture({String text = ''' + + "'''\n#### not a section\n'''" + + '''}) +``` +$heading +| 参数 | 类型 | 默认值 | 说明 | 必填 | +| --- | --- | --- | --- | --- | +| count | - | 1 | Count. | 否 | +#### 公开属性 +| 属性 | 类型 | 默认值 | 说明 | +| other | int | - | Other. | +'''; + expect(markdownDefaultCtorParamNames(section), {'count'}); + expect(markdownCtorParamsWithEmptyType(section), ['count']); + }); + } + + test( + 'external methods and default/named/factory constructors generate', + () async { + final markdown = await generate( + ''' +class External { + external External(); + external External.named(); + external factory External.factory(); + external void run(); + external static void invoke(); +} +''', + ['External'], + ); + for (final signature in [ + 'external External()', + 'external External.named()', + 'external factory External.factory()', + 'external void run()', + 'external static void invoke()', + ]) { + expect(markdown, contains(signature)); + } + }, + ); + + test( + 'signature tokens preserve multiline and interpolated string contents', + () { + for (final signature in [ + "Fixture({String text = '''line1\n line2\n line3'''})", + r"Fixture({String text = '''line1" + '\n ' + r'${1 + 2}' + '\n ' + r"line3'''})", + ]) { + final rendered = formatApiSignature( + signature, + ownerDeclaration: 'class Fixture', + kind: ApiCallableKind.constructor, + ); + expect(tokens(rendered), tokens(signature)); + } + }, + ); + + test( + 'code table cells preserve whitespace, entities, pipes and generics', + () { + const value = "'''line1\n line2\t&|'''"; + final encoded = sanitizeApiType(value); + final decoded = encoded + .replaceAll(' ', '\n') + .replaceAll(' ', '\t') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replaceAll(r'\|', '|'); + expect(decoded, value); + expect(sanitizeApiType("'>'"), "'&gt;'"); + }, + ); + + test( + 'business build and custom overrides remain public without comments', + () { + final info = + parse( + ''' +abstract class Contract { void close(); } +abstract class Builder implements Contract { + /// Build a business value. + String build() => 'value'; + @override + void close() {} +} +''', + ['Builder'], + ).single; + expect(info.componentInfo!.instanceMethodList.map((m) => m.name), [ + 'build', + 'close', + ]); + expect(info.componentInfo!.instanceMethodList.last.introduction, isEmpty); + }, + ); + + test( + 'framework hooks require an owner, including local superclass chains', + () { + final infos = parse( + ''' +class Intermediate extends StatelessWidget {} +class View extends Intermediate { + /// Render the widget. + @override + Widget build(BuildContext context) => throw UnimplementedError(); + void close() {} +} +class Widget { void build() {} } +class Business extends Widget { void build() {} } +''', + ['View', 'Business'], + ); + // A locally declared Widget is a business type, never a Flutter identity. + expect(infos.first.componentInfo!.instanceMethodList.map((m) => m.name), [ + 'close', + ]); + expect(infos.last.componentInfo!.instanceMethodList.map((m) => m.name), [ + 'build', + ]); + }, + ); + + test('custom methods on framework owners are not blanket excluded', () { + final info = + parse( + ''' +class CustomMap extends MapBase { + @override + void close() {} + @override + void clear() {} + @override + Object? operator [](Object key) => null; +} +''', + ['CustomMap'], + ).single; + expect(info.componentInfo!.instanceMethodList.map((m) => m.name), [ + 'close', + '[]', + ]); + }); + + test( + 'legacy function typed parameters retain generic/nullable signatures', + () { + final info = + parse( + ''' +class CallbackHost { + void run(void callback(int value), E mapper(E input), void nullable(int value)?, dynamic explicit, untyped) {} +} +''', + ['CallbackHost'], + ).single; + expect( + info.componentInfo!.instanceMethodList.single.params.map((p) => p.type), + [ + 'void Function(int value)', + 'E Function(E input)', + 'void Function(int value)?', + 'dynamic', + 'dynamic', + ], + ); + }, + ); + + test('unresolved super types are not reported as dynamic', () { + final info = + parse('class Child extends Unknown { Child.named(super.value); }', [ + 'Child', + ]).single; + expect( + info.componentInfo!.constructorMethodList.single.params.single.type, + '-', + ); + }); + + test('rendering is repeatable without reordering parsed method models', () { + final infos = parse('class Fixture { void z() {} void a() {} }', [ + 'Fixture', + ]); + final before = + infos.single.componentInfo!.instanceMethodList + .map((m) => m.name) + .toList(); + final first = renderApiMarkdown(infos, names: ['Fixture']); + expect(renderApiMarkdown(infos, names: ['Fixture']), first); + expect( + infos.single.componentInfo!.instanceMethodList.map((m) => m.name), + before, + ); + }); + + test('unknown inferred field types do not become dynamic', () { + final info = + parse( + 'class Fixture { final unknown = loadValue(); final empty = null; }', + ['Fixture'], + ).single; + expect( + info.extraPropertyList.firstWhere((p) => p.name == 'unknown').type, + '-', + ); + expect( + info.extraPropertyList.firstWhere((p) => p.name == 'empty').type, + 'Null', + ); + }); + + test('render object, element and Material Tab hooks are framework APIs', () { + final infos = parse( + ''' +class View extends RenderObjectWidget { + @override + Element createElement() => throw UnimplementedError(); + @override + RenderObject createRenderObject(BuildContext context) => throw UnimplementedError(); +} +class ViewElement extends RenderObjectElement { + @override + void mount(Element? parent, Object? slot) {} + @override + void businessCommand() {} +} +class MaterialTab extends Tab { + @override + Widget build(BuildContext context) => throw UnimplementedError(); + @override + Size get preferredSize => throw UnimplementedError(); +} +''', + ['View', 'ViewElement', 'MaterialTab'], + ); + expect(infos[0].componentInfo!.instanceMethodList, isEmpty); + expect(infos[1].componentInfo!.instanceMethodList.map((m) => m.name), [ + 'businessCommand', + ]); + expect(infos[2].componentInfo!.instanceMethodList, isEmpty); + expect(infos[2].extraPropertyList, isEmpty); + }); +} diff --git a/test/ctor_defaults_test.dart b/test/ctor_defaults_test.dart index 867e533..d92fc47 100644 --- a/test/ctor_defaults_test.dart +++ b/test/ctor_defaults_test.dart @@ -48,10 +48,10 @@ void main() { test('TPopupOptions default ctor captures field defaults on develop', () { final list = _analysePopupPart(['TPopupOptions'], 't_popup_options.dart'); final info = list.first; - final showOverlay = info.propertyList.firstWhere( - (PropertyInfo p) => p.name == 'showOverlay', + final destroyOnClose = info.propertyList.firstWhere( + (PropertyInfo p) => p.name == 'destroyOnClose', ); - expect(showOverlay.defaultValue, 'true'); + expect(destroyOnClose.defaultValue, 'false'); }); test('TPopupBasePanel ctor captures field defaults', () { diff --git a/test/fixtures/public_members_fixture.dart b/test/fixtures/public_members_fixture.dart new file mode 100644 index 0000000..f7e7173 --- /dev/null +++ b/test/fixtures/public_members_fixture.dart @@ -0,0 +1,104 @@ +/// A concrete lifecycle handle. +class DemoHandle { + DemoHandle._hidden(); + + /// Whether the handle is open. + bool get isShowing => true; + + /// Close safely. + void close({ + /// Whether closure is animated. + bool animated = true, + }) {} + + /// Current generic values. + List get values => const []; + + /// Mutable text. + set label(String value) {} + + /// A business method named build is still public. + void build() {} +} + +/// A controller with an implicit empty constructor. +class DemoController { + /// Reset the controller. + void reset() {} +} + +/// Public named constructors. +class DemoOptions { + /// Create a default configuration. + DemoOptions(); + + /// Create a named configuration. + DemoOptions.named(); + + /// Create a configuration through a factory. + factory DemoOptions.factory() => DemoOptions(); + + /// Two independently listed values. + final int first = 1, second = 2; + + /// A public value beside a private field. + final int _hidden = 0, publicMixed = 3; + + /// Read the internal value without documenting the private field. + int get internalValue => _hidden; +} + +/// Public extension helpers. +extension DemoHelpers on DemoHandle { + /// Whether this handle can close. + bool get canClose => isShowing; + + /// Close through the extension. + void dismiss() => close(); +} + +/// An auxiliary type excluded by strict manifests. +enum UnregisteredEnum { value } + +/// A generic callback. +typedef GenericBuilder = T Function(T value); + +/// Generic declarations and colliding parameter names. +class DemoGeneric extends _GenericBase { + /// The current index, not a target index. + int get index => 0; + + /// Move to a target. No parameter docs have been supplied. + void jump(int index) {} + + /// Read a Token, not a Widget key. + void lookup(String? key) {} + + /// Return a generic value with optional positional input. + T? read(E value, [T? fallback]) => fallback; + + @override + DemoGeneric copyWith() => this; + + @override + T? operator [](Object? key) => null; +} + +abstract class _GenericBase { + _GenericBase copyWith(); + T? operator [](Object? key); +} + +/// A const redirecting factory has no callable body in its declaration. +sealed class DemoRedirect { + /// Fixed layout. + const factory DemoRedirect.fixed({ + /// Number of columns; runtime fallback is supplied by the target. + int count, + }) = _DemoFixed; +} + +class _DemoFixed implements DemoRedirect { + const _DemoFixed({this.count = 2}); + final int count; +} diff --git a/test/folder_order_test.dart b/test/folder_order_test.dart index 23f749a..d74a300 100644 --- a/test/folder_order_test.dart +++ b/test/folder_order_test.dart @@ -24,25 +24,37 @@ void main() { 'class AWidget {}\ntypedef ATypedef = void Function();\n', ); + final Directory nested = Directory(p.join(tempDir.path, 'nested')); + await nested.create(); + await File( + p.join(nested.path, 'nested.dart'), + ).writeAsString('class NestedWidget {}'); + await File(p.join(tempDir.path, 'ignore.txt')).writeAsString('not Dart'); + final SmartCreator creator = SmartCreator( isFileMode: false, onlyApi: true, - nameList: ['AWidget', 'ZWidget'], + nameList: ['AWidget', 'ZWidget', 'NestedWidget'], basePath: tempDir.path, path: '', folderName: 'order', ); - final List parsed = - await creator.parseOnly(quiet: true); + final List parsed = await creator.parseOnly( + quiet: true, + ); - final List names = parsed - .map((ParsedComponentInfoInfo e) => e.componentInfo!.name!) - .where((String n) => n.isNotEmpty) - .toList(); + final List names = + parsed + .map((ParsedComponentInfoInfo e) => e.componentInfo!.name!) + .where((String n) => n.isNotEmpty) + .toList(); // 两个目标类都出现,且字母序文件(a 在 z 之前)先生成。 - expect(names, containsAll(['AWidget', 'ZWidget'])); + expect( + names, + containsAll(['AWidget', 'ZWidget', 'NestedWidget']), + ); expect(names.indexOf('AWidget'), lessThan(names.indexOf('ZWidget'))); } finally { await tempDir.delete(recursive: true); diff --git a/test/public_members_test.dart b/test/public_members_test.dart new file mode 100644 index 0000000..0c2bbaa --- /dev/null +++ b/test/public_members_test.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:tdesign_flutter_tools/component_rule.dart'; +import 'package:tdesign_flutter_tools/model.dart'; +import 'package:tdesign_flutter_tools/smart_create.dart'; +import 'package:test/test.dart'; +import 'package:markdown/markdown.dart' as md; + +import 'support/analyzer_context.dart'; +import 'support/fixture_paths.dart'; + +void main() { + test( + 'documents concrete handles, accessors, constructors and extensions', + () async { + final path = fixtureSourcePath('public_members_fixture.dart'); + final context = testAnalysisContextCollection( + includedPaths: [path], + ); + final parsed = + context.contextFor(path).currentSession.getParsedUnit(path) + as ParsedUnitResult; + final names = [ + 'DemoHandle', + 'DemoController', + 'DemoOptions', + 'DemoHelpers', + 'GenericBuilder', + 'DemoGeneric', + 'DemoRedirect', + ]; + final infos = + ComponentRule(parsedUnitResult: parsed, nameList: names).analyse(); + final handle = infos.firstWhere( + (info) => info.componentInfo!.name == 'DemoHandle', + ); + expect( + handle.extraPropertyList.map((field) => field.name), + containsAll(['isShowing', 'label']), + ); + expect( + handle.extraPropertyList + .firstWhere((field) => field.name == 'label') + .type, + 'String', + ); + expect( + handle.componentInfo!.instanceMethodList.map((method) => method.name), + ['close', 'build'], + ); + final options = infos.firstWhere( + (info) => info.componentInfo!.name == 'DemoOptions', + ); + expect( + options.extraPropertyList.map((field) => field.name), + containsAll(['first', 'second', 'publicMixed']), + ); + final controller = infos.firstWhere( + (info) => info.componentInfo!.name == 'DemoController', + ); + expect(controller.componentInfo!.hasDefaultConstructor, isTrue); + final generic = infos.firstWhere( + (info) => info.componentInfo!.name == 'DemoGeneric', + ); + final methods = generic.componentInfo!.instanceMethodList; + expect( + methods.map((method) => method.name), + containsAll(['jump', 'lookup', 'read', 'copyWith', '[]']), + ); + expect( + methods + .firstWhere((method) => method.name == 'jump') + .params + .single + .introduction, + isEmpty, + ); + expect( + methods + .firstWhere((method) => method.name == 'lookup') + .params + .single + .introduction, + isEmpty, + ); + expect( + methods + .firstWhere((method) => method.name == 'lookup') + .params + .single + .type, + 'String?', + ); + final temp = await Directory.systemTemp.createTemp( + 'tdesign_public_members_', + ); + try { + final creator = SmartCreator( + isFileMode: true, + onlyApi: true, + nameList: names, + basePath: temp.path, + output: '', + folderName: 'members', + commandInfo: + CommandInfo() + ..folderName = 'members' + ..output = '${temp.path}/' + ..isGetComments = true + ..strictNames = true, + ); + await creator.generateApiInfoFile(infos); + final markdown = File('${temp.path}/members_api.md').readAsStringSync(); + expect(markdown, contains('### DemoHelpers')); + expect(markdown, contains('| values | List<String> |')); + final html = md.markdownToHtml( + markdown, + extensionSet: md.ExtensionSet.gitHubWeb, + ); + expect(html, contains('List<String>')); + expect(html, isNot(contains(''))); + expect(markdown, contains('typedef GenericBuilder')); + expect(markdown, contains('class DemoGeneric')); + expect( + markdown, + contains('T? read(E value, [T? fallback])'), + ); + expect(markdown, contains('##### DemoGeneric.copyWith')); + expect(markdown, contains('##### DemoGeneric.[]')); + expect(markdown, contains('const factory DemoRedirect.fixed(')); + expect(markdown, isNot(contains('### UnregisteredEnum'))); + expect(markdown, contains('| canClose | bool |')); + expect(markdown, contains('##### DemoHelpers.dismiss')); + expect(markdown, contains('#### 命名构造方法')); + expect(markdown, contains('#### 工厂构造方法')); + expect(markdown, contains('```dart\nDemoController()\n```')); + expect(markdown, contains('Whether closure is animated.')); + expect( + markdown, + contains( + '| animated | bool | true | Whether closure is animated. | 否 |', + ), + ); + expect(markdown, isNot(contains('| build |'))); + expect(markdown, isNot(contains('##### DemoHandle._hidden'))); + expect(markdown, contains('| publicMixed | int | 3 |')); + expect(markdown, contains('| first | int | 1 |')); + } finally { + await temp.delete(recursive: true); + } + }, + ); +} diff --git a/test/static_method_doc_test.dart b/test/static_method_doc_test.dart index 7bca498..a9132c0 100644 --- a/test/static_method_doc_test.dart +++ b/test/static_method_doc_test.dart @@ -54,7 +54,9 @@ Future _generateFixtureApi({ commandInfo: commandInfo, ); await creator.generateApiInfoFile(infos); - return File(p.join(tempDir.path, '${folderName}_api.md')).readAsString(); + return await File( + p.join(tempDir.path, '${folderName}_api.md'), + ).readAsString(); } finally { await tempDir.delete(recursive: true); } @@ -306,7 +308,8 @@ void main() { r'### TypeAlpha[\s\S]*?\n\n### TypeBeta', ).firstMatch(content); expect(sectionGap, isNotNull); - expect(sectionGap!.group(0), isNot(contains('```'))); + expect(sectionGap!.group(0), contains('class TypeAlpha')); + expect(content, contains('class TypeBeta')); }); test( @@ -322,7 +325,10 @@ void main() { expect(content, contains('#### 简介')); expect(content, contains('第一个组件说明')); expect(content, isNot(contains('TypeAlpha.demo()'))); - expect(content, isNot(matches(RegExp(r'#### 简介[\s\S]*```')))); + expect( + content.split('#### 简介').last.split('\n#### ').first, + isNot(contains('```')), + ); }, );