├── packages ├── solid_annotations │ ├── LICENSE │ ├── example │ ├── README.md │ ├── CHANGELOG.md │ ├── .gitignore │ ├── pubspec.yaml │ └── lib │ │ ├── solid_annotations.dart │ │ ├── provider.dart │ │ └── extensions.dart └── solid_generator │ ├── LICENSE │ ├── example │ ├── README.md │ ├── .gitignore │ ├── lib │ ├── builder.dart │ ├── solid_generator.dart │ └── src │ │ ├── result.dart │ │ ├── transformation_error.dart │ │ ├── ast_models.dart │ │ ├── field_analyzer.dart │ │ ├── annotation_parser.dart │ │ ├── code_generator.dart │ │ └── reactive_state_transformer.dart │ ├── CHANGELOG.md │ ├── pubspec.yaml │ ├── analysis_options.yaml │ ├── test │ ├── ast_dependency_test.dart │ ├── transpiler_logic_test.dart │ ├── reactive_state_transformer_test.dart │ └── solid_builder_test.dart │ └── bin │ └── solid_generator.dart ├── docs ├── public │ ├── ale.png │ └── favicon.svg ├── src │ ├── assets │ │ ├── houston.webp │ │ └── solid_demo.gif │ ├── content.config.ts │ ├── content │ │ └── docs │ │ │ ├── guides │ │ │ ├── effect.mdx │ │ │ ├── environment.mdx │ │ │ ├── state.mdx │ │ │ ├── getting-started.mdx │ │ │ └── query.mdx │ │ │ ├── author.mdx │ │ │ ├── index.md │ │ │ └── faq.mdx │ └── components │ │ └── AuthorCard.astro ├── .vscode │ ├── extensions.json │ └── launch.json ├── tsconfig.json ├── .gitignore ├── package.json ├── astro.config.mjs └── README.md ├── analysis_options.yaml ├── example ├── analysis_options.yaml ├── README.md ├── pubspec.yaml ├── .gitignore ├── .metadata ├── source │ └── main.dart └── lib │ └── main.dart ├── pubspec.yaml ├── .vscode └── launch.json ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE └── README.md /packages/solid_annotations/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /packages/solid_annotations/example: -------------------------------------------------------------------------------- 1 | ../../example -------------------------------------------------------------------------------- /packages/solid_generator/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /packages/solid_generator/example: -------------------------------------------------------------------------------- 1 | ../../example -------------------------------------------------------------------------------- /packages/solid_annotations/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /packages/solid_generator/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /packages/solid_annotations/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | 3 | - Initial version. 4 | -------------------------------------------------------------------------------- /docs/public/ale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nank1ro/solid/HEAD/docs/public/ale.png -------------------------------------------------------------------------------- /docs/src/assets/houston.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nank1ro/solid/HEAD/docs/src/assets/houston.webp -------------------------------------------------------------------------------- /docs/src/assets/solid_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nank1ro/solid/HEAD/docs/src/assets/solid_demo.gif -------------------------------------------------------------------------------- /docs/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /packages/solid_generator/.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | -------------------------------------------------------------------------------- /packages/solid_annotations/.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.yaml 2 | analyzer: 3 | errors: 4 | always_put_required_named_parameters_first: ignore 5 | package_names: ignore 6 | -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.yaml 2 | analyzer: 3 | errors: 4 | must_be_immutable: ignore 5 | linter: 6 | rules: 7 | public_member_api_docs: false 8 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: _ 2 | publish_to: none 3 | environment: 4 | sdk: ^3.9.2 5 | workspace: 6 | - packages/solid_generator 7 | - packages/solid_annotations 8 | - example 9 | 10 | dev_dependencies: 11 | very_good_analysis: ^10.0.0 12 | -------------------------------------------------------------------------------- /docs/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsLoader } from '@astrojs/starlight/loaders'; 3 | import { docsSchema } from '@astrojs/starlight/schema'; 4 | 5 | export const collections = { 6 | docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), 7 | }; 8 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:build/build.dart'; 2 | 3 | import 'src/solid_builder.dart'; 4 | 5 | /// Factory function for creating the SolidBuilder. 6 | /// This is called by build_runner when processing files. 7 | Builder solidBuilder(BuilderOptions options) { 8 | print('DEBUG: solidBuilder factory called!'); 9 | return SolidBuilder(); 10 | } 11 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/starlight": "^0.36.1", 14 | "astro": "^5.6.1", 15 | "sharp": "^0.34.2" 16 | } 17 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "example", 9 | "cwd": "example", 10 | "request": "launch", 11 | "type": "dart" 12 | }, 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/solid_generator/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.3 2 | 3 | - **FIX**: Missing `flutter_solidart` import in generated `main.dart` file, if no reactive annotations are used. 4 | 5 | ## 1.0.2 6 | 7 | - **FIX**: Generator not transpiling code correctly in some cases. 8 | 9 | ## 1.0.1 10 | 11 | - **FIX**: Remove Flutter SDK. 12 | 13 | ## 1.0.0+2 14 | 15 | - **CHORE**: Add `flutter` sdk to resolve score on pub.dev. 16 | 17 | ## 1.0.0 18 | 19 | - Initial version. 20 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # example 2 | 3 | A new Flutter project. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) 13 | 14 | For help getting started with Flutter development, view the 15 | [online documentation](https://docs.flutter.dev/), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | description: "A new Flutter project." 3 | publish_to: 'none' 4 | version: 1.0.0+1 5 | 6 | environment: 7 | sdk: ^3.9.2 8 | 9 | resolution: workspace 10 | 11 | dependencies: 12 | dart_mappable: ^4.6.1 13 | flutter: 14 | sdk: flutter 15 | flutter_solidart: ^2.7.0 16 | solid_annotations: ^1.0.0 17 | 18 | dev_dependencies: 19 | build_runner: ^2.10.0 20 | dart_mappable_builder: ^4.6.1 21 | flutter_test: 22 | sdk: flutter 23 | very_good_analysis: ^10.0.0 24 | 25 | dependency_overrides: 26 | test_api: ^0.7.6 27 | 28 | flutter: 29 | uses-material-design: true 30 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/solid_generator.dart: -------------------------------------------------------------------------------- 1 | /// Solid Generator - A functional code generator for reactive Flutter applications. 2 | /// 3 | /// This library transforms reactive annotations into flutter_solidart code using 4 | /// pure functional programming principles as specified in GENERATOR_PLAN.md. 5 | library; 6 | 7 | export 'src/result.dart'; 8 | export 'src/transformation_error.dart'; 9 | export 'src/ast_models.dart'; 10 | export 'src/annotation_parser.dart'; 11 | export 'src/field_analyzer.dart'; 12 | export 'src/code_generator.dart'; 13 | export 'src/reactive_state_transformer.dart'; 14 | export 'builder.dart'; 15 | -------------------------------------------------------------------------------- /packages/solid_annotations/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: solid_annotations 2 | description: Annotations for the solid transpiler to enable fine-grained reactivity in Flutter applications. 3 | version: 1.0.0 4 | homepage: https://solid.mariuti.com 5 | repository: https://github.com/nank1ro/solid 6 | documentation: https://solid.mariuti.com 7 | topics: 8 | - framework 9 | - zero-boilerplate 10 | - fine-grained-reactivity 11 | - swiftui 12 | - solidjs 13 | 14 | environment: 15 | sdk: ^3.9.2 16 | 17 | resolution: workspace 18 | 19 | dependencies: 20 | flutter: 21 | sdk: flutter 22 | meta: ^1.16.0 23 | 24 | dev_dependencies: 25 | very_good_analysis: ^10.0.0 26 | -------------------------------------------------------------------------------- /docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: nank1ro 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: nank1ro 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /docs/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig } from 'astro/config'; 3 | import starlight from '@astrojs/starlight'; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | integrations: [ 8 | starlight({ 9 | title: 'Flutter Solid Framework', 10 | social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/nank1ro/solid' }], 11 | sidebar: [ 12 | { 13 | label: '', 14 | link: 'https://pub.dev/packages/solid_generator', 15 | badge: { text: 'pub.dev', variant: 'tip' }, 16 | attrs: { target: '_blank', rel: 'noopener noreferrer' }, 17 | }, 18 | { 19 | label: 'Guides', 20 | autogenerate: { directory: 'guides' }, 21 | }, 22 | { label: 'FAQ', link: 'faq' }, 23 | { label: 'Author', link: 'author' }, 24 | ], 25 | }), 26 | ], 27 | }); 28 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .build/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | .swiftpm/ 13 | migrate_working_dir/ 14 | 15 | # IntelliJ related 16 | *.iml 17 | *.ipr 18 | *.iws 19 | .idea/ 20 | 21 | # The .vscode folder contains launch configuration and tasks you configure in 22 | # VS Code which you may wish to be included in version control, so this line 23 | # is commented out by default. 24 | #.vscode/ 25 | 26 | # Flutter/Dart/Pub related 27 | **/doc/api/ 28 | **/ios/Flutter/.last_build_id 29 | .dart_tool/ 30 | .flutter-plugins-dependencies 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | /coverage/ 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | 42 | # Android Studio will place build artifacts here 43 | /android/app/debug 44 | /android/app/profile 45 | /android/app/release 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. 26 | pubspec.lock 27 | **/doc/api/ 28 | .dart_tool/ 29 | .packages 30 | build/ 31 | 32 | # Ignore all the platform apps 33 | ios 34 | macos 35 | web 36 | android 37 | linux 38 | windows 39 | 40 | coverage/ 41 | lcov.info 42 | .fvm 43 | .vscode/settings.json 44 | .flutter-plugins-dependencies 45 | .flutter-plugins 46 | 47 | 48 | .firebase 49 | .fvmrc 50 | .supermaven 51 | -------------------------------------------------------------------------------- /docs/src/content/docs/guides/effect.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Effect 3 | description: Learn to write reactive effects in Solid. 4 | sidebar: 5 | order: 3 6 | --- 7 | import { Aside } from '@astrojs/starlight/components'; 8 | 9 | The `@SolidEffect()` annotation will trigger a side effect whenever the reactive state variables it depends on change. 10 | 11 | ## Usage 12 | 13 | ```dart {7-10} title="source/effect_example.dart" 14 | class EffectExample extends StatelessWidget { 15 | EffectExample({super.key}); 16 | 17 | @SolidState() 18 | int counter = 0; 19 | 20 | @SolidEffect() 21 | void logCounter() { 22 | print('Counter changed: $counter'); 23 | } 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return Scaffold( 28 | appBar: AppBar(title: const Text('Effect')), 29 | body: Center(child: Text('Counter: $counter')), 30 | floatingActionButton: FloatingActionButton( 31 | onPressed: () => counter++, 32 | child: const Icon(Icons.add), 33 | ), 34 | ); 35 | } 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /packages/solid_generator/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: solid_generator 2 | description: The Solid transpiler to generate fine-grained reactivity in Flutter applications. 3 | version: 1.0.3 4 | homepage: https://solid.mariuti.com 5 | repository: https://github.com/nank1ro/solid 6 | documentation: https://solid.mariuti.com 7 | topics: 8 | - framework 9 | - zero-boilerplate 10 | - fine-grained-reactivity 11 | - swiftui 12 | - solidjs 13 | 14 | environment: 15 | sdk: ^3.9.2 16 | 17 | resolution: workspace 18 | 19 | dependencies: 20 | analyzer: ^8.0.0 21 | args: ^2.7.0 22 | build: ^4.0.2 23 | build_runner: ^2.10.1 24 | crypto: ^3.0.6 25 | path: ^1.9.1 26 | source_gen: ^4.0.2 27 | 28 | dev_dependencies: 29 | build_test: ^3.5.1 30 | lints: ^6.0.0 31 | test: ^1.25.6 32 | 33 | executables: 34 | solid: solid_generator 35 | 36 | builders: 37 | solid_builder: 38 | import: "package:solid_generator/builder.dart" 39 | builder_factories: ["solidBuilder"] 40 | build_extensions: {".dart": [".solid.dart"]} 41 | auto_apply: dependents 42 | build_to: source 43 | -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "9f455d2486bcb28cad87b062475f42edc959f636" 8 | channel: "stable" 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 17 | base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 18 | - platform: macos 19 | create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 20 | base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 21 | 22 | # User provided section 23 | 24 | # List of Local paths (relative to this file) that should be 25 | # ignored by the migrate tool. 26 | # 27 | # Files that are not part of the templates will be ignored by default. 28 | unmanaged_files: 29 | - 'lib/main.dart' 30 | - 'ios/Runner.xcodeproj/project.pbxproj' 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alexandru Florian Mariuti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/solid_generator/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | 16 | # Uncomment the following section to specify additional rules. 17 | 18 | # linter: 19 | # rules: 20 | # - camel_case_types 21 | 22 | # analyzer: 23 | # exclude: 24 | # - path/to/excluded/files/** 25 | 26 | # For more information about the core and recommended set of lints, see 27 | # https://dart.dev/go/core-lints 28 | 29 | # For additional information about configuring this file, see 30 | # https://dart.dev/guides/language/analysis-options 31 | -------------------------------------------------------------------------------- /docs/src/components/AuthorCard.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { imageSrc, name, twitterUrl, githubUrl } = Astro.props; 3 | import { Icon } from '@astrojs/starlight/components'; 4 | --- 5 | 6 |
7 | {`Image 8 |
9 |

{name}

10 | 22 |
23 |
24 | 25 | 60 | -------------------------------------------------------------------------------- /docs/src/content/docs/author.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Author 3 | description: The author of Solid 4 | --- 5 | import { Card, CardGrid } from '@astrojs/starlight/components'; 6 | import AuthorCard from '../../components/AuthorCard.astro'; 7 | 8 | 9 | 15 | 16 | 17 | ## Alexandru Mariuti 18 | 19 | I am an open source enthusiast, passionate about Dart and Flutter, and a strong believer in the power of community. 20 | 21 | Maintainer of: 22 | - [solidart](https://pub.dev/packages/solidart) and [flutter_solidart](https://pub.dev/packages/flutter_solidart) (Dart/Flutter state management library inspired by [SolidJS](https://www.solidjs.com/)) 23 | - [shadcn_ui (flutter port)](https://pub.dev/packages/shadcn_ui) (Design system inspired by [ui.shadcn.com](https://ui.shadcn.com/)) 24 | - [solid](https://pub.dev/packages/solid_generator) (Flutter framework, zero boilerplate, fine-grained reactivity) 25 | - [awesome_flutter_extensions](https://pub.dev/packages/awesome_flutter_extensions) (Useful Flutter extensions) 26 | - [disable_web_context_menu](https://pub.dev/packages/disable_web_context_menu) (Disable native web context menu) 27 | - [disco](https://pub.dev/packages/disco) (Dependency injection library for Flutter) 28 | - [prompt_parser](https://pypi.org/project/prompt-parser/) (Python library for parsing LLM prompts) 29 | 30 | ## Sponsorship 31 | 32 | If you find Solid useful and would like to support its development, consider sponsoring the project on [GitHub Sponsors](https://github.com/sponsors/nank1ro/). 33 | I love building open-source software and your support helps me dedicate more time to improving Solid and adding new features. 34 | 35 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 10 | 11 | ## 🚀 Project Structure 12 | 13 | Inside of your Astro + Starlight project, you'll see the following folders and files: 14 | 15 | ``` 16 | . 17 | ├── public/ 18 | ├── src/ 19 | │ ├── assets/ 20 | │ ├── content/ 21 | │ │ └── docs/ 22 | │ └── content.config.ts 23 | ├── astro.config.mjs 24 | ├── package.json 25 | └── tsconfig.json 26 | ``` 27 | 28 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 29 | 30 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 31 | 32 | Static assets, like favicons, can be placed in the `public/` directory. 33 | 34 | ## 🧞 Commands 35 | 36 | All commands are run from the root of the project, from a terminal: 37 | 38 | | Command | Action | 39 | | :------------------------ | :----------------------------------------------- | 40 | | `npm install` | Installs dependencies | 41 | | `npm run dev` | Starts local dev server at `localhost:4321` | 42 | | `npm run build` | Build your production site to `./dist/` | 43 | | `npm run preview` | Preview your build locally, before deploying | 44 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 45 | | `npm run astro -- --help` | Get help using the Astro CLI | 46 | 47 | ## 👀 Want to learn more? 48 | 49 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 50 | -------------------------------------------------------------------------------- /docs/src/content/docs/guides/environment.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Environment 3 | description: Learn to inject environment providers in Solid. 4 | sidebar: 5 | order: 4 6 | --- 7 | import { Aside } from '@astrojs/starlight/components'; 8 | 9 | The `@SolidEnvironment()` annotation will inject a provider from the widget tree into your Solid widget. It's pure dependency injection managed by Solid. 10 | 11 | ## Usage 12 | 13 | To work, you've to provide an instance of the class you want to allow to be injected using the `SolidProvider` widget. 14 | 15 | ```dart {6-9} title="source/environment_example.dart" 16 | class EnvironmentExample extends StatelessWidget { 17 | const EnvironmentExample({super.key}); 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return SolidProvider( 22 | create: (context) => Counter(), 23 | child: EnvironmentInjectionExample(), 24 | ); 25 | } 26 | } 27 | ``` 28 | 29 | Alternatively, you can use the `environment` extension (like SwiftUI) to provide the instance: 30 | 31 | ```dart {6} title="source/environment_example.dart" 32 | class EnvironmentExample extends StatelessWidget { 33 | const EnvironmentExample({super.key}); 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return EnvironmentInjectionExample.environment((context) => Counter()); 38 | } 39 | } 40 | ``` 41 | 42 | Then you can inject the instance (from a descendant) in your widget using the `@Environment()` annotation: 43 | 44 | ```dart {4-5} title="source/environment_example.dart" 45 | class EnvironmentInjectionExample extends StatelessWidget { 46 | EnvironmentInjectionExample({super.key}); 47 | 48 | @SolidEnvironment() 49 | late Counter counter; 50 | 51 | @override 52 | Widget build(BuildContext context) { 53 | return Scaffold( 54 | appBar: AppBar(title: const Text('Environment')), 55 | body: Center(child: Text(counter.value.toString())), 56 | floatingActionButton: FloatingActionButton( 57 | onPressed: () => counter.value++, 58 | child: const Icon(Icons.add), 59 | ), 60 | ); 61 | } 62 | } 63 | ``` 64 | 65 | For instance the code for `Counter` is: 66 | 67 | ```dart title="source/environment_example.dart" 68 | class Counter { 69 | @SolidState() 70 | int value = 0; 71 | } 72 | ``` 73 | -------------------------------------------------------------------------------- /packages/solid_annotations/lib/solid_annotations.dart: -------------------------------------------------------------------------------- 1 | /// Annotations for Solid framework to mark reactive state, effects, queries, 2 | /// and environment variables. 3 | library; 4 | 5 | import 'package:meta/meta_meta.dart'; 6 | 7 | /// {@template SolidAnnotations.SolidState} 8 | /// Marks a variable or getter as reactive state. 9 | /// The compiler transforms fields into Signal\ and getters into Computed\. 10 | /// {@endtemplate} 11 | @Target({TargetKind.field, TargetKind.getter}) 12 | class SolidState { 13 | /// {@macro SolidAnnotations.SolidState} 14 | const SolidState({this.name}); 15 | 16 | /// Optional name for the reactive state, useful for debugging. 17 | final String? name; 18 | } 19 | 20 | /// {@template SolidAnnotations.SolidEffect} 21 | /// Marks a method as a reactive effect that runs whenever its dependencies 22 | /// change. The compiler transforms this into an Effect that tracks reactive 23 | /// state usage. 24 | /// {@endtemplate} 25 | @Target({TargetKind.method}) 26 | class SolidEffect { 27 | /// {@macro SolidAnnotations.SolidEffect} 28 | const SolidEffect(); 29 | } 30 | 31 | /// {@template SolidAnnotations.SolidQuery} 32 | /// Marks a method as a SolidQuery that will be transformed into a Resource. 33 | /// The method must return a Future and will be automatically managed 34 | /// for loading, error, and data states. 35 | /// {@endtemplate} 36 | @Target({TargetKind.method}) 37 | class SolidQuery { 38 | /// {@macro SolidAnnotations.SolidQuery} 39 | const SolidQuery({this.name, this.debounce, this.useRefreshing}); 40 | 41 | /// Optional name for the SolidQuery, useful for debugging. 42 | final String? name; 43 | 44 | /// Optional debounce duration for the SolidQuery, to limit how often it runs 45 | /// when its sources change. 46 | final Duration? debounce; 47 | 48 | /// By default, queries stay in the current state while refreshing. 49 | /// If you set this to false, the SolidQuery will enter the loading state when 50 | /// refreshed. 51 | final bool? useRefreshing; 52 | } 53 | 54 | /// {@template SolidAnnotations.SolidEnvironment} 55 | /// Marks a field as part of the Solid environment, making it accessible 56 | /// throughout the widget tree. 57 | /// {@endtemplate} 58 | @Target({TargetKind.field}) 59 | class SolidEnvironment { 60 | /// {@macro SolidAnnotations.SolidEnvironment} 61 | const SolidEnvironment(); 62 | } 63 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/src/result.dart: -------------------------------------------------------------------------------- 1 | /// Base class for Result types - immutable and pure 2 | abstract class Result { 3 | const Result(); 4 | 5 | /// True if this is a successful result 6 | bool get isSuccess => this is Success; 7 | 8 | /// True if this is a failure result 9 | bool get isFailure => this is Failure; 10 | 11 | /// Map over the success value 12 | Result map(U Function(T) f) { 13 | if (this is Success) { 14 | return Success(f((this as Success).value)); 15 | } 16 | return Failure((this as Failure).error); 17 | } 18 | 19 | /// FlatMap for chaining Results - key for functional composition 20 | Result flatMap(Result Function(T) f) { 21 | if (this is Success) { 22 | return f((this as Success).value); 23 | } 24 | return Failure((this as Failure).error); 25 | } 26 | 27 | /// Map over the error type 28 | Result mapError(F Function(E) f) { 29 | if (this is Success) { 30 | return Success((this as Success).value); 31 | } 32 | return Failure(f((this as Failure).error)); 33 | } 34 | 35 | /// Fold the result into a single value 36 | U fold(U Function(E) onFailure, U Function(T) onSuccess) { 37 | if (this is Success) { 38 | return onSuccess((this as Success).value); 39 | } 40 | return onFailure((this as Failure).error); 41 | } 42 | } 43 | 44 | /// Success case - immutable value container 45 | class Success extends Result { 46 | const Success(this.value); 47 | 48 | final T value; 49 | 50 | @override 51 | bool operator ==(Object other) => 52 | identical(this, other) || 53 | other is Success && 54 | runtimeType == other.runtimeType && 55 | value == other.value; 56 | 57 | @override 58 | int get hashCode => value.hashCode; 59 | 60 | @override 61 | String toString() => 'Success($value)'; 62 | } 63 | 64 | /// Failure case - immutable error container 65 | class Failure extends Result { 66 | const Failure(this.error); 67 | 68 | final E error; 69 | 70 | @override 71 | bool operator ==(Object other) => 72 | identical(this, other) || 73 | other is Failure && 74 | runtimeType == other.runtimeType && 75 | error == other.error; 76 | 77 | @override 78 | int get hashCode => error.hashCode; 79 | 80 | @override 81 | String toString() => 'Failure($error)'; 82 | } 83 | -------------------------------------------------------------------------------- /docs/src/content/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome to Solid 3 | description: Get started building amazing Flutter apps with Solid. 4 | hero: 5 | tagline: Congrats on your interest in Solid! Let's make Flutter development even more enjoyable. 6 | image: 7 | file: ../../assets/houston.webp 8 | actions: 9 | - text: Getting started 10 | link: /guides/getting-started 11 | icon: right-arrow 12 | next: 13 | label: Getting Started 14 | link: /guides/getting-started 15 | --- 16 | 17 | Solid is a tiny framework built on top of Flutter that makes building apps easier and more enjoyable. 18 | The benefits of using Solid include: 19 | 1. **Don't write boilerplate**: Solid generates boilerplate code for you, so you can focus on building your app. Inspired by SwiftUI. 20 | 2. **No state management/dependency injection manual work**: Solid has built-in state management and dependency injection. Just annotate your variables and Solid takes care of the rest. 21 | 3. **Fine-grained reactivity**: Solid's reactivity system is inspired by SolidJS, allowing for efficient and fine-grained updates to your UI. Only the parts of the UI that depend on changed state are updated, leading to better performance. And the best is that you don't have to think about it, Solid does it for you automatically. 22 | 23 | ## Example 24 | 25 | You write this code, without any boilerplate and manual state management: 26 | 27 | ```dart 28 | import 'package:flutter/material.dart'; 29 | import 'package:solid_annotations/solid_annotations.dart'; 30 | 31 | class Counter extends StatelessWidget { 32 | Counter({super.key}); 33 | 34 | @SolidState() 35 | int counter = 0; 36 | 37 | @override 38 | Widget build(BuildContext context) { 39 | return Scaffold( 40 | body: Center( 41 | child: Column( 42 | mainAxisSize: MainAxisSize.min, 43 | children: [ 44 | Text('Date: ${DateTime.now()}'), 45 | Text('Counter is $counter'), 46 | ], 47 | ), 48 | ), 49 | floatingActionButton: FloatingActionButton( 50 | onPressed: () => counter++, 51 | child: const Icon(Icons.add), 52 | ), 53 | ); 54 | } 55 | } 56 | ``` 57 | 58 | You get this result, with real fine-grained reactivity: 59 | [![Demo of Solid fine-grained reactivity](../../assets/solid_demo.gif)](../../assets/solid_demo.gif) 60 | 61 | As you can see, the `DateTime.now()` text does not update when the counter changes, only the `Counter is X` text updates. This is because Solid tracks which parts of the UI depend on which state, and only updates those parts when the state changes, without any manual work from you. 62 | 63 | If this sounds interesting, check out the [getting started guide](/guides/getting-started) to learn how to set up Solid in your Flutter project! 64 | -------------------------------------------------------------------------------- /docs/src/content/docs/guides/state.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: State 3 | description: Learn to write reactive state in Solid. 4 | sidebar: 5 | order: 2 6 | --- 7 | import { Aside } from '@astrojs/starlight/components'; 8 | 9 | The `@SolidState()` annotation allows you to create reactive state variables in your Solid widgets. 10 | 11 | When you annotate a variable with `@SolidState()`, Solid will generate the necessary boilerplate code to make that variable reactive. This means that whenever the variable changes, any UI elements that depend on it will automatically update. 12 | 13 | ## Usage 14 | 15 | ```dart {7-8, 14, 17} title="source/counter.dart" 16 | import 'package:solid_annotations/solid_annotations.dart'; 17 | import 'package:flutter/material.dart'; 18 | 19 | class Counter extends StatelessWidget { 20 | Counter({super.key}); 21 | 22 | @SolidState() 23 | int counter = 0; 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return Scaffold( 28 | body: Center( 29 | child: Text('Counter is $counter'), 30 | ), 31 | floatingActionButton: FloatingActionButton( 32 | onPressed: () => counter++, 33 | child: const Icon(Icons.add), 34 | ), 35 | ); 36 | } 37 | } 38 | ``` 39 | 40 | In this example, the `counter` variable is annotated with `@SolidState()`. Whenever the floating action button is pressed, the `counter` variable is incremented, and the text displaying the counter value will automatically update to reflect the new value. 41 | 42 | 45 | 46 | ## Derived state 47 | 48 | You can also create computed properties that derive their values from reactive state variables. 49 | To do this, simply annotate a getter that accesses the reactive state variables with `@SolidState`. 50 | 51 | ```dart {7-8, 15} title="source/computed_counter.dart" 52 | class Counter extends StatelessWidget { 53 | Counter({super.key}); 54 | 55 | @SolidState() 56 | int counter = 0; 57 | 58 | @SolidState() 59 | int get doubleCounter => counter * 2; 60 | 61 | @override 62 | Widget build(BuildContext context) { 63 | return Scaffold( 64 | appBar: AppBar(title: const Text('Computed')), 65 | body: Center( 66 | child: Text('Counter: $counter, DoubleCounter: $doubleCounter'), 67 | ), 68 | floatingActionButton: FloatingActionButton( 69 | onPressed: () => counter++, 70 | child: const Icon(Icons.add), 71 | ), 72 | ); 73 | } 74 | } 75 | ``` 76 | 77 | In this example, the `doubleCounter` getter computes its value based on the `counter` variable. Whenever `counter` changes, `doubleCounter` will also update, and any UI elements that depend on it will automatically refresh. 78 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/src/transformation_error.dart: -------------------------------------------------------------------------------- 1 | /// Base class for all transformation errors - immutable 2 | abstract class TransformationError { 3 | const TransformationError(this.message, this.location); 4 | 5 | final String message; 6 | final String? location; 7 | 8 | @override 9 | String toString() => location != null 10 | ? 'TransformationError at $location: $message' 11 | : 'TransformationError: $message'; 12 | } 13 | 14 | /// Error during annotation parsing - immutable 15 | class AnnotationParseError extends TransformationError { 16 | const AnnotationParseError( 17 | super.message, 18 | super.location, 19 | this.annotationName, 20 | ); 21 | 22 | final String annotationName; 23 | 24 | @override 25 | String toString() => location != null 26 | ? 'AnnotationParseError at $location: Failed to parse @$annotationName - $message' 27 | : 'AnnotationParseError: Failed to parse @$annotationName - $message'; 28 | } 29 | 30 | /// Error during field/method analysis - immutable 31 | class AnalysisError extends TransformationError { 32 | const AnalysisError(super.message, super.location, this.elementName); 33 | 34 | final String elementName; 35 | 36 | @override 37 | String toString() => location != null 38 | ? 'AnalysisError at $location: Failed to analyze $elementName - $message' 39 | : 'AnalysisError: Failed to analyze $elementName - $message'; 40 | } 41 | 42 | /// Error during code generation - immutable 43 | class CodeGenerationError extends TransformationError { 44 | const CodeGenerationError(super.message, super.location, this.targetType); 45 | 46 | final String targetType; 47 | 48 | @override 49 | String toString() => location != null 50 | ? 'CodeGenerationError at $location: Failed to generate $targetType - $message' 51 | : 'CodeGenerationError: Failed to generate $targetType - $message'; 52 | } 53 | 54 | /// Validation error for reactive annotations - immutable 55 | class ValidationError extends TransformationError { 56 | const ValidationError(super.message, super.location, this.violationType); 57 | 58 | final String violationType; 59 | 60 | /// Factory constructors for common validation errors 61 | const ValidationError.invalidAnnotationTarget( 62 | String elementName, 63 | String location, 64 | ) : violationType = 'INVALID_TARGET', 65 | super('@SolidState can only be applied to fields and getters', location); 66 | 67 | const ValidationError.missingAnnotation(String elementName, String? location) 68 | : violationType = 'MISSING_ANNOTATION', 69 | super('Expected reactive annotation not found on $elementName', location); 70 | 71 | const ValidationError.invalidType(String typeName, String? location) 72 | : violationType = 'INVALID_TYPE', 73 | super('Type $typeName is not supported for reactive state', location); 74 | 75 | @override 76 | String toString() => location != null 77 | ? 'ValidationError at $location: [$violationType] $message' 78 | : 'ValidationError: [$violationType] $message'; 79 | } 80 | -------------------------------------------------------------------------------- /packages/solid_generator/test/ast_dependency_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:analyzer/dart/analysis/features.dart'; 3 | import 'package:analyzer/dart/analysis/utilities.dart'; 4 | import 'package:analyzer/dart/ast/ast.dart'; 5 | 6 | import 'package:solid_generator/src/reactive_state_transformer.dart'; 7 | 8 | void main() { 9 | test('AST visitor extracts user-defined variable names', () { 10 | final code = ''' 11 | class TestClass { 12 | @SolidState() 13 | String get fullName => firstName + ' ' + lastName; 14 | } 15 | '''; 16 | 17 | final parseResult = parseString( 18 | content: code, 19 | featureSet: FeatureSet.latestLanguageVersion(), 20 | ); 21 | 22 | final unit = parseResult.unit; 23 | final classDeclaration = unit.declarations.first as ClassDeclaration; 24 | final getterDeclaration = 25 | classDeclaration.members.first as MethodDeclaration; 26 | 27 | final transformer = SolidComputedTransformer(); 28 | final dependencies = transformer.extractDependencies(getterDeclaration); 29 | 30 | // Should find firstName and lastName (user-defined variables) 31 | expect(dependencies, contains('firstName')); 32 | expect(dependencies, contains('lastName')); 33 | 34 | // Should not find string literals 35 | expect(dependencies, isNot(contains(' '))); 36 | }); 37 | 38 | test('AST visitor handles complex expressions with user variables', () { 39 | final code = ''' 40 | class TestClass { 41 | @SolidState() 42 | String get calculation => myVariable * anotherVar + someField - customValue; 43 | } 44 | '''; 45 | 46 | final parseResult = parseString( 47 | content: code, 48 | featureSet: FeatureSet.latestLanguageVersion(), 49 | ); 50 | 51 | final unit = parseResult.unit; 52 | final classDeclaration = unit.declarations.first as ClassDeclaration; 53 | final getterDeclaration = 54 | classDeclaration.members.first as MethodDeclaration; 55 | 56 | final transformer = SolidComputedTransformer(); 57 | final dependencies = transformer.extractDependencies(getterDeclaration); 58 | 59 | // Should find all user-defined variables 60 | expect(dependencies, contains('myVariable')); 61 | expect(dependencies, contains('anotherVar')); 62 | expect(dependencies, contains('someField')); 63 | expect(dependencies, contains('customValue')); 64 | }); 65 | 66 | test('AST visitor excludes method calls and property access', () { 67 | final code = ''' 68 | class TestClass { 69 | @SolidState() 70 | String get result => someVar + getMethod() + object.property; 71 | } 72 | '''; 73 | 74 | final parseResult = parseString( 75 | content: code, 76 | featureSet: FeatureSet.latestLanguageVersion(), 77 | ); 78 | 79 | final unit = parseResult.unit; 80 | final classDeclaration = unit.declarations.first as ClassDeclaration; 81 | final getterDeclaration = 82 | classDeclaration.members.first as MethodDeclaration; 83 | 84 | final transformer = SolidComputedTransformer(); 85 | final dependencies = transformer.extractDependencies(getterDeclaration); 86 | 87 | // Should find the variable 88 | expect(dependencies, contains('someVar')); 89 | expect(dependencies, contains('object')); 90 | 91 | // Should NOT find method names or property names 92 | expect(dependencies, isNot(contains('getMethod'))); 93 | expect(dependencies, isNot(contains('property'))); 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /packages/solid_annotations/lib/provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | 3 | /// {@template SolidAnnotations.SolidProvider} 4 | /// A provider widget that supplies data of type T to its descendants. 5 | /// It uses an InheritedWidget to propagate the data down the widget tree. 6 | /// {@endtemplate} 7 | class SolidProvider extends StatefulWidget { 8 | /// {@macro SolidAnnotations.SolidProvider} 9 | const SolidProvider({ 10 | super.key, 11 | required this.child, 12 | required this.create, 13 | this.notifyUpdate, 14 | }); 15 | 16 | /// The function to create the data to be provided. 17 | final T Function(BuildContext context) create; 18 | 19 | /// The child widget which will have access to the provided data. 20 | final Widget child; 21 | 22 | /// Whether to notify the update of the provider, defaults to false. 23 | final bool Function(InheritedSolidProvider oldWidget)? notifyUpdate; 24 | 25 | /// Retrieves the nearest SolidProvider of type T from the widget tree. 26 | /// Throws an error if no provider is found. 27 | static T of(BuildContext context, {bool listen = true}) { 28 | final inherited = maybeOf(context, listen: listen); 29 | if (inherited == null) { 30 | throw FlutterError( 31 | 'Could not find SolidProvider<$T> in the ancestor widget tree. ' 32 | 'Make sure you have a SolidProvider<$T> widget as an ancestor of the ' 33 | 'widget that is trying to access it.', 34 | ); 35 | } 36 | return inherited; 37 | } 38 | 39 | /// Retrieves the nearest SolidProvider of type T from the widget tree. 40 | /// Returns null if no provider is found. 41 | static T? maybeOf(BuildContext context, {bool listen = true}) { 42 | if (listen) { 43 | return context 44 | .dependOnInheritedWidgetOfExactType>() 45 | ?.data; 46 | } 47 | final provider = context 48 | .getElementForInheritedWidgetOfExactType>() 49 | ?.widget; 50 | return (provider as InheritedSolidProvider?)?.data; 51 | } 52 | 53 | @override 54 | State> createState() => _SolidProviderState(); 55 | } 56 | 57 | class _SolidProviderState extends State> { 58 | late final T data; 59 | bool initialized = false; 60 | 61 | @override 62 | void dispose() { 63 | // Call dispose on the data 64 | (data as dynamic).dispose(); 65 | super.dispose(); 66 | } 67 | 68 | @override 69 | Widget build(BuildContext context) { 70 | if (!initialized) { 71 | data = widget.create(context); 72 | initialized = true; 73 | } 74 | return InheritedSolidProvider( 75 | data: data, 76 | notifyUpdate: widget.notifyUpdate, 77 | child: widget.child, 78 | ); 79 | } 80 | } 81 | 82 | /// {@template SolidAnnotations.InheritedSolidProvider} 83 | /// An InheritedWidget that holds the provided data of type T. 84 | /// It notifies its descendants when the data changes based on the 85 | /// notifyUpdate callback. 86 | /// {@endtemplate} 87 | class InheritedSolidProvider extends InheritedWidget { 88 | /// {@macro SolidAnnotations.InheritedSolidProvider} 89 | const InheritedSolidProvider({ 90 | super.key, 91 | required super.child, 92 | required this.data, 93 | this.notifyUpdate, 94 | }); 95 | 96 | /// The data to be provided 97 | final T data; 98 | 99 | /// Whether to notify the update of the provider, defaults to false 100 | final bool Function(InheritedSolidProvider oldWidget)? notifyUpdate; 101 | 102 | @override 103 | bool updateShouldNotify(covariant InheritedSolidProvider oldWidget) { 104 | return notifyUpdate?.call(oldWidget) ?? false; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /docs/src/content/docs/faq.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: FAQ 3 | description: Frequently Asked Questions about Solid. 4 | sidebar: 5 | order: 6 6 | --- 7 | import { Aside } from '@astrojs/starlight/components'; 8 | 9 | ## Why using `StatelessWidget` instead of `StatefulWidget`? 10 | 11 | The usage of `StatelessWidget` is intentional to reduce the boilerplate code you have to write. 12 | If the widget contains any solid annotation, it will be converted into a `StatefulWidget` by the code generator. 13 | 14 | 17 | 18 | ## Why my `StatelessWidget` is not immutable? 19 | 20 | The `StatelessWidget` you write is not immutable because it contains mutable variables. 21 | That's why I suggest to add: 22 | ```yaml title='analysis_options.yaml' 23 | analyzer: 24 | errors: 25 | must_be_immutable: ignore 26 | ``` 27 | 28 | So you can ignore it. But **don't worry**, the generated code will be immutable and follow all the best practices. 29 | The code you write in the `source` directory needs to be valid Dart code, even if it's optimized later. 30 | 31 | ## Why not using Macros? 32 | 33 | The feature to add macros in Dart has been stopped, as you can [read here](https://blog.dart.dev/an-update-on-dart-macros-data-serialization-06d3037d4f12?gi=608cf334bf75) 34 | 35 | ## Why not using augmentations? 36 | 37 | Augmentations in Dart have not been released yet, in addition, they are more limited than macros. 38 | 39 | For instance, you cannot augment a function that contains a body. So we can't have fine grained reactivity in the `build` of our widgets. 40 | 41 | ## Why flutter_solidart as state management? 42 | 43 | I'm the author of [flutter_solidart](https://pub.dev/packages/flutter_solidart) so I'm surely biased. 44 | But having a state manament library that has an automatic reactive system helps a lot. 45 | For instance, in solidart you can write: 46 | ```dart 47 | final counter = Signal(0); 48 | late final doubleCounter = Computed(() => counter.value * 2); 49 | ``` 50 | And it just works. 51 | 52 | In addition, `SignalBuilder` automatically reacts to any signal detected inside its builder. So I didn't have to generate any complex transpiler. 53 | If I had to work with `ValueNotifier` and `ValueListenableBuilder`, the amount of complexity and boilerplate would have been enormous. 54 | 55 | ## Why writing code in the `source` directory and not on lib? 56 | 57 | Solid is not a simple code generator. In fact it doesn't compile code, but it transpiles it. 58 | For instance, the code you write gets transformed in a working code. 59 | 60 | For the limitations of how Flutter works, your app needs to be built from the `lib` directory. 61 | So the code I transpile must be transpiled in the `lib` folder. 62 | 63 | 66 | 67 | ## Why did I created Solid? 68 | 69 | You can learn more in my [blog post here](https://mariuti.com/posts/flutter-in-2025-forget-mvvm-embrace-fine-grained-reactivity-with-solid/) 70 | 71 | ## How can I use Solid with other generators? 72 | 73 | Modify (or add) the `build.yaml` file in your project root. 74 | 75 | ```yaml {6} title="build.yaml" 76 | targets: 77 | $default: 78 | sources: 79 | - lib/** 80 | - $package$ 81 | - source/** 82 | ``` 83 | 84 | This way, other generators will also consider the `source` directory as input. 85 | 86 | Then in one Terminal tab you simply use `solid --watch` to keep Solid running in watch mode. 87 | In another Terminal tab you can run `dart run build_runner build --delete-conflicting-outputs` to run other generators. 88 | 89 | Then write your code in the `source` directory, using `part` statements or what generators need. 90 | -------------------------------------------------------------------------------- /docs/src/content/docs/guides/getting-started.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | description: Get started building amazing Flutter apps with Solid. 4 | prev: 5 | label: Welcome to Solid 6 | link: / 7 | sidebar: 8 | order: 1 9 | --- 10 | import { Aside } from '@astrojs/starlight/components'; 11 | import { Steps } from '@astrojs/starlight/components'; 12 | 13 | ## Installation 14 | 15 | 16 | 17 | 1. To install Solid, run the following command 18 | 19 | ```bash 20 | dart pub global activate solid_generator 21 | ``` 22 | 23 | 2. Then, add the dependencies needed by running this command in your Flutter project 24 | 25 | ```bash 26 | flutter pub add solid_annotations flutter_solidart 27 | ``` 28 | 29 | [flutter_solidart](https://pub.dev/packages/flutter_solidart) is the state management library used by Solid, but you don't have to learn how to use it, Solid will generate all the boilerplate code for you. 30 | 31 | 3. I'd also suggest installing the [very_good_analysis](https://pub.dev/packages/very_good_analysis) package to get amazing linting rules for your Flutter project. 32 | 33 | ```bash 34 | flutter pub add dev:very_good_analysis 35 | ``` 36 | 37 | Then in your `analysis_options.yaml` file, add the following line 38 | 39 | ```yaml title="analysis_options.yaml" 40 | include: package:very_good_analysis/very_good_analysis.yaml 41 | ``` 42 | 43 | This is an example of an `analysis_options.yaml` file 44 | 45 | ```yaml title="analysis_options.yaml" 46 | include: package:very_good_analysis/analysis_options.yaml 47 | analyzer: 48 | errors: 49 | must_be_immutable: ignore # Ignore immutability rule, because Solid generates immutable classes but you write mutable ones 50 | linter: 51 | rules: 52 | public_member_api_docs: false # Disable documentation requirement for public members 53 | ``` 54 | 55 | 56 | ## How it works 57 | 58 | The code you write is the source of truth, and Solid generates the boilerplate code needed to make the app working. 59 | 60 | There is an important difference between writing normal Flutter code and writing Solid code: 61 | In Flutter, the `lib` folder is the source of truth, while in Solid, the `source` folder is the source of truth. 62 | 63 | 66 | 67 | You never have to edit the `lib` folder manually, as it is generated by Solid. 68 | 69 | I decided not to use the `lib` folder directly because I don't want to touch the original files, but I wanted to keep the same naming and structure, without adding complexity to you like `part` files or similar. 70 | 71 | I wanted to use `lib` as the input folder, but `flutter build` doesn't allow building (as far as I know) an app from a different folder than `lib`. 72 | 73 | In addition, Solid is not a common generator because it transpiles your existing code to an optimized version of it. 74 | 75 | ## Usage 76 | 77 | ```sh 78 | Usage: solid [options] 79 | 80 | -s, --source Source directory to read from 81 | (defaults to "source") 82 | -o, --output Output directory to write to 83 | (defaults to "lib") 84 | -w, --[no-]watch Watch for file changes and auto-regenerate 85 | -c, --[no-]clean Deletes the build cache. The next build will be a full build. 86 | -v, --[no-]verbose Verbose output 87 | -h, --[no-]help Show this help message 88 | 89 | Examples: 90 | solid # Basic transpilation 91 | solid --watch # Watch mode 92 | solid --clean --verbose # Clean build with verbose output 93 | ``` 94 | 95 | > The next chapters expect that you have setup Solid in your Flutter project, created the `source` folder and started the command with `solid --watch` to auto-generate the `lib` folder on file changes. 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solid Framework 2 | 3 | [![License: MIT](https://img.shields.io/badge/license-MIT-purple.svg)](https://github.com/nank1ro/solid/blob/main/LICENSE) 4 | [![GitHub stars](https://img.shields.io/github/stars/nank1ro/solid)](https://gitHub.com/nank1ro/solid/stargazers/) 5 | [![GitHub issues](https://img.shields.io/github/issues/nank1ro/solid)](https://gitHub.com/nank1ro/solid/issues/) 6 | [![GitHub pull-requests](https://img.shields.io/github/issues-pr/nank1ro/solid.svg)](https://gitHub.com/nank1ro/solid/pull/) 7 | [![solid_generator Pub Version (including pre-releases)](https://img.shields.io/pub/v/solid_generator?include_prereleases&label=solid_generator)](https://pub.dev/packages/solid_generator) 8 | [![solid_annotations Pub Version (including pre-releases)](https://img.shields.io/pub/v/solid_annotations?include_prereleases&label=solid_annotations)](https://pub.dev/packages/solid_annotations) 9 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/nank1ro)](https://github.com/sponsors/nank1ro) 10 | 11 | Buy Me A Coffee 12 | 13 | Congrats on your interest in **Solid**! Let's make Flutter development even more enjoyable. 14 | 15 | Solid is a tiny framework built on top of Flutter that makes building apps easier and more enjoyable. 16 | The benefits of using Solid include: 17 | 1. **Don't write boilerplate**: Solid generates boilerplate code for you, so you can focus on building your app. Inspired by SwiftUI. 18 | 2. **No state management/dependency injection manual work**: Solid has built-in state management and dependency injection. Just annotate your variables and Solid takes care of the rest. 19 | 3. **Fine-grained reactivity**: Solid's reactivity system is inspired by SolidJS, allowing for efficient and fine-grained updates to your UI. Only the parts of the UI that depend on changed state are updated, leading to better performance. And the best is that you don't have to think about it, Solid does it for you automatically. 20 | 21 | ## Example 22 | 23 | You write this code, without any boilerplate and manual state management: 24 | 25 | ```dart 26 | import 'package:flutter/material.dart'; 27 | import 'package:solid_annotations/solid_annotations.dart'; 28 | 29 | class Counter extends StatelessWidget { 30 | Counter({super.key}); 31 | 32 | @SolidState() 33 | int counter = 0; 34 | 35 | @override 36 | Widget build(BuildContext context) { 37 | return Scaffold( 38 | body: Center( 39 | child: Column( 40 | mainAxisSize: MainAxisSize.min, 41 | children: [ 42 | Text('Date: ${DateTime.now()}'), 43 | Text('Counter is $counter'), 44 | ], 45 | ), 46 | ), 47 | floatingActionButton: FloatingActionButton( 48 | onPressed: () => counter++, 49 | child: const Icon(Icons.add), 50 | ), 51 | ); 52 | } 53 | } 54 | ``` 55 | 56 | You get this result, with real fine-grained reactivity: 57 | 58 | [![Demo of Solid fine-grained reactivity](./docs/src/assets/solid_demo.gif)](../../assets/solid_demo.gif) 59 | 60 | As you can see, the `DateTime.now()` text does not update when the counter changes, only the `Counter is X` text updates. This is because Solid tracks which parts of the UI depend on which state, and only updates those parts when the state changes, without any manual work from you. 61 | 62 | If this sounds interesting, check out the [Getting Started Guide](https://solid.mariuti.com/guides/getting-started) to learn how to set up Solid in your Flutter project! 63 | 64 | ## License 65 | 66 | The Solid framework is open-source software licensed under the [MIT License](./LICENSE). 67 | 68 | ## Sponsorship 69 | 70 | If you find Solid useful and would like to support its development, consider sponsoring the project on [GitHub Sponsors](https://github.com/sponsors/nank1ro/). 71 | I love building open-source software and your support helps me dedicate more time to improving Solid and adding new features. 72 | -------------------------------------------------------------------------------- /packages/solid_annotations/lib/extensions.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:solid_annotations/provider.dart'; 3 | 4 | /// Extension methods for Future and Stream to handle loading, error, and data 5 | /// states. 6 | extension FutureWhen on Future { 7 | /// Handles the different states of the Future: loading, error, and data 8 | /// ready. 9 | Widget when({ 10 | required Widget Function(T data) ready, 11 | required Widget Function() loading, 12 | required Widget Function(Object error, StackTrace stack) error, 13 | }) { 14 | throw Exception('This is just a stub for code generation.'); 15 | } 16 | 17 | /// Handles the different states of the Future with an orElse fallback. 18 | Widget maybeWhen({ 19 | required Widget Function() orElse, 20 | Widget Function(T data)? ready, 21 | Widget Function(Object error, StackTrace stack)? error, 22 | Widget Function()? loading, 23 | }) { 24 | throw Exception('This is just a stub for code generation.'); 25 | } 26 | } 27 | 28 | /// Extension methods for Stream to handle loading, error, and data states. 29 | extension StreamWhen on Stream { 30 | /// Handles the different states of the Stream: loading, error, and data 31 | Widget when({ 32 | required Widget Function(T data) ready, 33 | required Widget Function() loading, 34 | required Widget Function(Object error, StackTrace stack) error, 35 | }) { 36 | throw Exception('This is just a stub for code generation.'); 37 | } 38 | 39 | /// Handles the different states of the Stream with an orElse fallback. 40 | Widget maybeWhen({ 41 | required Widget Function() orElse, 42 | Widget Function(T data)? ready, 43 | Widget Function(Object error, StackTrace stack)? error, 44 | Widget Function()? loading, 45 | }) { 46 | throw Exception('This is just a stub for code generation.'); 47 | } 48 | } 49 | 50 | /// Extension method to easily wrap a widget with an InheritedSolidProvider 51 | extension EnvironmentExtension on Widget { 52 | /// Wraps the widget with a SolidProvider that provides data of type T. 53 | Widget environment( 54 | T Function(BuildContext) create, { 55 | 56 | /// Whether to notify the update of the provider, defaults to false. 57 | bool Function(InheritedSolidProvider oldWidget)? notifyUpdate, 58 | }) => 59 | SolidProvider(create: create, notifyUpdate: notifyUpdate, child: this); 60 | } 61 | 62 | /// Extension methods on BuildContext to read and watch provided data. 63 | extension ProviderReadExt on BuildContext { 64 | /// Reads the provided data of type T without listening for updates. 65 | T read() => SolidProvider.of(this, listen: false); 66 | 67 | /// Reads the provided data of type T without listening for updates. 68 | T? maybeRead() => SolidProvider.maybeOf(this, listen: false); 69 | } 70 | 71 | /// Extension methods on BuildContext to watch for updates in provided data. 72 | extension ProviderWatchExt on BuildContext { 73 | /// Watches the provided data of type T and rebuilds when it changes. 74 | T watch() => SolidProvider.of(this); 75 | 76 | /// Watches the provided data of type T and rebuilds when it changes. 77 | T? maybeWatch() => SolidProvider.maybeOf(this); 78 | } 79 | 80 | /// Extension methods to refresh Future functions 81 | extension RefreshFuture on Future Function() { 82 | /// Triggers a refresh of the Future function. 83 | Future refresh() { 84 | throw Exception('This is just a stub for code generation.'); 85 | } 86 | } 87 | 88 | /// Extension methods to refresh Stream functions 89 | extension RefreshStream on Stream Function() { 90 | /// Triggers a refresh of the Stream function. 91 | Future refresh() { 92 | throw Exception('This is just a stub for code generation.'); 93 | } 94 | } 95 | 96 | /// Extension to check if a Future is in refreshing state 97 | extension IsRefreshingFuture on Future { 98 | /// Checks if the Future is currently refreshing. 99 | bool get isRefreshing { 100 | throw Exception('This is just a stub for code generation.'); 101 | } 102 | } 103 | 104 | /// Extension to check if a Stream is in refreshing state 105 | extension IsRefreshingStream on Stream { 106 | /// Checks if the Stream is currently refreshing. 107 | bool get isRefreshing { 108 | throw Exception('This is just a stub for code generation.'); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /docs/src/content/docs/guides/query.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Query 3 | description: Learn to write queries in Solid. 4 | sidebar: 5 | order: 5 6 | --- 7 | import { Aside } from '@astrojs/starlight/components'; 8 | 9 | The `@SolidQuery()` annotation allows you to create reactive state based on asynchronous data sources, such as fetching data from a network. 10 | 11 | ## Usage 12 | 13 | ```dart {4-8, 15-19} title="source/query_example.dart" 14 | class QueryExample extends StatelessWidget { 15 | const QueryExample({super.key}); 16 | 17 | @SolidQuery() 18 | Future fetchData() async { 19 | await Future.delayed(const Duration(seconds: 1)); 20 | return 'Fetched Data'; 21 | } 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return Scaffold( 26 | appBar: AppBar(title: const Text('Query')), 27 | body: Center( 28 | child: fetchData().when( 29 | ready: (data) => Text(data), 30 | loading: () => CircularProgressIndicator(), 31 | error: (error, stackTrace) => Text('Error: $error'), 32 | ), 33 | ), 34 | ); 35 | } 36 | } 37 | ``` 38 | 39 | This example defines a `fetchData` method annotated with `@SolidQuery()`, which simulates fetching data asynchronously. 40 | The `when` method is used to handle the build different widgets based on the states of the query: `ready`, `loading`, and `error`. 41 | 42 | 45 | 46 | --- 47 | 48 | You can also use the `@SolidQuery()` annotation for streams. 49 | 50 | ```dart title="source/query_example.dart" 51 | @SolidQuery() 52 | Stream fetchData() { 53 | return Stream.periodic(const Duration(seconds: 1), (i) => i); 54 | } 55 | ``` 56 | 57 | And **magically**, you don't have to change anything else. 58 | 59 | ## Reacting to state 60 | 61 | A query can also depend on reactive state variables. 62 | 63 | ```dart {4-5, 7-12, 19-28, 30-34} title="source/query_with_source_example.dart" 64 | class QueryWithSourceExample extends StatelessWidget { 65 | QueryWithSourceExample({super.key}); 66 | 67 | @SolidState() 68 | String? userId; 69 | 70 | @SolidQuery(debounce: Duration(seconds: 1)) 71 | Future fetchData() async { 72 | if (userId == null) return null; 73 | await Future.delayed(const Duration(seconds: 1)); 74 | return 'Fetched Data for $userId'; 75 | } 76 | 77 | @override 78 | Widget build(BuildContext context) { 79 | return Scaffold( 80 | appBar: AppBar(title: const Text('QueryWithSource')), 81 | body: Center( 82 | child: fetchData().when( 83 | ready: (data) { 84 | if (data == null) { 85 | return const Text('No user ID provided'); 86 | } 87 | return Text(data); 88 | }, 89 | loading: () => CircularProgressIndicator(), 90 | error: (error, stackTrace) => Text('Error: $error'), 91 | ), 92 | ), 93 | floatingActionButton: FloatingActionButton( 94 | onPressed: () => 95 | userId = 'user_${DateTime.now().millisecondsSinceEpoch}', 96 | child: const Icon(Icons.refresh), 97 | ), 98 | ); 99 | } 100 | } 101 | ``` 102 | 103 | 106 | 107 | 111 | 112 | 115 | 116 | ## Detect if a query is refreshing 117 | 118 | By default, when a query is re-executed due to a dependency change, it doesn't enter the loading state again. 119 | Instead, it stays in the current state while the new data is being fetched. When the new data arrives, the state updates accordingly. 120 | This behavior is designed to provide a smoother user experience by avoiding unnecessary loading indicators during data refreshes. 121 | 122 | You can detect if a query is currently refreshing by using the `isRefreshing` property. 123 | 124 | ```dart 125 | fetchData().isRefreshing 126 | ``` 127 | 128 | This property returns `true` if the query is in the process of refreshing its data, allowing you to adjust your UI accordingly (e.g., showing a subtle loading indicator or a refresh icon). 129 | 130 | You can disable this behavior by setting the `useRefreshing` parameter to `false` in the `@SolidQuery()` annotation. 131 | 132 | ```dart 133 | @SolidQuery(useRefreshing: false) 134 | ``` 135 | 136 | In this case, the query will enter the loading state again when it is re-executed due to a dependency change. 137 | 138 | ## Manually refreshing a query 139 | 140 | You can manually refresh a query by calling the `refresh` method on it. 141 | 142 | ```dart 143 | fetchData.refresh(); 144 | ``` 145 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/src/ast_models.dart: -------------------------------------------------------------------------------- 1 | /// Immutable container for annotation information 2 | class AnnotationInfo { 3 | const AnnotationInfo({ 4 | required this.name, 5 | this.customName, 6 | this.debounceExpression, 7 | this.useRefreshing, 8 | }); 9 | 10 | final String name; 11 | final String? customName; 12 | final String? debounceExpression; 13 | final bool? useRefreshing; 14 | 15 | @override 16 | bool operator ==(Object other) => 17 | identical(this, other) || 18 | other is AnnotationInfo && 19 | runtimeType == other.runtimeType && 20 | name == other.name && 21 | customName == other.customName && 22 | debounceExpression == other.debounceExpression && 23 | useRefreshing == other.useRefreshing; 24 | 25 | @override 26 | int get hashCode => 27 | Object.hash(name, customName, debounceExpression, useRefreshing); 28 | 29 | @override 30 | String toString() => 31 | 'AnnotationInfo(name: $name, customName: $customName, debounceExpression: $debounceExpression, useRefreshing: $useRefreshing)'; 32 | } 33 | 34 | /// Immutable container for field information 35 | class FieldInfo { 36 | const FieldInfo({ 37 | required this.name, 38 | required this.type, 39 | this.initialValue, 40 | required this.isNullable, 41 | required this.isFinal, 42 | required this.isConst, 43 | required this.location, 44 | }); 45 | 46 | final String name; 47 | final String type; 48 | final String? initialValue; 49 | final bool isNullable; 50 | final bool isFinal; 51 | final bool isConst; 52 | final String location; 53 | 54 | @override 55 | bool operator ==(Object other) => 56 | identical(this, other) || 57 | other is FieldInfo && 58 | runtimeType == other.runtimeType && 59 | name == other.name && 60 | type == other.type && 61 | initialValue == other.initialValue && 62 | isNullable == other.isNullable && 63 | isFinal == other.isFinal && 64 | isConst == other.isConst && 65 | location == other.location; 66 | 67 | @override 68 | int get hashCode => Object.hash( 69 | name, 70 | type, 71 | initialValue, 72 | isNullable, 73 | isFinal, 74 | isConst, 75 | location, 76 | ); 77 | 78 | @override 79 | String toString() => 80 | 'FieldInfo(' 81 | 'name: $name, ' 82 | 'type: $type, ' 83 | 'initialValue: $initialValue, ' 84 | 'isNullable: $isNullable, ' 85 | 'isFinal: $isFinal, ' 86 | 'isConst: $isConst, ' 87 | 'location: $location' 88 | ')'; 89 | } 90 | 91 | /// Immutable container for getter information 92 | class GetterInfo { 93 | const GetterInfo({ 94 | required this.name, 95 | required this.returnType, 96 | required this.expression, 97 | required this.isNullable, 98 | required this.location, 99 | }); 100 | 101 | final String name; 102 | final String returnType; 103 | final String expression; 104 | final bool isNullable; 105 | final String location; 106 | 107 | @override 108 | bool operator ==(Object other) => 109 | identical(this, other) || 110 | other is GetterInfo && 111 | runtimeType == other.runtimeType && 112 | name == other.name && 113 | returnType == other.returnType && 114 | expression == other.expression && 115 | isNullable == other.isNullable && 116 | location == other.location; 117 | 118 | @override 119 | int get hashCode => 120 | Object.hash(name, returnType, expression, isNullable, location); 121 | 122 | @override 123 | String toString() => 124 | 'GetterInfo(' 125 | 'name: $name, ' 126 | 'returnType: $returnType, ' 127 | 'expression: $expression, ' 128 | 'isNullable: $isNullable, ' 129 | 'location: $location' 130 | ')'; 131 | } 132 | 133 | /// Immutable container for method information 134 | class MethodInfo { 135 | const MethodInfo({ 136 | required this.name, 137 | required this.returnType, 138 | required this.body, 139 | required this.parameters, 140 | required this.isAsync, 141 | required this.location, 142 | }); 143 | 144 | final String name; 145 | final String returnType; 146 | final String body; 147 | final List parameters; 148 | final bool isAsync; 149 | final String location; 150 | 151 | @override 152 | bool operator ==(Object other) => 153 | identical(this, other) || 154 | other is MethodInfo && 155 | runtimeType == other.runtimeType && 156 | name == other.name && 157 | returnType == other.returnType && 158 | body == other.body && 159 | _listEquals(parameters, other.parameters) && 160 | isAsync == other.isAsync && 161 | location == other.location; 162 | 163 | @override 164 | int get hashCode => Object.hash( 165 | name, 166 | returnType, 167 | body, 168 | Object.hashAll(parameters), 169 | isAsync, 170 | location, 171 | ); 172 | 173 | @override 174 | String toString() => 175 | 'MethodInfo(' 176 | 'name: $name, ' 177 | 'returnType: $returnType, ' 178 | 'body: $body, ' 179 | 'parameters: $parameters, ' 180 | 'isAsync: $isAsync, ' 181 | 'location: $location' 182 | ')'; 183 | } 184 | 185 | /// Immutable container for parameter information 186 | class ParameterInfo { 187 | const ParameterInfo({ 188 | required this.name, 189 | required this.type, 190 | required this.isOptional, 191 | required this.isNamed, 192 | this.defaultValue, 193 | }); 194 | 195 | final String name; 196 | final String type; 197 | final bool isOptional; 198 | final bool isNamed; 199 | final String? defaultValue; 200 | 201 | @override 202 | bool operator ==(Object other) => 203 | identical(this, other) || 204 | other is ParameterInfo && 205 | runtimeType == other.runtimeType && 206 | name == other.name && 207 | type == other.type && 208 | isOptional == other.isOptional && 209 | isNamed == other.isNamed && 210 | defaultValue == other.defaultValue; 211 | 212 | @override 213 | int get hashCode => 214 | Object.hash(name, type, isOptional, isNamed, defaultValue); 215 | 216 | @override 217 | String toString() => 218 | 'ParameterInfo(' 219 | 'name: $name, ' 220 | 'type: $type, ' 221 | 'isOptional: $isOptional, ' 222 | 'isNamed: $isNamed, ' 223 | 'defaultValue: $defaultValue' 224 | ')'; 225 | } 226 | 227 | /// Helper function for comparing lists (since List.== doesn't exist) 228 | bool _listEquals(List a, List b) { 229 | if (a.length != b.length) return false; 230 | for (int i = 0; i < a.length; i++) { 231 | if (a[i] != b[i]) return false; 232 | } 233 | return true; 234 | } 235 | -------------------------------------------------------------------------------- /packages/solid_generator/test/transpiler_logic_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:analyzer/dart/analysis/features.dart'; 3 | import 'package:analyzer/dart/analysis/utilities.dart'; 4 | 5 | import 'package:solid_generator/src/solid_builder.dart'; 6 | 7 | void main() { 8 | group('Transpiler Logic Tests', () { 9 | test('transforms @SolidState fields correctly', () async { 10 | const input = ''' 11 | import 'package:flutter/material.dart'; 12 | import 'package:solid_annotations/solid_annotations.dart'; 13 | 14 | class Counter { 15 | @SolidState() 16 | int count = 0; 17 | 18 | @SolidState(name: 'customCounter') 19 | int value = 5; 20 | } 21 | '''; 22 | 23 | final builder = SolidBuilder(); 24 | 25 | // Parse the input 26 | final parseResult = parseString( 27 | content: input, 28 | featureSet: FeatureSet.latestLanguageVersion(), 29 | ); 30 | 31 | expect(parseResult.errors, isEmpty); 32 | 33 | // Transform using the internal method 34 | final result = await builder.transformAstForTesting( 35 | parseResult.unit, 36 | 'test.dart', 37 | input, 38 | ); 39 | 40 | // Verify transformations 41 | expect(result, contains('final count = Signal(0, name: \'count\')')); 42 | expect( 43 | result, 44 | contains('final value = Signal(5, name: \'customCounter\')'), 45 | ); 46 | expect( 47 | result, 48 | contains('import \'package:flutter_solidart/flutter_solidart.dart\''), 49 | ); 50 | 51 | // Ensure original annotations are removed 52 | expect(result, isNot(contains('@SolidState()'))); 53 | expect(result, isNot(contains('int count = 0'))); 54 | }); 55 | 56 | test('copies files without reactive annotations unchanged', () async { 57 | const input = ''' 58 | class RegularClass { 59 | int normalField = 0; 60 | String get normalGetter => 'hello'; 61 | void normalMethod() { 62 | print('hello'); 63 | } 64 | } 65 | '''; 66 | 67 | final builder = SolidBuilder(); 68 | 69 | // Parse the input 70 | final parseResult = parseString( 71 | content: input, 72 | featureSet: FeatureSet.latestLanguageVersion(), 73 | ); 74 | 75 | expect(parseResult.errors, isEmpty); 76 | 77 | // Transform using the internal method 78 | final result = await builder.transformAstForTesting( 79 | parseResult.unit, 80 | 'test.dart', 81 | input, 82 | ); 83 | 84 | // Should be identical (just with formatting) 85 | expect(result, contains('class RegularClass')); 86 | expect(result, contains('int normalField = 0')); 87 | expect(result, isNot(contains('Signal'))); 88 | expect( 89 | result, 90 | isNot( 91 | contains('import \'package:flutter_solidart/flutter_solidart.dart\''), 92 | ), 93 | ); 94 | }); 95 | 96 | test('transforms @SolidState getters to Computed', () async { 97 | const input = ''' 98 | import 'package:solid_annotations/solid_annotations.dart'; 99 | 100 | class Calculator { 101 | @SolidState() 102 | String get result => firstName + ' ' + lastName; 103 | } 104 | '''; 105 | 106 | final builder = SolidBuilder(); 107 | 108 | // Parse the input 109 | final parseResult = parseString( 110 | content: input, 111 | featureSet: FeatureSet.latestLanguageVersion(), 112 | ); 113 | 114 | expect(parseResult.errors, isEmpty); 115 | 116 | // Transform using the internal method 117 | final result = await builder.transformAstForTesting( 118 | parseResult.unit, 119 | 'test.dart', 120 | input, 121 | ); 122 | 123 | // Verify transformation 124 | expect(result, contains('final result = Computed')); 125 | expect(result, contains('firstName.value + \' \' + lastName.value')); 126 | expect( 127 | result, 128 | contains('import \'package:flutter_solidart/flutter_solidart.dart\''), 129 | ); 130 | }); 131 | 132 | test('transforms @SolidEffect methods to Effects', () async { 133 | const input = ''' 134 | import 'package:solid_annotations/solid_annotations.dart'; 135 | 136 | class Logger { 137 | @SolidEffect() 138 | void logCounter() { 139 | print(counter); 140 | } 141 | } 142 | '''; 143 | 144 | final builder = SolidBuilder(); 145 | 146 | // Parse the input 147 | final parseResult = parseString( 148 | content: input, 149 | featureSet: FeatureSet.latestLanguageVersion(), 150 | ); 151 | 152 | expect(parseResult.errors, isEmpty); 153 | 154 | // Transform using the internal method 155 | final result = await builder.transformAstForTesting( 156 | parseResult.unit, 157 | 'test.dart', 158 | input, 159 | ); 160 | 161 | // Verify transformation 162 | expect(result, contains('final logCounter = Effect')); 163 | expect(result, contains('print(counter.value)')); 164 | expect( 165 | result, 166 | contains('import \'package:flutter_solidart/flutter_solidart.dart\''), 167 | ); 168 | }); 169 | 170 | test('transforms @SolidQuery methods to Resources', () async { 171 | const input = ''' 172 | import 'package:solid_annotations/solid_annotations.dart'; 173 | 174 | class DataService { 175 | @SolidQuery(name: 'userData', debounce: Duration(milliseconds: 300)) 176 | Future fetchUser() async { 177 | return 'user data'; 178 | } 179 | } 180 | '''; 181 | 182 | final builder = SolidBuilder(); 183 | 184 | // Parse the input 185 | final parseResult = parseString( 186 | content: input, 187 | featureSet: FeatureSet.latestLanguageVersion(), 188 | ); 189 | 190 | expect(parseResult.errors, isEmpty); 191 | 192 | // Transform using the internal method 193 | final result = await builder.transformAstForTesting( 194 | parseResult.unit, 195 | 'test.dart', 196 | input, 197 | ); 198 | 199 | // Verify transformation 200 | expect(result, contains('late final fetchUser = Resource')); 201 | expect(result, contains('name: \'userData\'')); 202 | expect( 203 | result, 204 | contains('debounceDelay: const Duration(milliseconds: 300)'), 205 | ); 206 | expect( 207 | result, 208 | contains('import \'package:flutter_solidart/flutter_solidart.dart\''), 209 | ); 210 | }); 211 | 212 | test( 213 | 'transforms @SolidQuery methods with multiple dependencies to Resource with Computed source', 214 | () async { 215 | const input = ''' 216 | import 'package:solid_annotations/solid_annotations.dart'; 217 | 218 | class DataService { 219 | @SolidState() 220 | String? userId; 221 | 222 | @SolidState() 223 | String? authToken; 224 | 225 | @SolidQuery() 226 | Future fetchData() async { 227 | if (userId == null || authToken == null) return 'no data'; 228 | return 'user data'; 229 | } 230 | } 231 | '''; 232 | 233 | final builder = SolidBuilder(); 234 | 235 | // Parse the input 236 | final parseResult = parseString( 237 | content: input, 238 | featureSet: FeatureSet.latestLanguageVersion(), 239 | ); 240 | 241 | expect(parseResult.errors, isEmpty); 242 | 243 | // Transform using the internal method 244 | final result = await builder.transformAstForTesting( 245 | parseResult.unit, 246 | 'test.dart', 247 | input, 248 | ); 249 | 250 | // Verify transformation with multiple dependencies generates Computed source 251 | expect(result, contains('late final fetchData = Resource')); 252 | expect( 253 | result, 254 | contains( 255 | 'source: Computed(() => (userId.value, authToken.value), name: \'fetchDataSource\')', 256 | ), 257 | ); 258 | expect(result, contains('name: \'fetchData\'')); 259 | expect( 260 | result, 261 | contains('import \'package:flutter_solidart/flutter_solidart.dart\''), 262 | ); 263 | }, 264 | ); 265 | }); 266 | } 267 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/src/field_analyzer.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/ast/ast.dart'; 2 | 3 | import 'result.dart'; 4 | import 'transformation_error.dart'; 5 | import 'ast_models.dart'; 6 | 7 | /// Pure function to extract field information from a FieldDeclaration. 8 | /// Returns immutable FieldInfo without modifying the input AST. 9 | Result extractFieldInfo(FieldDeclaration field) { 10 | try { 11 | // Immutably extract field information 12 | final fields = field.fields; 13 | final variables = List.unmodifiable(fields.variables); 14 | 15 | if (variables.isEmpty) { 16 | return Failure( 17 | AnalysisError( 18 | 'Field declaration has no variables', 19 | _getLocationString(field), 20 | 'unknown', 21 | ), 22 | ); 23 | } 24 | 25 | // Take the first variable (fields can declare multiple variables) 26 | final variable = variables.first; 27 | final variableName = variable.name.lexeme; 28 | 29 | // Extract type information immutably 30 | final typeAnnotation = fields.type; 31 | String typeString; 32 | bool isNullable = false; 33 | 34 | if (typeAnnotation != null) { 35 | typeString = typeAnnotation.toSource(); 36 | // Check for nullable type (ends with ?) 37 | isNullable = typeString.endsWith('?'); 38 | } else { 39 | // Type inference case - we'll need to analyze the initializer 40 | typeString = 'dynamic'; // Fallback 41 | } 42 | 43 | // Extract initialization value immutably 44 | String? initialValue; 45 | final initializer = variable.initializer; 46 | if (initializer != null) { 47 | initialValue = initializer.toSource(); 48 | } 49 | 50 | // Extract modifiers immutably 51 | final keyword = field.fields.keyword; 52 | final isFinal = keyword?.lexeme == 'final'; 53 | final isConst = keyword?.lexeme == 'const'; 54 | 55 | return Success( 56 | FieldInfo( 57 | name: variableName, 58 | type: typeString, 59 | initialValue: initialValue, 60 | isNullable: isNullable, 61 | isFinal: isFinal, 62 | isConst: isConst, 63 | location: _getLocationString(field), 64 | ), 65 | ); 66 | } catch (e) { 67 | return Failure( 68 | AnalysisError( 69 | 'Failed to extract field information: $e', 70 | _getLocationString(field), 71 | 'field', 72 | ), 73 | ); 74 | } 75 | } 76 | 77 | /// Pure function to extract getter information from a MethodDeclaration. 78 | /// Returns immutable GetterInfo without modifying the input AST. 79 | Result extractGetterInfo(MethodDeclaration getter) { 80 | try { 81 | if (!getter.isGetter) { 82 | return Failure( 83 | AnalysisError( 84 | 'Method is not a getter', 85 | _getLocationString(getter), 86 | getter.name.lexeme, 87 | ), 88 | ); 89 | } 90 | 91 | final getterName = getter.name.lexeme; 92 | 93 | // Extract return type immutably 94 | final returnTypeAnnotation = getter.returnType; 95 | String returnTypeString; 96 | bool isNullable = false; 97 | 98 | if (returnTypeAnnotation != null) { 99 | returnTypeString = returnTypeAnnotation.toSource(); 100 | isNullable = returnTypeString.endsWith('?'); 101 | } else { 102 | returnTypeString = 'dynamic'; // Fallback for type inference 103 | } 104 | 105 | // Extract getter body expression immutably 106 | String expression = ''; 107 | final body = getter.body; 108 | if (body is ExpressionFunctionBody) { 109 | expression = body.expression.toSource(); 110 | } else if (body is BlockFunctionBody) { 111 | // For block bodies, we need to extract the return statement 112 | final statements = body.block.statements; 113 | if (statements.isNotEmpty) { 114 | final lastStatement = statements.last; 115 | if (lastStatement is ReturnStatement && 116 | lastStatement.expression != null) { 117 | expression = lastStatement.expression!.toSource(); 118 | } 119 | } 120 | } 121 | 122 | return Success( 123 | GetterInfo( 124 | name: getterName, 125 | returnType: returnTypeString, 126 | expression: expression, 127 | isNullable: isNullable, 128 | location: _getLocationString(getter), 129 | ), 130 | ); 131 | } catch (e) { 132 | return Failure( 133 | AnalysisError( 134 | 'Failed to extract getter information: $e', 135 | _getLocationString(getter), 136 | getter.name.lexeme, 137 | ), 138 | ); 139 | } 140 | } 141 | 142 | /// Pure function to extract method information from a MethodDeclaration. 143 | /// Returns immutable MethodInfo without modifying the input AST. 144 | Result extractMethodInfo(MethodDeclaration method) { 145 | try { 146 | final methodName = method.name.lexeme; 147 | 148 | // Extract return type immutably 149 | final returnTypeAnnotation = method.returnType; 150 | String returnTypeString; 151 | if (returnTypeAnnotation != null) { 152 | returnTypeString = returnTypeAnnotation.toSource(); 153 | } else { 154 | returnTypeString = 'void'; // Default for methods 155 | } 156 | 157 | // Extract method body immutably 158 | String bodyString = ''; 159 | final body = method.body; 160 | if (body is BlockFunctionBody) { 161 | bodyString = body.block.toSource(); 162 | } else if (body is ExpressionFunctionBody) { 163 | bodyString = '=> ${body.expression.toSource()};'; 164 | } 165 | 166 | // Extract parameters immutably 167 | final parameterList = method.parameters; 168 | final parameters = []; 169 | if (parameterList != null) { 170 | for (final param in parameterList.parameters) { 171 | final paramInfo = _extractParameterInfo(param); 172 | parameters.add(paramInfo); 173 | } 174 | } 175 | 176 | // Check if method is async 177 | final isAsync = method.body is BlockFunctionBody 178 | ? (method.body as BlockFunctionBody).keyword?.lexeme == 'async' 179 | : false; 180 | 181 | return Success( 182 | MethodInfo( 183 | name: methodName, 184 | returnType: returnTypeString, 185 | body: bodyString, 186 | parameters: List.unmodifiable(parameters), 187 | isAsync: isAsync, 188 | location: _getLocationString(method), 189 | ), 190 | ); 191 | } catch (e) { 192 | return Failure( 193 | AnalysisError( 194 | 'Failed to extract method information: $e', 195 | _getLocationString(method), 196 | method.name.lexeme, 197 | ), 198 | ); 199 | } 200 | } 201 | 202 | /// Pure helper function to extract parameter information 203 | ParameterInfo _extractParameterInfo(FormalParameter param) { 204 | String name = ''; 205 | String type = 'dynamic'; 206 | bool isOptional = false; 207 | bool isNamed = false; 208 | String? defaultValue; 209 | 210 | if (param is SimpleFormalParameter) { 211 | name = param.name?.lexeme ?? ''; 212 | if (param.type != null) { 213 | type = param.type!.toSource(); 214 | } 215 | } else if (param is DefaultFormalParameter) { 216 | final parameter = param.parameter; 217 | if (parameter is SimpleFormalParameter) { 218 | name = parameter.name?.lexeme ?? ''; 219 | if (parameter.type != null) { 220 | type = parameter.type!.toSource(); 221 | } 222 | } 223 | isOptional = true; 224 | isNamed = param.isNamed; 225 | if (param.defaultValue != null) { 226 | defaultValue = param.defaultValue!.toSource(); 227 | } 228 | } 229 | 230 | return ParameterInfo( 231 | name: name, 232 | type: type, 233 | isOptional: isOptional, 234 | isNamed: isNamed, 235 | defaultValue: defaultValue, 236 | ); 237 | } 238 | 239 | /// Pure helper function to extract location string from AST node 240 | String _getLocationString(AstNode node) { 241 | try { 242 | final source = node.root.toSource(); 243 | final offset = node.offset; 244 | final lines = source.substring(0, offset).split('\n'); 245 | return 'line ${lines.length}:${lines.last.length + 1}'; 246 | } catch (e) { 247 | return 'unknown location'; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/src/annotation_parser.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/ast/ast.dart'; 2 | 3 | import 'result.dart'; 4 | import 'transformation_error.dart'; 5 | import 'ast_models.dart'; 6 | 7 | /// Pure function to parse @SolidState annotation from a field declaration. 8 | /// Returns Result to avoid throwing exceptions (functional error handling). 9 | Result parseSolidStateAnnotation( 10 | FieldDeclaration field, 11 | ) { 12 | try { 13 | // Extract annotations immutably 14 | final annotations = List.unmodifiable(field.metadata); 15 | 16 | // Find @SolidState annotation 17 | final stateAnnotation = annotations.where((annotation) { 18 | final name = annotation.name.name; 19 | return name == 'SolidState'; 20 | }).firstOrNull; 21 | 22 | if (stateAnnotation == null) { 23 | return const Failure( 24 | AnnotationParseError( 25 | 'No @SolidState annotation found', 26 | null, 27 | 'SolidState', 28 | ), 29 | ); 30 | } 31 | 32 | // Parse annotation arguments immutably 33 | String? customName; 34 | final arguments = stateAnnotation.arguments; 35 | if (arguments != null && arguments.arguments.isNotEmpty) { 36 | for (final arg in arguments.arguments) { 37 | if (arg is NamedExpression && arg.name.label.name == 'name') { 38 | if (arg.expression is StringLiteral) { 39 | customName = (arg.expression as StringLiteral).stringValue; 40 | } 41 | } 42 | } 43 | } 44 | 45 | return Success(AnnotationInfo(name: 'SolidState', customName: customName)); 46 | } catch (e) { 47 | return Failure( 48 | AnnotationParseError( 49 | 'Failed to parse @SolidState annotation: $e', 50 | _getLocationString(field), 51 | 'SolidState', 52 | ), 53 | ); 54 | } 55 | } 56 | 57 | /// Pure function to parse @SolidState annotation from a getter method. 58 | /// Handles the case where @SolidState is used on getters for Computed. 59 | Result 60 | parseSolidStateAnnotationFromGetter(MethodDeclaration getter) { 61 | try { 62 | // Extract annotations immutably 63 | final annotations = List.unmodifiable(getter.metadata); 64 | 65 | // Find @SolidState annotation 66 | final stateAnnotation = annotations.where((annotation) { 67 | final name = annotation.name.name; 68 | return name == 'SolidState'; 69 | }).firstOrNull; 70 | 71 | if (stateAnnotation == null) { 72 | return const Failure( 73 | AnnotationParseError( 74 | 'No @SolidState annotation found', 75 | null, 76 | 'SolidState', 77 | ), 78 | ); 79 | } 80 | 81 | // Parse annotation arguments immutably 82 | String? customName; 83 | final arguments = stateAnnotation.arguments; 84 | if (arguments != null && arguments.arguments.isNotEmpty) { 85 | for (final arg in arguments.arguments) { 86 | if (arg is NamedExpression && arg.name.label.name == 'name') { 87 | if (arg.expression is StringLiteral) { 88 | customName = (arg.expression as StringLiteral).stringValue; 89 | } 90 | } 91 | } 92 | } 93 | 94 | return Success(AnnotationInfo(name: 'SolidState', customName: customName)); 95 | } catch (e) { 96 | return Failure( 97 | AnnotationParseError( 98 | 'Failed to parse @SolidState annotation from getter: $e', 99 | _getLocationString(getter), 100 | 'SolidState', 101 | ), 102 | ); 103 | } 104 | } 105 | 106 | /// Pure function to parse @SolidEffect annotation from a method. 107 | Result parseSolidEffectAnnotation( 108 | MethodDeclaration method, 109 | ) { 110 | try { 111 | // Extract annotations immutably 112 | final annotations = List.unmodifiable(method.metadata); 113 | 114 | // Find @SolidEffect annotation 115 | final effectAnnotation = annotations.where((annotation) { 116 | final name = annotation.name.name; 117 | return name == 'SolidEffect'; 118 | }).firstOrNull; 119 | 120 | if (effectAnnotation == null) { 121 | return const Failure( 122 | AnnotationParseError( 123 | 'No @SolidEffect annotation found', 124 | null, 125 | 'SolidEffect', 126 | ), 127 | ); 128 | } 129 | 130 | // @SolidEffect currently has no parameters 131 | return const Success(AnnotationInfo(name: 'SolidEffect')); 132 | } catch (e) { 133 | return Failure( 134 | AnnotationParseError( 135 | 'Failed to parse @SolidEffect annotation: $e', 136 | _getLocationString(method), 137 | 'SolidEffect', 138 | ), 139 | ); 140 | } 141 | } 142 | 143 | /// Pure function to parse @SolidQuery annotation from a method. 144 | Result parseQueryAnnotation( 145 | MethodDeclaration method, 146 | ) { 147 | try { 148 | // Extract annotations immutably 149 | final annotations = List.unmodifiable(method.metadata); 150 | 151 | // Find @SolidQuery annotation 152 | final queryAnnotation = annotations.where((annotation) { 153 | final name = annotation.name.name; 154 | return name == 'SolidQuery'; 155 | }).firstOrNull; 156 | 157 | if (queryAnnotation == null) { 158 | return const Failure( 159 | AnnotationParseError( 160 | 'No @SolidQuery annotation found', 161 | null, 162 | 'SolidQuery', 163 | ), 164 | ); 165 | } 166 | 167 | // Parse annotation arguments immutably 168 | String? customName; 169 | String? debounceExpression; 170 | bool? useRefreshing; 171 | final arguments = queryAnnotation.arguments; 172 | if (arguments != null && arguments.arguments.isNotEmpty) { 173 | for (final arg in arguments.arguments) { 174 | if (arg is NamedExpression) { 175 | final paramName = arg.name.label.name; 176 | if (paramName == 'name' && arg.expression is StringLiteral) { 177 | customName = (arg.expression as StringLiteral).stringValue; 178 | } else if (paramName == 'debounce') { 179 | // Store the original Duration expression as string 180 | debounceExpression = arg.expression.toSource(); 181 | } else if (paramName == 'useRefreshing' && 182 | arg.expression is BooleanLiteral) { 183 | useRefreshing = (arg.expression as BooleanLiteral).value; 184 | } 185 | } 186 | } 187 | } 188 | 189 | return Success( 190 | AnnotationInfo( 191 | name: 'SolidQuery', 192 | customName: customName, 193 | debounceExpression: debounceExpression, 194 | useRefreshing: useRefreshing, 195 | ), 196 | ); 197 | } catch (e) { 198 | return Failure( 199 | AnnotationParseError( 200 | 'Failed to parse @SolidQuery annotation: $e', 201 | _getLocationString(method), 202 | 'SolidQuery', 203 | ), 204 | ); 205 | } 206 | } 207 | 208 | /// Pure function to parse @Environment annotation from a field. 209 | Result parseEnvironmentAnnotation( 210 | FieldDeclaration field, 211 | ) { 212 | try { 213 | // Extract annotations immutably 214 | final annotations = List.unmodifiable(field.metadata); 215 | 216 | // Find @Environment annotation 217 | final environmentAnnotation = annotations.where((annotation) { 218 | final name = annotation.name.name; 219 | return name == 'SolidEnvironment'; 220 | }).firstOrNull; 221 | 222 | if (environmentAnnotation == null) { 223 | return const Failure( 224 | AnnotationParseError( 225 | 'No @SolidEnvironment annotation found', 226 | null, 227 | 'SolidEnvironment', 228 | ), 229 | ); 230 | } 231 | 232 | // @Environment currently has no parameters 233 | return const Success(AnnotationInfo(name: 'SolidEnvironment')); 234 | } catch (e) { 235 | return Failure( 236 | AnnotationParseError( 237 | 'Failed to parse @SolidEnvironment annotation: $e', 238 | _getLocationString(field), 239 | 'SolidEnvironment', 240 | ), 241 | ); 242 | } 243 | } 244 | 245 | /// Pure helper function to extract location string from AST node 246 | String _getLocationString(AstNode node) { 247 | try { 248 | final source = node.root.toSource(); 249 | final offset = node.offset; 250 | final lines = source.substring(0, offset).split('\n'); 251 | return 'line ${lines.length}:${lines.last.length + 1}'; 252 | } catch (e) { 253 | return 'unknown location'; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/src/code_generator.dart: -------------------------------------------------------------------------------- 1 | import 'ast_models.dart'; 2 | 3 | /// Pure function to generate Signal declaration code. 4 | /// Same input always produces identical output (deterministic). 5 | String generateSignalDeclaration( 6 | FieldInfo fieldInfo, 7 | AnnotationInfo annotationInfo, 8 | ) { 9 | final signalName = fieldInfo.name; 10 | final signalType = fieldInfo.type; 11 | final initialValue = fieldInfo.initialValue ?? _getDefaultValue(signalType); 12 | final customName = annotationInfo.customName ?? signalName; 13 | 14 | // Generate the Signal declaration 15 | return 'final $signalName = Signal<$signalType>($initialValue, name: \'$customName\');'; 16 | } 17 | 18 | /// Pure function to generate Computed declaration code. 19 | /// Same inputs always produce identical output (deterministic). 20 | String generateComputedDeclaration( 21 | GetterInfo getterInfo, 22 | AnnotationInfo annotationInfo, 23 | List dependencies, 24 | ) { 25 | final computedName = getterInfo.name; 26 | final computedType = getterInfo.returnType; 27 | final customName = annotationInfo.customName ?? computedName; 28 | 29 | // Transform the expression to use .value for reactive dependencies 30 | final transformedExpression = _transformReactiveAccess( 31 | getterInfo.expression, 32 | dependencies, 33 | ); 34 | 35 | // Use 'late final' when Computed references other reactive signals/computed values 36 | final modifier = dependencies.isNotEmpty ? 'late final' : 'final'; 37 | 38 | // Generate the Computed declaration 39 | return '$modifier $computedName = Computed<$computedType>(() => $transformedExpression, name: \'$customName\');'; 40 | } 41 | 42 | /// Pure function to generate Effect declaration code. 43 | /// Same inputs always produce identical output (deterministic). 44 | String generateEffectDeclaration( 45 | MethodInfo methodInfo, 46 | String transformedBody, 47 | ) { 48 | final effectName = methodInfo.name; 49 | 50 | // Generate the Effect declaration using late final to avoid initializer issues 51 | return 'late final $effectName = Effect(() $transformedBody, name: \'$effectName\');'; 52 | } 53 | 54 | /// Pure function to generate Environment declaration code. 55 | /// Same inputs always produce identical output (deterministic). 56 | String generateEnvironmentDeclaration( 57 | FieldInfo fieldInfo, 58 | AnnotationInfo annotationInfo, 59 | ) { 60 | final fieldName = fieldInfo.name; 61 | final fieldType = fieldInfo.type; 62 | 63 | // Generate the context.read() declaration 64 | return 'late final $fieldName = context.read<$fieldType>();'; 65 | } 66 | 67 | /// Pure function to generate Resource declaration code. 68 | /// Same inputs always produce identical output (deterministic). 69 | String generateResourceDeclaration( 70 | MethodInfo methodInfo, 71 | AnnotationInfo annotationInfo, 72 | List dependencies, 73 | ) { 74 | final resourceName = methodInfo.name; 75 | final customName = annotationInfo.customName ?? resourceName; 76 | 77 | // Extract the return type from Future or Stream -> T 78 | final returnType = _extractGenericType(methodInfo.returnType); 79 | 80 | // Check if this is a Stream method 81 | final isStream = methodInfo.returnType.startsWith('Stream<'); 82 | 83 | // Transform the method body to use .value for reactive dependencies 84 | final transformedBody = _transformReactiveAccess( 85 | methodInfo.body, 86 | dependencies, 87 | ); 88 | 89 | // Generate the Resource declaration 90 | final buffer = StringBuffer(); 91 | 92 | if (isStream) { 93 | // For streams, use Resource.stream() constructor 94 | buffer.write('late final $resourceName = Resource<$returnType>.stream('); 95 | } else { 96 | // For futures, use regular Resource() constructor 97 | buffer.write('late final $resourceName = Resource<$returnType>('); 98 | } 99 | 100 | // If it's an async method, wrap in async function 101 | if (methodInfo.isAsync) { 102 | buffer.write('() async $transformedBody'); 103 | } else { 104 | buffer.write('() $transformedBody'); 105 | } 106 | 107 | // Add source parameter if there are reactive dependencies 108 | if (dependencies.isNotEmpty) { 109 | if (dependencies.length == 1) { 110 | buffer.write(', source: ${dependencies.first}'); 111 | } else { 112 | // Multiple dependencies - create a Computed that combines all dependencies 113 | final sourceName = '${resourceName}Source'; 114 | final dependencyValues = dependencies 115 | .map((dep) => '$dep.value') 116 | .join(', '); 117 | buffer.write( 118 | ', source: Computed(() => ($dependencyValues), name: \'$sourceName\')', 119 | ); 120 | } 121 | } 122 | 123 | // Add name parameter 124 | buffer.write(', name: \'$customName\''); 125 | 126 | // Add debounce parameter if specified 127 | if (annotationInfo.debounceExpression != null) { 128 | buffer.write(', debounceDelay: const ${annotationInfo.debounceExpression}'); 129 | } 130 | 131 | // Add useRefreshing parameter if specified 132 | if (annotationInfo.useRefreshing != null) { 133 | buffer.write(', useRefreshing: ${annotationInfo.useRefreshing}'); 134 | } 135 | 136 | buffer.write(');'); 137 | 138 | return buffer.toString(); 139 | } 140 | 141 | /// Pure function to generate SignalBuilder widget wrapper. 142 | /// Same inputs always produce identical output (deterministic). 143 | String generateSignalBuilderWrapper( 144 | String originalWidget, 145 | List dependencies, 146 | ) { 147 | if (dependencies.isEmpty) { 148 | return originalWidget; 149 | } 150 | 151 | return 'SignalBuilder(\n' 152 | ' builder: (context, child) {\n' 153 | ' return $originalWidget;\n' 154 | ' }\n' 155 | ')'; 156 | } 157 | 158 | /// Pure function to generate disposal code for reactive primitives. 159 | /// Same inputs always produce identical output (deterministic). 160 | String generateDisposalCode(List reactiveNames) { 161 | if (reactiveNames.isEmpty) { 162 | return ''; 163 | } 164 | 165 | final buffer = StringBuffer(); 166 | buffer.writeln('@override'); 167 | buffer.writeln('void dispose() {'); 168 | 169 | // Dispose in proper order: Effects, Resources, Computed, Signals 170 | for (final name in reactiveNames) { 171 | buffer.writeln(' $name.dispose();'); 172 | } 173 | 174 | buffer.writeln(' super.dispose();'); 175 | buffer.writeln('}'); 176 | 177 | return buffer.toString(); 178 | } 179 | 180 | /// Pure helper function to get default value for a type 181 | String _getDefaultValue(String type) { 182 | // Check for nullable first 183 | if (type.endsWith('?')) { 184 | return 'null'; 185 | } 186 | 187 | final cleanType = type.replaceAll('?', ''); // Remove nullable marker 188 | 189 | switch (cleanType) { 190 | case 'int': 191 | return '0'; 192 | case 'double': 193 | return '0.0'; 194 | case 'bool': 195 | return 'false'; 196 | case 'String': 197 | return "''"; 198 | default: 199 | // For complex types, try to provide a reasonable default 200 | if (cleanType.startsWith('List')) { 201 | return '[]'; 202 | } 203 | if (cleanType.startsWith('Map')) { 204 | return '{}'; 205 | } 206 | if (cleanType.startsWith('Set')) { 207 | return '{}'; 208 | } 209 | // For custom types, use null as default 210 | return 'null'; 211 | } 212 | } 213 | 214 | /// Pure helper function to transform reactive variable access 215 | /// Converts variable references to variable.value for reactive dependencies 216 | String _transformReactiveAccess(String code, List dependencies) { 217 | String transformedCode = code; 218 | 219 | // Transform reactive variable access to use .value 220 | for (final dependency in dependencies) { 221 | // Handle string interpolation first - transform $dependency to ${dependency.value} 222 | final interpolationPattern = RegExp( 223 | r'\$' + dependency + r'(?!\.value)(?!\w)', 224 | ); 225 | transformedCode = transformedCode.replaceAll( 226 | interpolationPattern, 227 | '\${$dependency.value}', 228 | ); 229 | 230 | // Handle other variable access patterns (not in string interpolation) 231 | final variablePattern = RegExp( 232 | r'(? or Stream\ 244 | String _extractGenericType(String type) { 245 | if (type.startsWith('Future<') && type.endsWith('>')) { 246 | return type.substring(7, type.length - 1); 247 | } 248 | if (type.startsWith('Stream<') && type.endsWith('>')) { 249 | return type.substring(7, type.length - 1); 250 | } 251 | return type; 252 | } 253 | -------------------------------------------------------------------------------- /example/source/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:solid_annotations/extensions.dart'; 3 | import 'package:solid_annotations/provider.dart'; 4 | import 'package:solid_annotations/solid_annotations.dart'; 5 | 6 | final routes = { 7 | '/state': (_) => CounterPage(), 8 | '/computed': (_) => ComputedExample(), 9 | '/effect': (_) => EffectExample(), 10 | '/query': (_) => const QueryExample(), 11 | '/query_with_source': (_) => QueryWithSourceExample(), 12 | '/query_with_multiple_sources': (_) => QueryWithMultipleSourcesExample(), 13 | '/environment': (_) => const EnvironmentExample(), 14 | '/query_with_stream': (_) => const QueryWithStreamExample(), 15 | '/query_with_stream_and_source': (_) => QueryWithStreamAndSourceExample(), 16 | }; 17 | 18 | final routeToNameRegex = RegExp('(?:^/|-)([a-zA-Z])'); 19 | 20 | void main() { 21 | runApp(const MyApp()); 22 | } 23 | 24 | class MyApp extends StatelessWidget { 25 | const MyApp({super.key}); 26 | 27 | @override 28 | Widget build(BuildContext context) { 29 | return MaterialApp( 30 | title: 'Solid Demo', 31 | home: const MainPage(), 32 | routes: routes, 33 | ); 34 | } 35 | } 36 | 37 | class MainPage extends StatelessWidget { 38 | const MainPage({super.key}); 39 | 40 | @override 41 | Widget build(BuildContext context) { 42 | return Scaffold( 43 | body: ListView.builder( 44 | itemCount: routes.length, 45 | itemBuilder: (BuildContext context, int index) { 46 | final route = routes.keys.elementAt(index); 47 | 48 | final name = route.replaceAllMapped( 49 | routeToNameRegex, 50 | (match) => match.group(0)!.substring(1).toUpperCase(), 51 | ); 52 | 53 | return Material( 54 | child: ListTile( 55 | title: Text(name), 56 | onTap: () { 57 | Navigator.of(context).pushNamed(route); 58 | }, 59 | ), 60 | ); 61 | }, 62 | ), 63 | ); 64 | } 65 | } 66 | 67 | class CounterPage extends StatelessWidget { 68 | CounterPage({super.key}); 69 | 70 | @SolidState(name: 'customName') 71 | int counter = 0; 72 | 73 | @override 74 | Widget build(BuildContext context) { 75 | return Scaffold( 76 | appBar: AppBar(title: const Text('State')), 77 | body: Center( 78 | child: Column( 79 | mainAxisAlignment: MainAxisAlignment.center, 80 | children: [ 81 | Text(DateTime.now().toString()), 82 | Text('Counter: $counter'), 83 | const SizedBox(height: 12), 84 | ElevatedButton( 85 | onPressed: () => counter++, 86 | child: const Text('Increment'), 87 | ), 88 | ], 89 | ), 90 | ), 91 | ); 92 | } 93 | } 94 | 95 | class ComputedExample extends StatelessWidget { 96 | ComputedExample({super.key}); 97 | @SolidState() 98 | int counter = 0; 99 | 100 | @SolidState() 101 | int get doubleCounter => counter * 2; 102 | 103 | @override 104 | Widget build(BuildContext context) { 105 | return Scaffold( 106 | appBar: AppBar(title: const Text('Computed')), 107 | body: Center( 108 | child: Text('Counter: $counter, DoubleCounter: $doubleCounter'), 109 | ), 110 | floatingActionButton: FloatingActionButton( 111 | onPressed: () => counter++, 112 | child: const Icon(Icons.add), 113 | ), 114 | ); 115 | } 116 | } 117 | 118 | class EffectExample extends StatelessWidget { 119 | EffectExample({super.key}); 120 | 121 | @SolidState() 122 | int counter = 0; 123 | 124 | @SolidEffect() 125 | void logCounter() { 126 | print('Counter changed: $counter'); 127 | } 128 | 129 | @override 130 | Widget build(BuildContext context) { 131 | return Scaffold( 132 | appBar: AppBar(title: const Text('Effect')), 133 | body: Center(child: Text('Counter: $counter')), 134 | floatingActionButton: FloatingActionButton( 135 | onPressed: () => counter++, 136 | child: const Icon(Icons.add), 137 | ), 138 | ); 139 | } 140 | } 141 | 142 | class QueryExample extends StatelessWidget { 143 | const QueryExample({super.key}); 144 | 145 | @SolidQuery() 146 | Future fetchData() async { 147 | await Future.delayed(const Duration(seconds: 1)); 148 | return 'Fetched Data'; 149 | } 150 | 151 | @override 152 | Widget build(BuildContext context) { 153 | return Scaffold( 154 | appBar: AppBar(title: const Text('Query')), 155 | body: Center( 156 | child: fetchData().when( 157 | ready: (data) => Text(data), 158 | loading: () => const CircularProgressIndicator(), 159 | error: (error, stackTrace) => Text('Error: $error'), 160 | ), 161 | ), 162 | ); 163 | } 164 | } 165 | 166 | class QueryWithSourceExample extends StatelessWidget { 167 | QueryWithSourceExample({super.key}); 168 | 169 | @SolidState() 170 | String? userId; 171 | 172 | @SolidQuery(debounce: Duration(seconds: 1)) 173 | Future fetchData() async { 174 | if (userId == null) return null; 175 | await Future.delayed(const Duration(seconds: 1)); 176 | return 'Fetched Data for $userId'; 177 | } 178 | 179 | @override 180 | Widget build(BuildContext context) { 181 | return Scaffold( 182 | appBar: AppBar(title: const Text('QueryWithSource')), 183 | body: Center( 184 | child: fetchData().when( 185 | ready: (data) { 186 | if (data == null) { 187 | return const Text('No user ID provided'); 188 | } 189 | return Text(data); 190 | }, 191 | loading: () => const CircularProgressIndicator(), 192 | error: (error, stackTrace) => Text('Error: $error'), 193 | ), 194 | ), 195 | floatingActionButton: FloatingActionButton( 196 | onPressed: () => 197 | userId = 'user_${DateTime.now().millisecondsSinceEpoch}', 198 | child: const Icon(Icons.refresh), 199 | ), 200 | ); 201 | } 202 | } 203 | 204 | class QueryWithMultipleSourcesExample extends StatelessWidget { 205 | QueryWithMultipleSourcesExample({super.key}); 206 | 207 | @SolidState() 208 | String? userId; 209 | 210 | @SolidState() 211 | String? authToken; 212 | 213 | @SolidQuery() 214 | Future fetchData() async { 215 | if (userId == null || authToken == null) return null; 216 | await Future.delayed(const Duration(seconds: 1)); 217 | return 'Fetched Data for $userId'; 218 | } 219 | 220 | @override 221 | Widget build(BuildContext context) { 222 | return Scaffold( 223 | appBar: AppBar(title: const Text('QueryWithMultipleSources')), 224 | body: Center( 225 | child: Column( 226 | spacing: 8, 227 | children: [ 228 | const Text('Complex SolidQuery example'), 229 | fetchData().when( 230 | ready: (data) { 231 | if (data == null) { 232 | return const Text('No user ID provided'); 233 | } 234 | return Text(data); 235 | }, 236 | loading: () => const CircularProgressIndicator(), 237 | error: (error, stackTrace) => Text('Error: $error'), 238 | ), 239 | ], 240 | ), 241 | ), 242 | floatingActionButton: FloatingActionButton( 243 | onPressed: () { 244 | userId = 'user_${DateTime.now().millisecondsSinceEpoch}'; 245 | authToken = 'token_${DateTime.now().millisecondsSinceEpoch}'; 246 | }, 247 | child: const Icon(Icons.refresh), 248 | ), 249 | ); 250 | } 251 | } 252 | 253 | class ACustomClassWithSolidState { 254 | @SolidState() 255 | int value = 0; 256 | 257 | void dispose() { 258 | print('ACustomClass disposed'); 259 | } 260 | } 261 | 262 | class ACustomClass { 263 | void doNothing() { 264 | // no-op 265 | } 266 | } 267 | 268 | class EnvironmentExample extends StatelessWidget { 269 | const EnvironmentExample({super.key}); 270 | 271 | @override 272 | Widget build(BuildContext context) { 273 | return SolidProvider( 274 | create: (context) => ACustomClassWithSolidState(), 275 | child: EnvironmentInjectionExample(), 276 | ); 277 | } 278 | } 279 | 280 | class EnvironmentInjectionExample extends StatelessWidget { 281 | EnvironmentInjectionExample({super.key}); 282 | 283 | @SolidEnvironment() 284 | late ACustomClassWithSolidState myData; 285 | 286 | @override 287 | Widget build(BuildContext context) { 288 | return Scaffold( 289 | appBar: AppBar(title: const Text('Environment')), 290 | body: Center(child: Text(myData.value.toString())), 291 | floatingActionButton: FloatingActionButton( 292 | onPressed: () => myData.value++, 293 | child: const Icon(Icons.add), 294 | ), 295 | ); 296 | } 297 | } 298 | 299 | class QueryWithStreamExample extends StatelessWidget { 300 | const QueryWithStreamExample({super.key}); 301 | 302 | @SolidQuery() 303 | Stream fetchData() { 304 | return Stream.periodic(const Duration(seconds: 1), (i) => i); 305 | } 306 | 307 | @override 308 | Widget build(BuildContext context) { 309 | return Scaffold( 310 | appBar: AppBar(title: const Text('QueryWithStream')), 311 | body: Center( 312 | child: fetchData().when( 313 | ready: (data) => Text(data.toString()), 314 | loading: () => const CircularProgressIndicator(), 315 | error: (error, stackTrace) => Text('Error: $error'), 316 | ), 317 | ), 318 | ); 319 | } 320 | } 321 | 322 | class QueryWithStreamAndSourceExample extends StatelessWidget { 323 | QueryWithStreamAndSourceExample({super.key}); 324 | 325 | @SolidState() 326 | int multiplier = 1; 327 | 328 | @SolidQuery(useRefreshing: false) 329 | Stream fetchData() { 330 | return Stream.periodic(const Duration(seconds: 1), (i) => i * multiplier); 331 | } 332 | 333 | @override 334 | Widget build(BuildContext context) { 335 | return Scaffold( 336 | appBar: AppBar(title: const Text('QueryWithStream')), 337 | body: Center( 338 | child: Column( 339 | children: [ 340 | Text('Is refreshing: ${fetchData().isRefreshing}'), 341 | fetchData().when( 342 | ready: (data) => Text(data.toString()), 343 | loading: CircularProgressIndicator.new, 344 | error: (error, stackTrace) => Text('Error: $error'), 345 | ), 346 | ElevatedButton( 347 | onPressed: fetchData.refresh, 348 | child: const Text('Manual Refresh'), 349 | ), 350 | ], 351 | ), 352 | ), 353 | floatingActionButton: FloatingActionButton( 354 | onPressed: () => multiplier++, 355 | child: const Icon(Icons.add), 356 | ), 357 | ); 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /packages/solid_generator/lib/src/reactive_state_transformer.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/ast/ast.dart'; 2 | import 'package:analyzer/dart/ast/visitor.dart'; 3 | 4 | import 'result.dart'; 5 | import 'transformation_error.dart'; 6 | import 'annotation_parser.dart'; 7 | import 'field_analyzer.dart'; 8 | import 'code_generator.dart'; 9 | 10 | /// Abstract base class for functional transformers. 11 | /// Each transformer handles exactly one AST transformation type. 12 | abstract class FunctionalTransformer { 13 | /// Pure function - no side effects 14 | Result transform(TInput input); 15 | 16 | /// Validation as pure function 17 | bool canTransform(TInput input); 18 | 19 | /// Immutable dependency extraction 20 | List extractDependencies(TInput input); 21 | } 22 | 23 | /// Functional transformer for @SolidState fields -> Signal declarations. 24 | /// Follows single responsibility principle and pure function requirements. 25 | class SolidStateTransformer 26 | extends FunctionalTransformer { 27 | SolidStateTransformer(); 28 | 29 | @override 30 | Result transform(FieldDeclaration field) { 31 | // Functional pipeline with immutable data flow 32 | return parseSolidStateAnnotation(field) 33 | .mapError((error) => error) 34 | .flatMap( 35 | (annotation) => extractFieldInfo(field) 36 | .mapError((error) => error) 37 | .map( 38 | (fieldInfo) => generateSignalDeclaration(fieldInfo, annotation), 39 | ), 40 | ); 41 | } 42 | 43 | @override 44 | bool canTransform(FieldDeclaration field) { 45 | // Pure validation function 46 | try { 47 | final annotations = field.metadata; 48 | return annotations.any( 49 | (annotation) => annotation.name.name == 'SolidState', 50 | ); 51 | } catch (e) { 52 | return false; 53 | } 54 | } 55 | 56 | @override 57 | List extractDependencies(FieldDeclaration field) { 58 | // Fields don't have dependencies (unlike getters/methods) 59 | return const []; 60 | } 61 | } 62 | 63 | /// Functional transformer for @SolidState getters -> Computed declarations. 64 | /// Handles getters with reactive dependencies. 65 | class SolidComputedTransformer 66 | extends FunctionalTransformer { 67 | SolidComputedTransformer(); 68 | 69 | @override 70 | Result transform(MethodDeclaration getter) { 71 | // Functional pipeline with immutable data flow 72 | return parseSolidStateAnnotationFromGetter(getter) 73 | .mapError((error) => error) 74 | .flatMap( 75 | (annotation) => extractGetterInfo(getter) 76 | .mapError((error) => error) 77 | .map( 78 | (getterInfo) => generateComputedDeclaration( 79 | getterInfo, 80 | annotation, 81 | extractDependencies(getter), 82 | ), 83 | ), 84 | ); 85 | } 86 | 87 | @override 88 | bool canTransform(MethodDeclaration getter) { 89 | // Pure validation function 90 | try { 91 | if (!getter.isGetter) return false; 92 | final annotations = getter.metadata; 93 | return annotations.any( 94 | (annotation) => annotation.name.name == 'SolidState', 95 | ); 96 | } catch (e) { 97 | return false; 98 | } 99 | } 100 | 101 | @override 102 | List extractDependencies(MethodDeclaration getter) { 103 | // Extract reactive variable dependencies from getter expression using AST visitor 104 | try { 105 | final body = getter.body; 106 | 107 | if (body is ExpressionFunctionBody) { 108 | return _extractReactiveDependencies(body.expression); 109 | } else if (body is BlockFunctionBody) { 110 | return _extractReactiveDependencies(body.block); 111 | } 112 | 113 | return const []; 114 | } catch (e) { 115 | return const []; 116 | } 117 | } 118 | } 119 | 120 | /// Functional transformer for @SolidEffect methods -> Effect declarations. 121 | class SolidEffectTransformer 122 | extends FunctionalTransformer { 123 | SolidEffectTransformer(); 124 | 125 | @override 126 | Result transform(MethodDeclaration method) { 127 | // Functional pipeline with immutable data flow 128 | return parseSolidEffectAnnotation(method) 129 | .mapError((error) => error) 130 | .flatMap( 131 | (_) => extractMethodInfo(method) 132 | .mapError((error) => error) 133 | .map((methodInfo) { 134 | final dependencies = extractDependencies(method); 135 | final transformedBody = _transformEffectBody( 136 | methodInfo.body, 137 | dependencies, 138 | ); 139 | return generateEffectDeclaration(methodInfo, transformedBody); 140 | }), 141 | ); 142 | } 143 | 144 | @override 145 | bool canTransform(MethodDeclaration method) { 146 | // Pure validation function 147 | try { 148 | final annotations = method.metadata; 149 | return annotations.any( 150 | (annotation) => annotation.name.name == 'SolidEffect', 151 | ); 152 | } catch (e) { 153 | return false; 154 | } 155 | } 156 | 157 | @override 158 | List extractDependencies(MethodDeclaration method) { 159 | // Extract reactive variable dependencies from method body using AST visitor 160 | try { 161 | final body = method.body; 162 | 163 | if (body is BlockFunctionBody) { 164 | return _extractReactiveDependencies(body.block); 165 | } else if (body is ExpressionFunctionBody) { 166 | return _extractReactiveDependencies(body.expression); 167 | } 168 | 169 | return const []; 170 | } catch (e) { 171 | return const []; 172 | } 173 | } 174 | 175 | String _transformEffectBody(String body, List dependencies) { 176 | String transformedBody = body; 177 | 178 | // Transform reactive variable access to use .value 179 | for (final dependency in dependencies) { 180 | // Handle string interpolation first - transform $dependency to ${dependency.value} 181 | final interpolationPattern = RegExp( 182 | r'\$' + dependency + r'(?!\.value)(?!\w)', 183 | ); 184 | transformedBody = transformedBody.replaceAll( 185 | interpolationPattern, 186 | '\${$dependency.value}', 187 | ); 188 | 189 | // Handle other variable access patterns (not in string interpolation) 190 | final variablePattern = RegExp( 191 | r'(? Resource declarations. 204 | class SolidQueryTransformer 205 | extends FunctionalTransformer { 206 | SolidQueryTransformer(); 207 | 208 | @override 209 | Result transform(MethodDeclaration method) { 210 | // Functional pipeline with immutable data flow 211 | return parseQueryAnnotation(method) 212 | .mapError((error) => error) 213 | .flatMap( 214 | (annotation) => extractMethodInfo(method) 215 | .mapError((error) => error) 216 | .map((methodInfo) { 217 | final dependencies = extractDependencies(method); 218 | return generateResourceDeclaration( 219 | methodInfo, 220 | annotation, 221 | dependencies, 222 | ); 223 | }), 224 | ); 225 | } 226 | 227 | @override 228 | bool canTransform(MethodDeclaration method) { 229 | // Pure validation function 230 | try { 231 | final annotations = method.metadata; 232 | return annotations.any( 233 | (annotation) => annotation.name.name == 'SolidQuery', 234 | ); 235 | } catch (e) { 236 | return false; 237 | } 238 | } 239 | 240 | @override 241 | List extractDependencies(MethodDeclaration method) { 242 | // Extract reactive variable dependencies from method body using AST visitor 243 | try { 244 | final body = method.body; 245 | 246 | if (body is BlockFunctionBody) { 247 | return _extractReactiveDependencies(body.block); 248 | } else if (body is ExpressionFunctionBody) { 249 | return _extractReactiveDependencies(body.expression); 250 | } 251 | 252 | return const []; 253 | } catch (e) { 254 | return const []; 255 | } 256 | } 257 | } 258 | 259 | /// Pure helper function to extract reactive dependencies from AST node. 260 | /// Uses proper AST analysis instead of regex patterns. 261 | List _extractReactiveDependencies(AstNode node) { 262 | final visitor = _ReactiveVariableVisitor(); 263 | node.accept(visitor); 264 | return List.unmodifiable(visitor.dependencies); 265 | } 266 | 267 | /// Functional transformer for @Environment fields -> context.read\() calls. 268 | /// Handles dependency injection from environment context. 269 | class EnvironmentTransformer 270 | extends FunctionalTransformer { 271 | EnvironmentTransformer(); 272 | 273 | @override 274 | Result transform(FieldDeclaration field) { 275 | // Functional pipeline with immutable data flow 276 | return parseEnvironmentAnnotation(field) 277 | .mapError((error) => error) 278 | .flatMap( 279 | (annotation) => extractFieldInfo(field) 280 | .mapError((error) => error) 281 | .map( 282 | (fieldInfo) => 283 | generateEnvironmentDeclaration(fieldInfo, annotation), 284 | ), 285 | ); 286 | } 287 | 288 | @override 289 | bool canTransform(FieldDeclaration field) { 290 | // Pure validation function 291 | try { 292 | final annotations = field.metadata; 293 | return annotations.any( 294 | (annotation) => annotation.name.name == 'SolidEnvironment', 295 | ); 296 | } catch (e) { 297 | return false; 298 | } 299 | } 300 | 301 | @override 302 | List extractDependencies(FieldDeclaration field) { 303 | // Environment fields don't have reactive dependencies (they come from context) 304 | return const []; 305 | } 306 | } 307 | 308 | /// AST visitor to extract potential reactive variable dependencies. 309 | /// Looks for SimpleIdentifier nodes that could be field references. 310 | class _ReactiveVariableVisitor extends RecursiveAstVisitor { 311 | final Set _dependencies = {}; 312 | 313 | List get dependencies => _dependencies.toList(); 314 | 315 | @override 316 | void visitSimpleIdentifier(SimpleIdentifier node) { 317 | // Only consider identifiers that could be field references 318 | // Exclude: 319 | // - Method calls (parent is MethodInvocation and this is the methodName) 320 | // - Property access (parent is PropertyAccess and this is the propertyName) 321 | // - Constructor names, type names, etc. 322 | 323 | final parent = node.parent; 324 | 325 | // Skip if this is a method name in a method invocation 326 | if (parent is MethodInvocation && parent.methodName == node) { 327 | return; 328 | } 329 | 330 | // Skip if this is a property name in property access 331 | if (parent is PropertyAccess && parent.propertyName == node) { 332 | return; 333 | } 334 | 335 | // Skip if this is the target of a property access (but consider the base object) 336 | // For example: in "object.property", we want "object" but not "property" 337 | if (parent is PrefixedIdentifier && parent.identifier == node) { 338 | return; 339 | } 340 | 341 | // Skip if this is a named expression label 342 | if (parent is NamedExpression && parent.name.label == node) { 343 | return; 344 | } 345 | 346 | // Skip if this is a type annotation 347 | if (parent is NamedType) { 348 | return; 349 | } 350 | 351 | // Skip keywords and special identifiers 352 | if (node.name == 'this' || 353 | node.name == 'super' || 354 | node.name == 'null' || 355 | node.name == 'true' || 356 | node.name == 'false') { 357 | return; 358 | } 359 | 360 | // Skip constructor names and type references 361 | if (parent is ConstructorName || 362 | parent is TypeArgumentList || 363 | parent is ExtendsClause || 364 | parent is ImplementsClause || 365 | parent is WithClause) { 366 | return; 367 | } 368 | 369 | // Skip import prefixes and library names 370 | if (parent is ImportDirective || 371 | parent is LibraryDirective || 372 | parent is PartDirective) { 373 | return; 374 | } 375 | 376 | // Skip named parameters in constructors and method calls 377 | if (parent is Label && parent.parent is NamedExpression) { 378 | return; 379 | } 380 | 381 | // Skip function parameters - walk up the AST to check if this identifier is a parameter 382 | AstNode? current = node; 383 | while (current != null) { 384 | if (current is FunctionExpression) { 385 | final params = current.parameters?.parameters ?? []; 386 | if (params.any( 387 | (param) => 388 | (param is SimpleFormalParameter && 389 | param.name?.lexeme == node.name) || 390 | (param is DefaultFormalParameter && 391 | param.parameter.name?.lexeme == node.name), 392 | )) { 393 | return; // This is a function parameter, skip it 394 | } 395 | } 396 | current = current.parent; 397 | } 398 | 399 | // Skip class names and built-in types 400 | final builtInTypes = { 401 | 'int', 402 | 'double', 403 | 'String', 404 | 'bool', 405 | 'List', 406 | 'Map', 407 | 'Set', 408 | 'Future', 409 | 'Stream', 410 | 'Duration', 411 | 'DateTime', 412 | }; 413 | if (builtInTypes.contains(node.name)) { 414 | return; 415 | } 416 | 417 | // Skip common Flutter/Dart classes that are not fields 418 | final commonClasses = { 419 | 'Widget', 420 | 'State', 421 | 'StatefulWidget', 422 | 'StatelessWidget', 423 | 'BuildContext', 424 | 'MaterialApp', 425 | 'Scaffold', 426 | 'AppBar', 427 | 'Text', 428 | 'ElevatedButton', 429 | 'CircularProgressIndicator', 430 | 'SizedBox', 431 | 'Column', 432 | 'Row', 433 | 'Center', 434 | 'Container', 435 | }; 436 | if (commonClasses.contains(node.name)) { 437 | return; 438 | } 439 | 440 | // This could be a field reference - add it as a potential dependency 441 | _dependencies.add(node.name); 442 | 443 | super.visitSimpleIdentifier(node); 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /packages/solid_generator/test/reactive_state_transformer_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:analyzer/dart/analysis/features.dart'; 3 | import 'package:analyzer/dart/analysis/utilities.dart'; 4 | import 'package:analyzer/dart/ast/ast.dart'; 5 | 6 | import 'package:solid_generator/src/reactive_state_transformer.dart'; 7 | import 'package:solid_generator/src/result.dart'; 8 | import 'package:solid_generator/src/transformation_error.dart'; 9 | 10 | void main() { 11 | group('SolidStateTransformer', () { 12 | late SolidStateTransformer transformer; 13 | 14 | setUp(() { 15 | transformer = SolidStateTransformer(); 16 | }); 17 | 18 | group('canTransform', () { 19 | test('returns true for field with @SolidState annotation', () { 20 | final code = ''' 21 | class TestClass { 22 | @SolidState() 23 | int counter = 0; 24 | } 25 | '''; 26 | 27 | final fieldDeclaration = _parseFieldDeclaration(code); 28 | expect(transformer.canTransform(fieldDeclaration), isTrue); 29 | }); 30 | 31 | test('returns false for field without @SolidState annotation', () { 32 | final code = ''' 33 | class TestClass { 34 | int counter = 0; 35 | } 36 | '''; 37 | 38 | final fieldDeclaration = _parseFieldDeclaration(code); 39 | expect(transformer.canTransform(fieldDeclaration), isFalse); 40 | }); 41 | 42 | test('returns false for field with different annotation', () { 43 | final code = ''' 44 | class TestClass { 45 | @override 46 | int counter = 0; 47 | } 48 | '''; 49 | 50 | final fieldDeclaration = _parseFieldDeclaration(code); 51 | expect(transformer.canTransform(fieldDeclaration), isFalse); 52 | }); 53 | }); 54 | 55 | group('transform', () { 56 | test('transforms simple field to Signal declaration', () { 57 | final code = ''' 58 | class TestClass { 59 | @SolidState() 60 | int counter = 0; 61 | } 62 | '''; 63 | 64 | final fieldDeclaration = _parseFieldDeclaration(code); 65 | final result = transformer.transform(fieldDeclaration); 66 | 67 | expect(result.isSuccess, isTrue); 68 | if (result is Success) { 69 | final generatedCode = result.value; 70 | expect(generatedCode, contains('final counter = Signal(0')); 71 | expect(generatedCode, contains("name: 'counter'")); 72 | } 73 | }); 74 | 75 | test('transforms field with custom name', () { 76 | final code = ''' 77 | class TestClass { 78 | @SolidState(name: 'customCounter') 79 | int counter = 0; 80 | } 81 | '''; 82 | 83 | final fieldDeclaration = _parseFieldDeclaration(code); 84 | final result = transformer.transform(fieldDeclaration); 85 | 86 | expect(result.isSuccess, isTrue); 87 | if (result is Success) { 88 | final generatedCode = result.value; 89 | expect(generatedCode, contains("name: 'customCounter'")); 90 | } 91 | }); 92 | 93 | test('transforms field with String type', () { 94 | final code = ''' 95 | class TestClass { 96 | @SolidState() 97 | String name = 'test'; 98 | } 99 | '''; 100 | 101 | final fieldDeclaration = _parseFieldDeclaration(code); 102 | final result = transformer.transform(fieldDeclaration); 103 | 104 | expect(result.isSuccess, isTrue); 105 | if (result is Success) { 106 | final generatedCode = result.value; 107 | expect(generatedCode, contains('final name = Signal(')); 108 | expect(generatedCode, contains("'test'")); 109 | } 110 | }); 111 | 112 | test('transforms field with nullable type', () { 113 | final code = ''' 114 | class TestClass { 115 | @SolidState() 116 | String? name; 117 | } 118 | '''; 119 | 120 | final fieldDeclaration = _parseFieldDeclaration(code); 121 | final result = transformer.transform(fieldDeclaration); 122 | 123 | expect(result.isSuccess, isTrue); 124 | if (result is Success) { 125 | final generatedCode = result.value; 126 | expect(generatedCode, contains('final name = Signal(null')); 127 | } 128 | }); 129 | 130 | test('returns failure for field without @SolidState annotation', () { 131 | final code = ''' 132 | class TestClass { 133 | int counter = 0; 134 | } 135 | '''; 136 | 137 | final fieldDeclaration = _parseFieldDeclaration(code); 138 | final result = transformer.transform(fieldDeclaration); 139 | 140 | expect(result.isFailure, isTrue); 141 | if (result is Failure) { 142 | expect(result.error, isA()); 143 | } 144 | }); 145 | }); 146 | 147 | group('extractDependencies', () { 148 | test('returns empty list for fields (no dependencies)', () { 149 | final code = ''' 150 | class TestClass { 151 | @SolidState() 152 | int counter = 0; 153 | } 154 | '''; 155 | 156 | final fieldDeclaration = _parseFieldDeclaration(code); 157 | final dependencies = transformer.extractDependencies(fieldDeclaration); 158 | 159 | expect(dependencies, isEmpty); 160 | }); 161 | }); 162 | }); 163 | 164 | group('ComputedTransformer', () { 165 | late SolidComputedTransformer transformer; 166 | 167 | setUp(() { 168 | transformer = SolidComputedTransformer(); 169 | }); 170 | 171 | group('canTransform', () { 172 | test('returns true for getter with @SolidState annotation', () { 173 | final code = ''' 174 | class TestClass { 175 | @SolidState() 176 | String get fullName => 'John Doe'; 177 | } 178 | '''; 179 | 180 | final getterDeclaration = _parseGetterDeclaration(code); 181 | expect(transformer.canTransform(getterDeclaration), isTrue); 182 | }); 183 | 184 | test( 185 | 'returns false for method (not getter) with @SolidState annotation', 186 | () { 187 | final code = ''' 188 | class TestClass { 189 | @SolidState() 190 | String fullName() => 'John Doe'; 191 | } 192 | '''; 193 | 194 | final methodDeclaration = _parseMethodDeclaration(code); 195 | expect(transformer.canTransform(methodDeclaration), isFalse); 196 | }, 197 | ); 198 | 199 | test('returns false for getter without @SolidState annotation', () { 200 | final code = ''' 201 | class TestClass { 202 | String get fullName => 'John Doe'; 203 | } 204 | '''; 205 | 206 | final getterDeclaration = _parseGetterDeclaration(code); 207 | expect(transformer.canTransform(getterDeclaration), isFalse); 208 | }); 209 | }); 210 | 211 | group('transform', () { 212 | test('transforms getter to Computed declaration', () { 213 | final code = ''' 214 | class TestClass { 215 | @SolidState() 216 | String get fullName => 'John Doe'; 217 | } 218 | '''; 219 | 220 | final getterDeclaration = _parseGetterDeclaration(code); 221 | final result = transformer.transform(getterDeclaration); 222 | 223 | expect(result.isSuccess, isTrue); 224 | if (result is Success) { 225 | final generatedCode = result.value; 226 | expect(generatedCode, contains('final fullName = Computed')); 227 | expect(generatedCode, contains("'John Doe'")); 228 | expect(generatedCode, contains("name: 'fullName'")); 229 | } 230 | }); 231 | 232 | test('transforms getter with custom name', () { 233 | final code = ''' 234 | class TestClass { 235 | @SolidState(name: 'customName') 236 | String get fullName => 'John Doe'; 237 | } 238 | '''; 239 | 240 | final getterDeclaration = _parseGetterDeclaration(code); 241 | final result = transformer.transform(getterDeclaration); 242 | 243 | expect(result.isSuccess, isTrue); 244 | if (result is Success) { 245 | final generatedCode = result.value; 246 | expect(generatedCode, contains("name: 'customName'")); 247 | } 248 | }); 249 | }); 250 | 251 | group('extractDependencies', () { 252 | test('extracts dependencies from getter expression', () { 253 | final code = ''' 254 | class TestClass { 255 | @SolidState() 256 | String get fullName => firstName + ' ' + lastName; 257 | } 258 | '''; 259 | 260 | final getterDeclaration = _parseGetterDeclaration(code); 261 | final dependencies = transformer.extractDependencies(getterDeclaration); 262 | 263 | // Note: This is a simplified test - the actual dependency extraction 264 | // uses simple pattern matching, not full AST analysis 265 | expect(dependencies, isNotEmpty); 266 | }); 267 | }); 268 | }); 269 | 270 | group('EffectTransformer', () { 271 | late SolidEffectTransformer transformer; 272 | 273 | setUp(() { 274 | transformer = SolidEffectTransformer(); 275 | }); 276 | 277 | group('canTransform', () { 278 | test('returns true for method with @SolidEffect annotation', () { 279 | final code = ''' 280 | class TestClass { 281 | @SolidEffect() 282 | void logCounter() { 283 | print(counter); 284 | } 285 | } 286 | '''; 287 | 288 | final methodDeclaration = _parseMethodDeclaration(code); 289 | expect(transformer.canTransform(methodDeclaration), isTrue); 290 | }); 291 | 292 | test('returns false for method without @SolidEffect annotation', () { 293 | final code = ''' 294 | class TestClass { 295 | void logCounter() { 296 | print(counter); 297 | } 298 | } 299 | '''; 300 | 301 | final methodDeclaration = _parseMethodDeclaration(code); 302 | expect(transformer.canTransform(methodDeclaration), isFalse); 303 | }); 304 | }); 305 | 306 | group('transform', () { 307 | test('transforms method to Effect declaration', () { 308 | final code = ''' 309 | class TestClass { 310 | @SolidEffect() 311 | void logCounter() { 312 | print(counter); 313 | } 314 | } 315 | '''; 316 | 317 | final methodDeclaration = _parseMethodDeclaration(code); 318 | final result = transformer.transform(methodDeclaration); 319 | 320 | expect(result.isSuccess, isTrue); 321 | if (result is Success) { 322 | final generatedCode = result.value; 323 | expect(generatedCode, contains('final logCounter = Effect')); 324 | expect(generatedCode, contains('counter.value')); 325 | } 326 | }); 327 | }); 328 | }); 329 | 330 | group('QueryTransformer', () { 331 | late SolidQueryTransformer transformer; 332 | 333 | setUp(() { 334 | transformer = SolidQueryTransformer(); 335 | }); 336 | 337 | group('canTransform', () { 338 | test('returns true for method with @SolidQuery annotation', () { 339 | final code = ''' 340 | class TestClass { 341 | @SolidQuery() 342 | Future fetchData() async { 343 | return 'data'; 344 | } 345 | } 346 | '''; 347 | 348 | final methodDeclaration = _parseMethodDeclaration(code); 349 | expect(transformer.canTransform(methodDeclaration), isTrue); 350 | }); 351 | 352 | test('returns false for method without @SolidQuery annotation', () { 353 | final code = ''' 354 | class TestClass { 355 | Future fetchData() async { 356 | return 'data'; 357 | } 358 | } 359 | '''; 360 | 361 | final methodDeclaration = _parseMethodDeclaration(code); 362 | expect(transformer.canTransform(methodDeclaration), isFalse); 363 | }); 364 | }); 365 | 366 | group('transform', () { 367 | test('transforms async method to Resource declaration', () { 368 | final code = ''' 369 | class TestClass { 370 | @SolidQuery() 371 | Future fetchData() async { 372 | return 'data'; 373 | } 374 | } 375 | '''; 376 | 377 | final methodDeclaration = _parseMethodDeclaration(code); 378 | final result = transformer.transform(methodDeclaration); 379 | 380 | expect(result.isSuccess, isTrue); 381 | if (result is Success) { 382 | final generatedCode = result.value; 383 | expect( 384 | generatedCode, 385 | contains('late final fetchData = Resource'), 386 | ); 387 | expect(generatedCode, contains('() async')); 388 | expect(generatedCode, contains("name: 'fetchData'")); 389 | } 390 | }); 391 | 392 | test('transforms method with custom name and debounce', () { 393 | final code = ''' 394 | class TestClass { 395 | @SolidQuery(name: 'customQuery', debounce: Duration(milliseconds: 500)) 396 | Future fetchData() async { 397 | return 'data'; 398 | } 399 | } 400 | '''; 401 | 402 | final methodDeclaration = _parseMethodDeclaration(code); 403 | final result = transformer.transform(methodDeclaration); 404 | 405 | expect(result.isSuccess, isTrue); 406 | if (result is Success) { 407 | final generatedCode = result.value; 408 | expect(generatedCode, contains("name: 'customQuery'")); 409 | expect( 410 | generatedCode, 411 | contains('debounceDelay: const Duration(milliseconds: 500)'), 412 | ); 413 | } 414 | }); 415 | }); 416 | }); 417 | } 418 | 419 | /// Helper function to parse a field declaration from code 420 | FieldDeclaration _parseFieldDeclaration(String code) { 421 | final parseResult = parseString( 422 | content: code, 423 | featureSet: FeatureSet.latestLanguageVersion(), 424 | ); 425 | 426 | final unit = parseResult.unit; 427 | final classDeclaration = unit.declarations.first as ClassDeclaration; 428 | return classDeclaration.members.first as FieldDeclaration; 429 | } 430 | 431 | /// Helper function to parse a getter declaration from code 432 | MethodDeclaration _parseGetterDeclaration(String code) { 433 | final parseResult = parseString( 434 | content: code, 435 | featureSet: FeatureSet.latestLanguageVersion(), 436 | ); 437 | 438 | final unit = parseResult.unit; 439 | final classDeclaration = unit.declarations.first as ClassDeclaration; 440 | return classDeclaration.members.first as MethodDeclaration; 441 | } 442 | 443 | /// Helper function to parse a method declaration from code 444 | MethodDeclaration _parseMethodDeclaration(String code) { 445 | final parseResult = parseString( 446 | content: code, 447 | featureSet: FeatureSet.latestLanguageVersion(), 448 | ); 449 | 450 | final unit = parseResult.unit; 451 | final classDeclaration = unit.declarations.first as ClassDeclaration; 452 | return classDeclaration.members.first as MethodDeclaration; 453 | } 454 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_solidart/flutter_solidart.dart'; 3 | import 'package:solid_annotations/extensions.dart'; 4 | import 'package:solid_annotations/provider.dart'; 5 | 6 | final routes = { 7 | '/state': (_) => const CounterPage(), 8 | '/computed': (_) => const ComputedExample(), 9 | '/effect': (_) => const EffectExample(), 10 | '/query': (_) => const QueryExample(), 11 | '/query_with_source': (_) => const QueryWithSourceExample(), 12 | '/query_with_multiple_sources': (_) => const QueryWithMultipleSourcesExample(), 13 | '/environment': (_) => const EnvironmentExample(), 14 | '/query_with_stream': (_) => const QueryWithStreamExample(), 15 | '/query_with_stream_and_source': (_) => const QueryWithStreamAndSourceExample(), 16 | }; 17 | 18 | final routeToNameRegex = RegExp('(?:^/|-)([a-zA-Z])'); 19 | 20 | void main() { 21 | SolidartConfig.autoDispose = false; 22 | runApp(const MyApp()); 23 | } 24 | 25 | class MyApp extends StatelessWidget { 26 | const MyApp({super.key}); 27 | 28 | @override 29 | Widget build(BuildContext context) { 30 | return MaterialApp( 31 | title: 'Solid Demo', 32 | home: const MainPage(), 33 | routes: routes, 34 | ); 35 | } 36 | } 37 | 38 | class MainPage extends StatelessWidget { 39 | const MainPage({super.key}); 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | return Scaffold( 44 | body: ListView.builder( 45 | itemCount: routes.length, 46 | itemBuilder: (BuildContext context, int index) { 47 | final route = routes.keys.elementAt(index); 48 | 49 | final name = route.replaceAllMapped( 50 | routeToNameRegex, 51 | (match) => match.group(0)!.substring(1).toUpperCase(), 52 | ); 53 | 54 | return Material( 55 | child: ListTile( 56 | title: Text(name), 57 | onTap: () { 58 | Navigator.of(context).pushNamed(route); 59 | }, 60 | ), 61 | ); 62 | }, 63 | ), 64 | ); 65 | } 66 | } 67 | 68 | class CounterPage extends StatefulWidget { 69 | const CounterPage({super.key}); 70 | 71 | @override 72 | State createState() => _CounterPageState(); 73 | } 74 | 75 | class _CounterPageState extends State { 76 | final counter = Signal(0, name: 'customName'); 77 | 78 | @override 79 | void dispose() { 80 | counter.dispose(); 81 | super.dispose(); 82 | } 83 | 84 | @override 85 | Widget build(BuildContext context) { 86 | return Scaffold( 87 | appBar: AppBar(title: const Text('State')), 88 | body: Center( 89 | child: Column( 90 | mainAxisAlignment: MainAxisAlignment.center, 91 | children: [ 92 | Text(DateTime.now().toString()), 93 | SignalBuilder( 94 | builder: (context, child) { 95 | return Text('Counter: ${counter.value}'); 96 | }, 97 | ), 98 | const SizedBox(height: 12), 99 | ElevatedButton( 100 | onPressed: () => counter.value++, 101 | child: const Text('Increment'), 102 | ), 103 | ], 104 | ), 105 | ), 106 | ); 107 | } 108 | } 109 | 110 | class ComputedExample extends StatefulWidget { 111 | const ComputedExample({super.key}); 112 | 113 | @override 114 | State createState() => _ComputedExampleState(); 115 | } 116 | 117 | class _ComputedExampleState extends State { 118 | final counter = Signal(0, name: 'counter'); 119 | late final doubleCounter = Computed( 120 | () => counter.value * 2, 121 | name: 'doubleCounter', 122 | ); 123 | 124 | @override 125 | void dispose() { 126 | counter.dispose(); 127 | doubleCounter.dispose(); 128 | super.dispose(); 129 | } 130 | 131 | @override 132 | Widget build(BuildContext context) { 133 | return Scaffold( 134 | appBar: AppBar(title: const Text('Computed')), 135 | body: Center( 136 | child: SignalBuilder( 137 | builder: (context, child) { 138 | return Text( 139 | 'Counter: ${counter.value}, DoubleCounter: ${doubleCounter.value}', 140 | ); 141 | }, 142 | ), 143 | ), 144 | floatingActionButton: FloatingActionButton( 145 | onPressed: () => counter.value++, 146 | child: const Icon(Icons.add), 147 | ), 148 | ); 149 | } 150 | } 151 | 152 | class EffectExample extends StatefulWidget { 153 | const EffectExample({super.key}); 154 | 155 | @override 156 | State createState() => _EffectExampleState(); 157 | } 158 | 159 | class _EffectExampleState extends State { 160 | final counter = Signal(0, name: 'counter'); 161 | late final logCounter = Effect(() { 162 | print('Counter changed: ${counter.value}'); 163 | }, name: 'logCounter'); 164 | 165 | @override 166 | void initState() { 167 | super.initState(); 168 | logCounter; 169 | } 170 | 171 | @override 172 | void dispose() { 173 | counter.dispose(); 174 | logCounter.dispose(); 175 | super.dispose(); 176 | } 177 | 178 | @override 179 | Widget build(BuildContext context) { 180 | return Scaffold( 181 | appBar: AppBar(title: const Text('Effect')), 182 | body: SignalBuilder( 183 | builder: (context, child) { 184 | return Center(child: Text('Counter: ${counter.value}')); 185 | }, 186 | ), 187 | floatingActionButton: FloatingActionButton( 188 | onPressed: () => counter.value++, 189 | child: const Icon(Icons.add), 190 | ), 191 | ); 192 | } 193 | } 194 | 195 | class QueryExample extends StatefulWidget { 196 | const QueryExample({super.key}); 197 | 198 | @override 199 | State createState() => _QueryExampleState(); 200 | } 201 | 202 | class _QueryExampleState extends State { 203 | late final fetchData = Resource(() async { 204 | await Future.delayed(const Duration(seconds: 1)); 205 | return 'Fetched Data'; 206 | }, name: 'fetchData'); 207 | 208 | @override 209 | void dispose() { 210 | fetchData.dispose(); 211 | super.dispose(); 212 | } 213 | 214 | @override 215 | Widget build(BuildContext context) { 216 | return Scaffold( 217 | appBar: AppBar(title: const Text('Query')), 218 | body: Center( 219 | child: SignalBuilder( 220 | builder: (context, child) { 221 | return fetchData().when( 222 | ready: Text.new, 223 | loading: () => const CircularProgressIndicator(), 224 | error: (error, stackTrace) => Text('Error: $error'), 225 | ); 226 | }, 227 | ), 228 | ), 229 | ); 230 | } 231 | } 232 | 233 | class QueryWithSourceExample extends StatefulWidget { 234 | const QueryWithSourceExample({super.key}); 235 | 236 | @override 237 | State createState() => _QueryWithSourceExampleState(); 238 | } 239 | 240 | class _QueryWithSourceExampleState extends State { 241 | final userId = Signal(null, name: 'userId'); 242 | late final fetchData = Resource( 243 | () async { 244 | if (userId.value == null) return null; 245 | await Future.delayed(const Duration(seconds: 1)); 246 | return 'Fetched Data for ${userId.value}'; 247 | }, 248 | source: userId, 249 | name: 'fetchData', 250 | debounceDelay: const Duration(seconds: 1), 251 | ); 252 | 253 | @override 254 | void dispose() { 255 | userId.dispose(); 256 | fetchData.dispose(); 257 | super.dispose(); 258 | } 259 | 260 | @override 261 | Widget build(BuildContext context) { 262 | return Scaffold( 263 | appBar: AppBar(title: const Text('QueryWithSource')), 264 | body: Center( 265 | child: SignalBuilder( 266 | builder: (context, child) { 267 | return fetchData().when( 268 | ready: (data) { 269 | if (data == null) { 270 | return const Text('No user ID provided'); 271 | } 272 | return Text(data); 273 | }, 274 | loading: () => const CircularProgressIndicator(), 275 | error: (error, stackTrace) => Text('Error: $error'), 276 | ); 277 | }, 278 | ), 279 | ), 280 | floatingActionButton: FloatingActionButton( 281 | onPressed: () => 282 | userId.value = 'user_${DateTime.now().millisecondsSinceEpoch}', 283 | child: const Icon(Icons.refresh), 284 | ), 285 | ); 286 | } 287 | } 288 | 289 | class QueryWithMultipleSourcesExample extends StatefulWidget { 290 | const QueryWithMultipleSourcesExample({super.key}); 291 | 292 | @override 293 | State createState() => 294 | _QueryWithMultipleSourcesExampleState(); 295 | } 296 | 297 | class _QueryWithMultipleSourcesExampleState 298 | extends State { 299 | final userId = Signal(null, name: 'userId'); 300 | final authToken = Signal(null, name: 'authToken'); 301 | late final fetchData = Resource( 302 | () async { 303 | if (userId.value == null || authToken.value == null) return null; 304 | await Future.delayed(const Duration(seconds: 1)); 305 | return 'Fetched Data for ${userId.value}'; 306 | }, 307 | source: Computed( 308 | () => (userId.value, authToken.value), 309 | name: 'fetchDataSource', 310 | ), 311 | name: 'fetchData', 312 | ); 313 | 314 | @override 315 | void dispose() { 316 | userId.dispose(); 317 | authToken.dispose(); 318 | fetchData.dispose(); 319 | super.dispose(); 320 | } 321 | 322 | @override 323 | Widget build(BuildContext context) { 324 | return Scaffold( 325 | appBar: AppBar(title: const Text('QueryWithMultipleSources')), 326 | body: Center( 327 | child: Column( 328 | spacing: 8, 329 | children: [ 330 | const Text('Complex SolidQuery example'), 331 | SignalBuilder( 332 | builder: (context, child) { 333 | return fetchData().when( 334 | ready: (data) { 335 | if (data == null) { 336 | return const Text('No user ID provided'); 337 | } 338 | return Text(data); 339 | }, 340 | loading: () => const CircularProgressIndicator(), 341 | error: (error, stackTrace) => Text('Error: $error'), 342 | ); 343 | }, 344 | ), 345 | ], 346 | ), 347 | ), 348 | floatingActionButton: FloatingActionButton( 349 | onPressed: () { 350 | userId.value = 'user_${DateTime.now().millisecondsSinceEpoch}'; 351 | authToken.value = 'token_${DateTime.now().millisecondsSinceEpoch}'; 352 | }, 353 | child: const Icon(Icons.refresh), 354 | ), 355 | ); 356 | } 357 | } 358 | 359 | class ACustomClassWithSolidState { 360 | final value = Signal(0, name: 'value'); 361 | 362 | void dispose() { 363 | print('ACustomClass disposed'); 364 | value.dispose(); 365 | } 366 | } 367 | 368 | class ACustomClass { 369 | void doNothing() { 370 | // no-op 371 | } 372 | } 373 | 374 | class EnvironmentExample extends StatelessWidget { 375 | const EnvironmentExample({super.key}); 376 | 377 | @override 378 | Widget build(BuildContext context) { 379 | return SolidProvider( 380 | create: (context) => ACustomClassWithSolidState(), 381 | child: const EnvironmentInjectionExample(), 382 | ); 383 | } 384 | } 385 | 386 | class EnvironmentInjectionExample extends StatefulWidget { 387 | const EnvironmentInjectionExample({super.key}); 388 | 389 | @override 390 | State createState() => 391 | _EnvironmentInjectionExampleState(); 392 | } 393 | 394 | class _EnvironmentInjectionExampleState 395 | extends State { 396 | late final ACustomClassWithSolidState myData = context.read(); 397 | 398 | @override 399 | Widget build(BuildContext context) { 400 | return Scaffold( 401 | appBar: AppBar(title: const Text('Environment')), 402 | body: SignalBuilder( 403 | builder: (context, child) { 404 | return Center(child: Text(myData.value.value.toString())); 405 | }, 406 | ), 407 | floatingActionButton: FloatingActionButton( 408 | onPressed: () => myData.value.value++, 409 | child: const Icon(Icons.add), 410 | ), 411 | ); 412 | } 413 | } 414 | 415 | class QueryWithStreamExample extends StatefulWidget { 416 | const QueryWithStreamExample({super.key}); 417 | 418 | @override 419 | State createState() => _QueryWithStreamExampleState(); 420 | } 421 | 422 | class _QueryWithStreamExampleState extends State { 423 | late final fetchData = Resource.stream(() { 424 | return Stream.periodic(const Duration(seconds: 1), (i) => i); 425 | }, name: 'fetchData'); 426 | 427 | @override 428 | void dispose() { 429 | fetchData.dispose(); 430 | super.dispose(); 431 | } 432 | 433 | @override 434 | Widget build(BuildContext context) { 435 | return Scaffold( 436 | appBar: AppBar(title: const Text('QueryWithStream')), 437 | body: Center( 438 | child: SignalBuilder( 439 | builder: (context, child) { 440 | return fetchData().when( 441 | ready: (data) => Text(data.toString()), 442 | loading: () => const CircularProgressIndicator(), 443 | error: (error, stackTrace) => Text('Error: $error'), 444 | ); 445 | }, 446 | ), 447 | ), 448 | ); 449 | } 450 | } 451 | 452 | class QueryWithStreamAndSourceExample extends StatefulWidget { 453 | const QueryWithStreamAndSourceExample({super.key}); 454 | 455 | @override 456 | State createState() => 457 | _QueryWithStreamAndSourceExampleState(); 458 | } 459 | 460 | class _QueryWithStreamAndSourceExampleState 461 | extends State { 462 | final multiplier = Signal(1, name: 'multiplier'); 463 | late final fetchData = Resource.stream( 464 | () { 465 | return Stream.periodic( 466 | const Duration(seconds: 1), 467 | (i) => i * multiplier.value, 468 | ); 469 | }, 470 | source: multiplier, 471 | name: 'fetchData', 472 | useRefreshing: false, 473 | ); 474 | 475 | @override 476 | void dispose() { 477 | multiplier.dispose(); 478 | fetchData.dispose(); 479 | super.dispose(); 480 | } 481 | 482 | @override 483 | Widget build(BuildContext context) { 484 | return Scaffold( 485 | appBar: AppBar(title: const Text('QueryWithStream')), 486 | body: Center( 487 | child: Column( 488 | children: [ 489 | SignalBuilder( 490 | builder: (context, child) { 491 | return Text('Is refreshing: ${fetchData().isRefreshing}'); 492 | }, 493 | ), 494 | SignalBuilder( 495 | builder: (context, child) { 496 | return fetchData().when( 497 | ready: (data) => Text(data.toString()), 498 | loading: CircularProgressIndicator.new, 499 | error: (error, stackTrace) => Text('Error: $error'), 500 | ); 501 | }, 502 | ), 503 | ElevatedButton( 504 | onPressed: fetchData.refresh, 505 | child: const Text('Manual Refresh'), 506 | ), 507 | ], 508 | ), 509 | ), 510 | floatingActionButton: FloatingActionButton( 511 | onPressed: () => multiplier.value++, 512 | child: const Icon(Icons.add), 513 | ), 514 | ); 515 | } 516 | } 517 | -------------------------------------------------------------------------------- /packages/solid_generator/bin/solid_generator.dart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env dart 2 | 3 | /// Solid Generator CLI - Transpiles reactive annotations to flutter_solidart code 4 | /// 5 | /// Usage: solid [options] 6 | /// 7 | /// This CLI directly transpiles files from source/ to lib/ directory, 8 | /// applying all transformations, formatting, and lint fixes in a single command. 9 | library; 10 | 11 | import 'dart:io'; 12 | import 'dart:async'; 13 | import 'dart:convert'; 14 | import 'package:args/args.dart'; 15 | import 'package:path/path.dart' as path; 16 | import 'package:analyzer/dart/analysis/features.dart'; 17 | import 'package:analyzer/dart/analysis/utilities.dart'; 18 | import 'package:crypto/crypto.dart'; 19 | 20 | import 'package:solid_generator/src/solid_builder.dart'; 21 | 22 | void main(List arguments) async { 23 | final parser = ArgParser() 24 | ..addOption( 25 | 'source', 26 | abbr: 's', 27 | defaultsTo: 'source', 28 | help: 'Source directory to read from', 29 | ) 30 | ..addOption( 31 | 'output', 32 | abbr: 'o', 33 | defaultsTo: 'lib', 34 | help: 'Output directory to write to', 35 | ) 36 | ..addFlag( 37 | 'watch', 38 | abbr: 'w', 39 | help: 'Watch for file changes and auto-regenerate', 40 | ) 41 | ..addFlag( 42 | 'clean', 43 | abbr: 'c', 44 | help: 'Deletes the build cache. The next build will be a full build.', 45 | ) 46 | ..addFlag('verbose', abbr: 'v', help: 'Verbose output') 47 | ..addFlag('help', abbr: 'h', help: 'Show this help message'); 48 | 49 | try { 50 | final results = parser.parse(arguments); 51 | 52 | if (results['help'] as bool) { 53 | print('Solid Generator - Direct source/ to lib/ transpilation'); 54 | print(''); 55 | print('Usage: solid [options]'); 56 | print(''); 57 | print(parser.usage); 58 | print(''); 59 | print('Examples:'); 60 | print(' solid # Basic transpilation'); 61 | print(' solid --watch # Watch mode'); 62 | print(' solid --clean --verbose # Clean build with verbose output'); 63 | return; 64 | } 65 | 66 | final sourceDir = results['source'] as String; 67 | final outputDir = results['output'] as String; 68 | final watchMode = results['watch'] as bool; 69 | final cleanBuild = results['clean'] as bool; 70 | final verbose = results['verbose'] as bool; 71 | 72 | final generator = SolidGeneratorCLI( 73 | sourceDir: sourceDir, 74 | outputDir: outputDir, 75 | verbose: verbose, 76 | ); 77 | 78 | if (cleanBuild) { 79 | await generator.clean(); 80 | } 81 | 82 | if (watchMode) { 83 | await generator.watch(); 84 | } else if (!cleanBuild) { 85 | // Only generate if not just cleaning 86 | await generator.generate(); 87 | } 88 | } catch (e) { 89 | print('Error: $e'); 90 | print(''); 91 | print('Use --help for usage information'); 92 | exit(1); 93 | } 94 | } 95 | 96 | class SolidGeneratorCLI { 97 | final String sourceDir; 98 | final String outputDir; 99 | final bool verbose; 100 | 101 | // For watch mode cancellation and debouncing 102 | Timer? _debounceTimer; 103 | bool _isGenerating = false; 104 | bool _cancelRequested = false; 105 | 106 | // Content tracking for smart regeneration 107 | final Map _fileContentHashes = {}; 108 | final Map _fileModificationTimes = {}; 109 | int _generationCount = 0; 110 | 111 | SolidGeneratorCLI({ 112 | required this.sourceDir, 113 | required this.outputDir, 114 | required this.verbose, 115 | }); 116 | 117 | /// Wait for file to be stable (completely written) before processing 118 | /// Returns the stable content or null if file is still being written 119 | Future _waitForStableFile(File file) async { 120 | const maxAttempts = 5; 121 | const checkInterval = Duration(milliseconds: 100); 122 | 123 | for (int attempt = 0; attempt < maxAttempts; attempt++) { 124 | try { 125 | // Read file size and content 126 | final stat1 = await file.stat(); 127 | final content1 = await file.readAsString(); 128 | 129 | // Wait a short time 130 | await Future.delayed(checkInterval); 131 | 132 | // Read again 133 | final stat2 = await file.stat(); 134 | final content2 = await file.readAsString(); 135 | 136 | // Check if file is stable (same size, modification time, and content) 137 | if (stat1.size == stat2.size && 138 | stat1.modified == stat2.modified && 139 | content1 == content2) { 140 | if (verbose && attempt > 0) { 141 | print('✅ File stable after ${attempt + 1} attempts'); 142 | } 143 | return content2; 144 | } 145 | 146 | if (verbose) { 147 | print( 148 | '⏳ File still changing (attempt ${attempt + 1}/$maxAttempts)...', 149 | ); 150 | } 151 | } catch (e) { 152 | if (verbose) { 153 | print( 154 | '⚠️ Error reading file (attempt ${attempt + 1}/$maxAttempts): $e', 155 | ); 156 | } 157 | } 158 | } 159 | 160 | // File is still not stable after max attempts 161 | return null; 162 | } 163 | 164 | /// Calculate content hash for a file 165 | String _calculateContentHash(String content) { 166 | final bytes = utf8.encode(content); 167 | final digest = sha256.convert(bytes); 168 | return digest.toString(); 169 | } 170 | 171 | /// Check if any source files have changed since last generation 172 | Future _hasSourceFilesChanged() async { 173 | final sourceDirectory = Directory(sourceDir); 174 | if (!await sourceDirectory.exists()) return false; 175 | 176 | final dartFiles = await _findDartFiles(sourceDirectory); 177 | 178 | for (final file in dartFiles) { 179 | final relativePath = path.relative(file.path, from: sourceDir); 180 | final stat = await file.stat(); 181 | 182 | // Use stable file reading for reliable content comparison 183 | final content = await _waitForStableFile(file); 184 | if (content == null) { 185 | // File is still being written, consider it changed 186 | return true; 187 | } 188 | 189 | final contentHash = _calculateContentHash(content); 190 | 191 | // Check if file is new or content has changed 192 | if (!_fileContentHashes.containsKey(relativePath) || 193 | _fileContentHashes[relativePath] != contentHash || 194 | !_fileModificationTimes.containsKey(relativePath) || 195 | _fileModificationTimes[relativePath] != stat.modified) { 196 | return true; 197 | } 198 | } 199 | 200 | return false; 201 | } 202 | 203 | /// Update file tracking information 204 | Future _updateFileTracking() async { 205 | final sourceDirectory = Directory(sourceDir); 206 | if (!await sourceDirectory.exists()) return; 207 | 208 | final dartFiles = await _findDartFiles(sourceDirectory); 209 | 210 | _fileContentHashes.clear(); 211 | _fileModificationTimes.clear(); 212 | 213 | for (final file in dartFiles) { 214 | final relativePath = path.relative(file.path, from: sourceDir); 215 | final stat = await file.stat(); 216 | 217 | // Use stable file reading for reliable content tracking 218 | final content = await _waitForStableFile(file); 219 | if (content != null) { 220 | final contentHash = _calculateContentHash(content); 221 | _fileContentHashes[relativePath] = contentHash; 222 | _fileModificationTimes[relativePath] = stat.modified; 223 | } 224 | } 225 | } 226 | 227 | /// Determine if a clean rebuild is needed 228 | Future _needsCleanRebuild() async { 229 | // Always clean rebuild on first generation 230 | if (_generationCount == 0) return true; 231 | 232 | // Check if output directory exists 233 | final outputDirectory = Directory(outputDir); 234 | if (!await outputDirectory.exists()) return true; 235 | 236 | // Check if any generated files are missing 237 | final sourceDirectory = Directory(sourceDir); 238 | if (await sourceDirectory.exists()) { 239 | final dartFiles = await _findDartFiles(sourceDirectory); 240 | for (final file in dartFiles) { 241 | final relativePath = path.relative(file.path, from: sourceDir); 242 | final outputPath = path.join(outputDir, relativePath); 243 | final outputFile = File(outputPath); 244 | if (!await outputFile.exists()) return true; 245 | } 246 | } 247 | 248 | return false; 249 | } 250 | 251 | Future generate() async { 252 | // Check if generation should be cancelled 253 | if (_cancelRequested) { 254 | _cancelRequested = false; 255 | if (verbose) print('⏹️ Generation cancelled - newer changes detected'); 256 | return; 257 | } 258 | 259 | _isGenerating = true; 260 | _cancelRequested = false; 261 | 262 | // Smart change detection - skip generation if no changes 263 | if (_generationCount > 0 && !await _hasSourceFilesChanged()) { 264 | if (verbose) { 265 | print('✅ No source file changes detected - skipping generation'); 266 | } 267 | _isGenerating = false; 268 | return; 269 | } 270 | 271 | // Check if clean rebuild is needed 272 | final needsClean = await _needsCleanRebuild(); 273 | if (needsClean && _generationCount > 0) { 274 | print('🧹 Source files out of sync - performing clean rebuild...'); 275 | await clean(); 276 | } 277 | 278 | print('🚀 Solid Generator - Transpiling reactive code...'); 279 | print('📁 Source: $sourceDir/ → Output: $outputDir/'); 280 | if (needsClean && _generationCount > 0) { 281 | print('🔄 Clean rebuild triggered'); 282 | } 283 | print(''); 284 | 285 | final stopwatch = Stopwatch()..start(); 286 | 287 | try { 288 | // Ensure output directory exists 289 | final outputDirectory = Directory(outputDir); 290 | if (!await outputDirectory.exists()) { 291 | await outputDirectory.create(recursive: true); 292 | if (verbose) print('📁 Created output directory: $outputDir/'); 293 | } 294 | 295 | // Find all Dart files in source directory 296 | final sourceDirectory = Directory(sourceDir); 297 | if (!await sourceDirectory.exists()) { 298 | throw Exception('Source directory "$sourceDir" does not exist'); 299 | } 300 | 301 | final dartFiles = await _findDartFiles(sourceDirectory); 302 | if (dartFiles.isEmpty) { 303 | print('⚠️ No Dart files found in $sourceDir/'); 304 | return; 305 | } 306 | 307 | if (verbose) print('📋 Found ${dartFiles.length} Dart files to process'); 308 | 309 | // Process each file 310 | int transformedCount = 0; 311 | int copiedCount = 0; 312 | final builder = SolidBuilder(); 313 | 314 | for (final file in dartFiles) { 315 | // Check for cancellation during file processing 316 | if (_cancelRequested) { 317 | if (verbose) print('⏹️ Generation cancelled during file processing'); 318 | _isGenerating = false; 319 | return; 320 | } 321 | 322 | final relativePath = path.relative(file.path, from: sourceDir); 323 | final outputPath = path.join(outputDir, relativePath); 324 | 325 | if (verbose) print('🔄 Processing: $relativePath'); 326 | 327 | try { 328 | // Use stable file reading to avoid processing partial content 329 | final content = await _waitForStableFile(file); 330 | if (content == null) { 331 | if (verbose) { 332 | print('⏭️ File $relativePath still being written - skipping...'); 333 | } 334 | continue; 335 | } 336 | 337 | // Parse and check if file needs transformation 338 | final parseResult = parseString( 339 | content: content, 340 | featureSet: FeatureSet.latestLanguageVersion(), 341 | ); 342 | 343 | if (parseResult.errors.isNotEmpty) { 344 | print('⚠️ Parse errors in $relativePath: ${parseResult.errors}'); 345 | continue; 346 | } 347 | 348 | // Transform the file using our existing builder logic 349 | final transformedCode = await builder.transformAstForTesting( 350 | parseResult.unit, 351 | file.path, 352 | content, 353 | ); 354 | 355 | // Check if transformation actually occurred 356 | final needsTransformation = transformedCode != content; 357 | 358 | if (needsTransformation) { 359 | await _writeTransformedFile(outputPath, transformedCode); 360 | transformedCount++; 361 | if (verbose) print('✨ Transformed: $relativePath'); 362 | } else { 363 | await _copyFile(file.path, outputPath); 364 | copiedCount++; 365 | if (verbose) print('📄 Copied: $relativePath'); 366 | } 367 | } catch (e) { 368 | print('❌ Error processing $relativePath: $e'); 369 | } 370 | } 371 | 372 | // Format all generated files 373 | print(''); 374 | print('🎨 Formatting generated code...'); 375 | await _formatGeneratedFiles(); 376 | 377 | // Apply lint fixes 378 | print('🔧 Applying lint fixes...'); 379 | await _applyLintFixes(); 380 | 381 | // Update file tracking information after successful generation 382 | await _updateFileTracking(); 383 | _generationCount++; 384 | 385 | stopwatch.stop(); 386 | print(''); 387 | print('✅ Generation complete!'); 388 | print('📊 Transformed: $transformedCount files'); 389 | print('📊 Copied: $copiedCount files'); 390 | print('⏱️ Time: ${stopwatch.elapsed.inMilliseconds}ms'); 391 | if (verbose) print('🔢 Generation #$_generationCount'); 392 | print(''); 393 | print('🎯 Your app is ready to run from $outputDir/main.dart'); 394 | } catch (e) { 395 | print('❌ Generation failed: $e'); 396 | exit(1); 397 | } finally { 398 | _isGenerating = false; 399 | } 400 | } 401 | 402 | Future clean() async { 403 | print('🧹 Cleaning output directory: $outputDir/'); 404 | 405 | final outputDirectory = Directory(outputDir); 406 | if (await outputDirectory.exists()) { 407 | await outputDirectory.delete(recursive: true); 408 | if (verbose) print('🗑️ Deleted: $outputDir/'); 409 | } 410 | 411 | await outputDirectory.create(recursive: true); 412 | if (verbose) print('📁 Recreated: $outputDir/'); 413 | print(''); 414 | } 415 | 416 | Future watch() async { 417 | print('👀 Watching for changes in $sourceDir/...'); 418 | print('Press Ctrl+C to stop'); 419 | print(''); 420 | 421 | // Initial generation 422 | await generate(); 423 | 424 | // Watch for file changes 425 | final sourceDirectory = Directory(sourceDir); 426 | await for (final event in sourceDirectory.watch(recursive: true)) { 427 | if (event.path.endsWith('.dart')) { 428 | final relativePath = path.relative(event.path, from: sourceDir); 429 | print(''); 430 | print('🔄 File changed: $relativePath'); 431 | 432 | // Cancel any ongoing generation immediately 433 | if (_isGenerating) { 434 | _cancelRequested = true; 435 | if (verbose) print('⏸️ Stopping ongoing generation...'); 436 | 437 | // Wait for generation to stop 438 | while (_isGenerating) { 439 | await Future.delayed(const Duration(milliseconds: 50)); 440 | } 441 | } 442 | 443 | // Cancel existing debounce timer 444 | _debounceTimer?.cancel(); 445 | 446 | // Set up new debounce timer (1500ms - allows more time for file writing) 447 | _debounceTimer = Timer(const Duration(milliseconds: 1500), () async { 448 | try { 449 | // Ensure file is stable before processing 450 | final file = File(event.path); 451 | if (await file.exists()) { 452 | // Wait for file to be stable (not being written) 453 | final stableContent = await _waitForStableFile(file); 454 | 455 | if (stableContent == null) { 456 | if (verbose) { 457 | print( 458 | '⏭️ File still being written - skipping generation...', 459 | ); 460 | } 461 | return; 462 | } 463 | 464 | try { 465 | final parseResult = parseString( 466 | content: stableContent, 467 | featureSet: FeatureSet.latestLanguageVersion(), 468 | ); 469 | 470 | if (parseResult.errors.isNotEmpty) { 471 | if (verbose) { 472 | print( 473 | '⏭️ File has parse errors - waiting for valid syntax...', 474 | ); 475 | } 476 | return; 477 | } 478 | } catch (e) { 479 | if (verbose) { 480 | print( 481 | '⏭️ File cannot be parsed yet - waiting for completion...', 482 | ); 483 | } 484 | return; 485 | } 486 | } 487 | 488 | print('🔄 Regenerating files...'); 489 | 490 | // Clear cached state to detect changes properly 491 | _fileContentHashes.clear(); 492 | _fileModificationTimes.clear(); 493 | 494 | // Let generate() decide if cleaning is needed based on its built-in logic 495 | await generate(); 496 | } catch (e) { 497 | print('⚠️ Error during restart: $e'); 498 | // Fall back to generation on error 499 | await generate(); 500 | } 501 | }); 502 | } 503 | } 504 | } 505 | 506 | Future> _findDartFiles(Directory directory) async { 507 | final files = []; 508 | 509 | await for (final entity in directory.list(recursive: true)) { 510 | if (entity is File && 511 | entity.path.endsWith('.dart') && 512 | !entity.path.endsWith('.solid.dart')) { 513 | files.add(entity); 514 | } 515 | } 516 | 517 | return files; 518 | } 519 | 520 | Future _writeTransformedFile(String outputPath, String content) async { 521 | final outputFile = File(outputPath); 522 | await outputFile.parent.create(recursive: true); 523 | await outputFile.writeAsString(content); 524 | } 525 | 526 | Future _copyFile(String inputPath, String outputPath) async { 527 | final inputFile = File(inputPath); 528 | final outputFile = File(outputPath); 529 | await outputFile.parent.create(recursive: true); 530 | await inputFile.copy(outputPath); 531 | } 532 | 533 | Future _formatGeneratedFiles() async { 534 | try { 535 | final result = await Process.run('dart', ['format', outputDir]); 536 | 537 | if (result.exitCode == 0) { 538 | final output = result.stdout.toString().trim(); 539 | if (output.isNotEmpty) { 540 | print('✅ $output'); 541 | } else { 542 | print('✅ Code already properly formatted'); 543 | } 544 | } else { 545 | print('⚠️ Format warnings: ${result.stderr}'); 546 | } 547 | } catch (e) { 548 | print('⚠️ Could not format code: $e'); 549 | } 550 | } 551 | 552 | Future _applyLintFixes() async { 553 | try { 554 | final result = await Process.run('dart', ['fix', '--apply', outputDir]); 555 | 556 | if (result.exitCode == 0) { 557 | final stdout = result.stdout.toString().trim(); 558 | final stderr = result.stderr.toString().trim(); 559 | 560 | if (stdout.isNotEmpty && stdout.contains('fix')) { 561 | print('✅ Applied fixes: $stdout'); 562 | } else if (stderr.isNotEmpty && stderr.contains('fixed')) { 563 | print('✅ Applied fixes: $stderr'); 564 | } else { 565 | print('✅ No fixes needed - code is already compliant'); 566 | } 567 | } else { 568 | print('⚠️ Fix warnings: ${result.stderr}'); 569 | } 570 | } catch (e) { 571 | print('⚠️ Could not apply fixes: $e'); 572 | } 573 | } 574 | } 575 | -------------------------------------------------------------------------------- /packages/solid_generator/test/solid_builder_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:build_test/build_test.dart'; 3 | 4 | import 'package:solid_generator/src/solid_builder.dart'; 5 | 6 | void main() { 7 | group('SolidBuilder', () { 8 | test('transpiles @SolidState fields to Signal declarations', () async { 9 | const input = ''' 10 | import 'package:flutter/material.dart'; 11 | import 'package:solid_annotations/solid_annotations.dart'; 12 | 13 | class Counter { 14 | @SolidState() 15 | int count = 0; 16 | 17 | @SolidState(name: 'customCounter') 18 | int value = 5; 19 | } 20 | '''; 21 | 22 | final expectedOutput = ''' 23 | import 'package:flutter/material.dart'; 24 | import 'package:solid_annotations/solid_annotations.dart'; 25 | 26 | import 'package:flutter_solidart/flutter_solidart.dart'; 27 | 28 | class Counter { 29 | final count = Signal(0, name: 'count'); 30 | 31 | final value = Signal(5, name: 'customCounter'); 32 | 33 | void dispose() { 34 | count.dispose(); 35 | value.dispose(); 36 | } 37 | } 38 | '''; 39 | 40 | await testBuilder( 41 | SolidBuilder(), 42 | {'a|source/counter.dart': input}, 43 | outputs: {'a|source/counter.solid.dart': expectedOutput}, 44 | ); 45 | }); 46 | 47 | test('transpiles @SolidState getters to Computed declarations', () async { 48 | const input = ''' 49 | import 'package:solid_annotations/solid_annotations.dart'; 50 | 51 | class Calculator { 52 | @SolidState() 53 | String firstName = 'John'; 54 | 55 | @SolidState() 56 | String lastName = 'Doe'; 57 | 58 | @SolidState() 59 | String get result => firstName + ' ' + lastName; 60 | } 61 | '''; 62 | 63 | final expectedOutput = ''' 64 | import 'package:solid_annotations/solid_annotations.dart'; 65 | 66 | import 'package:flutter_solidart/flutter_solidart.dart'; 67 | 68 | class Calculator { 69 | final firstName = Signal('John', name: 'firstName'); 70 | 71 | final lastName = Signal('Doe', name: 'lastName'); 72 | 73 | late final result = Computed( 74 | () => firstName.value + ' ' + lastName.value, 75 | name: 'result', 76 | ); 77 | 78 | void dispose() { 79 | firstName.dispose(); 80 | lastName.dispose(); 81 | result.dispose(); 82 | } 83 | } 84 | '''; 85 | 86 | await testBuilder( 87 | SolidBuilder(), 88 | {'a|source/calculator.dart': input}, 89 | outputs: {'a|source/calculator.solid.dart': expectedOutput}, 90 | ); 91 | }); 92 | 93 | test('transpiles @SolidEffect methods to Effect declarations', () async { 94 | const input = ''' 95 | import 'package:solid_annotations/solid_annotations.dart'; 96 | 97 | class Logger { 98 | @SolidEffect() 99 | void logCounter() { 100 | print(counter); 101 | } 102 | } 103 | '''; 104 | 105 | final expectedOutput = ''' 106 | import 'package:solid_annotations/solid_annotations.dart'; 107 | 108 | import 'package:flutter_solidart/flutter_solidart.dart'; 109 | 110 | class Logger { 111 | late final logCounter = Effect(() { 112 | print(counter.value); 113 | }, name: 'logCounter'); 114 | 115 | void dispose() { 116 | logCounter.dispose(); 117 | } 118 | } 119 | '''; 120 | 121 | await testBuilder( 122 | SolidBuilder(), 123 | {'a|source/logger.dart': input}, 124 | outputs: {'a|source/logger.solid.dart': expectedOutput}, 125 | ); 126 | }); 127 | 128 | test('transpiles @SolidQuery methods to Resource declarations', () async { 129 | const input = ''' 130 | import 'package:solid_annotations/solid_annotations.dart'; 131 | 132 | class DataService { 133 | @SolidQuery(name: 'userData', debounce: Duration(milliseconds: 300)) 134 | Future fetchUser() async { 135 | return 'user data'; 136 | } 137 | } 138 | '''; 139 | 140 | final expectedOutput = ''' 141 | import 'package:solid_annotations/solid_annotations.dart'; 142 | 143 | import 'package:flutter_solidart/flutter_solidart.dart'; 144 | 145 | class DataService { 146 | late final fetchUser = Resource( 147 | () async { 148 | return 'user data'; 149 | }, 150 | name: 'userData', 151 | debounceDelay: const Duration(milliseconds: 300), 152 | ); 153 | 154 | void dispose() { 155 | fetchUser.dispose(); 156 | } 157 | } 158 | '''; 159 | 160 | await testBuilder( 161 | SolidBuilder(), 162 | {'a|source/data_service.dart': input}, 163 | outputs: {'a|source/data_service.solid.dart': expectedOutput}, 164 | ); 165 | }); 166 | }); 167 | 168 | test('transpiles CounterPage', () async { 169 | const input = r''' 170 | class CounterPage extends StatelessWidget { 171 | CounterPage({super.key}); 172 | 173 | @SolidState(name: 'customName') 174 | int counter = 0; 175 | 176 | @override 177 | Widget build(BuildContext context) { 178 | return Scaffold( 179 | appBar: AppBar(title: const Text('Reactivity Demo')), 180 | body: Center( 181 | child: Column( 182 | mainAxisAlignment: MainAxisAlignment.center, 183 | children: [ 184 | Text('Counter: $counter'), 185 | const SizedBox(height: 12), 186 | ElevatedButton( 187 | onPressed: () => counter++, 188 | child: const Text('Increment updated'), 189 | ), 190 | ], 191 | ), 192 | ), 193 | ); 194 | } 195 | } 196 | '''; 197 | 198 | final expectedOutput = r''' 199 | import 'package:flutter_solidart/flutter_solidart.dart'; 200 | 201 | class CounterPage extends StatefulWidget { 202 | CounterPage({super.key}); 203 | 204 | @override 205 | State createState() => _CounterPageState(); 206 | } 207 | 208 | class _CounterPageState extends State { 209 | final counter = Signal(0, name: 'customName'); 210 | 211 | @override 212 | void dispose() { 213 | counter.dispose(); 214 | super.dispose(); 215 | } 216 | 217 | @override 218 | Widget build(BuildContext context) { 219 | return Scaffold( 220 | appBar: AppBar(title: const Text('Reactivity Demo')), 221 | body: Center( 222 | child: Column( 223 | mainAxisAlignment: MainAxisAlignment.center, 224 | children: [ 225 | SignalBuilder( 226 | builder: (context, child) { 227 | return Text('Counter: ${counter.value}'); 228 | }, 229 | ), 230 | const SizedBox(height: 12), 231 | ElevatedButton( 232 | onPressed: () => counter.value++, 233 | child: const Text('Increment updated'), 234 | ), 235 | ], 236 | ), 237 | ), 238 | ); 239 | } 240 | } 241 | '''; 242 | 243 | await testBuilder( 244 | SolidBuilder(), 245 | {'a|source/example.dart': input}, 246 | outputs: {'a|source/example.solid.dart': expectedOutput}, 247 | ); 248 | }); 249 | 250 | test('transpiles ComputedExample', () async { 251 | const input = r''' 252 | class ComputedExample extends StatelessWidget { 253 | ComputedExample({super.key}); 254 | @SolidState() 255 | int counter = 0; 256 | 257 | @SolidState() 258 | int get doubleCounter => counter * 2; 259 | 260 | @override 261 | Widget build(BuildContext context) { 262 | return Text('Counter: $counter, DoubleCounter: $doubleCounter'); 263 | } 264 | } 265 | '''; 266 | 267 | final expectedOutput = r''' 268 | import 'package:flutter_solidart/flutter_solidart.dart'; 269 | 270 | class ComputedExample extends StatefulWidget { 271 | ComputedExample({super.key}); 272 | 273 | @override 274 | State createState() => _ComputedExampleState(); 275 | } 276 | 277 | class _ComputedExampleState extends State { 278 | final counter = Signal(0, name: 'counter'); 279 | late final doubleCounter = Computed( 280 | () => counter.value * 2, 281 | name: 'doubleCounter', 282 | ); 283 | 284 | @override 285 | void dispose() { 286 | counter.dispose(); 287 | doubleCounter.dispose(); 288 | super.dispose(); 289 | } 290 | 291 | @override 292 | Widget build(BuildContext context) { 293 | return SignalBuilder( 294 | builder: (context, child) { 295 | return Text( 296 | 'Counter: ${counter.value}, DoubleCounter: ${doubleCounter.value}', 297 | ); 298 | }, 299 | ); 300 | } 301 | } 302 | '''; 303 | 304 | await testBuilder( 305 | SolidBuilder(), 306 | {'a|source/example.dart': input}, 307 | outputs: {'a|source/example.solid.dart': expectedOutput}, 308 | ); 309 | }); 310 | 311 | test('transpiles EffectExample', () async { 312 | const input = r''' 313 | class EffectExample extends StatelessWidget { 314 | EffectExample({super.key}); 315 | 316 | @SolidState() 317 | int counter = 0; 318 | 319 | @SolidEffect() 320 | void logCounter() { 321 | print('Counter changed: $counter'); 322 | } 323 | 324 | @override 325 | Widget build(BuildContext context) { 326 | return Text('Counter: $counter'); 327 | } 328 | } 329 | '''; 330 | 331 | final expectedOutput = r''' 332 | import 'package:flutter_solidart/flutter_solidart.dart'; 333 | 334 | class EffectExample extends StatefulWidget { 335 | EffectExample({super.key}); 336 | 337 | @override 338 | State createState() => _EffectExampleState(); 339 | } 340 | 341 | class _EffectExampleState extends State { 342 | final counter = Signal(0, name: 'counter'); 343 | late final logCounter = Effect(() { 344 | print('Counter changed: ${counter.value}'); 345 | }, name: 'logCounter'); 346 | 347 | @override 348 | initState() { 349 | super.initState(); 350 | logCounter; 351 | } 352 | 353 | @override 354 | void dispose() { 355 | counter.dispose(); 356 | logCounter.dispose(); 357 | super.dispose(); 358 | } 359 | 360 | @override 361 | Widget build(BuildContext context) { 362 | return SignalBuilder( 363 | builder: (context, child) { 364 | return Text('Counter: ${counter.value}'); 365 | }, 366 | ); 367 | } 368 | } 369 | '''; 370 | 371 | await testBuilder( 372 | SolidBuilder(), 373 | {'a|source/example.dart': input}, 374 | outputs: {'a|source/example.solid.dart': expectedOutput}, 375 | ); 376 | }); 377 | 378 | test('transpiles QueryExample', () async { 379 | const input = r''' 380 | class QueryExample extends StatelessWidget { 381 | const QueryExample({super.key}); 382 | 383 | @SolidQuery() 384 | Future fetchData() async { 385 | await Future.delayed(const Duration(seconds: 1)); 386 | return 'Fetched Data'; 387 | } 388 | 389 | @override 390 | Widget build(BuildContext context) { 391 | return fetchData().when( 392 | ready: (data) { 393 | return Text(data); 394 | }, 395 | loading: () => CircularProgressIndicator(), 396 | error: (error, stackTrace) => Text('Error: $error'), 397 | ); 398 | } 399 | } 400 | '''; 401 | 402 | final expectedOutput = r''' 403 | import 'package:flutter_solidart/flutter_solidart.dart'; 404 | 405 | class QueryExample extends StatefulWidget { 406 | const QueryExample({super.key}); 407 | 408 | @override 409 | State createState() => _QueryExampleState(); 410 | } 411 | 412 | class _QueryExampleState extends State { 413 | late final fetchData = Resource(() async { 414 | await Future.delayed(const Duration(seconds: 1)); 415 | return 'Fetched Data'; 416 | }, name: 'fetchData'); 417 | 418 | @override 419 | void dispose() { 420 | fetchData.dispose(); 421 | super.dispose(); 422 | } 423 | 424 | @override 425 | Widget build(BuildContext context) { 426 | return SignalBuilder( 427 | builder: (context, child) { 428 | return fetchData().when( 429 | ready: (data) { 430 | return Text(data); 431 | }, 432 | loading: () => CircularProgressIndicator(), 433 | error: (error, stackTrace) => Text('Error: $error'), 434 | ); 435 | }, 436 | ); 437 | } 438 | } 439 | '''; 440 | 441 | await testBuilder( 442 | SolidBuilder(), 443 | {'a|source/example.dart': input}, 444 | outputs: {'a|source/example.solid.dart': expectedOutput}, 445 | ); 446 | }); 447 | 448 | test('transpiles QueryWithSourceExample', () async { 449 | const input = r''' 450 | class QueryWithSourceExample extends StatelessWidget { 451 | QueryWithSourceExample({super.key}); 452 | 453 | @SolidState() 454 | String? userId; 455 | 456 | @SolidQuery(debounce: Duration(seconds: 1)) 457 | Future fetchData() async { 458 | if (userId == null) return null; 459 | await Future.delayed(const Duration(seconds: 1)); 460 | return 'Fetched Data'; 461 | } 462 | 463 | @override 464 | Widget build(BuildContext context) { 465 | return fetchData().when( 466 | ready: (data) { 467 | if (data == null) { 468 | return const Text('No user ID provided'); 469 | } 470 | return Text(data); 471 | }, 472 | loading: () => CircularProgressIndicator(), 473 | error: (error, stackTrace) => Text('Error: $error'), 474 | ); 475 | } 476 | } 477 | '''; 478 | 479 | final expectedOutput = r''' 480 | import 'package:flutter_solidart/flutter_solidart.dart'; 481 | 482 | class QueryWithSourceExample extends StatefulWidget { 483 | QueryWithSourceExample({super.key}); 484 | 485 | @override 486 | State createState() => _QueryWithSourceExampleState(); 487 | } 488 | 489 | class _QueryWithSourceExampleState extends State { 490 | final userId = Signal(null, name: 'userId'); 491 | late final fetchData = Resource( 492 | () async { 493 | if (userId.value == null) return null; 494 | await Future.delayed(const Duration(seconds: 1)); 495 | return 'Fetched Data'; 496 | }, 497 | source: userId, 498 | name: 'fetchData', 499 | debounceDelay: const Duration(seconds: 1), 500 | ); 501 | 502 | @override 503 | void dispose() { 504 | userId.dispose(); 505 | fetchData.dispose(); 506 | super.dispose(); 507 | } 508 | 509 | @override 510 | Widget build(BuildContext context) { 511 | return SignalBuilder( 512 | builder: (context, child) { 513 | return fetchData().when( 514 | ready: (data) { 515 | if (data == null) { 516 | return const Text('No user ID provided'); 517 | } 518 | return Text(data); 519 | }, 520 | loading: () => CircularProgressIndicator(), 521 | error: (error, stackTrace) => Text('Error: $error'), 522 | ); 523 | }, 524 | ); 525 | } 526 | } 527 | '''; 528 | 529 | await testBuilder( 530 | SolidBuilder(), 531 | {'a|source/example.dart': input}, 532 | outputs: {'a|source/example.solid.dart': expectedOutput}, 533 | ); 534 | }); 535 | 536 | test('transpiles QueryWithMultipleSourcesExample', () async { 537 | const input = r''' 538 | class QueryWithMultipleSourcesExample extends StatelessWidget { 539 | QueryWithMultipleSourcesExample({super.key}); 540 | 541 | @SolidState() 542 | String? userId; 543 | 544 | @SolidState() 545 | String? authToken; 546 | 547 | @SolidQuery() 548 | Future fetchData() async { 549 | if (userId == null || authToken == null) return null; 550 | await Future.delayed(const Duration(seconds: 1)); 551 | return 'Fetched Data'; 552 | } 553 | 554 | @override 555 | Widget build(BuildContext context) { 556 | return Column( 557 | spacing: 8, 558 | children: [ 559 | Text('Complex Query example'), 560 | fetchData().when( 561 | ready: (data) { 562 | if (data == null) { 563 | return const Text('No user ID provided'); 564 | } 565 | return Text(data); 566 | }, 567 | loading: () => CircularProgressIndicator(), 568 | error: (error, stackTrace) => Text('Error: $error'), 569 | ), 570 | ], 571 | ); 572 | } 573 | } 574 | '''; 575 | 576 | final expectedOutput = r''' 577 | import 'package:flutter_solidart/flutter_solidart.dart'; 578 | 579 | class QueryWithMultipleSourcesExample extends StatefulWidget { 580 | QueryWithMultipleSourcesExample({super.key}); 581 | 582 | @override 583 | State createState() => 584 | _QueryWithMultipleSourcesExampleState(); 585 | } 586 | 587 | class _QueryWithMultipleSourcesExampleState 588 | extends State { 589 | final userId = Signal(null, name: 'userId'); 590 | final authToken = Signal(null, name: 'authToken'); 591 | late final fetchData = Resource( 592 | () async { 593 | if (userId.value == null || authToken.value == null) return null; 594 | await Future.delayed(const Duration(seconds: 1)); 595 | return 'Fetched Data'; 596 | }, 597 | source: Computed( 598 | () => (userId.value, authToken.value), 599 | name: 'fetchDataSource', 600 | ), 601 | name: 'fetchData', 602 | ); 603 | 604 | @override 605 | void dispose() { 606 | userId.dispose(); 607 | authToken.dispose(); 608 | fetchData.dispose(); 609 | super.dispose(); 610 | } 611 | 612 | @override 613 | Widget build(BuildContext context) { 614 | return Column( 615 | spacing: 8, 616 | children: [ 617 | Text('Complex Query example'), 618 | SignalBuilder( 619 | builder: (context, child) { 620 | return fetchData().when( 621 | ready: (data) { 622 | if (data == null) { 623 | return const Text('No user ID provided'); 624 | } 625 | return Text(data); 626 | }, 627 | loading: () => CircularProgressIndicator(), 628 | error: (error, stackTrace) => Text('Error: $error'), 629 | ); 630 | }, 631 | ), 632 | ], 633 | ); 634 | } 635 | } 636 | '''; 637 | 638 | await testBuilder( 639 | SolidBuilder(), 640 | {'a|source/example.dart': input}, 641 | outputs: {'a|source/example.solid.dart': expectedOutput}, 642 | ); 643 | }); 644 | 645 | test('transpiles ACustomClassWithSolidState', () async { 646 | const input = r''' 647 | class ACustomClassWithSolidState { 648 | @SolidState() 649 | int value = 0; 650 | } 651 | '''; 652 | 653 | final expectedOutput = r''' 654 | import 'package:flutter_solidart/flutter_solidart.dart'; 655 | 656 | class ACustomClassWithSolidState { 657 | final value = Signal(0, name: 'value'); 658 | 659 | void dispose() { 660 | value.dispose(); 661 | } 662 | } 663 | '''; 664 | 665 | await testBuilder( 666 | SolidBuilder(), 667 | {'a|source/example.dart': input}, 668 | outputs: {'a|source/example.solid.dart': expectedOutput}, 669 | ); 670 | }); 671 | 672 | test('transpiles ACustomClass', () async { 673 | const input = r''' 674 | class ACustomClass { 675 | void doNothing() { 676 | // no-op 677 | } 678 | } 679 | '''; 680 | 681 | final expectedOutput = r''' 682 | class ACustomClass { 683 | void doNothing() { 684 | // no-op 685 | } 686 | } 687 | '''; 688 | 689 | await testBuilder( 690 | SolidBuilder(), 691 | {'a|source/example.dart': input}, 692 | outputs: {'a|source/example.solid.dart': expectedOutput}, 693 | ); 694 | }); 695 | 696 | test('transpiles EnvironmentExample', () async { 697 | const input = r''' 698 | class EnvironmentExample extends StatelessWidget { 699 | EnvironmentExample({super.key}); 700 | 701 | @SolidEnvironment() 702 | late ACustomClassWithSolidState myData; 703 | 704 | @override 705 | Widget build(BuildContext context) { 706 | return Text(myData.value.toString()); 707 | } 708 | } 709 | '''; 710 | 711 | final expectedOutput = r''' 712 | import 'package:flutter_solidart/flutter_solidart.dart'; 713 | 714 | class EnvironmentExample extends StatefulWidget { 715 | EnvironmentExample({super.key}); 716 | 717 | @override 718 | State createState() => _EnvironmentExampleState(); 719 | } 720 | 721 | class _EnvironmentExampleState extends State { 722 | late final myData = context.read(); 723 | 724 | @override 725 | Widget build(BuildContext context) { 726 | return SignalBuilder( 727 | builder: (context, child) { 728 | return Text(myData.value.value.toString()); 729 | }, 730 | ); 731 | } 732 | } 733 | '''; 734 | 735 | await testBuilder( 736 | SolidBuilder(), 737 | {'a|source/example.dart': input}, 738 | outputs: {'a|source/example.solid.dart': expectedOutput}, 739 | ); 740 | }); 741 | 742 | test( 743 | 'transpiles StatefulWidget with existing initState and Effects', 744 | () async { 745 | const input = r''' 746 | class EffectExampleWithExistingInitState extends StatefulWidget { 747 | EffectExampleWithExistingInitState({super.key}); 748 | 749 | @override 750 | State createState() => _EffectExampleWithExistingInitStateState(); 751 | } 752 | 753 | class _EffectExampleWithExistingInitStateState extends State { 754 | @SolidState() 755 | int counter = 0; 756 | 757 | @SolidEffect() 758 | void logCounter() { 759 | print('Counter changed: $counter'); 760 | } 761 | 762 | @override 763 | initState() { 764 | super.initState(); 765 | print('Custom user initialization logic here'); 766 | // User's existing code should be preserved 767 | } 768 | 769 | @override 770 | Widget build(BuildContext context) { 771 | return Text('Counter: $counter'); 772 | } 773 | } 774 | '''; 775 | 776 | final expectedOutput = r''' 777 | import 'package:flutter_solidart/flutter_solidart.dart'; 778 | 779 | class EffectExampleWithExistingInitState extends StatefulWidget { 780 | EffectExampleWithExistingInitState({super.key}); 781 | 782 | @override 783 | State createState() => 784 | _EffectExampleWithExistingInitStateState(); 785 | } 786 | 787 | class _EffectExampleWithExistingInitStateState 788 | extends State { 789 | final counter = Signal(0, name: 'counter'); 790 | 791 | late final logCounter = Effect(() { 792 | print('Counter changed: ${counter.value}'); 793 | }, name: 'logCounter'); 794 | 795 | @override 796 | initState() { 797 | super.initState(); 798 | logCounter; 799 | print('Custom user initialization logic here'); 800 | // User's existing code should be preserved 801 | } 802 | 803 | @override 804 | void dispose() { 805 | counter.dispose(); 806 | logCounter.dispose(); 807 | super.dispose(); 808 | } 809 | 810 | @override 811 | Widget build(BuildContext context) { 812 | return SignalBuilder( 813 | builder: (context, child) { 814 | return Text('Counter: ${counter.value}'); 815 | }, 816 | ); 817 | } 818 | } 819 | '''; 820 | 821 | await testBuilder( 822 | SolidBuilder(), 823 | {'a|source/example.dart': input}, 824 | outputs: {'a|source/example.solid.dart': expectedOutput}, 825 | ); 826 | }, 827 | ); 828 | 829 | test('transpiles StatefulWidget with existing dispose method', () async { 830 | const input = r''' 831 | class EffectExampleWithExistingDispose extends StatefulWidget { 832 | EffectExampleWithExistingDispose({super.key}); 833 | 834 | @override 835 | State createState() => _EffectExampleWithExistingDisposeState(); 836 | } 837 | 838 | class _EffectExampleWithExistingDisposeState extends State { 839 | @SolidState() 840 | int counter = 0; 841 | 842 | @SolidEffect() 843 | void logCounter() { 844 | print('Counter changed: $counter'); 845 | } 846 | 847 | @override 848 | void dispose() { 849 | print('Custom user disposal logic here'); 850 | super.dispose(); 851 | } 852 | 853 | @override 854 | Widget build(BuildContext context) { 855 | return Text('Counter: $counter'); 856 | } 857 | } 858 | '''; 859 | 860 | final expectedOutput = r''' 861 | import 'package:flutter_solidart/flutter_solidart.dart'; 862 | 863 | class EffectExampleWithExistingDispose extends StatefulWidget { 864 | EffectExampleWithExistingDispose({super.key}); 865 | 866 | @override 867 | State createState() => 868 | _EffectExampleWithExistingDisposeState(); 869 | } 870 | 871 | class _EffectExampleWithExistingDisposeState 872 | extends State { 873 | final counter = Signal(0, name: 'counter'); 874 | 875 | late final logCounter = Effect(() { 876 | print('Counter changed: ${counter.value}'); 877 | }, name: 'logCounter'); 878 | 879 | @override 880 | void dispose() { 881 | print('Custom user disposal logic here'); 882 | counter.dispose(); 883 | logCounter.dispose(); 884 | super.dispose(); 885 | } 886 | 887 | @override 888 | initState() { 889 | super.initState(); 890 | logCounter; 891 | } 892 | 893 | @override 894 | Widget build(BuildContext context) { 895 | return SignalBuilder( 896 | builder: (context, child) { 897 | return Text('Counter: ${counter.value}'); 898 | }, 899 | ); 900 | } 901 | } 902 | '''; 903 | 904 | await testBuilder( 905 | SolidBuilder(), 906 | {'a|source/example.dart': input}, 907 | outputs: {'a|source/example.solid.dart': expectedOutput}, 908 | ); 909 | }); 910 | 911 | test('transpiles regular class with existing dispose method', () async { 912 | const input = r''' 913 | class DataServiceWithExistingDispose { 914 | @SolidState() 915 | String? userId; 916 | 917 | @SolidQuery() 918 | Future fetchData() async { 919 | if (userId == null) return null; 920 | await Future.delayed(const Duration(seconds: 1)); 921 | return 'Fetched Data'; 922 | } 923 | 924 | void dispose() { 925 | print('Custom user disposal logic here'); 926 | // User's existing disposal code should be preserved 927 | } 928 | } 929 | '''; 930 | 931 | final expectedOutput = r''' 932 | import 'package:flutter_solidart/flutter_solidart.dart'; 933 | 934 | class DataServiceWithExistingDispose { 935 | final userId = Signal(null, name: 'userId'); 936 | 937 | late final fetchData = Resource( 938 | () async { 939 | if (userId.value == null) return null; 940 | await Future.delayed(const Duration(seconds: 1)); 941 | return 'Fetched Data'; 942 | }, 943 | source: userId, 944 | name: 'fetchData', 945 | ); 946 | 947 | void dispose() { 948 | print('Custom user disposal logic here'); 949 | // User's existing disposal code should be preserved 950 | userId.dispose(); 951 | fetchData.dispose(); 952 | } 953 | } 954 | '''; 955 | 956 | await testBuilder( 957 | SolidBuilder(), 958 | {'a|source/example.dart': input}, 959 | outputs: {'a|source/example.solid.dart': expectedOutput}, 960 | ); 961 | }); 962 | 963 | test('transpiles CounterPage with Custom Text widget', () async { 964 | const input = r''' 965 | class CustomWidget extends StatelessWidget { 966 | const CustomWidget({super.key, required this.text}); 967 | 968 | final String text; 969 | 970 | @override 971 | Widget build(BuildContext context) { 972 | return Text(text); 973 | } 974 | } 975 | 976 | class CounterPage extends StatelessWidget { 977 | CounterPage({super.key}); 978 | 979 | @SolidState(name: 'customName') 980 | int counter = 0; 981 | 982 | @override 983 | Widget build(BuildContext context) { 984 | return Scaffold( 985 | appBar: AppBar(title: const Text('Reactivity Demo')), 986 | body: Center( 987 | child: Column( 988 | mainAxisAlignment: MainAxisAlignment.center, 989 | children: [ 990 | CustomWidget(text: 'Counter: $counter'), 991 | const SizedBox(height: 12), 992 | ElevatedButton( 993 | onPressed: () => counter++, 994 | child: const Text('Increment updated'), 995 | ), 996 | ], 997 | ), 998 | ), 999 | ); 1000 | } 1001 | } 1002 | '''; 1003 | 1004 | final expectedOutput = r''' 1005 | import 'package:flutter_solidart/flutter_solidart.dart'; 1006 | 1007 | class CustomWidget extends StatelessWidget { 1008 | const CustomWidget({super.key, required this.text}); 1009 | 1010 | final String text; 1011 | 1012 | @override 1013 | Widget build(BuildContext context) { 1014 | return Text(text); 1015 | } 1016 | } 1017 | 1018 | class CounterPage extends StatefulWidget { 1019 | CounterPage({super.key}); 1020 | 1021 | @override 1022 | State createState() => _CounterPageState(); 1023 | } 1024 | 1025 | class _CounterPageState extends State { 1026 | final counter = Signal(0, name: 'customName'); 1027 | 1028 | @override 1029 | void dispose() { 1030 | counter.dispose(); 1031 | super.dispose(); 1032 | } 1033 | 1034 | @override 1035 | Widget build(BuildContext context) { 1036 | return Scaffold( 1037 | appBar: AppBar(title: const Text('Reactivity Demo')), 1038 | body: Center( 1039 | child: Column( 1040 | mainAxisAlignment: MainAxisAlignment.center, 1041 | children: [ 1042 | SignalBuilder( 1043 | builder: (context, child) { 1044 | return CustomWidget(text: 'Counter: ${counter.value}'); 1045 | }, 1046 | ), 1047 | const SizedBox(height: 12), 1048 | ElevatedButton( 1049 | onPressed: () => counter.value++, 1050 | child: const Text('Increment updated'), 1051 | ), 1052 | ], 1053 | ), 1054 | ), 1055 | ); 1056 | } 1057 | } 1058 | '''; 1059 | 1060 | await testBuilder( 1061 | SolidBuilder(), 1062 | {'a|source/example.dart': input}, 1063 | outputs: {'a|source/example.solid.dart': expectedOutput}, 1064 | ); 1065 | }); 1066 | 1067 | test('transpiles QueryWithStreamExample', () async { 1068 | const input = r''' 1069 | class QueryWithStreamExample extends StatelessWidget { 1070 | const QueryWithStreamExample({super.key}); 1071 | 1072 | @SolidQuery(useRefreshing: false) 1073 | Stream fetchData() { 1074 | return Stream.periodic(const Duration(seconds: 1), (i) => i); 1075 | } 1076 | 1077 | @override 1078 | Widget build(BuildContext context) { 1079 | return Scaffold( 1080 | appBar: AppBar(title: const Text('QueryWithStream')), 1081 | body: Center( 1082 | child: fetchData().when( 1083 | ready: (data) => Text(data.toString()), 1084 | loading: () => CircularProgressIndicator(), 1085 | error: (error, stackTrace) => Text('Error: $error'), 1086 | ), 1087 | ), 1088 | ); 1089 | } 1090 | } 1091 | '''; 1092 | 1093 | final expectedOutput = r''' 1094 | import 'package:flutter_solidart/flutter_solidart.dart'; 1095 | 1096 | class QueryWithStreamExample extends StatefulWidget { 1097 | const QueryWithStreamExample({super.key}); 1098 | 1099 | @override 1100 | State createState() => _QueryWithStreamExampleState(); 1101 | } 1102 | 1103 | class _QueryWithStreamExampleState extends State { 1104 | late final fetchData = Resource.stream( 1105 | () { 1106 | return Stream.periodic(const Duration(seconds: 1), (i) => i); 1107 | }, 1108 | name: 'fetchData', 1109 | useRefreshing: false, 1110 | ); 1111 | 1112 | @override 1113 | void dispose() { 1114 | fetchData.dispose(); 1115 | super.dispose(); 1116 | } 1117 | 1118 | @override 1119 | Widget build(BuildContext context) { 1120 | return Scaffold( 1121 | appBar: AppBar(title: const Text('QueryWithStream')), 1122 | body: Center( 1123 | child: SignalBuilder( 1124 | builder: (context, child) { 1125 | return fetchData().when( 1126 | ready: (data) => Text(data.toString()), 1127 | loading: () => CircularProgressIndicator(), 1128 | error: (error, stackTrace) => Text('Error: $error'), 1129 | ); 1130 | }, 1131 | ), 1132 | ), 1133 | ); 1134 | } 1135 | } 1136 | '''; 1137 | 1138 | await testBuilder( 1139 | SolidBuilder(), 1140 | {'a|source/example.dart': input}, 1141 | outputs: {'a|source/example.solid.dart': expectedOutput}, 1142 | ); 1143 | }); 1144 | 1145 | test('transpiles main with SolidartConfig', () async { 1146 | const input = r''' 1147 | import 'package:flutter/material.dart'; 1148 | 1149 | void main() { 1150 | runApp(MaterialApp()); 1151 | } 1152 | '''; 1153 | 1154 | final expectedOutput = r''' 1155 | import 'package:flutter/material.dart'; 1156 | 1157 | import 'package:flutter_solidart/flutter_solidart.dart'; 1158 | 1159 | void main() { 1160 | SolidartConfig.autoDispose = false; 1161 | runApp(MaterialApp()); 1162 | } 1163 | '''; 1164 | 1165 | await testBuilder( 1166 | SolidBuilder(), 1167 | {'a|source/main.dart': input}, 1168 | outputs: {'a|source/main.solid.dart': expectedOutput}, 1169 | ); 1170 | }); 1171 | } 1172 | --------------------------------------------------------------------------------