diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..491513c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + name: Conventional Commit Release + runs-on: ubuntu-latest + steps: + - name: Run release automation + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GH_TOKEN }} + config-file: .release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-config.json b/.release-please-config.json new file mode 100644 index 0000000..9ff6e09 --- /dev/null +++ b/.release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "dart", + "include-v-in-tag": true, + "pull-request-title-pattern": "chore(main): release ${version}", + "packages": { + ".": { + "component": "nocterm_lints", + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..acccd42 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.3.0+beta.3" +} diff --git a/.vscode/launch.json b/.vscode/launch.json index b08e52b..58f83f4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,17 +4,18 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { "name": "nocterm_lints", "request": "launch", - "type": "dart" + "type": "dart", + "console": "debugConsole" }, { "name": "example", "cwd": "example", "request": "launch", - "type": "dart" + "type": "dart", + "console": "debugConsole" } ] } \ No newline at end of file diff --git a/README.md b/README.md index d5532b6..2c6efd5 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,21 @@ - - # nocterm_lints +[![ZOOCITYBOY][logo_white]][zoocityboy_link_dark] +[![ZOOCITYBOY][logo_black]][zoocityboy_link_light] [![Pub](https://img.shields.io/pub/v/nocterm_lints.svg)](https://pub.dev/packages/nocterm_lints) [![Dart](https://img.shields.io/badge/Dart-0175C2?logo=dart)](https://dart.dev) -[![Nocterm](https://img.shields.io/badge/NOCTERM-f2f2f2?logo=nocterm&logoColor=000000)](https://nocterm.dev) - -**Productivity assists for Nocterm terminal UI development** - -An analysis server plugin providing intelligent IDE assists and refactoring tools for building Nocterm terminal UI components. Works seamlessly in VS Code, IntelliJ IDEA, Android Studio, and other Dart-enabled editors. - -> [!info] -> Built on the modern `analysis_server_plugin` framework (Dart 3.10+). Originally derived from Dart project foundations and enhanced for Nocterm-specific workflows. - -## Features - -**19 productivity assists** organized into four categories: - -### Component Manipulation (5) -- **Move Up/Down** — Reorder components up or down in the tree -- **Swap with Child** — Exchange positions with immediate child -- **Swap with Parent** — Exchange positions with parent component -- **Remove Component** — Delete wrapper while preserving children +[![Nocterm](https://img.shields.io/badge/Nocterm-terminal%20UI-black)](https://pub.dev/packages/nocterm) -### Component Wrapping (10) -- **Wrap with Component** — Choose from available component types -- **Wrap with Generic** — Container with customizable child -- **Wrap with Center** — Center-align component -- **Wrap with Container** — Add styling container -- **Wrap with Padding** — Add spacing (default: 8dp) -- **Wrap with Row/Column** — Create horizontal/vertical layouts -- **Wrap with Expanded/Flexible** — Control sizing in flex contexts -- **Wrap with SizedBox** — Define explicit dimensions +`nocterm_lints` is a Dart analysis server plugin focused on Nocterm UI productivity. +It provides code actions for wrapping, moving, swapping, removing, and converting Nocterm components directly in your editor. -### Layout Builders (2) -- **Wrap with Builder** — Builder pattern wrapper -- **Wrap with ValueListenableBuilder** — Reactive state pattern +> [!NOTE] +> The plugin targets Dart SDK `>=3.10.0 <4.0.0` and uses the modern `analysis_server_plugin` stack. -### Component Conversion (2) -- **Convert to Stateful** — Refactor to StatefulComponent -- **Convert to Stateless** — Refactor to StatelessComponent +## Installation -## Getting Started - -### For Development (Local Path) - -Add to your project's `analysis_options.yaml`: +### Use from local path (development) ```yaml include: package:nocterm_lints/recommended.yaml @@ -58,157 +25,102 @@ plugins: path: ../path/to/nocterm_lints ``` -### For Published Package +### Use from pub package ```yaml include: package:nocterm_lints/recommended.yaml plugins: - nocterm_lints: ^0.1.0 + nocterm_lints: ^0.3.0 ``` -### Activate Assists - -After updating `analysis_options.yaml`, restart the Dart Analysis Server: - -| Editor | Command | -|--------|---------| -| **VS Code** | `Cmd+Shift+P` → "Dart: Restart Analysis Server" | -| **IntelliJ / Android Studio** | Tools → Dart Analysis → Restart | - -> [!tip] -> Use `Cmd+.` (macOS) or `Ctrl+.` (Windows/Linux) to see available assists when the cursor is on a component. - -## Usage Examples - -### Wrap with Padding +After updating `analysis_options.yaml`, restart the Dart Analysis Server. + +## Compatibility Matrix + +| Component | Supported / Tested Range | +| --- | --- | +| Dart SDK | `>=3.10.0 <4.0.0` | +| `analysis_server_plugin` | `>=0.3.8 <0.4.0` | +| `analyzer` | `>=10.0.2 <11.0.0` | +| `analyzer_plugin` | `>=0.14.2 <0.15.0` | +| `nocterm` | `^0.5.0` | +| Editors | VS Code, IntelliJ IDEA, Android Studio (via Dart Analysis Server) | + +## All Assistants + +The table below lists every registered assistant in this project. + +| Category | Assistant | Code Action label | Description | +| --- | --- | --- | --- | +| Move | `MoveUp` | `Move component up` | Swaps the selected component with the previous sibling in a list. | +| Move | `MoveDown` | `Move component down` | Swaps the selected component with the next sibling in a list. | +| Swap | `SwapWithChild` | `Swap with child` | Swaps a parent component with its direct child when structure is valid. | +| Swap | `SwapWithParent` | `Swap with parent` | Swaps a child component with its direct parent when structure is valid. | +| Remove | `RemoveWidget` | `Remove this component` | Removes a wrapper and preserves valid `child` or `children` content when possible. | +| Wrap | `WrapComponent` | `Wrap with component...` | Wraps a selected list of components with a generic component wrapper. | +| Wrap | `WrapGeneric` | `Wrap with component...` | Wraps a single component with a generic configurable wrapper. | +| Wrap | `WrapCenter` | `Wrap with Center` | Wraps the selected component in `Center`. | +| Wrap | `WrapContainer` | `Wrap with Container` | Wraps the selected component in `Container`. | +| Wrap | `WrapExpanded` | `Wrap with Expanded` | Wraps the selected component in `Expanded` in compatible flex contexts. | +| Wrap | `WrapFlexible` | `Wrap with Flexible` | Wraps the selected component in `Flexible` in compatible flex contexts. | +| Wrap | `WrapPadding` | `Wrap with Padding` | Wraps the selected component in `Padding` with default edge insets. | +| Wrap | `WrapSizedBox` | `Wrap with SizedBox` | Wraps the selected component in `SizedBox`. | +| Wrap | `WrapRow` | `Wrap with Row` | Wraps selected component(s) in a `Row(children: [...])`. | +| Wrap | `WrapColumn` | `Wrap with Column` | Wraps selected component(s) in a `Column(children: [...])`. | +| Wrap | `WrapBuilder` | `Wrap with Builder` | Wraps the selected component in `Builder` and generates a builder closure. | +| Wrap | `WrapValueListenableBuilder` | `Wrap with ValueListenableBuilder` | Wraps the selected component in `ValueListenableBuilder`. | +| Convert | `ConvertToStatefulComponent` | `Convert to StatefulComponent` | Refactors a stateless component into StatefulComponent + State classes. | +| Convert | `ConvertToStatelessComponent` | `Convert to StatelessComponent` | Refactors eligible stateful component/state pair into a stateless component. | + +## Usage + +1. Place the cursor on, or inside, a Nocterm component expression. +2. Open code actions (`Cmd+.` on macOS, `Ctrl+.` on Windows/Linux). +3. Pick an assist from the list. + +> [!TIP] +> If assists do not appear after install/config changes, run `Dart: Restart Analysis Server`. + +## Example + +Before: -**Before:** ```dart -final component = MyComponent(child: Text('Hello')); +return Text('hello'); ``` -**After:** Invoke "Wrap with Padding" assist +After `Wrap with Padding`: + ```dart -final component = Padding( +return Padding( padding: const EdgeInsets.all(8), - child: MyComponent(child: Text('Hello')), + child: Text('hello'), ); ``` -### Convert to Stateless Component - -**Before:** -```dart -class MyComponent extends StatefulComponent { - @override - State createState() => _MyComponentState(); -} - -class _MyComponentState extends State { - @override - Component build(BuildContext context) => Text('Hello'); -} -``` - -**After:** Invoke "Convert to Stateless Component" assist -```dart -class MyComponent extends StatelessComponent { - const MyComponent({super.key}); - - @override - Component build(BuildContext context) => Text('Hello'); -} -``` - -## Configuration - -Enable or disable diagnostics in `analysis_options.yaml`: - -```yaml -plugins: - nocterm_lints: - path: ../nocterm_lints -``` - ## Development -### Setup - -Bootstrap dependencies: ```bash dart pub get -``` - -### Run Tests - -```bash -dart test -``` - -### Format Code - -```bash -dart format . -``` - -### Analyze - -```bash dart analyze +dart test test/assistants ``` -### Test Against Local Project - -1. Add to test project's `analysis_options.yaml`: - ```yaml - plugins: - nocterm_lints: - path: /path/to/nocterm_lints - ``` - -2. Restart the Dart Analysis Server - -## Project Structure - -``` -nocterm_lints/ -├── lib/ -│ ├── main.dart # Plugin entry point -│ ├── src/assistants/ # Individual assists -│ │ ├── wrap_*.dart -│ │ ├── move_*.dart -│ │ ├── convert_*.dart -│ │ └── ... -│ ├── services/ # Core services -│ └── utilities/ # Shared extensions -├── test/ # Unit tests -├── example/ # Example project -├── analysis_options.yaml -├── pubspec.yaml -└── recommended.yaml # Default lint config +## Project Layout + +```text +lib/ + main.dart + assistants/ + services/ + utilities/ +test/ + assistants/ +example/ ``` -## Requirements - -| Requirement | Version | -|-------------|---------| -| Dart SDK | >= 3.10.0 | -| analysis_server_plugin | ^0.3.4 | -| analyzer | >= 8.0.0, < 10.0.0 | - -## Supported Editors - -- VS Code (via Dart extension) -- IntelliJ IDEA -- Android Studio -- Other Dart analyzer-compatible editors - -## Licensing - -Dual-licensed for compatibility: - -- **New code**: [MIT License](LICENSE) -- **Derived from Dart SDK**: [BSD-3-Clause License](https://github.com/dart-lang/sdk/blob/main/LICENSE) - -See [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md) and [LICENSE](LICENSE) for detailed attribution. +[logo_black]: https://raw.githubusercontent.com/zoocityboy/zoo_brand/main/styles/README/zoocityboy_dark.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/zoocityboy/zoo_brand/main/styles/README/zoocityboy_light.png#gh-dark-mode-only +[zoocityboy_link_dark]: https://github.com/zoocityboy#gh-dark-mode-only +[zoocityboy_link_light]: https://github.com/zoocityboy#gh-light-mode-only diff --git a/analysis_options.yaml b/analysis_options.yaml index 6c338df..c37fa12 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -19,6 +19,7 @@ analyzer: avoid_catches_without_on_clauses: ignore dangling_library_doc_comments: ignore no_runtimetype_tostring: ignore + lines_longer_than_80_chars: ignore exclude: - test/.test_coverage.dart diff --git a/example/lib/widgets/counter.dart b/example/lib/widgets/counter.dart index 54ac8af..3e9b44f 100644 --- a/example/lib/widgets/counter.dart +++ b/example/lib/widgets/counter.dart @@ -5,6 +5,6 @@ class Counter extends StatelessComponent { @override Component build(BuildContext context) { - return const SizedBox.shrink(); + return Center(child: const SizedBox.shrink()); } } diff --git a/example/lib/widgets/statefull_component.dart b/example/lib/widgets/statefull_component.dart index 0d900dc..bd9bf60 100644 --- a/example/lib/widgets/statefull_component.dart +++ b/example/lib/widgets/statefull_component.dart @@ -1,17 +1,15 @@ import 'package:nocterm/nocterm.dart'; -class StatefulComponentX extends StatefulComponent { +class MyComponent extends StatefulComponent { @override - State createState() => _StatefulComponentXState(); + State createState() => _MyComponentState(); } -class _StatefulComponentXState extends State +class _MyComponentState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { - // TODO: implement initState - super.initState(); _controller = AnimationController( vsync: this, @@ -31,3 +29,17 @@ class _StatefulComponentXState extends State ); } } + +class MyWidget extends StatefulComponent { + const MyWidget({super.key}); + + @override + State createState() => _MyWidgetState(); +} + +class _MyWidgetState extends State { + @override + Component build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/hooks/pre_commit.dart b/hooks/pre_commit.dart new file mode 100644 index 0000000..8561ce8 --- /dev/null +++ b/hooks/pre_commit.dart @@ -0,0 +1,18 @@ +import 'package:hooksman/hooksman.dart'; + +Hook main() { + return PreCommitHook( + tasks: [ + // ReRegisterHooks(), + ShellTask( + name: 'Lint & Format', + include: [Glob('lib/**.dart')], + exclude: [Glob('hooks/**.dart'), Glob('example/**.dart')], + commands: (filePaths) => [ + 'dart analyze --fatal-infos ${filePaths.join(' ')}', + 'dart format ${filePaths.join(' ')}', + ], + ), + ], + ); +} diff --git a/hooks/pre_push.dart b/hooks/pre_push.dart new file mode 100644 index 0000000..333aff2 --- /dev/null +++ b/hooks/pre_push.dart @@ -0,0 +1,17 @@ +import 'package:hooksman/hooksman.dart'; + +Hook main() { + return PrePushHook( + tasks: [ + // ReRegisterHooks(), + ShellTask( + name: 'Tests', + include: [Glob('**.dart')], + exclude: [Glob('hooks/**')], + commands: (filePaths) => [ + 'dart test -r github -j 4', + ], + ), + ], + ); +} diff --git a/lib/assistants/convert_to_stateful_component.dart b/lib/assistants/convert_to_stateful_component.dart index 444b930..3ff7da9 100644 --- a/lib/assistants/convert_to_stateful_component.dart +++ b/lib/assistants/convert_to_stateful_component.dart @@ -33,267 +33,275 @@ class ConvertToStatefulComponent extends ResolvedCorrectionProducer { @override Future compute(ChangeBuilder builder) async { - useDeclaringConstructorsAst = true; - logInfo( - 'Computing assist at offset $selectionOffset', - ); - - final componentClass = node.thisOrAncestorOfType(); - final superclass = componentClass?.extendsClause?.superclass; - if (componentClass == null || superclass == null) { - logError( - 'No class declaration or superclass found at offset $selectionOffset', + try { + logInfo( + 'Computing assist at offset $selectionOffset', ); - return; - } - late final body = componentClass.body; - try { - if (body is! BlockClassBody) { + final componentClass = node.thisOrAncestorOfType(); + final superclass = componentClass?.extendsClause?.superclass; + if (componentClass == null || superclass == null) { + logError( + 'No class declaration or superclass found at offset $selectionOffset', + ); return; } - } catch (e, s) { - logError( - 'Failed to analyze class body at offset $selectionOffset', - e, - s, - ); - return; - } - - // Don't spam, activate only from the `class` keyword to the class body. - if (selectionOffset < componentClass.classKeyword.offset || - selectionOffset > body.leftBracket.end) { - return; - } - // Must be a StatelessWidget subclass. - final componentClassElement = componentClass.declaredFragment!.element; - final superType = componentClassElement.supertype; - if (superType == null || !superType.isExactlyStatelessComponentType) { - return; - } + late final body = componentClass.body; + try { + if (body is! BlockClassBody) { + return; + } + } catch (e, s) { + logError( + 'Failed to analyze class body at offset $selectionOffset', + e, + s, + ); + return; + } - final buildMethod = _findBuildMethod(componentClass); - if (buildMethod == null) { - return; - } + // Don't spam, activate only from the `class` keyword to the class body. + if (selectionOffset < componentClass.classKeyword.offset || + selectionOffset > body.leftBracket.end) { + return; + } - final componentName = componentClassElement.displayName; - final stateName = componentClassElement.isPrivate - ? '${componentName}State' - : '_${componentName}State'; + // Must be a StatelessWidget subclass. + final componentClassElement = componentClass.declaredFragment!.element; + final superType = componentClassElement.supertype; + if (superType == null || !superType.isExactlyStatelessComponentType) { + return; + } - // Find fields assigned in constructors. - final visitor = _FieldFinder(); - for (final member in body.members) { - if (member is ConstructorDeclaration) { - member.accept(visitor); + final buildMethod = _findBuildMethod(componentClass); + if (buildMethod == null) { + return; } - } - final fieldsAssignedInConstructors = visitor.fieldsAssignedInConstructors; - - // Prepare nodes to move. - final nodesToMove = {}; - final elementsToMove = {}; - for (final member in body.members) { - if (member is FieldDeclaration && !member.isStatic) { - for (final fieldNode in member.fields.variables) { - final fieldFragment = fieldNode.declaredFragment! as FieldFragment; - final fieldElement = fieldFragment.element; - if (!fieldsAssignedInConstructors.contains(fieldElement)) { - nodesToMove.add(member); - elementsToMove.add(fieldElement); - - final getter = fieldElement.getter; - if (getter != null) { - elementsToMove.add(getter); - } - final setter = fieldElement.setter; - if (setter != null) { - elementsToMove.add(setter); + final componentName = componentClassElement.displayName; + final stateName = componentClassElement.isPrivate + ? '${componentName}State' + : '_${componentName}State'; + + // Find fields assigned in constructors. + final visitor = _FieldFinder(); + for (final member in body.members) { + if (member is ConstructorDeclaration) { + member.accept(visitor); + } + } + final fieldsAssignedInConstructors = visitor.fieldsAssignedInConstructors; + + // Prepare nodes to move. + final nodesToMove = {}; + final elementsToMove = {}; + for (final member in body.members) { + if (member is FieldDeclaration && !member.isStatic) { + for (final fieldNode in member.fields.variables) { + final fieldFragment = fieldNode.declaredFragment! as FieldFragment; + final fieldElement = fieldFragment.element; + if (!fieldsAssignedInConstructors.contains(fieldElement)) { + nodesToMove.add(member); + elementsToMove.add(fieldElement); + + final getter = fieldElement.getter; + if (getter != null) { + elementsToMove.add(getter); + } + + final setter = fieldElement.setter; + if (setter != null) { + elementsToMove.add(setter); + } } } + } else if (member is MethodDeclaration && !member.isStatic) { + nodesToMove.add(member); + elementsToMove.add(member.declaredFragment!.element); } - } else if (member is MethodDeclaration && !member.isStatic) { - nodesToMove.add(member); - elementsToMove.add(member.declaredFragment!.element); } - } - /// Return the code for the [movedNode] which is suitable to be used - /// inside the `State` class, so that references to the widget fields and - /// methods, that are not moved, are qualified with the corresponding - /// instance `widget.`, or static `MyWidgetClass.` qualifier. - String rewriteWidgetMemberReferences(AstNode movedNode) { - final linesRange = utils.getLinesRange(range.node(movedNode)); - final text = utils.getRangeText(linesRange); - - // Insert `widget.` before references to the widget instance members. - final visitor = _ReplacementEditBuilder( - componentClassElement, - elementsToMove, - linesRange, - ); - movedNode.accept(visitor); - return SourceEdit.applySequence(text, visitor.edits.reversed.toList()); - } + /// Return the code for the [movedNode] which is suitable to be used + /// inside the `State` class, so that references to the component fields + /// and methods, that are not moved, are qualified with the corresponding + /// instance `component.`, or static `MyComponentClass.` qualifier. + String rewriteComponentMemberReferences(AstNode movedNode) { + final linesRange = utils.getLinesRange(range.node(movedNode)); + final text = utils.getRangeText(linesRange); + + // Insert `component.` before references + //to the component instance members. + final visitor = _ReplacementEditBuilder( + componentClassElement, + elementsToMove, + linesRange, + ); + movedNode.accept(visitor); + return SourceEdit.applySequence(text, visitor.edits.reversed.toList()); + } - final statefulComponentClass = await getNoctermClass( - sessionHelper, - 'StatefulComponent', - ); - logInfo( - 'Found StatefulComponent class: ${statefulComponentClass != null}', - ); - final stateClass = await getNoctermClass(sessionHelper, 'State'); - logInfo( - 'Found State class: ${stateClass != null}', - ); - if (statefulComponentClass == null || stateClass == null) { - return; - } + final statefulComponentClass = await getNoctermClass( + sessionHelper, + 'StatefulComponent', + ); + logInfo( + 'Found StatefulComponent class: ${statefulComponentClass != null}', + ); + final stateClass = await getNoctermClass(sessionHelper, 'State'); + logInfo( + 'Found State class: ${stateClass != null}', + ); + if (statefulComponentClass == null || stateClass == null) { + return; + } - await builder.addDartFileEdit(file, (builder) { - builder.addReplacement(range.node(superclass), (builder) { - builder.writeReference(statefulComponentClass); - }); + await builder.addDartFileEdit(file, (builder) { + builder.addReplacement(range.node(superclass), (builder) { + builder.writeReference(statefulComponentClass); + }); - var replaceOffset = 0; - var hasBuildMethod = false; + var replaceOffset = 0; + var hasBuildMethod = false; - var typeParams = ''; - final typeParameters = componentClass.namePart.typeParameters; - if (typeParameters != null) { - typeParams = utils.getNodeText(typeParameters); - } + var typeParams = ''; + final typeParameters = componentClass.namePart.typeParameters; + if (typeParameters != null) { + typeParams = utils.getNodeText(typeParameters); + } - /// Replace code between [replaceOffset] and [replaceEnd] with - /// `createState()`, empty line, or nothing. - void replaceInterval( - int replaceEnd, { - bool replaceWithEmptyLine = false, - bool hasEmptyLineBeforeCreateState = false, - bool hasEmptyLineAfterCreateState = true, - }) { - final replaceLength = replaceEnd - replaceOffset; - builder.addReplacement(SourceRange(replaceOffset, replaceLength), ( - builder, - ) { - if (hasBuildMethod) { - if (hasEmptyLineBeforeCreateState) { - builder.writeln(); - } - builder.writeln(' @override'); - builder.write(' '); - builder.writeReference(stateClass); - builder.write( - '<${componentClass.namePart.typeName.lexeme}$typeParams>', - ); - builder.writeln(' createState() => $stateName$typeParams();'); - if (hasEmptyLineAfterCreateState) { + /// Replace code between [replaceOffset] and [replaceEnd] with + /// `createState()`, empty line, or nothing. + void replaceInterval( + int replaceEnd, { + bool replaceWithEmptyLine = false, + bool hasEmptyLineBeforeCreateState = false, + bool hasEmptyLineAfterCreateState = true, + }) { + final replaceLength = replaceEnd - replaceOffset; + builder.addReplacement(SourceRange(replaceOffset, replaceLength), ( + builder, + ) { + if (hasBuildMethod) { + if (hasEmptyLineBeforeCreateState) { + builder.writeln(); + } + builder.writeln(' @override'); + builder.write(' '); + builder.writeReference(stateClass); + builder.write( + '<${componentClass.namePart.typeName.lexeme}$typeParams>', + ); + builder.writeln(' createState() => $stateName$typeParams();'); + if (hasEmptyLineAfterCreateState) { + builder.writeln(); + } + hasBuildMethod = false; + } else if (replaceWithEmptyLine) { builder.writeln(); } - hasBuildMethod = false; - } else if (replaceWithEmptyLine) { - builder.writeln(); - } - }); - replaceOffset = 0; - } + }); + replaceOffset = 0; + } - // Remove continuous ranges of lines of nodes being moved. - var lastToRemoveIsField = false; - var endOfLastNodeToKeep = 0; - for (final node in body.members) { - if (nodesToMove.contains(node)) { - if (replaceOffset == 0) { - final comments = node.beginToken.precedingComments; - final start = comments ?? node; - replaceOffset = utils.getLineContentStart(start.offset); - } - if (node == buildMethod) { - hasBuildMethod = true; - } - lastToRemoveIsField = node is FieldDeclaration; - } else { - final linesRange = utils.getLinesRange(range.node(node)); - endOfLastNodeToKeep = linesRange.end; - if (replaceOffset != 0) { - replaceInterval( - linesRange.offset, - replaceWithEmptyLine: - lastToRemoveIsField && node is! FieldDeclaration, - ); + // Remove continuous ranges of lines of nodes being moved. + var lastToRemoveIsField = false; + var endOfLastNodeToKeep = 0; + for (final node in body.members) { + if (nodesToMove.contains(node)) { + if (replaceOffset == 0) { + final comments = node.beginToken.precedingComments; + final start = comments ?? node; + replaceOffset = utils.getLineContentStart(start.offset); + } + if (node == buildMethod) { + hasBuildMethod = true; + } + lastToRemoveIsField = node is FieldDeclaration; + } else { + final linesRange = utils.getLinesRange(range.node(node)); + endOfLastNodeToKeep = linesRange.end; + if (replaceOffset != 0) { + replaceInterval( + linesRange.offset, + replaceWithEmptyLine: + lastToRemoveIsField && node is! FieldDeclaration, + ); + } } } - } - // Remove nodes at the end of the widget class. - if (replaceOffset != 0) { - // Remove from the last node to keep, so remove empty lines. - if (endOfLastNodeToKeep != 0) { - replaceOffset = endOfLastNodeToKeep; + // Remove nodes at the end of the widget class. + if (replaceOffset != 0) { + // Remove from the last node to keep, so remove empty lines. + if (endOfLastNodeToKeep != 0) { + replaceOffset = endOfLastNodeToKeep; + } + replaceInterval( + body.rightBracket.offset, + hasEmptyLineBeforeCreateState: endOfLastNodeToKeep != 0, + hasEmptyLineAfterCreateState: false, + ); } - replaceInterval( - body.rightBracket.offset, - hasEmptyLineBeforeCreateState: endOfLastNodeToKeep != 0, - hasEmptyLineAfterCreateState: false, - ); - } - // Create the State subclass. - builder.addInsertion(componentClass.end, (builder) { - builder.writeln(); - builder.writeln(); - - builder.write('class $stateName$typeParams extends '); - builder.writeReference(stateClass); - - // Write just param names (and not bounds, metadata and docs). - builder.write('<${componentClass.namePart.typeName.lexeme}'); - if (typeParameters != null) { - builder.write('<'); - var first = true; - for (final param in typeParameters.typeParameters) { - if (!first) { - builder.write(', '); - first = false; + // Create the State subclass. + builder.addInsertion(componentClass.end, (builder) { + builder.writeln(); + builder.writeln(); + + builder.write('class $stateName$typeParams extends '); + builder.writeReference(stateClass); + + // Write just param names (and not bounds, metadata and docs). + builder.write('<${componentClass.namePart.typeName.lexeme}'); + if (typeParameters != null) { + builder.write('<'); + var first = true; + for (final param in typeParameters.typeParameters) { + if (!first) { + builder.write(', '); + first = false; + } + builder.write(param.name.lexeme); } - builder.write(param.name.lexeme); + builder.write('>'); } - builder.write('>'); - } - builder.writeln('> {'); + builder.writeln('> {'); - var writeEmptyLine = false; - for (final member in nodesToMove) { - if (writeEmptyLine) { - builder.writeln(); - } + var writeEmptyLine = false; + for (final member in nodesToMove) { + if (writeEmptyLine) { + builder.writeln(); + } - final comments = member.beginToken.precedingComments; - if (comments != null) { - final offset = utils.getLineContentStart(comments.offset); - final length = comments.end - offset; - builder.writeln(utils.getText(offset, length)); - } + final comments = member.beginToken.precedingComments; + if (comments != null) { + final offset = utils.getLineContentStart(comments.offset); + final length = comments.end - offset; + builder.writeln(utils.getText(offset, length)); + } - final text = rewriteWidgetMemberReferences(member); - builder.write(text); - // Write empty lines between members, but not before the first. - writeEmptyLine = true; - } + final text = rewriteComponentMemberReferences(member); + builder.write(text); + // Write empty lines between members, but not before the first. + writeEmptyLine = true; + } - builder.write('}'); + builder.write('}'); + }); }); - }); + } catch (e, s) { + logError( + 'Failed to compute assist at offset $selectionOffset', + e, + s, + ); + } } - MethodDeclaration? _findBuildMethod(ClassDeclaration widgetClass) { - for (final member in widgetClass.members2) { + MethodDeclaration? _findBuildMethod(ClassDeclaration componentClass) { + for (final member in componentClass.members2) { if (member is MethodDeclaration && member.name.lexeme == 'build') { final parameters = member.parameters; if (parameters != null && parameters.parameters.length == 1) { diff --git a/lib/assistants/convert_to_stateless_component.dart b/lib/assistants/convert_to_stateless_component.dart index 8d88b61..8bb0bb8 100644 --- a/lib/assistants/convert_to_stateless_component.dart +++ b/lib/assistants/convert_to_stateless_component.dart @@ -1,11 +1,12 @@ // Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. -import 'package:_fe_analyzer_shared/src/scanner/token.dart'; -// import 'package:analysis_server/src/services/correction/assist.dart'; import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/dart/ast/visitor.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; @@ -16,8 +17,8 @@ import 'package:analyzer_plugin/utilities/assist/assist.dart'; import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; import 'package:analyzer_plugin/utilities/range_factory.dart'; -import '../../../utilities/extensions/ast.dart'; import '../services/correction/assist.dart'; +import '../utilities/extensions/ast.dart'; import '../utilities/extensions/logging_extensions.dart'; import '../utilities/extensions/nocterm.dart'; import '../utilities/extensions/session_helper.dart'; @@ -35,203 +36,212 @@ class ConvertToStatelessComponent extends ResolvedCorrectionProducer { @override Future compute(ChangeBuilder builder) async { - logInfo('Starting assist: Convert to StatelessComponent'); - final componentClass = node.thisOrAncestorOfType(); - final superclass = componentClass?.extendsClause?.superclass; - logInfo( - 'Found component class: ${componentClass?.name.lexeme}, ' - 'superclass: ${superclass?.name.lexeme}', - ); - if (componentClass == null || superclass == null) { - logError( - ' No class declaration or superclass found at offset $selectionOffset', - ); - return; - } - // useDeclaringConstructorsAst = true; - final componentClassBody = componentClass.body; - if (componentClassBody is! BlockClassBody) { - logError( - ' Component class body is not a ' - 'block ${componentClassBody.runtimeType}', - ); - return; - } - - // Don't spam, activate only from the `class` keyword to the class body. - if (selectionOffset < componentClass.classKeyword.offset || - selectionOffset > componentClassBody.leftBracket.end) { - logError( - ' Selection is outside the component class body', + try { + logInfo('Starting assist: Convert to StatelessComponent'); + final componentClass = node.thisOrAncestorOfType(); + final superclass = componentClass?.extendsClause?.superclass; + logInfo( + 'Found component class: ${componentClass?.namePart.typeName.lexeme}, ' + 'superclass: ${superclass?.name.lexeme}', ); - return; - } - - // Must be a StatefulComponent subclass. - final componentClassFragment = componentClass.declaredFragment!; - final componentClassElement = componentClassFragment.element; - final superType = componentClassElement.supertype; - if (superType == null || !superType.isExactlyStatefulComponentType) { - logError( - ' Component class is not a StatefulComponent subclass', - ); - return; - } - - final createStateMethod = _findCreateStateMethod(componentClass); - if (createStateMethod == null) return; - - final stateClass = _findStateClass(componentClassElement); - final stateClassElement = stateClass?.declaredFragment!.element; - if (stateClass == null || - stateClassElement == null || - !Identifier.isPrivateName(stateClass.namePart.typeName.lexeme) || - !_isSameTypeParameters(componentClass, stateClass)) { - logError(' State class is not valid'); - return; - } - - final verifier = _StatelessVerifier(); - final fieldFinder = _FieldFinder(); - - for (final member in stateClass.members2) { - if (member is ConstructorDeclaration) { - member.accept(fieldFinder); - } else if (member is MethodDeclaration) { - member.accept(verifier); - if (!verifier.canBeStateless) { - logError( - ' State class cannot be converted to StatelessComponent', - ); + if (componentClass == null || superclass == null) { + logError( + ' No class declaration or superclass found at offset $selectionOffset', + ); + return; + } + late final componentClassBody = componentClass.body; + try { + if (componentClassBody is! BlockClassBody) { + logError(' Component class body is not a block'); return; } + } catch (e, s) { + logError( + 'Failed to analyze class body at offset $selectionOffset', + e, + s, + ); + return; } - } - final usageVerifier = _StateUsageVisitor( - componentClassElement, - stateClassElement, - ); - unit.visitChildren(usageVerifier); - if (usageVerifier.used) { - logError( - ' State class is used in a way that prevents' - ' conversion to StatelessComponent', - ); - return; - } + // Don't spam, activate only from the `class` keyword to the class body. + if (selectionOffset < componentClass.classKeyword.offset || + selectionOffset > componentClassBody.rightBracket.end) { + logError( + ' Selection is outside the component class body', + ); + return; + } - final fieldsAssignedInConstructors = - fieldFinder.fieldsAssignedInConstructors; - - // Prepare nodes to move. - final nodesToMove = []; - final elementsToMove = {}; - for (final member in stateClass.members2) { - if (member is FieldDeclaration) { - if (member.isStatic) { - logWarning( - ' Static members cannot be moved to StatelessComponent ' - ' and will be skipped: ' - '${member.fields.variables.map((v) => v.name.lexeme).join(', ')}', - ); - return; - } - for (final fieldNode in member.fields.variables) { - final fieldElement = - fieldNode.declaredFragment!.element as FieldElement; - if (!fieldsAssignedInConstructors.contains(fieldElement)) { - nodesToMove.add(member); - elementsToMove.add(fieldElement); + // Must be a StatefulComponent subclass. + final componentClassFragment = componentClass.declaredFragment!; + final componentClassElement = componentClassFragment.element; + final superType = componentClassElement.supertype; + if (superType == null || !superType.isExactlyStatefulComponentType) { + logError( + ' Component class is not a StatefulComponent subclass', + ); + return; + } - final getter = fieldElement.getter; - if (getter != null) { - elementsToMove.add(getter); - } + final createStateMethod = _findCreateStateMethod(componentClass); + if (createStateMethod == null) return; + + final stateClass = _findStateClass(componentClassElement); + final stateClassElement = stateClass?.declaredFragment!.element; + if (stateClass == null || + stateClassElement == null || + !Identifier.isPrivateName(stateClass.namePart.typeName.lexeme) || + !_isSameTypeParameters(componentClass, stateClass)) { + logError(' State class is not valid'); + return; + } - final setter = fieldElement.setter; - if (setter != null) { - elementsToMove.add(setter); - } + final verifier = _StatelessVerifier(); + final fieldFinder = _FieldFinder(); + + for (final member in stateClass.members2) { + if (member is ConstructorDeclaration) { + member.accept(fieldFinder); + } else if (member is MethodDeclaration) { + member.accept(verifier); + if (!verifier.canBeStateless) { + logError( + ' State class cannot be converted to StatelessComponent', + ); + return; } } - } else if (member is MethodDeclaration) { - if (member.isStatic) { - logWarning( - ' Static members cannot be moved to StatelessComponent and will' - ' be skipped: ${member.name.lexeme}', - ); - return; - } - if (!_isDefaultOverride(member)) { - nodesToMove.add(member); - elementsToMove.add(member.declaredFragment!.element); - } } - } - /// Return the code for the [movedNode], so that qualification of the - /// references to the widget (`widget.` or static `MyComponentClass.`) - /// is removed - String rewriteComponentMemberReferences(AstNode movedNode) { - final linesRange = utils.getLinesRange(range.node(movedNode)); - final text = utils.getRangeText(linesRange); - - // Remove `widget.` before references to the widget instance members. - final visitor = _ReplacementEditBuilder( + final usageVerifier = _StateUsageVisitor( componentClassElement, - elementsToMove, - linesRange, + stateClassElement, ); - movedNode.accept(visitor); - return SourceEdit.applySequence(text, visitor.edits.reversed.toList()); - } - - final statelessComponentClass = await getNoctermClass( - sessionHelper, - 'StatelessComponent', - ); - if (statelessComponentClass == null) { - logError(' Could not find StatelessComponent class'); - return; - } + unit.visitChildren(usageVerifier); + if (usageVerifier.used) { + logError( + ' State class is used in a way that prevents' + ' conversion to StatelessComponent', + ); + return; + } - await builder.addDartFileEdit(file, (builder) { - builder.addReplacement(range.node(superclass), (builder) { - builder.writeReference(statelessComponentClass); - }); + final fieldsAssignedInConstructors = + fieldFinder.fieldsAssignedInConstructors; + + // Prepare nodes to move. + final nodesToMove = []; + final elementsToMove = {}; + for (final member in stateClass.members2) { + if (member is FieldDeclaration) { + if (member.isStatic) { + logWarning( + ' Static members cannot be moved to StatelessComponent ' + ' and will be skipped: ' + '${member.fields.variables.map((v) => v.name.lexeme).join(', ')}', + ); + continue; + } + for (final fieldNode in member.fields.variables) { + final fieldElement = + fieldNode.declaredFragment!.element as FieldElement; + if (!fieldsAssignedInConstructors.contains(fieldElement)) { + nodesToMove.add(member); + elementsToMove.add(fieldElement); + + final getter = fieldElement.getter; + if (getter != null) { + elementsToMove.add(getter); + } + + final setter = fieldElement.setter; + if (setter != null) { + elementsToMove.add(setter); + } + } + } + } else if (member is MethodDeclaration) { + if (member.isStatic) { + logWarning( + ' Static members cannot be moved to StatelessComponent and will' + ' be skipped: ${member.name.lexeme}', + ); + continue; + } + if (!_isDefaultOverride(member)) { + nodesToMove.add(member); + elementsToMove.add(member.declaredFragment!.element); + } + } + } - builder.addDeletion(range.deletionRange(stateClass)); + /// Return the code for the [movedNode], so that qualification of the + /// references to the component (`component.` or static `MyComponentClass.`) + /// is removed + String rewriteComponentMemberReferences(AstNode movedNode) { + final linesRange = utils.getLinesRange(range.node(movedNode)); + final text = utils.getRangeText(linesRange); + + // Remove `component.` before references to the component instance members. + final visitor = _ReplacementEditBuilder( + componentClassElement, + elementsToMove, + linesRange, + ); + movedNode.accept(visitor); + return SourceEdit.applySequence(text, visitor.edits.reversed.toList()); + } - var createStateNextToEnd = createStateMethod.endToken.next!; - createStateNextToEnd = - createStateNextToEnd.precedingComments ?? createStateNextToEnd; - final createStateRange = range.startOffsetEndOffset( - utils.getLineContentStart(createStateMethod.offset), - utils.getLineContentStart(createStateNextToEnd.offset), + final statelessComponentClass = await getNoctermClass( + sessionHelper, + 'StatelessComponent', ); + if (statelessComponentClass == null) { + logError(' Could not find StatelessComponent class'); + return; + } - final newLine = - createStateNextToEnd.type != TokenType.CLOSE_CURLY_BRACKET; - - builder.addReplacement(createStateRange, (builder) { - for (var i = 0; i < nodesToMove.length; i++) { - final member = nodesToMove[i]; - final comments = member.beginToken.precedingComments; - if (comments != null) { - final offset = utils.getLineContentStart(comments.offset); - final length = comments.end - offset; - builder.writeln(utils.getText(offset, length)); - } + await builder.addDartFileEdit(file, (builder) { + builder.addReplacement(range.node(superclass), (builder) { + builder.writeReference(statelessComponentClass); + }); + + builder.addDeletion(range.deletionRange(stateClass)); + + var createStateNextToEnd = createStateMethod.endToken.next!; + createStateNextToEnd = + createStateNextToEnd.precedingComments ?? createStateNextToEnd; + final createStateRange = range.startOffsetEndOffset( + utils.getLineContentStart(createStateMethod.offset), + utils.getLineContentStart(createStateNextToEnd.offset), + ); + + final newLine = + createStateNextToEnd.type != TokenType.CLOSE_CURLY_BRACKET; + + builder.addReplacement(createStateRange, (builder) { + for (var i = 0; i < nodesToMove.length; i++) { + final member = nodesToMove[i]; + final comments = member.beginToken.precedingComments; + if (comments != null) { + final offset = utils.getLineContentStart(comments.offset); + final length = comments.end - offset; + builder.writeln(utils.getText(offset, length)); + } - final text = rewriteComponentMemberReferences(member); - builder.write(text); - if (newLine || i < nodesToMove.length - 1) { - builder.writeln(); + final text = rewriteComponentMemberReferences(member); + builder.write(text); + if (newLine || i < nodesToMove.length - 1) { + builder.writeln(); + } } - } + }); }); - }); + } catch (e, st) { + logError('Exception during assist computation: $e\n$st'); + } } MethodDeclaration? _findCreateStateMethod(ClassDeclaration componentClass) { @@ -273,11 +283,13 @@ class ConvertToStatelessComponent extends ResolvedCorrectionProducer { final stateParams = parameters(stateClass); if (widgetParams == null && stateParams == null) { - logError('Both widget and state classes have no type parameters'); + logInfo('Both component and state classes have no type parameters'); return true; } if (widgetParams == null || stateParams == null) { - logError('Mismatch in type parameters between widget and state classes'); + logError( + 'Mismatch in type parameters between component and state classes', + ); return false; } if (widgetParams.length < stateParams.length) { @@ -345,16 +357,19 @@ class _FieldFinder extends RecursiveAstVisitor { Set fieldsAssignedInConstructors = {}; @override - void visitSimpleIdentifier(SimpleIdentifier node) { - if (node.parent is FieldFormalParameter) { - final element = node.element; - if (element is FieldFormalParameterElement) { - final field = element.field; - if (field != null) { - fieldsAssignedInConstructors.add(field); - } + void visitFieldFormalParameter(FieldFormalParameter node) { + final element = node.declaredFragment?.element; + if (element is FieldFormalParameterElement) { + final field = element.field; + if (field != null) { + fieldsAssignedInConstructors.add(field); } } + super.visitFieldFormalParameter(node); + } + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { if (node.parent is ConstructorFieldInitializer) { final element = node.element; if (element is FieldElement) { @@ -363,12 +378,11 @@ class _FieldFinder extends RecursiveAstVisitor { } if (node.inSetterContext()) { final element = node.writeOrReadElement; - final field = switch (element) { - PropertyAccessorElement(:final variable) => variable, - _ => null, - }; - if (field is FieldElement) { - fieldsAssignedInConstructors.add(field); + if (element is SetterElement) { + final field = element.variable; + if (field is FieldElement) { + fieldsAssignedInConstructors.add(field); + } } } } @@ -481,7 +495,7 @@ class _StateUsageVisitor extends RecursiveAstVisitor { ?.thisOrAncestorOfType(); if (methodDeclaration?.name.lexeme != 'createState' || - classDeclaration?.declaredFragment!.element != componentClassElement) { + classDeclaration?.declaredFragment?.element != componentClassElement) { used = true; } } diff --git a/lib/main.dart b/lib/main.dart index 5ade3a2..2eb1a06 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:analysis_server_plugin/plugin.dart'; import 'package:analysis_server_plugin/registry.dart'; +import 'assistants/convert_to_stateful_component.dart'; +import 'assistants/convert_to_stateless_component.dart'; import 'assistants/move_down.dart'; import 'assistants/move_up.dart'; import 'assistants/remove_widget.dart'; @@ -30,7 +32,7 @@ class NoctermLintsPlugin extends Plugin { @override FutureOr start() { final logger = NoctermLogger.instance; - logger.setEnabled(false); + logger.setEnabled(true); logger.setLogLevel(LogLevel.info); logger.clear(); logger.info('NoctermLintsPlugin starting'); @@ -50,12 +52,14 @@ class NoctermLintsPlugin extends Plugin { final logger = NoctermLogger.instance; try { /// Widget manipulation assists + logger.info('Registering assists...'); registry.registerAssist(MoveDown.new); registry.registerAssist(MoveUp.new); registry.registerAssist(RemoveWidget.new); registry.registerAssist(SwapWithChild.new); registry.registerAssist(SwapWithParent.new); + logger.info('Registered widget manipulation assists'); /// Component wrapping assists registry.registerAssist(WrapComponent.new); @@ -68,14 +72,17 @@ class NoctermLintsPlugin extends Plugin { registry.registerAssist(WrapSizedBox.new); registry.registerAssist(WrapRow.new); registry.registerAssist(WrapColumn.new); + logger.info('Registered widget manipulation and wrapping assists'); /// Builder wrap assists registry.registerAssist(WrapBuilder.new); registry.registerAssist(WrapValueListenableBuilder.new); + logger.info('Registered builder wrap assists'); /// Widget conversion assists - // registry.registerAssist(ConvertToStatefulComponent.new); - // registry.registerAssist(ConvertToStatelessComponent.new); + registry.registerAssist(ConvertToStatefulComponent.new); + registry.registerAssist(ConvertToStatelessComponent.new); + logger.info('Registered widget conversion assists'); } catch (e, stackTrace) { logger.error('[nocterm_lints] registering assists: $e', e, stackTrace); logger.flush(); diff --git a/lib/services/correction/assist.dart b/lib/services/correction/assist.dart index 0ff8a11..c796c9a 100644 --- a/lib/services/correction/assist.dart +++ b/lib/services/correction/assist.dart @@ -91,17 +91,17 @@ abstract final class DartAssistKind { static const noctermMoveDown = AssistKind( 'dart.assist.nocterm.move.down', DartAssistKindPriority.noctermMove, - 'Move widget down', + 'Move component down', ); static const noctermMoveUp = AssistKind( 'dart.assist.nocterm.move.up', DartAssistKindPriority.noctermMove, - 'Move widget up', + 'Move component up', ); static const noctermRemoveWidget = AssistKind( 'dart.assist.nocterm.removeWidget', DartAssistKindPriority.noctermRemove, - 'Remove this widget', + 'Remove this component', ); } diff --git a/lib/utilities/logger.dart b/lib/utilities/logger.dart index 47d8979..66ea659 100644 --- a/lib/utilities/logger.dart +++ b/lib/utilities/logger.dart @@ -59,8 +59,8 @@ class NoctermLogger { static NoctermLogger get instance => _instance ??= _createInstance(); late final File _logFile; - late LogLevel _logLevel; - late bool _enabled; + LogLevel _logLevel = LogLevel.error; + bool _enabled = false; final List _buffer = []; static const int _bufferSize = 1; diff --git a/nocterm_lints.code-workspace b/nocterm_lints.code-workspace index 26e15d0..116baea 100644 --- a/nocterm_lints.code-workspace +++ b/nocterm_lints.code-workspace @@ -36,6 +36,9 @@ "~/.agents/skills": true, "~/.claude/skills": true }, - "postman.settings.dotenv-detection-notification-visibility": false + "postman.settings.dotenv-detection-notification-visibility": false, + "chat.tools.terminal.autoApprove": { + "true": true + } } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index b14f4c6..c5f01cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,16 +13,18 @@ environment: sdk: '>=3.10.0 <4.0.0' dependencies: - _fe_analyzer_shared: ^92.0.0 - analysis_server_plugin: '>=0.3.4 <0.4.0' - analyzer: '>=9.0.0 <10.0.0' - analyzer_plugin: '>=0.13.8 <0.14.0' + _fe_analyzer_shared: ^94.0.0 + analysis_server_plugin: '>=0.3.8 <0.4.0' + analyzer: '>=10.0.2 <11.0.0' + analyzer_plugin: '>=0.14.2 <0.15.0' collection: ^1.19.0 - nocterm: ^0.5.0 + nocterm: '>=0.5.0 <0.6.0' path: ^1.9.1 dev_dependencies: - analyzer_testing: ^0.1.7 + analyzer_testing: ^0.1.10 + hooksman: ^3.1.2 lints: ^6.1.0 + meta: any test: ^1.28.0 test_reflective_loader: ^0.4.0 diff --git a/spec/spec-process-cicd-release.md b/spec/spec-process-cicd-release.md new file mode 100644 index 0000000..0a39cc3 --- /dev/null +++ b/spec/spec-process-cicd-release.md @@ -0,0 +1,301 @@ +--- +title: CI/CD Workflow Specification - Release +version: 1.0 +date_created: 2026-02-22 +last_updated: 2026-02-22 +owner: DevOps Team +tags: [process, cicd, github-actions, automation, release, conventional-commits, changelog, semver] +--- + +## Workflow Overview + +**Purpose**: Automate semantic version bumping and changelog updates from Conventional Commits, then prepare and publish a release. + +**Trigger Events**: + +- Push to default integration branch +- Manual workflow dispatch + +**Target Environments**: + +- Source repository (release branch and pull request process) +- Repository releases and tags + +## Execution Flow Diagram + +```mermaid +graph TD + A[Trigger Event] --> B[Collect Commit History] + B --> C[Determine Next Semantic Version] + C --> D[Generate Changelog Delta] + D --> E[Create or Update Release Pull Request] + E --> F{Release PR Merged?} + F -->|No| G[Wait for Merge] + F -->|Yes| H[Create Git Tag and Release] + H --> I[End] + + style A fill:#e1f5fe + style I fill:#e8f5e8 + style G fill:#fff3e0 +``` + +## Jobs & Dependencies + +- **release-orchestrator** + - Purpose: Resolve release intent and synchronize release artifacts + - Dependencies: Trigger event and repository write permissions + - Execution Context: Hosted Linux runner +- **release-pr-sync** + - Purpose: Maintain release PR with version/changelog updates + - Dependencies: release-orchestrator + - Execution Context: Repository pull request context +- **release-publication** + - Purpose: Create version tag and repository release after approved merge + - Dependencies: release-pr-sync + - Execution Context: Repository release context + +## Requirements Matrix + +### Functional Requirements + +- **REQ-001** (High) + - Requirement: Parse commit history using Conventional Commit semantics. + - Acceptance Criteria: `feat`, `fix`, and breaking indicators map to minor, patch, and major increments. +- **REQ-002** (High) + - Requirement: Compute next package version from the latest published baseline. + - Acceptance Criteria: Proposed version is deterministic for a fixed commit set. +- **REQ-003** (High) + - Requirement: Update package version metadata and `CHANGELOG.md` in a release PR. + - Acceptance Criteria: Release PR includes release metadata changes only. +- **REQ-004** (High) + - Requirement: Reuse existing open release PR instead of creating duplicates. + - Acceptance Criteria: At most one open release PR exists for the default branch. +- **REQ-005** (High) + - Requirement: Create a versioned tag and repository release after release PR merge. + - Acceptance Criteria: Tag and release match merged release version. +- **REQ-006** (Medium) + - Requirement: Support manual execution for recovery/re-synchronization. + - Acceptance Criteria: Maintainer can trigger run manually without code push. + +### Security Requirements + +- **SEC-001** + - Requirement: Workflow permissions are least-privilege for release automation. + - Implementation Constraint: Grant only repository content and PR scopes required for release operations. +- **SEC-002** + - Requirement: Authentication uses platform-provided ephemeral credentials. + - Implementation Constraint: Do not store long-lived personal tokens in repository files. +- **SEC-003** + - Requirement: Release metadata updates are traceable to workflow identity. + - Implementation Constraint: Ensure commits and tags are attributable in audit logs. + +### Performance Requirements + +- **PERF-001** + - Metric: End-to-end orchestration latency + - Target: ≤ 5 minutes for normal commit volume + - Measurement Method: Workflow run duration metrics +- **PERF-002** + - Metric: Release PR convergence time + - Target: ≤ 2 minutes after trigger + - Measurement Method: Time from run start to PR create/update event +- **PERF-003** + - Metric: Duplicate run suppression + - Target: 100% serialization per branch + - Measurement Method: Concurrency group behavior and run audit + +## Input/Output Contracts + +### Inputs + +```yaml +# Repository Triggers +events: + - push_to_default_branch + - manual_dispatch + +# Commit Semantics +conventional_commits: + required: true + breaking_change_indicators: + - exclamation_marker + - BREAKING_CHANGE_footer + +# Repository Files +release_files: + - pubspec.yaml + - CHANGELOG.md + - release_manifest +``` + +### Outputs + +```yaml +# Job Outputs +next_version: string # Description: computed semantic version candidate +release_pr_number: int # Description: created or updated release pull request +release_tag: string # Description: published version tag after merge +release_entry: string # Description: repository release identifier/url +``` + +### Secrets & Variables + +- Secret: `GITHUB_TOKEN` + - Purpose: Authorize repository content, PR, and release operations + - Scope: Workflow run +- Variable: `DEFAULT_BRANCH` + - Purpose: Defines integration branch used for release orchestration + - Scope: Repository + +## Execution Constraints + +### Runtime Constraints + +- **Timeout**: 15 minutes per run +- **Concurrency**: Single active release workflow per branch +- **Resource Limits**: Standard hosted runner resources + +### Environmental Constraints + +- **Runner Requirements**: Linux-based hosted runner +- **Network Access**: Access to source control and release APIs +- **Permissions**: Read/write repository contents and pull request metadata + +## Error Handling Strategy + +- Invalid commit format + - Response: Mark run non-releasable and log incompatible commits + - Recovery Action: Fix commit history and rerun workflow +- Version conflict + - Response: Recompute version against updated baseline + - Recovery Action: Re-run after syncing manifest/version baseline +- Changelog update failure + - Response: Fail run before PR publication + - Recovery Action: Correct changelog generation inputs and rerun +- Release publication failure + - Response: Keep release PR state intact and block duplicate publication + - Recovery Action: Retry after API/rate-limit condition clears + +## Quality Gates + +### Gate Definitions + +- Commit Semantics + - Criteria: Releasable commits parse under Conventional Commit rules + - Bypass Conditions: Manual maintainer override with explicit rationale +- Metadata Integrity + - Criteria: Version and changelog updates are internally consistent + - Bypass Conditions: None for automated publication +- PR Approval Policy + - Criteria: Release PR follows repository review rules before merge + - Bypass Conditions: Emergency branch admin override only + +## Monitoring & Observability + +### Key Metrics + +- **Success Rate**: ≥ 99% monthly successful orchestration runs +- **Execution Time**: Median ≤ 3 minutes +- **Resource Usage**: Monitor workflow runtime and API failure rates + +### Alerting + +- Condition: Release workflow failed on default branch + - Severity: High + - Notification Target: Maintainers team channel +- Condition: Release publication failed post-merge + - Severity: Critical + - Notification Target: Repository admins +- Condition: Repeated non-releasable commit parsing + - Severity: Medium + - Notification Target: Contributors and maintainers + +## Integration Points + +### External Systems + +- Source control API + - Integration Type: Read/write automation + - Data Exchange: Commits, pull requests, tags, releases + - SLA Requirements: API availability during release window +- Package registry process + - Integration Type: Downstream consumption + - Data Exchange: Version tag and changelog metadata + - SLA Requirements: Tag consistency with package version + +### Dependent Workflows + +- CI validation + - Relationship: Required quality signal before merge + - Trigger Mechanism: Pull request checks +- Publish workflow + - Relationship: Consumes release tag for package publication + - Trigger Mechanism: Tag push event + +## Compliance & Governance + +### Audit Requirements + +- **Execution Logs**: Retain workflow logs per repository retention policy +- **Approval Gates**: Enforce branch protection and review requirements on release PR +- **Change Control**: Update specification before behavior changes + +### Security Controls + +- **Access Control**: Branch protection and repository role-based permissions +- **Secret Management**: Platform-managed token issuance and rotation +- **Vulnerability Scanning**: Apply repository dependency and workflow scanning policy + +## Edge Cases & Exceptions + +### Scenario Matrix + +- Scenario: No releasable commits since last tag + - Expected Behavior: Do not create release PR or release tag + - Validation Method: Run log shows no-op decision +- Scenario: Existing open release PR + - Expected Behavior: Update existing PR and do not create duplicate + - Validation Method: One open release PR remains +- Scenario: Multiple workflow triggers in short window + - Expected Behavior: Serialize runs and process latest state deterministically + - Validation Method: Concurrency run history +- Scenario: Manual rerun after partial failure + - Expected Behavior: Reconcile and continue from repository state + - Validation Method: Idempotent rerun validation + +## Validation Criteria + +### Workflow Validation + +- **VLD-001**: Given `feat` commits only, candidate version increments minor. +- **VLD-002**: Given `fix` commits only, candidate version increments patch. +- **VLD-003**: Given breaking indicators, candidate version increments major. +- **VLD-004**: Release PR includes synchronized updates for version file and changelog. +- **VLD-005**: Merged release PR results in exactly one new tag and one release entry. + +### Performance Benchmarks + +- **PERF-001**: 95th percentile orchestration runtime ≤ 5 minutes. +- **PERF-002**: 95th percentile PR synchronization time ≤ 2 minutes. + +## Change Management + +### Update Process + +1. **Specification Update**: Modify this document first +2. **Review & Approval**: Maintainer review via pull request +3. **Implementation**: Update workflow and release configuration +4. **Testing**: Validate with manual dispatch and sample conventional commits +5. **Deployment**: Merge to default branch and monitor first release cycle + +### Version History + +- Version: 1.0 + - Date: 2026-02-22 + - Changes: Initial release workflow specification + - Author: DevOps Team + +## Related Specifications + +- Publish workflow for package registry release on version tags +- CI workflow for code quality and test gating diff --git a/test/assistants/convert_to_stateful_component_test.dart b/test/assistants/convert_to_stateful_component_test.dart new file mode 100644 index 0000000..6f936c0 --- /dev/null +++ b/test/assistants/convert_to_stateful_component_test.dart @@ -0,0 +1,250 @@ +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. +// See LICENSE file for details. + +// +// ignore_for_file: non_constant_identifier_names + +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/src/correction/dart_change_workspace.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:nocterm_lints/assistants/convert_to_stateful_component.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/assist_test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(ConvertToStatefulComponentTest); + }); +} + +/// Stub source for the `nocterm` framework. Only declares the minimal surface +/// needed for [ConvertToStatefulComponent] to resolve type checks +/// (`isExactlyStatelessComponentType`, `isExactState`, etc.). +const _frameworkStub = ''' +abstract class Component { + const Component({Object? key}); +} + +abstract class BuildContext {} + +abstract class StatelessComponent extends Component { + const StatelessComponent({Object? key}) : super(key: key); + Component build(BuildContext context); +} + +abstract class StatefulComponent extends Component { + const StatefulComponent({Object? key}) : super(key: key); + State createState(); +} + +abstract class State { + T get component; + Component build(BuildContext context); + void setState(void Function() fn) {} + void initState() {} + void dispose() {} +} +'''; + +@reflectiveTest +class ConvertToStatefulComponentTest extends AssistTestBase { + @override + void setUp() { + // Register the nocterm stub before calling super.setUp() so the package + // config is written with it included. + registerPackage('nocterm') + ..addFile('lib/nocterm.dart', "export 'src/framework/framework.dart';") + ..addFile('lib/src/framework/framework.dart', _frameworkStub); + super.setUp(); + } + + // --------------------------------------------------------------------------- + // Success cases – assist should be produced + // --------------------------------------------------------------------------- + + Future test_simpleStatelessComponent_producesAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatelessComponent { + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class MyComponent', + ); + + expect(change, isNotNull, reason: 'Assist should produce a change'); + final result = _applyEdits(code, change!); + expect(result, contains('extends StatefulComponent')); + expect(result, isNot(contains('extends StatelessComponent'))); + expect(result, contains('createState()')); + expect(result, contains('_MyComponentState')); + expect(result, contains('extends State')); + expect(result, contains('Component build(BuildContext context)')); + } + + Future test_statelessWithField_movesFieldToState() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class Counter extends StatelessComponent { + final String label = 'hello'; + + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class Counter', + ); + + expect(change, isNotNull, reason: 'Assist should produce a change'); + final result = _applyEdits(code, change!); + expect(result, contains('extends StatefulComponent')); + expect(result, contains('createState()')); + expect(result, contains('_CounterState')); + expect(result, contains("final String label = 'hello'")); + expect(result, isNot(contains('extends StatelessComponent'))); + } + + // --------------------------------------------------------------------------- + // Failure cases – assist should NOT be produced + // --------------------------------------------------------------------------- + + Future test_alreadyStatefulComponent_noAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatefulComponent { + @override + State createState() => _MyComponentState(); +} + +class _MyComponentState extends State { + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class MyComponent', + ); + + expect(change, isNull, reason: 'Already stateful – no assist expected'); + } + + Future test_noBuildMethod_noAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatelessComponent {} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class MyComponent', + ); + + expect(change, isNull, reason: 'No build method – no assist expected'); + } + + Future test_selectionOutsideClassHeader_noAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatelessComponent { + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + // Place cursor inside the build method body – outside the class header + // trigger zone (class keyword … opening brace). + final change = await _computeSourceChange( + code, + offsetToken: 'return Component', + ); + + expect( + change, + isNull, + reason: 'Cursor is outside class header – no assist', + ); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// Runs [ConvertToStatefulComponent.compute] at [offsetToken] in [code] and + /// returns the resulting [SourceChange], or `null` if the assist produced no + /// edits. + Future _computeSourceChange( + String code, { + required String offsetToken, + String fileName = 'test.dart', + }) async { + final filePath = convertPath('$testPackageLib/$fileName'); + newFile(filePath, code); + + final unitResult = await resolveFile(filePath); + final session = unitResult.session; + final libraryResult = + await session.getResolvedLibrary(filePath) as ResolvedLibraryResult; + + final selectionOffset = code.indexOf(offsetToken); + expect( + selectionOffset, + isNot(-1), + reason: 'offsetToken "$offsetToken" not found in code', + ); + + final context = CorrectionProducerContext.createResolved( + libraryResult: libraryResult, + unitResult: unitResult, + selectionOffset: selectionOffset, + ); + + final workspace = DartChangeWorkspace([session]); + final builder = ChangeBuilder( + workspace: workspace, + defaultEol: '\n', + ); + + final producer = ConvertToStatefulComponent(context: context); + await producer.compute(builder); + + final change = builder.sourceChange; + return change.edits.isEmpty ? null : change; + } + + /// Applies all [SourceFileEdit]s from [change] to [originalCode] and returns + /// the transformed source string. + /// + /// Edits are applied from highest to lowest offset so that earlier offsets + /// are not invalidated by later insertions/replacements. + String _applyEdits(String originalCode, SourceChange change) { + var result = originalCode; + for (final fileEdit in change.edits) { + final sortedEdits = [...fileEdit.edits] + ..sort((a, b) => b.offset.compareTo(a.offset)); + result = SourceEdit.applySequence(result, sortedEdits); + } + return result; + } +} diff --git a/test/assistants/convert_to_stateless_component_test.dart b/test/assistants/convert_to_stateless_component_test.dart new file mode 100644 index 0000000..3b1783c --- /dev/null +++ b/test/assistants/convert_to_stateless_component_test.dart @@ -0,0 +1,320 @@ +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. +// See LICENSE file for details. + +// +// ignore_for_file: non_constant_identifier_names + +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/src/correction/dart_change_workspace.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:nocterm_lints/assistants/convert_to_stateless_component.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/assist_test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(ConvertToStatelessComponentTest); + }); +} + +/// Stub source for the `nocterm` framework. Only declares the minimal surface +/// needed for [ConvertToStatelessComponent] to resolve type checks +/// (`isExactlyStatefulComponentType`, `isExactState`, etc.). +const _frameworkStub = ''' +abstract class Component { + const Component({Object? key}); +} + +abstract class BuildContext {} + +abstract class StatelessComponent extends Component { + const StatelessComponent({Object? key}) : super(key: key); + Component build(BuildContext context); +} + +abstract class StatefulComponent extends Component { + const StatefulComponent({Object? key}) : super(key: key); + State createState(); +} + +abstract class State { + T get component; + Component build(BuildContext context); + void setState(void Function() fn) {} + void initState() {} + void dispose() {} +} +'''; + +@reflectiveTest +class ConvertToStatelessComponentTest extends AssistTestBase { + @override + void setUp() { + // Register the nocterm stub before calling super.setUp() so the package + // config is written with it included. + registerPackage('nocterm') + ..addFile('lib/nocterm.dart', "export 'src/framework/framework.dart';") + ..addFile('lib/src/framework/framework.dart', _frameworkStub); + super.setUp(); + } + + // --------------------------------------------------------------------------- + // Success cases – assist should be produced + // --------------------------------------------------------------------------- + + Future test_simpleStatefulComponent_producesAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatefulComponent { + @override + State createState() => _MyComponentState(); +} + +class _MyComponentState extends State { + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class MyComponent', + ); + + expect(change, isNotNull, reason: 'Assist should produce a change'); + final edits = change!.edits; + expect(edits, isNotEmpty); + + // Applying all edits should produce code that extends StatelessComponent + // and no longer references _MyComponentState. + final result = _applyEdits(code, change); + expect(result, contains('extends StatelessComponent')); + expect(result, isNot(contains('StatefulComponent'))); + expect(result, isNot(contains('_MyComponentState'))); + expect(result, isNot(contains('createState'))); + expect(result, contains('Component build(BuildContext context)')); + } + + Future test_statefulWithFields_movesFieldsToStateless() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class Counter extends StatefulComponent { + @override + State createState() => _CounterState(); +} + +class _CounterState extends State { + final String label = 'hello'; + + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class Counter', + ); + + expect(change, isNotNull, reason: 'Assist should produce a change'); + final result = _applyEdits(code, change!); + expect(result, contains('extends StatelessComponent')); + expect(result, contains("final String label = 'hello'")); + expect(result, isNot(contains('_CounterState'))); + } + + Future test_selectionInsideComponentBody_producesAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatefulComponent { + @override + State createState() => _MyComponentState(); +} + +class _MyComponentState extends State { + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'State createState', + ); + + expect( + change, + isNotNull, + reason: 'Cursor inside component body should still offer the assist', + ); + } + + // --------------------------------------------------------------------------- + // Failure cases – assist should NOT be produced + // --------------------------------------------------------------------------- + + Future test_alreadyStatelessComponent_noAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatelessComponent { + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class MyComponent', + ); + + expect(change, isNull, reason: 'Already stateless – no assist expected'); + } + + Future test_stateCallsSetState_noAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatefulComponent { + @override + State createState() => _MyComponentState(); +} + +class _MyComponentState extends State { + @override + Component build(BuildContext context) { + setState(() {}); + return Component(); + } +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class MyComponent', + ); + + expect( + change, + isNull, + reason: 'State uses setState – cannot convert to stateless', + ); + } + + Future test_noStateClass_noAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatefulComponent { + @override + State createState() => _MyComponentState(); +} +'''; + final change = await _computeSourceChange( + code, + offsetToken: 'class MyComponent', + ); + + expect(change, isNull, reason: 'State class missing – no assist expected'); + } + + Future test_selectionOutsideClassKeyword_noAssist() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class MyComponent extends StatefulComponent { + @override + State createState() => _MyComponentState(); +} + +class _MyComponentState extends State { + @override + Component build(BuildContext context) { + return Component(); + } +} +'''; + // Place cursor inside the state class body — outside the component trigger + // zone (class keyword ... opening brace). + final change = await _computeSourceChange( + code, + offsetToken: '_MyComponentState extends', + ); + + expect( + change, + isNull, + reason: 'Cursor is outside component class header – no assist', + ); + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// Runs [ConvertToStatelessComponent.compute] at [offsetToken] in [code] and + /// returns the resulting [SourceChange], or `null` if the assist produced no + /// edits. + Future _computeSourceChange( + String code, { + required String offsetToken, + String fileName = 'test.dart', + }) async { + final filePath = convertPath('$testPackageLib/$fileName'); + newFile(filePath, code); + + final unitResult = await resolveFile(filePath); + final session = unitResult.session; + final libraryResult = + await session.getResolvedLibrary(filePath) as ResolvedLibraryResult; + + final selectionOffset = code.indexOf(offsetToken); + expect( + selectionOffset, + isNot(-1), + reason: 'offsetToken "$offsetToken" not found in code', + ); + + final context = CorrectionProducerContext.createResolved( + libraryResult: libraryResult, + unitResult: unitResult, + selectionOffset: selectionOffset, + ); + + final workspace = DartChangeWorkspace([session]); + final builder = ChangeBuilder( + workspace: workspace, + defaultEol: '\n', + ); + + final producer = ConvertToStatelessComponent(context: context); + await producer.compute(builder); + + final change = builder.sourceChange; + return change.edits.isEmpty ? null : change; + } + + /// Applies all [SourceFileEdit]s from [change] to [originalCode] and returns + /// the transformed source string. + String _applyEdits(String originalCode, SourceChange change) { + var result = originalCode; + for (final fileEdit in change.edits) { + result = SourceEdit.applySequence( + result, + fileEdit.edits.reversed.toList(), + ); + } + return result; + } +} diff --git a/test/assistants/move_assistants_test.dart b/test/assistants/move_assistants_test.dart new file mode 100644 index 0000000..b6e8156 --- /dev/null +++ b/test/assistants/move_assistants_test.dart @@ -0,0 +1,221 @@ +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. +// See LICENSE file for details. + +// This file is a part of nocterm_lints. +// ignore_for_file: non_constant_identifier_names + +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/src/correction/dart_change_workspace.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:nocterm_lints/assistants/move_down.dart'; +import 'package:nocterm_lints/assistants/move_up.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/assist_test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(MoveAssistantsTest); + }); +} + +const _frameworkStub = ''' +abstract class Component { + const Component({this.child, this.children}); + final Component? child; + final List? children; +} + +abstract class BuildContext {} + +abstract class StatelessComponent extends Component { + const StatelessComponent({super.child, super.children}); + Component build(BuildContext context); +} +'''; + +const _basicStub = ''' +import '../framework/framework.dart'; + +class Text extends Component { + const Text(String value); +} + +class Column extends Component { + const Column({required super.children}); +} +'''; + +@reflectiveTest +class MoveAssistantsTest extends AssistTestBase { + @override + void setUp() { + registerPackage('nocterm') + ..addFile( + 'lib/nocterm.dart', + """ +export 'src/framework/framework.dart'; +export 'src/components/basic.dart'; +s""", + ) + ..addFile('lib/src/framework/framework.dart', _frameworkStub) + ..addFile('lib/src/components/basic.dart', _basicStub); + super.setUp(); + } + + Future test_moveUp_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column(children: [ + Text('a'), + Text('b'), + Text('c'), + ]); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => MoveUp(context: context), + offsetToken: "Text('b')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result.indexOf("Text('b')") < result.indexOf("Text('a')"), isTrue); + } + + Future test_moveUp_negative_firstItem() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column(children: [ + Text('a'), + Text('b'), + ]); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => MoveUp(context: context), + offsetToken: "Text('a')", + ); + + expect(change, isNull); + } + + Future test_moveDown_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column(children: [ + Text('a'), + Text('b'), + Text('c'), + ]); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => MoveDown(context: context), + offsetToken: "Text('a')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result.indexOf("Text('b')") < result.indexOf("Text('a')"), isTrue); + } + + Future test_moveDown_negative_lastItem() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column(children: [ + Text('a'), + Text('b'), + ]); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => MoveDown(context: context), + offsetToken: "Text('b')", + ); + + expect(change, isNull); + } + + Future _computeSourceChange( + String code, { + required ResolvedCorrectionProducer Function({ + required CorrectionProducerContext context, + }) + producerFactory, + required String offsetToken, + String fileName = 'test.dart', + }) async { + final filePath = convertPath('$testPackageLib/$fileName'); + newFile(filePath, code); + + final unitResult = await resolveFile(filePath); + final session = unitResult.session; + final libraryResult = + await session.getResolvedLibrary(filePath) as ResolvedLibraryResult; + + final selectionOffset = code.indexOf(offsetToken); + expect(selectionOffset, isNot(-1)); + + final context = CorrectionProducerContext.createResolved( + libraryResult: libraryResult, + unitResult: unitResult, + selectionOffset: selectionOffset, + ); + + final workspace = DartChangeWorkspace([session]); + final builder = ChangeBuilder(workspace: workspace, defaultEol: '\n'); + + final producer = producerFactory(context: context); + await producer.compute(builder); + + final change = builder.sourceChange; + return change.edits.isEmpty ? null : change; + } + + String _applyEdits(String originalCode, SourceChange change) { + var result = originalCode; + for (final fileEdit in change.edits) { + final sortedEdits = [...fileEdit.edits] + ..sort((a, b) => b.offset.compareTo(a.offset)); + result = SourceEdit.applySequence(result, sortedEdits); + } + return result; + } +} diff --git a/test/assistants/remove_assistant_test.dart b/test/assistants/remove_assistant_test.dart new file mode 100644 index 0000000..da0a3e4 --- /dev/null +++ b/test/assistants/remove_assistant_test.dart @@ -0,0 +1,261 @@ +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. +// See LICENSE file for details. + +// This file is a part of nocterm_lints. +// ignore_for_file: non_constant_identifier_names + +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/src/correction/dart_change_workspace.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:nocterm_lints/assistants/remove_widget.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/assist_test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(RemoveAssistantTest); + }); +} + +const _frameworkStub = ''' +abstract class Component { + const Component({this.child, this.children}); + final Component? child; + final List? children; +} + +abstract class BuildContext {} + +abstract class StatelessComponent extends Component { + const StatelessComponent({super.child, super.children}); + Component build(BuildContext context); +} +'''; + +const _basicStub = ''' +import '../framework/framework.dart'; + +class Text extends Component { + const Text(String value); +} + +class Center extends Component { + const Center({required super.child}); +} + +class Column extends Component { + const Column({required super.children}); +} + +class Builder extends Component { + const Builder({required Component Function(BuildContext) builder}); +} +'''; + +@reflectiveTest +class RemoveAssistantTest extends AssistTestBase { + @override + void setUp() { + registerPackage('nocterm') + ..addFile( + 'lib/nocterm.dart', + """ +export 'src/framework/framework.dart'; +export 'src/components/basic.dart'; +""", + ) + ..addFile('lib/src/framework/framework.dart', _frameworkStub) + ..addFile('lib/src/components/basic.dart', _basicStub); + super.setUp(); + } + + Future test_remove_child_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Center(child: Text('x')); + } +} +'''; + + final change = await _computeSourceChange(code, offsetToken: 'Center('); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains("return Text('x');")); + expect(result, isNot(contains('Center('))); + } + + Future test_remove_children_positive_singleParentExpression() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column(children: [Text('x')]); + } +} +'''; + + final change = await _computeSourceChange(code, offsetToken: 'Column('); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains("return Text('x');")); + expect(result, isNot(contains('Column('))); + } + + Future test_remove_children_negative_multipleOutsideList() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column(children: [Text('a'), Text('b')]); + } +} +'''; + + final change = await _computeSourceChange(code, offsetToken: 'Column('); + + expect(change, isNull); + } + + Future test_remove_whenInList_positive_deletesEntry() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +final list = [ + Text('a'), + Center(child: Text('b')), + Text('c'), +]; +'''; + + final change = await _computeSourceChange(code, offsetToken: 'Center('); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, isNot(contains("Center(child: Text('b'))"))); + expect(result, contains("Text('a')")); + expect(result, contains("Text('c')")); + } + + Future test_remove_builder_positive_unusedContext() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Builder(builder: (context) { + return Text('x'); + }); + } +} +'''; + + final change = await _computeSourceChange(code, offsetToken: 'Builder('); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains("return Text('x');")); + expect(result, isNot(contains('Builder('))); + } + + Future test_remove_builder_negative_contextUsed() async { + const code = r''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Builder(builder: (context) { + final ctx = context; + return Text('$ctx'); + }); + } +} +'''; + + final change = await _computeSourceChange(code, offsetToken: 'Builder('); + + expect(change, isNull); + } + + Future test_remove_negative_nonComponentNode() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +void f() { + final n = 1; +} +'''; + + final change = await _computeSourceChange(code, offsetToken: 'n = 1'); + + expect(change, isNull); + } + + Future _computeSourceChange( + String code, { + required String offsetToken, + String fileName = 'test.dart', + }) async { + final filePath = convertPath('$testPackageLib/$fileName'); + newFile(filePath, code); + + final unitResult = await resolveFile(filePath); + final session = unitResult.session; + final libraryResult = + await session.getResolvedLibrary(filePath) as ResolvedLibraryResult; + + final selectionOffset = code.indexOf(offsetToken); + expect(selectionOffset, isNot(-1)); + + final context = CorrectionProducerContext.createResolved( + libraryResult: libraryResult, + unitResult: unitResult, + selectionOffset: selectionOffset, + ); + + final workspace = DartChangeWorkspace([session]); + final builder = ChangeBuilder(workspace: workspace, defaultEol: '\n'); + + final producer = RemoveWidget(context: context); + await producer.compute(builder); + + final change = builder.sourceChange; + return change.edits.isEmpty ? null : change; + } + + String _applyEdits(String originalCode, SourceChange change) { + var result = originalCode; + for (final fileEdit in change.edits) { + final sortedEdits = [...fileEdit.edits] + ..sort((a, b) => b.offset.compareTo(a.offset)); + result = SourceEdit.applySequence(result, sortedEdits); + } + return result; + } +} diff --git a/test/assistants/swap_assistants_test.dart b/test/assistants/swap_assistants_test.dart new file mode 100644 index 0000000..58e12f6 --- /dev/null +++ b/test/assistants/swap_assistants_test.dart @@ -0,0 +1,298 @@ +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. +// See LICENSE file for details. + +/// This file is a part of nocterm_lints. +// ignore_for_file: non_constant_identifier_names + +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/src/correction/dart_change_workspace.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:nocterm_lints/assistants/swap_with_child.dart'; +import 'package:nocterm_lints/assistants/swap_with_parent.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/assist_test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(SwapAssistantsTest); + }); +} + +const _frameworkStub = ''' +abstract class Component { + const Component({this.child, this.children}); + final Component? child; + final List? children; +} + +abstract class BuildContext {} + +abstract class StatelessComponent extends Component { + const StatelessComponent({super.child, super.children}); + Component build(BuildContext context); +} +'''; + +const _basicStub = ''' +import '../framework/framework.dart'; + +class Text extends Component { + const Text(String value); +} + +class Center extends Component { + const Center({required super.child}); +} + +class Container extends Component { + const Container({required super.child}); +} + +class Column extends Component { + const Column({required super.children}); +} + +class Empty extends Component { + const Empty(); +} +'''; + +@reflectiveTest +class SwapAssistantsTest extends AssistTestBase { + @override + void setUp() { + registerPackage('nocterm') + ..addFile( + 'lib/nocterm.dart', + """ +export 'src/framework/framework.dart'; +export 'src/components/basic.dart'; +""", + ) + ..addFile('lib/src/framework/framework.dart', _frameworkStub) + ..addFile('lib/src/components/basic.dart', _basicStub); + super.setUp(); + } + + Future test_swapWithChild_positive_childArgument() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Center( + child: Container( + child: Text('x'), + ), + ); + } +} +'''; + + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => SwapWithChild(context: context), + offsetToken: 'Center(', + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Container(')); + expect(result, contains('child: Center(')); + } + + Future test_swapWithChild_positive_childrenSingle() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column( + children: [ + Center(child: Text('x')), + ], + ); + } +} +'''; + + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => SwapWithChild(context: context), + offsetToken: 'Column(', + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Center(')); + expect(result, contains('child: Column(')); + } + + Future test_swapWithChild_negative_noChild() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Empty(); + } +} +'''; + + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => SwapWithChild(context: context), + offsetToken: 'Empty()', + ); + + expect(change, isNull); + } + + Future test_swapWithParent_positive_childArgument() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Center( + child: Container( + child: Text('x'), + ), + ); + } +} +'''; + + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => SwapWithParent(context: context), + offsetToken: 'Container(', + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Container(')); + expect(result, contains('child: Center(')); + } + + Future test_swapWithParent_positive_childrenSingle() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column( + children: [ + Center(child: Text('x')), + ], + ); + } +} +'''; + + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => SwapWithParent(context: context), + offsetToken: 'Center(', + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Center(')); + expect(result, contains('child: Column(')); + } + + Future test_swapWithParent_negative_childrenMultiple() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Column( + children: [ + Center(child: Text('a')), + Container(child: Text('b')), + ], + ); + } +} +'''; + + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => SwapWithParent(context: context), + offsetToken: 'Center(', + ); + + expect(change, isNull); + } + + Future _computeSourceChange( + String code, { + required ResolvedCorrectionProducer Function({ + required CorrectionProducerContext context, + }) + producerFactory, + required String offsetToken, + String fileName = 'test.dart', + }) async { + final filePath = convertPath('$testPackageLib/$fileName'); + newFile(filePath, code); + + final unitResult = await resolveFile(filePath); + final session = unitResult.session; + final libraryResult = + await session.getResolvedLibrary(filePath) as ResolvedLibraryResult; + + final selectionOffset = code.indexOf(offsetToken); + expect(selectionOffset, isNot(-1)); + + final context = CorrectionProducerContext.createResolved( + libraryResult: libraryResult, + unitResult: unitResult, + selectionOffset: selectionOffset, + ); + + final workspace = DartChangeWorkspace([session]); + final builder = ChangeBuilder(workspace: workspace, defaultEol: '\n'); + + final producer = producerFactory(context: context); + await producer.compute(builder); + + final change = builder.sourceChange; + return change.edits.isEmpty ? null : change; + } + + String _applyEdits(String originalCode, SourceChange change) { + var result = originalCode; + for (final fileEdit in change.edits) { + final sortedEdits = [...fileEdit.edits] + ..sort((a, b) => b.offset.compareTo(a.offset)); + result = SourceEdit.applySequence(result, sortedEdits); + } + return result; + } +} diff --git a/test/assistants/wrap_assistants_test.dart b/test/assistants/wrap_assistants_test.dart new file mode 100644 index 0000000..aade45e --- /dev/null +++ b/test/assistants/wrap_assistants_test.dart @@ -0,0 +1,615 @@ +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. +// See LICENSE file for details. + +// +// ignore_for_file: non_constant_identifier_names + +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart'; +import 'package:analysis_server_plugin/src/correction/dart_change_workspace.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element; +import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; +import 'package:nocterm_lints/assistants/wrap_builder.dart'; +import 'package:nocterm_lints/assistants/wrap_center.dart'; +import 'package:nocterm_lints/assistants/wrap_column.dart'; +import 'package:nocterm_lints/assistants/wrap_component.dart'; +import 'package:nocterm_lints/assistants/wrap_container.dart'; +import 'package:nocterm_lints/assistants/wrap_expanded.dart'; +import 'package:nocterm_lints/assistants/wrap_flexible.dart'; +import 'package:nocterm_lints/assistants/wrap_generic.dart'; +import 'package:nocterm_lints/assistants/wrap_padding.dart'; +import 'package:nocterm_lints/assistants/wrap_row.dart'; +import 'package:nocterm_lints/assistants/wrap_sized_box.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../support/assist_test_support.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(WrapAssistantsTest); + }); +} + +const _frameworkStub = ''' +abstract class Component { + const Component({this.child, this.children}); + final Component? child; + final List? children; +} + +abstract class BuildContext {} + +abstract class StatelessComponent extends Component { + const StatelessComponent({super.child, super.children}); + Component build(BuildContext context); +} + +abstract class StatefulComponent extends Component { + const StatefulComponent({super.child, super.children}); + State createState(); +} + +abstract class State { + T get component; + Component build(BuildContext context); +} +'''; + +const _basicStub = ''' +import '../framework/framework.dart'; + +class Text extends Component { + const Text(String value); +} + +class Flex extends Component { + const Flex({required super.children}); +} + +class Row extends Flex { + const Row({required super.children}); +} + +class Column extends Flex { + const Column({required super.children}); +} + +class Center extends Component { + const Center({required super.child}); +} + +class Builder extends Component { + const Builder({required Component Function(BuildContext) builder}); +} + +class Expanded extends Component { + const Expanded({required super.child}); +} + +class Flexible extends Component { + const Flexible({required super.child}); +} + +class EdgeInsetsGeometry { + const EdgeInsetsGeometry(); +} + +class EdgeInsets extends EdgeInsetsGeometry { + const EdgeInsets.all(num value); +} + +class Padding extends Component { + const Padding({required EdgeInsetsGeometry padding, required super.child}); +} + +class SizedBox extends Component { + const SizedBox({required super.child}); +} + +class ValueListenableBuilder extends Component { + const ValueListenableBuilder({ + required Object valueListenable, + required Component Function(BuildContext, Object?, Component?) builder, + }); +} +'''; + +const _decoratedBoxStub = ''' +import '../framework/framework.dart'; + +class Container extends Component { + const Container({required super.child}); +} +'''; + +@reflectiveTest +class WrapAssistantsTest extends AssistTestBase { + @override + void setUp() { + registerPackage('nocterm') + ..addFile('lib/nocterm.dart', """ +export 'src/framework/framework.dart'; +export 'src/components/basic.dart'; +export 'src/components/decorated_box.dart'; +""") + ..addFile('lib/src/framework/framework.dart', _frameworkStub) + ..addFile('lib/src/components/basic.dart', _basicStub) + ..addFile('lib/src/components/decorated_box.dart', _decoratedBoxStub); + super.setUp(); + } + + Future test_wrapBuilder_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Text('x'); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapBuilder(context: context), + offsetToken: "Text('x')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Builder(')); + expect(result, contains('builder: (context)')); + } + + Future test_wrapBuilder_negative_alreadyBuilder() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Builder(builder: (context) { + return Text('x'); + }); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapBuilder(context: context), + offsetToken: 'Builder(builder:', + ); + + expect(change, isNull); + } + + Future test_wrapValueListenableBuilder_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Text('x'); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => + WrapValueListenableBuilder(context: context), + offsetToken: "Text('x')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('ValueListenableBuilder(')); + expect(result, contains('valueListenable: valueListenable')); + expect(result, contains('builder: (context, value, child)')); + } + + Future test_wrapValueListenableBuilder_negative_nonComponent() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +void f() { + final n = 1; +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => + WrapValueListenableBuilder(context: context), + offsetToken: 'n = 1', + ); + + expect(change, isNull); + } + + Future test_wrapCenter_positive() async { + await _expectSingleWrapPositive( + producerFactory: ({required context}) => WrapCenter(context: context), + wrapperName: 'Center(', + ); + } + + Future test_wrapCenter_negative_alreadyWrapped() async { + await _expectSingleWrapNegativeAlreadyWrapped( + producerFactory: ({required context}) => WrapCenter(context: context), + wrappedExpr: "Center(child: Text('x'))", + offsetToken: 'Center(child:', + ); + } + + Future test_wrapContainer_positive() async { + await _expectSingleWrapPositive( + producerFactory: ({required context}) => WrapContainer(context: context), + wrapperName: 'Container(', + ); + } + + Future test_wrapContainer_negative_alreadyWrapped() async { + await _expectSingleWrapNegativeAlreadyWrapped( + producerFactory: ({required context}) => WrapContainer(context: context), + wrappedExpr: "Container(child: Text('x'))", + offsetToken: 'Container(child:', + ); + } + + Future test_wrapExpanded_positive() async { + await _expectSingleWrapPositive( + producerFactory: ({required context}) => WrapExpanded(context: context), + wrapperName: 'Expanded(', + ); + } + + Future test_wrapExpanded_negative_alreadyWrapped() async { + await _expectSingleWrapNegativeAlreadyWrapped( + producerFactory: ({required context}) => WrapExpanded(context: context), + wrappedExpr: "Expanded(child: Text('x'))", + offsetToken: 'Expanded(child:', + ); + } + + Future test_wrapFlexible_positive() async { + await _expectSingleWrapPositive( + producerFactory: ({required context}) => WrapFlexible(context: context), + wrapperName: 'Flexible(', + ); + } + + Future test_wrapFlexible_negative_alreadyWrapped() async { + await _expectSingleWrapNegativeAlreadyWrapped( + producerFactory: ({required context}) => WrapFlexible(context: context), + wrappedExpr: "Flexible(child: Text('x'))", + offsetToken: 'Flexible(child:', + ); + } + + Future test_wrapPadding_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Text('x'); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapPadding(context: context), + offsetToken: "Text('x')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Padding(')); + expect(result, contains('padding: const EdgeInsets.all(')); + expect(result, contains("child: Text('x')")); + } + + Future test_wrapPadding_negative_alreadyWrapped() async { + await _expectSingleWrapNegativeAlreadyWrapped( + producerFactory: ({required context}) => WrapPadding(context: context), + wrappedExpr: + "Padding(padding: const EdgeInsets.all(8), child: Text('x'))", + offsetToken: 'Padding(padding:', + ); + } + + Future test_wrapSizedBox_positive() async { + await _expectSingleWrapPositive( + producerFactory: ({required context}) => WrapSizedBox(context: context), + wrapperName: 'SizedBox(', + ); + } + + Future test_wrapSizedBox_negative_alreadyWrapped() async { + await _expectSingleWrapNegativeAlreadyWrapped( + producerFactory: ({required context}) => WrapSizedBox(context: context), + wrappedExpr: "SizedBox(child: Text('x'))", + offsetToken: 'SizedBox(child:', + ); + } + + Future test_wrapGeneric_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Text('x'); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapGeneric(context: context), + offsetToken: "Text('x')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('component(')); + expect(result, contains("child: Text('x')")); + } + + Future test_wrapGeneric_negative_nonComponent() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +void f() { + final value = 10; +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapGeneric(context: context), + offsetToken: 'value = 10', + ); + + expect(change, isNull); + } + + Future test_wrapRow_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Text('x'); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapRow(context: context), + offsetToken: "Text('x')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Row(')); + expect(result, contains('children: [')); + } + + Future test_wrapRow_negative_nonComponent() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +void f() { + final value = 10; +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapRow(context: context), + offsetToken: 'value = 10', + ); + + expect(change, isNull); + } + + Future test_wrapColumn_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Text('x'); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapColumn(context: context), + offsetToken: "Text('x')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('Column(')); + expect(result, contains('children: [')); + } + + Future test_wrapColumn_negative_nonComponent() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +void f() { + final value = 10; +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapColumn(context: context), + offsetToken: 'value = 10', + ); + + expect(change, isNull); + } + + Future test_wrapComponent_positive() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +final items = [ + Text('a'), + Text('b'), +]; +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapComponent(context: context), + offsetToken: "[\n Text('a')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains('component(')); + expect(result, contains('children: [')); + } + + Future test_wrapComponent_negative_nonComponentInList() async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +final items = [ + Text('a'), + 1, +]; +'''; + final change = await _computeSourceChange( + code, + producerFactory: ({required context}) => WrapComponent(context: context), + offsetToken: "[\n Text('a')", + ); + + expect(change, isNull); + } + + Future _expectSingleWrapPositive({ + required _ProducerFactory producerFactory, + required String wrapperName, + }) async { + const code = ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return Text('x'); + } +} +'''; + final change = await _computeSourceChange( + code, + producerFactory: producerFactory, + offsetToken: "Text('x')", + ); + + expect(change, isNotNull); + final result = _applyEdits(code, change!); + expect(result, contains(wrapperName)); + expect(result, contains("child: Text('x')")); + } + + Future _expectSingleWrapNegativeAlreadyWrapped({ + required _ProducerFactory producerFactory, + required String wrappedExpr, + required String offsetToken, + }) async { + final code = + ''' +import 'package:nocterm/nocterm.dart'; + +class A extends StatelessComponent { + const A(); + + @override + Component build(BuildContext context) { + return $wrappedExpr; + } +} +'''; + + final change = await _computeSourceChange( + code, + producerFactory: producerFactory, + offsetToken: offsetToken, + ); + + expect(change, isNull); + } + + Future _computeSourceChange( + String code, { + required _ProducerFactory producerFactory, + required String offsetToken, + String fileName = 'test.dart', + }) async { + final filePath = convertPath('$testPackageLib/$fileName'); + newFile(filePath, code); + + final unitResult = await resolveFile(filePath); + final session = unitResult.session; + final libraryResult = + await session.getResolvedLibrary(filePath) as ResolvedLibraryResult; + + final selectionOffset = code.indexOf(offsetToken); + expect(selectionOffset, isNot(-1)); + + final context = CorrectionProducerContext.createResolved( + libraryResult: libraryResult, + unitResult: unitResult, + selectionOffset: selectionOffset, + ); + + final workspace = DartChangeWorkspace([session]); + final builder = ChangeBuilder(workspace: workspace, defaultEol: '\n'); + + final producer = producerFactory(context: context); + await producer.compute(builder); + + final change = builder.sourceChange; + return change.edits.isEmpty ? null : change; + } + + String _applyEdits(String originalCode, SourceChange change) { + var result = originalCode; + for (final fileEdit in change.edits) { + final sortedEdits = [...fileEdit.edits] + ..sort((a, b) => b.offset.compareTo(a.offset)); + result = SourceEdit.applySequence(result, sortedEdits); + } + return result; + } +} + +typedef _ProducerFactory = + ResolvedCorrectionProducer Function({ + required CorrectionProducerContext context, + }); diff --git a/test/convert_to_stateful_component_test.dart b/test/convert_to_stateful_component_test.dart deleted file mode 100644 index fab7b19..0000000 --- a/test/convert_to_stateful_component_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:nocterm_lints/assistants/convert_to_stateful_component.dart'; -import 'package:nocterm_lints/assistants/convert_to_stateless_component.dart'; -import 'package:nocterm_lints/services/correction/assist.dart'; -import 'package:test/test.dart'; - -void main() { - group('ConvertToStatefulComponent', () { - test('assist is defined', () { - expect(ConvertToStatefulWidget, isNotNull); - }); - - test('assist kind is correct', () { - const assistKind = DartAssistKind.noctermConvertToStatefulWidget; - expect(assistKind.id, 'dart.assist.nocterm.convert.toStatefulComponent'); - expect(assistKind.message, 'Convert to StatefulComponent'); - }); - }); - - group('ConvertToStatelessComponent', () { - test('assist is defined', () { - expect(ConvertToStatelessWidget, isNotNull); - }); - - test('assist kind is correct', () { - const assistKind = DartAssistKind.noctermConvertToStatelessComponent; - expect(assistKind.id, 'dart.assist.nocterm.convert.toStatelessComponent'); - expect(assistKind.message, 'Convert to StatelessComponent'); - }); - }); -} diff --git a/test/support/assist_test_support.dart b/test/support/assist_test_support.dart new file mode 100644 index 0000000..c18a571 --- /dev/null +++ b/test/support/assist_test_support.dart @@ -0,0 +1,129 @@ +// Copyright (c) 2026, zoocityboy. +// Use of this source code is governed by a BSD-3-Clause license. +// See LICENSE file for details. + +// Test support utilities for assist tests. Provides a base class with common setup and utilities for testing [ResolvedCorrectionProducer] assists, as well as +// a stub implementation of the `nocterm` framework to allow assists to resolve types without needing the full framework source. +// ignore_for_file: lines_longer_than_80_chars, comment_references, library_private_types_in_public_api, avoid_dynamic_calls + +import 'dart:io' as io; + +import 'package:analysis_server_plugin/edit/dart/correction_producer.dart' + show ResolvedCorrectionProducer; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/file_system/file_system.dart'; +import 'package:analyzer/src/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/src/dart/analysis/byte_store.dart'; +import 'package:analyzer/src/test_utilities/mock_sdk.dart'; +import 'package:analyzer/utilities/package_config_file_builder.dart'; +import 'package:analyzer_testing/resource_provider_mixin.dart'; +import 'package:meta/meta.dart'; +import 'package:nocterm_lints/utilities/logger.dart'; + +/// A minimal test base for testing [ResolvedCorrectionProducer] assists. +/// +/// Deliberately avoids `withFineDependencies: true` (used by +/// [PubPackageResolutionTest]) which conflicts with +/// [ClassDeclarationImpl.name] inside the analyzer's `serializeAstUnlinked2`. +abstract class AssistTestBase with ResourceProviderMixin { + static final MemoryByteStore _sharedByteStore = MemoryByteStore(); + + final Map _extraPackages = {}; + + AnalysisContextCollectionImpl? _contextCollection; + + String get _sdkRootPath => '/sdk'; + String get _workspaceRoot => '/home'; + String get _testPackageRoot => '$_workspaceRoot/test'; + + /// The `lib/` directory of the test package. + String get testPackageLib => '$_testPackageRoot/lib'; + + Folder get _sdkRoot => newFolder(_sdkRootPath); + + // Use the test-package root (not the workspace root) as the included path, + // so the analyzer discovers the package_config.json at + // `$_testPackageRoot/.dart_tool/package_config.json`. + List get _collectionIncludedPaths => [_testPackageRoot]; + + /// Registers a stub package for use in analyzed test files. + /// + /// Returns a [_PackageBuilder] to add individual source files. + _PackageBuilder registerPackage(String name) { + _extraPackages[name] = '/package/$name'; + return _PackageBuilder('/package/$name', this); + } + + @mustCallSuper + void setUp() { + NoctermLogger.createForTesting(io.File('/tmp/nocterm_test.log')) + ..setEnabled(false) + ..setLogLevel(LogLevel.error); + + createMockSdk(resourceProvider: resourceProvider, root: _sdkRoot); + _writePackageConfig(); + } + + @mustCallSuper + Future tearDown() async { + await _contextCollection?.dispose(); + _contextCollection = null; + } + + /// Resolves the Dart file at [posixPath] and returns the + /// [ResolvedUnitResult]. + Future resolveFile(String posixPath) async { + final path = convertPath(posixPath); + final ctx = _contextFor(path); + await ctx.applyPendingFileChanges(); + return await ctx.currentSession.getResolvedUnit(path) as ResolvedUnitResult; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + void _writePackageConfig() { + // Write a minimal pubspec.yaml so that the analyzer discovers this + // directory as a Dart project root. + newFile('$_testPackageRoot/pubspec.yaml', 'name: test\n'); + + final config = PackageConfigFileBuilder() + ..add(name: 'test', rootPath: _testPackageRoot); + + for (final entry in _extraPackages.entries) { + config.add(name: entry.key, rootPath: entry.value); + } + + final configPath = '$_testPackageRoot/.dart_tool/package_config.json'; + newFile(configPath, config.toContent(pathContext: pathContext)); + } + + AnalysisContextCollectionImpl _createContextCollection() { + return AnalysisContextCollectionImpl( + byteStore: _sharedByteStore, + declaredVariables: {}, + enableIndex: true, + includedPaths: _collectionIncludedPaths.map(convertPath).toList(), + resourceProvider: resourceProvider, + sdkPath: _sdkRoot.path, + // Intentionally omitting `withFineDependencies: true` — that flag + // is not needed for assist correction producer tests. + ); + } + + dynamic _contextFor(String path) { + _contextCollection ??= _createContextCollection(); + return _contextCollection!.contextFor(path); + } +} + +class _PackageBuilder { + _PackageBuilder(this._packagePath, this._base); + final String _packagePath; + final AssistTestBase _base; + + void addFile(String localPath, String content) { + _base.newFile('$_packagePath/$localPath', content); + } +}