├── 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 |

8 |
9 |
{name}
10 |
11 | {githubUrl && (
12 |
13 |
14 |
15 | )}
16 | {twitterUrl && (
17 |
18 |
19 |
20 | )}
21 |
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 | [](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 | [](../../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 | [](https://github.com/nank1ro/solid/blob/main/LICENSE)
4 | [](https://gitHub.com/nank1ro/solid/stargazers/)
5 | [](https://gitHub.com/nank1ro/solid/issues/)
6 | [](https://gitHub.com/nank1ro/solid/pull/)
7 | [](https://pub.dev/packages/solid_generator)
8 | [](https://pub.dev/packages/solid_annotations)
9 | [](https://github.com/sponsors/nank1ro)
10 |
11 |
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 | [](../../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