diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d57e0ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IntelliAda is an IntelliJ platform plugin for Cardano blockchain development. It integrates with Blockfrost, Koios, and Yaci DevKit backends so developers can manage accounts, submit transactions, mint tokens, and write Aiken smart contracts without leaving the IDE. + +**Plugin ID:** `com.bloxbean.intelliada` | **Version:** 0.2.0-beta2 | **Min IDE:** 2024.2+ +**Supported IDEs:** IntelliJ IDEA, PyCharm, WebStorm + +## Build Commands + +```bash +./gradlew buildPlugin # Full build (CI uses this) +./gradlew build # Build + tests +./gradlew test # Run all tests +./gradlew test --tests "com.bloxbean.intelliada.idea.scripts.util.ScriptParserTest" # Single test class +./gradlew generateLexer # Regenerate Aiken lexer from .flex +./gradlew generateParser # Regenerate Aiken parser from .bnf +./gradlew runIde # Launch a sandboxed IDE with the plugin loaded +``` + +Requires **JDK 21**. Grammar generation must run before compilation if .bnf or .flex files change. + +## Source Layout + +- `src/main/java/` — Hand-written source code +- `src/main/gen/` — **Auto-generated** parser/lexer/PSI from GrammarKit. Never edit directly. +- `src/main/idea/` — Additional source root (configured in `sourceSets`) +- `src/main/resources/META-INF/plugin.xml` — Plugin extension points, actions, services +- `lib/` — Bundled JARs loaded via `fileTree` + +## Architecture + +### Backend Abstraction (Node Integration) + +The plugin supports multiple Cardano backends. `NodeType` enum defines them: Blockfrost (mainnet/preprod/preview/custom), Koios (preprod/mainnet/custom), YaciDevKit, and LocalYaciDevKit. + +- `CardanoServiceFactory` / `NodeServiceFactory` — create the appropriate service implementation based on the configured `RemoteNode` +- `nodeint/service/api/` — service interfaces (`CardanoAccountService`, `TransactionService`, `NetworkInfoService`, etc.) +- `nodeint/service/impl/` — Blockfrost/Koios implementations (via `cardano-client-lib`) +- `nodeint/service/impl/yaciprovider/` — Yaci DevKit implementations with direct REST calls +- `nodeint/devkit/` — DevKit lifecycle: download, process management, status monitoring + +Configuration is stored via IntelliJ's `PersistentStateComponent` pattern (`RemoteNodeState`, `CLIProvidersState`). + +### Actions + +All user-facing IDE actions extend `BaseAction` or `BaseTxnAction` (in `core/action/`). Transaction actions handle node configuration validation, logging to `CardanoConsole`, and error display. Each feature module (account, transaction, nativetoken, scripts, metadata) has its own `action/` package. + +### Aiken Language Support + +This is the largest subsystem. Two layers work together: + +1. **Custom grammar-based support** — BNF grammar (`aiken/lang/grammar/Aiken.bnf`) and JFlex lexer (`_AikenLexer.flex`) generate parser and PSI classes into `src/main/gen/`. `AikenPsiImplUtil` provides helper methods injected into generated PSI via `psiImplUtilClass`. When modifying the grammar, run `generateLexer` then `generateParser` before building. + +2. **LSP integration** — `AikenLanguageServerFactory` creates an LSP connection to the `aiken` CLI's language server via LSP4IJ. LSP provides folding, parameter info, and completion (as a fallback). Custom completion contributors are ordered explicitly in plugin.xml (`order="first"` for imports). + +Key Aiken subsystems: +- `aiken/completion/` — Multiple completion contributors (imports, types, validators, cross-file, chain-aware) with explicit ordering +- `aiken/navigation/` — Goto-declaration handler for stdlib and package navigation +- `aiken/reference/` — Reference contributor for stdlib symbol resolution +- `aiken/index/` — File-based index (`AikenSymbolIndex`) for symbol lookup +- `aiken/service/` — `AikenStdlibService` (stdlib file resolution), `AikenPackageService` (discovers packages in `build/packages/`) +- `aiken/module/` — Project creation wizards (`AikenModuleBuilder` for IDEA, `AikenDirectoryProjectGenerator` for other IDEs) + +### UI Components + +- **Cardano Tool Window** (right panel) — Tree-based explorer (`CardanoExplorerTreeStructure`) with nested action nodes +- **Cardano Log** (bottom panel) — Console output via `CardanoConsole` project service +- Forms use IntelliJ's `.form` + Java pattern (e.g., `DevKitNodeConfigPanel.form` / `.java`) + +## Key Dependencies + +- `cardano-client-lib` 0.6.2 — Core Cardano operations (transactions, accounts, keys) +- `cardano-client-backend-blockfrost` / `cardano-client-backend-koios` — Backend providers +- `com.redhat.devtools.lsp4ij` 0.7.0 — LSP4IJ plugin dependency for language server integration +- `toml4j` — Parses `aiken.toml` project files +- Lombok — Used for model classes (`@Data`, `@Getter`, etc.) + +## Testing + +Tests use JUnit 5 + Mockito + AssertJ. Most tests are unit tests for utilities and service logic. No IntelliJ platform test framework (`LightPlatformCodeInsightFixtureTestCase`) is currently used, so Aiken PSI/completion tests require the sandboxed IDE (`runIde`). + +## Important Patterns + +- **slf4j is excluded** from all `cardano-client-lib` transitive dependencies to avoid conflicts with the IDE's logging. +- **Plugin XML ordering matters** for completion contributors — import completions must be `order="first"` to take priority over LSP completions. +- The `ParseErrorHighlightFilter` suppresses grammar-level parse errors since the BNF grammar is intentionally incomplete (LSP handles full validation). + + +## JULC + +Repository: https://github.com/bloxbean/julc.git +Project path: /Users/satya/work/bloxbean/julc + +## Yano + +Repository: https://github.com/bloxbean/yano.git +Project path: /Users/satya/work/bloxbean/yano diff --git a/adr/001-julc-yano-integration.md b/adr/001-julc-yano-integration.md new file mode 100644 index 0000000..180e6f4 --- /dev/null +++ b/adr/001-julc-yano-integration.md @@ -0,0 +1,189 @@ +# ADR-001: julc + Yano Integration into IntelliAda + +**Status:** In Progress +**Date:** 2026-04-10 +**Authors:** Satya, Claude Code + +## Context + +IntelliAda is an IntelliJ plugin for Cardano blockchain development. It currently supports: +- **Aiken** smart contract language (grammar, LSP, completion, navigation) +- **Yaci DevKit** / Blockfrost / Koios backends for chain interaction +- Account management, transactions, token minting, UTXO explorer + +Two new bloxbean projects need IDE integration: + +1. **julc** (github.com/bloxbean/julc) — A Java-to-UPLC (Untyped Plutus Lambda Calculus) compiler. Developers write Cardano smart contracts in Java using annotations (`@SpendingValidator`, `@MintingValidator`, `@Entrypoint`, `@Param`, `@MultiValidator`) and julc compiles them to Plutus V3 bytecode. Features: CLI (`julc new/build/check/eval/repl`), Gradle plugin, Maven support, annotation processor, testkit, CIP-57 blueprint generation. + +2. **Yano** (github.com/bloxbean/yano) — A lightweight Cardano devnet node written in Java. Single JAR or native binary (no Docker). REST API on port 7070 (Blockfrost-compatible). Devnet features: faucet, named snapshots, rollback, time advance, epoch shifting (past time travel), Plutus script evaluation. + +**Goal:** Enable end-to-end Cardano dApp development entirely within IntelliJ: +Create julc project -> write Java validators -> build to UPLC -> test locally -> start Yano devnet -> fund accounts -> deploy & execute scripts -> iterate with snapshots/rollback/time travel. + +## Decision + +### Architecture Decisions + +1. **Yano coexists with Yaci DevKit** as a separate `NodeType.Yano` enum value. Existing DevKit configurations are preserved. Yano is preferred for lightweight embedded testing; DevKit for full-stack (node + store + viewer). + +2. **julc follows Aiken's integration patterns** — SDK config (`JulcSDKState`/`JulcProjectState`), module builder, compile service, action group. Since julc compiles Java, no grammar/lexer/parser is needed — the IDE already provides full Java support. + +3. **julc external annotator uses library dependency** — `julc-compiler` + `javaparser-core` added as `compileOnly` deps. `SubsetValidator` runs in-process for instant inline error highlighting of unsupported Java features. + +4. **Three julc project modes** — basic (`julc.toml`), Gradle (`build.gradle` with julc deps), Maven (`pom.xml` with julc deps). `JulcCompileService` auto-detects and routes to appropriate build command. + +5. **Yano process management** — Health-based startup detection (polling `/q/health/ready`) instead of stdout parsing. Prefers native binary over JVM JAR for faster startup (~500ms vs 3-5s). + +### Key Integration Points + +- `NodeServiceFactory` routes `NodeType.Yano` to `BFBackendService` (Blockfrost-compatible API) +- `CardanoServiceFactory` routes `NodeType.Yano` to `YaciAccountServiceImpl` +- `YaciBaseService.ensureYanoRunning()` auto-starts Yano when operations are triggered +- julc build actions reuse Aiken's `CompilationResultListener` and `CardanoConsole` for output streaming +- Yano Devnet Panel provides 5-tab UI: Status, Fund, Snapshots, Rollback, Time Machine + +## Implementation Plan & Status + +### Phase 0 (P0) — Core Foundation [COMPLETED] + +| Component | Files | Status | +|-----------|-------|--------| +| julc SDK config (JulcSDK, JulcSDKState, JulcProjectState, helper, UI) | 13 files in `julc/configuration/`, `julc/messaging/`, `julc/util/`, `julc/common/` | Done | +| julc project detection (JulcTomlService, JulcProjectOpenProcessor) | 2 files in `julc/module/` | Done | +| julc build/check actions (JulcCompileService, JulcBuildAction, JulcCheckAction, JulcActionGroup) | 4 files in `julc/compile/`, `julc/action/` | Done | +| Yano NodeType + process lifecycle (YanoProcessManager, YanoStatusMonitor, YanoLifecycleService) | 3 files in `nodeint/yano/` | Done | +| Yano backend routing (NodeServiceFactory, CardanoServiceFactory, YaciBaseService) | 3 modified files | Done | +| plugin.xml registrations | All services, actions, tool windows registered | Done | + +### Phase 1 (P1) — Project Creation & Devnet UI [COMPLETED] + +| Component | Files | Status | +|-----------|-------|--------| +| julc project wizard (JulcModuleBuilder with template/group/artifact/package) | 1 file in `julc/module/` | Done | +| Yano download (YanoDownloader — GitHub releases, platform-aware, native preferred) | 1 file in `nodeint/yano/` | Done | +| Yano devnet service (YanoDevnetService — full REST client) | 1 file in `nodeint/yano/` | Done | +| Yano DTOs (FundResponse, SnapshotResponse, RollbackResponse, TimeAdvanceResponse, EpochShiftResponse) | 5 files in `nodeint/yano/model/` | Done | +| Yano devnet tool window (YanoDevnetPanel — 5 tabs: Status, Fund, Snapshots, Rollback, Time Machine) | 2 files in `nodeint/yano/ui/` | Done | + +### Phase 2 (P2) — Smart Features [COMPLETED] + +| Component | Files | Status | +|-----------|-------|--------| +| julc external annotator (SubsetValidator inline errors) | Deferred — requires julc-compiler dependency | Deferred | +| julc blueprint viewer (UPLC, script hash, size from CIP-57) | `julc/blueprint/` (2 files) + `julc/service/` (2 files) | Done | +| Deploy validator action (with @Param support) | `julc/action/DeployValidatorAction.java` | Done | +| Blueprint loading service | `julc/service/BlueprintLoadService.java`, `PlutusBlueprint.java` | Done | + +### Phase 3 (P3) — Polish & Advanced Workflows [COMPLETED] + +| Component | Files | Status | +|-----------|-------|--------| +| julc REPL tool window | `julc/repl/JulcReplToolWindowFactory.java`, `JulcReplPanel.java` | Done | +| Live templates (@SpendingValidator, @MintingValidator, @MultiValidator, etc.) | `resources/liveTemplates/julc.xml` | Done | +| Yano status bar widget | `yano/ui/YanoStatusBarWidgetFactory.java` | Done | + +### Deferred Items + +| Component | Reason | +|-----------|--------| +| julc external annotator | Requires adding `julc-compiler` + `javaparser` as compileOnly deps. Should be done when julc artifacts are published to Maven Central. | +| Run configurations + gutter icons | Requires `ConfigurationType` + `RunConfigurationProducer` — complex IntelliJ API work, best done iteratively with manual testing via `runIde`. | +| Execute script action | Requires full transaction building with collateral selection — depends on existing transaction UI patterns. | +| One-click "Build, Deploy & Test" | Depends on Deploy + Execute actions being complete. | +| UTXO explorer enhancements | Enhancement to existing UI — should be done as a separate PR. | +| Scenario comparison | Enhancement to snapshot tab — can be added incrementally. | + +## File Summary + +### New Files Created (39 total including tests) + +``` +src/main/java/com/bloxbean/intelliada/idea/julc/ +├── action/JulcActionGroup.java +├── common/JulcIcons.java +├── compile/ +│ ├── JulcCompileService.java +│ └── action/ +│ ├── JulcBuildAction.java +│ └── JulcCheckAction.java +├── configuration/ +│ ├── JulcConfigurationAction.java +│ ├── JulcConfigurationHelperService.java +│ ├── JulcSDK.java +│ ├── service/ +│ │ ├── JulcProjectState.java +│ │ └── JulcSDKState.java +│ └── ui/ +│ ├── JulcProjectConfig.java +│ ├── JulcProjectConfigurationDialog.java +│ ├── JulcSDKDialog.java +│ └── JulcSDKPanel.java +├── messaging/ +│ ├── JulcProjectConfigChangeNotifier.java +│ └── JulcSDKChangeNotifier.java +├── module/ +│ ├── JulcModuleBuilder.java +│ ├── pkg/JulcTomlService.java +│ └── project/JulcProjectOpenProcessor.java +└── util/JulcSdkUtil.java + +src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ +├── YanoDevnetService.java +├── YanoDownloader.java +├── YanoLifecycleService.java +├── YanoProcessManager.java +├── YanoStatusMonitor.java +├── model/ +│ ├── EpochShiftResponse.java +│ ├── FundResponse.java +│ ├── RollbackResponse.java +│ ├── SnapshotResponse.java +│ └── TimeAdvanceResponse.java +└── ui/ + ├── YanoDevnetPanel.java + ├── YanoDevnetToolWindowFactory.java + └── YanoStatusBarWidgetFactory.java + +src/main/resources/liveTemplates/julc.xml + +src/test/java/com/bloxbean/intelliada/idea/julc/ +├── configuration/JulcSDKTest.java +└── service/BlueprintLoadServiceTest.java + +src/test/java/com/bloxbean/intelliada/idea/nodeint/yano/ +├── YanoDevnetServiceTest.java +└── YanoProcessManagerTest.java +``` + +### Existing Files Modified + +| File | Change | +|------|--------| +| `core/util/NodeType.java` | Added `Yano("Yano Devnet")` | +| `nodeint/service/NodeServiceFactory.java` | Route Yano -> BFBackendService | +| `nodeint/service/CardanoServiceFactory.java` | Route Yano -> YaciAccountServiceImpl | +| `nodeint/service/impl/yaciprovider/YaciBaseService.java` | Added `ensureYanoRunning()` | +| `resources/META-INF/plugin.xml` | All julc + Yano registrations | + +## Verification + +- **Build:** `export JAVA_HOME=~/.sdkman/candidates/java/21.0.3-tem && ./gradlew compileJava` — SUCCESSFUL +- **Tests:** `export JAVA_HOME=~/.sdkman/candidates/java/21.0.3-tem && ./gradlew test` — ALL PASSING +- **Note:** JDK 21 required for compilation (Lombok 1.18.34 incompatible with JDK 25) + +## Wow Features (Yano) + +1. **Time Machine** — Advance by slots/seconds/epochs + past time travel (shift genesis back N epochs, inject transactions at historical points, catch up to wall clock) +2. **Named Snapshots** — Create/restore/delete chain state snapshots for scenario testing +3. **Rollback** — Undo last N blocks instantly +4. **Script Cost Explorer** — Pre-evaluate Plutus scripts via `/api/v1/utils/txs/evaluate` before submitting +5. **One-Click "Build, Deploy & Test"** — Chains entire workflow from build to devnet deployment +6. **Parameterized Validator Support** — Deploy dialog handles `@Param` fields with per-parameter inputs +7. **Redeemer Variant Selector** — For sealed interface redeemers, shows variant picker with per-variant fields + +## References + +- julc repo: /Users/satya/work/bloxbean/julc +- julc examples: /Users/satya/work/bloxbean/julc-examples +- Yano repo: /Users/satya/work/bloxbean/yano +- Implementation plan: /Users/satya/.claude/plans/buzzing-spinning-bird.md diff --git a/adr/002-e2e-julc-yano-flow.md b/adr/002-e2e-julc-yano-flow.md new file mode 100644 index 0000000..799be60 --- /dev/null +++ b/adr/002-e2e-julc-yano-flow.md @@ -0,0 +1,95 @@ +# ADR-002: End-to-End julc + Yano Developer Flow + +**Status:** Proposed +**Date:** 2026-04-10 + +## The Developer Journey + +A developer writing Cardano smart contracts with julc in IntelliJ should have a seamless flow from writing code to testing on a live devnet. Here's the ideal experience: + +### Step 1: Create Project +- **File > New > Project > julc** (Gradle template) +- Generates: `build.gradle` with julc deps, `AlwaysSucceeds.java` starter, test file +- IntelliJ recognizes as Gradle project, resolves all julc imports +- **Issue today:** Gradle auto-import sometimes doesn't trigger. Fix: call `ExternalSystemUtil.refreshProject()` after scaffolding. + +### Step 2: Write Validator +- Full Java IDE support: completion, refactoring, type checking +- Live templates: `julcspend`, `julcmint`, `julcmulti` for quick scaffolding +- julc action group in context menu (Build, Run Tests, View Blueprint, Configuration) + +### Step 3: Build +- Right-click > **julc > Build** (or `./gradlew build` in terminal) +- Annotation processor compiles validators to UPLC +- Output: `build/classes/java/main/META-INF/plutus/*.plutus.json` +- Console shows build result + script sizes + +### Step 4: Test Locally (No Node Needed) +- Right-click > **julc > Run Tests** +- JUnit tests using `ContractTest` base class evaluate validators on local VM +- Budget tracking shows CPU/memory per evaluation +- Fast feedback loop — no devnet required + +### Step 5: Start Devnet +- **Yano Devnet panel > Start Yano** (one click after initial download) +- Status shows: Running, slot, block number (live) +- ~500ms startup for native, ~3-5s for JVM + +### Step 6: Create & Fund Test Account +- **Cardano panel > Account > Create Account** (testnet) +- **Yano Devnet > Fund tab > Choose Account > Fund 10000 ADA** +- Inline status: "Funded 10000 ADA | tx: abc123..." + +### Step 7: Deploy Validator +- Right-click > **julc > Deploy Validator** +- Select compiled validator from blueprint dropdown +- Enter @Param values (if parameterized) +- Set datum, ADA to lock +- "Evaluate Cost" shows CPU/memory before submitting +- Submit -> logs script address, tx hash +- **TODAY:** Deploy dialog exists but needs Yano integration for auto-selecting the node + +### Step 8: Interact with Deployed Validator +- **Cardano panel > UTXO Explorer** > query the script address +- See locked UTXOs with datum +- Submit spending transaction with redeemer +- **TODAY:** Execute Script dialog is a future item + +### Step 9: Iterate with Snapshots +- Before testing: **Yano > Snapshots > Create "before-test"** +- Run test scenario +- If failed: **Restore "before-test"** — instant rollback to clean state +- Modify validator, rebuild, re-deploy, re-test + +### Step 10: Test Epoch Boundaries +- **Yano > Time Machine > Advance 1 Epoch** +- Verify vesting unlock, governance proposals, delegation rewards +- **Shift Genesis Back N Epochs** for past-time-travel testing + +## What's Missing for End-to-End + +### Critical (blocks the flow) + +1. **Gradle project auto-import** — After `julc new` scaffolds the project, IntelliJ must auto-detect and import the Gradle project. Currently sometimes fails. + - Fix: After copying files, explicitly call Gradle project link/refresh + +2. **Yano as default node for transactions** — When developer uses existing transaction features (Payment, Token Minting, UTXO Explorer), they should automatically route through the running Yano instance. + - Fix: Auto-create a RemoteNode entry for the running Yano in RemoteNodeState, set as default + +3. **Deploy Validator → Yano integration** — The Deploy Validator dialog should auto-detect the running Yano node and submit through it. + - Fix: Wire DeployValidatorAction to use Yano's `/api/v1/tx/submit` + +### Important (improves experience) + +4. **Script address in UTXO Explorer** — After deploying, user should be able to one-click query UTXOs at the script address + - Add "Query Script Address" in the UTXO dialog + +5. **Inline budget display** — After build, show script sizes and estimated costs in the blueprint viewer + +6. **Auto-fund on deploy** — If sender account doesn't have enough funds, offer to auto-fund via Yano faucet + +### Nice-to-have (polish) + +7. **One-click "Build & Deploy"** — Single action: build → deploy to Yano +8. **Watch mode** — Auto-rebuild on file save +9. **Test result panel** — Show test pass/fail with budget in a dedicated view (not just console) diff --git a/adr/003-julc-vm-integration-features.md b/adr/003-julc-vm-integration-features.md new file mode 100644 index 0000000..3026471 --- /dev/null +++ b/adr/003-julc-vm-integration-features.md @@ -0,0 +1,280 @@ +# ADR-003: julc VM Integration — Real-time Feedback & Execution Trace + +**Status:** Approved +**Date:** 2026-04-10 +**Goal:** Make IntelliAda the best Cardano smart contract development experience — instant compile, evaluate, trace, all from the editor. + +## Context + +The julc-compiler and julc-vm shadow JARs are loaded at runtime via isolated URLClassLoaders on JBR 25. This gives us: +- `JulcCompiler.compile(source)` → `CompileResult` (program, diagnostics, script size, UPLC, source map) +- `JulcVm.evaluate(program)` → `EvalResult` (success/failure, budget, traces, execution trace, builtin trace) +- `ScriptContextTestBuilder` → mock ScriptContext for testing + +These enable 9 features that no other Cardano IDE provides. + +## Features + +### Feature 1: Live Script Size in Gutter + +**Priority:** P0 — Quick win, high visibility + +Show compiled script size next to `@Validator` class in the gutter. Updates on file save. + +``` + 🔷 342 B | @SpendingValidator + | public class VestingValidator { +``` + +- Compile in background on file save (debounced, 500ms delay) +- Show size as gutter annotation text or tooltip +- Color coding: green (< 8KB), yellow (8-14KB), red (> 14KB approaching 16KB limit) +- Uses `CompileResult.scriptSizeBytes()` + +**File:** `julc/annotator/JulcScriptSizeAnnotator.java` + +--- + +### Feature 2: Inline Budget Estimation + +**Priority:** P1 — Enhances #1 + +After compiling, show estimated CPU/memory cost next to `@Entrypoint` method. Requires a test evaluation with mock inputs to get budget. + +``` + ▶ CPU: 1.2M | Mem: 45K | @Entrypoint + | public static boolean validate(...) { +``` + +- Auto-evaluate with default/empty inputs after compile +- Show budget in gutter tooltip +- Compare with protocol max (from Yano if running, or hardcoded defaults) + +**File:** Enhancement to `JulcScriptSizeAnnotator.java` + +--- + +### Feature 3: "Run Validator" from Gutter + +**Priority:** P0 — The killer feature + +Click the play icon on `@Entrypoint` → opens a dialog where user provides datum, redeemer, and context inputs → compiles → evaluates → shows result. + +**Input Dialog:** +- Extract parameter types from `@Entrypoint` method signature via PSI +- For `record` types (datum): generate input fields per record field + - `byte[]` → hex input + - `BigInteger` → number input + - `String` → text input + - Nested records → expandable tree +- For `PlutusData` → raw JSON/CBOR editor +- For `ScriptContext` → auto-generate mock with configurable: + - Signatories (list of pubkey hashes) + - Validity range (from/to slots) + - Inputs/outputs (simplified) + - Or "custom JSON" for advanced users + +**Result Panel:** +- ✅ PASS / ❌ FAIL +- Budget: CPU steps / Memory units +- Script size +- User trace messages (from `Builtins.trace()`) +- Link to full execution trace (Feature 7) + +**Implementation:** +- `JulcRunValidatorAction.java` — triggered from gutter or context menu +- `JulcRunValidatorDialog.java` — input collection with type-aware fields +- `JulcRunResultPanel.java` — result display +- Uses `JulcVmBridge.compile()` then `JulcVmBridge.evaluate()` +- For `ScriptContext`: load `ScriptContextTestBuilder` from VM shadow JAR via reflection + +**File:** `julc/run/JulcRunValidatorAction.java`, `julc/run/JulcRunValidatorDialog.java`, `julc/run/JulcRunResultPanel.java` + +--- + +### Feature 4: Script Size Warning Banner + +**Priority:** P1 — Safety net + +When compiled script exceeds warning threshold, show an editor notification banner: + +``` +⚠️ Script size: 14.2 KB / 16 KB — consider optimizing [Dismiss] [Show UPLC] +``` + +- Yellow at > 12KB, red at > 15KB +- Triggers after compile (from gutter or build action) +- "Show UPLC" opens the UPLC preview (Feature 5) +- Uses IntelliJ's `EditorNotificationProvider` API + +**File:** `julc/editor/JulcScriptSizeNotificationProvider.java` + +--- + +### Feature 5: UPLC Preview Panel + +**Priority:** P2 — Educational, helps optimization + +Split editor or tool window showing generated UPLC alongside Java source. Updates on save. + +``` +┌─ VestingValidator.java ────────┬─ UPLC Preview ──────────────┐ +│ @SpendingValidator │ (program 1.1.0 │ +│ public class VestingValidator │ (lam i_0 │ +│ record VestingDatum(...) │ (lam i_1 │ +│ │ (force │ +│ @Entrypoint │ (force │ +│ static boolean validate(...) │ (builtin ifThenE │ +│ return signed && past; │ (lam ... │ +└────────────────────────────────┴──────────────────────────────┘ +``` + +- Tool window tab "UPLC" — auto-activates for julc validator files +- Recompiles on file save, shows formatted UPLC +- Syntax highlighting for UPLC (keywords, builtins, constants) +- Click on UPLC line → highlights corresponding Java source (via SourceMap) + +**File:** `julc/editor/JulcUplcPreviewToolWindowFactory.java`, `julc/editor/JulcUplcPreviewPanel.java` + +--- + +### Feature 6: Budget Comparison (Delta Tracking) + +**Priority:** P2 — Helps optimization workflow + +Track budget changes between saves. Show delta: + +``` +Budget: CPU 1,234,567 (+50,234 from last save) | Memory 45,678 (-1,200) +``` + +- Store last-known budget per file in memory +- After each compile+evaluate, compare and show delta +- Green arrow (↓ decreased), red arrow (↑ increased) +- Shown in the Run Result panel and gutter tooltip +- Helps developers see the cost impact of each code change + +**File:** Enhancement to `JulcRunResultPanel.java` + `JulcBudgetTracker.java` + +--- + +### Feature 7: Visual Execution Trace + +**Priority:** P1 — Unique differentiator + +After "Run with Trace", show a step-by-step execution trace panel: + +``` +┌─ Execution Trace ─────────────────────────────────────────────┐ +│ Step │ Operation │ CPU │ Source │ +│──────│────────────────────│────────│───────────────────────────│ +│ 1 │ Lam (validate) │ 12 │ VestingValidator.java:15 │ +│ 2 │ App │ 8 │ VestingValidator.java:16 │ +│ 3 │ Force │ 4 │ │ +│ 4 │ Builtin signedBy │ 12,456 │ VestingValidator.java:17 │ +│ 5 │ Builtin afterSlot │ 8,234 │ VestingValidator.java:18 │ +│ 6 │ IfThenElse (true) │ 12 │ VestingValidator.java:19 │ +│ 7 │ Const True │ 4 │ VestingValidator.java:19 │ +├───────────────────────────────────────────────────────────────┤ +│ Total: CPU 21,234 | Memory 3,456 | Result: ✅ True │ +└───────────────────────────────────────────────────────────────┘ +``` + +- Uses `EvalResult.executionTrace()` (list of `ExecutionTraceEntry`) +- Map UPLC positions to Java source lines via `CompileResult.sourceMap()` +- Click on a row → navigate to the Java source line +- Sortable by CPU cost to find hotspots +- User trace messages (`Builtins.trace()`) shown inline + +**File:** `julc/trace/JulcExecutionTracePanel.java`, `julc/trace/JulcTraceToolWindowFactory.java` + +--- + +### Feature 8: Trace-to-Source Mapping + +**Priority:** P2 — Enhances #7 + +Overlay execution trace on the source editor: + +- After "Run with Trace", show colored line markers in the gutter +- Green: executed, passed +- Red: executed, failed here +- Gray: not reached (dead code in this execution path) +- Hover on marker → shows CPU/memory cost for that line +- Like a code coverage overlay but for on-chain execution + +Uses `SourceMap` from `CompileResult` + `executionTrace` from `EvalResult`. + +**File:** Enhancement to `JulcLineMarkerProvider.java` or new `JulcTraceOverlayAnnotator.java` + +--- + +### Feature 9: Budget Hotspot Visualization + +**Priority:** P2 — Helps optimization + +Use the `builtinTrace` (list of `BuiltinExecution`) to show per-line costs: + +``` + 142 │ 12,456 CPU │ boolean signed = ContextsLib.signedBy(txInfo, beneficiary); + 143 │ 8,234 CPU │ boolean past = IntervalLib.after(deadline, txInfo.validRange()); + 144 │ 16 CPU │ return signed && past; +``` + +- Color intensity proportional to cost (heatmap effect) +- Most expensive lines highlighted in red/orange +- Gutter shows CPU cost per line +- Bottom summary: "Top 3 costly operations: signedBy (60%), after (39%), return (1%)" + +**File:** `julc/trace/JulcBudgetHeatmapAnnotator.java` + +--- + +## Implementation Order + +| Phase | Features | Effort | Impact | +|-------|----------|--------|--------| +| **Phase 1** | #3 Run Validator (basic, auto inputs) | Medium | Killer feature — compile + evaluate from gutter | +| **Phase 1** | #1 Live Script Size | Low | Quick win — size in gutter | +| **Phase 2** | #3 Run Validator (user inputs dialog) | High | Full input customization | +| **Phase 2** | #7 Visual Execution Trace | Medium | Unique differentiator | +| **Phase 2** | #4 Script Size Warning | Low | Safety net | +| **Phase 3** | #2 Inline Budget Estimation | Medium | Enhances gutter info | +| **Phase 3** | #5 UPLC Preview Panel | Medium | Educational | +| **Phase 3** | #6 Budget Delta Tracking | Low | Optimization workflow | +| **Phase 3** | #8 Trace-to-Source Mapping | Medium | Enhances trace | +| **Phase 3** | #9 Budget Hotspot Visualization | Medium | Optimization visualization | + +## Technical Notes + +### Shadow JAR Classes Used + +From `julc-compiler-all.jar`: +- `JulcCompiler.compile(source)` → `CompileResult` +- `CompileResult.program()`, `.scriptSizeBytes()`, `.diagnostics()`, `.sourceMap()`, `.uplcFormatted()` +- `SubsetValidator.validate(cu)` → diagnostics + +From `julc-vm-java-all.jar`: +- `JulcVm.create()` → VM instance +- `JulcVm.evaluate(program)` → `EvalResult` +- `EvalResult.Success/Failure/BudgetExhausted` +- `EvalResult.consumed()` → `ExBudget` (cpu, mem) +- `EvalResult.traces()` → user trace messages +- `EvalResult.executionTrace()` → per-step trace +- `EvalResult.builtinTrace()` → per-builtin cost +- `ScriptContextTestBuilder` → mock context for testing + +### Reflection Pattern + +All calls go through `JulcVmBridge` which handles: +- Classloader isolation (parent = null for compiler, compiler as parent for VM) +- Reflection-based method invocation +- Result mapping to Java 21 compatible POJOs (`CompileInfo`, `EvalInfo`) +- Graceful error handling + +### Performance + +- `JulcCompiler.compile()` takes ~50-200ms for typical validators +- `JulcVm.evaluate()` takes ~10-50ms +- Total round-trip for "Run Validator": < 500ms +- Suitable for real-time feedback on file save (debounced) diff --git a/build.gradle b/build.gradle index ac1936a..f665a2e 100644 --- a/build.gradle +++ b/build.gradle @@ -17,13 +17,11 @@ buildscript { plugins { id 'java' id 'idea' - id 'org.jetbrains.intellij.platform' version '2.1.0' - id "org.jetbrains.kotlin.jvm" version "1.9.25" - id "org.jetbrains.grammarkit" version "2022.3.2.2" + id 'org.jetbrains.intellij.platform' version '2.14.0' + id "org.jetbrains.kotlin.jvm" version "2.1.10" + id "org.jetbrains.intellij.platform.grammarkit" version "2.14.0" } -apply plugin: "kotlin" - group 'com.bloxbean' version '0.2.0-beta2' @@ -36,19 +34,11 @@ repositories { } } -compileJava { - sourceCompatibility = '21' - targetCompatibility = '21' -} - dependencies { intellijPlatform { - intellijIdeaCommunity("2024.2") -// pycharmProfessional("2024.2") - plugin("com.redhat.devtools.lsp4ij:0.5.0") - pluginVerifier() - zipSigner() - instrumentationTools() + intellijIdea("2026.1") + bundledPlugin("com.intellij.java") + plugin("com.redhat.devtools.lsp4ij:0.10.0") } implementation fileTree(include: ['*.jar'], dir: 'lib') @@ -64,7 +54,6 @@ dependencies { exclude group: 'com.bloxbean.cardano', module: 'cardano-client-lib' } implementation('com.moandjiezana.toml:toml4j:0.7.2') - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.10") implementation 'org.json:json:20240303' @@ -90,24 +79,42 @@ String changeLogAsHtml() { renderer.render(changeLogDocument) } -grammarKit { - jflexRelease = "1.7.0-1" - grammarKitRelease = "2021.1.2" - generateLexer { - sourceFile = file('src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/_AikenLexer.flex') - targetOutputDir = file('src/main/gen/com/bloxbean/intelliada/idea/aiken/lang') - } +// GrammarKit tasks (bundled with platform plugin 2.12+) +generateLexer { + sourceFile = file('src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/_AikenLexer.flex') + targetOutputDir = file('src/main/gen/com/bloxbean/intelliada/idea/aiken/lang') +} - generateParser { - sourceFile = file('src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/Aiken.bnf') - targetRootOutputDir = file('src/main/gen') - pathToParser = 'com/bloxbean/intelliada/idea/aiken/lang/parser' - pathToPsiRoot = 'com/bloxbean/intelliada/idea/aiken/lang/psi' +generateParser { + sourceFile = file('src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/Aiken.bnf') + targetRootOutputDir = file('src/main/gen') + pathToParser = 'com/bloxbean/intelliada/idea/aiken/lang/parser' + pathToPsiRoot = 'com/bloxbean/intelliada/idea/aiken/lang/psi' +} + +test { + failOnNoDiscoveredTests = false +} + +// Copy julc shadow JARs to plugin sandbox for runtime classloader +// These go in julc-runtime/ (NOT lib/) to avoid IntelliJ classloader scanning them +tasks.named('prepareSandbox') { + doLast { + def julcRuntimeDir = file("julc-runtime") + if (julcRuntimeDir.exists()) { + def targetDir = new File(it.destinationDir, "${rootProject.name}/julc-runtime") + targetDir.mkdirs() + copy { + from julcRuntimeDir + into targetDir + include '*.jar' + } + } } } patchPluginXml { changeNotes = changeLogAsHtml() - sinceBuild = "242" + sinceBuild = "261" untilBuild = provider {null} } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index afa1e8e..da80f8d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip diff --git a/julc-runtime/julc-compiler-all.jar b/julc-runtime/julc-compiler-all.jar new file mode 100644 index 0000000..e987a9a Binary files /dev/null and b/julc-runtime/julc-compiler-all.jar differ diff --git a/julc-runtime/julc-vm-java-all.jar b/julc-runtime/julc-vm-java-all.jar new file mode 100644 index 0000000..502f041 Binary files /dev/null and b/julc-runtime/julc-vm-java-all.jar differ diff --git a/settings.gradle b/settings.gradle index d2025e0..ee27df7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1 @@ rootProject.name = 'intelliada' -include 'gen:blockfrost-api' - diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/action/TestStdlibNavigationAction.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/action/TestStdlibNavigationAction.java new file mode 100644 index 0000000..dd6aad3 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/action/TestStdlibNavigationAction.java @@ -0,0 +1,166 @@ +package com.bloxbean.intelliada.idea.aiken.action; + +import com.bloxbean.intelliada.idea.aiken.service.AikenStdlibService; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +/** + * Test action to verify stdlib navigation is working + */ +public class TestStdlibNavigationAction extends AnAction { + + public TestStdlibNavigationAction() { + super("Test Stdlib Navigation"); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + Messages.showErrorDialog("No project found", "Error"); + return; + } + + // Get current editor and cursor position + com.intellij.openapi.editor.Editor editor = com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR.getData(e.getDataContext()); + if (editor != null) { + // Get element at cursor + com.intellij.psi.PsiFile psiFile = com.intellij.openapi.actionSystem.CommonDataKeys.PSI_FILE.getData(e.getDataContext()); + if (psiFile != null) { + int offset = editor.getCaretModel().getOffset(); + com.intellij.psi.PsiElement element = psiFile.findElementAt(offset); + + System.out.println("DEBUG: Element at cursor: " + (element != null ? element.getText() : "null")); + + // Try to extract module from cursor position + String modulePath = extractModuleFromElement(element); + System.out.println("DEBUG: Extracted module path: " + modulePath); + + if (modulePath != null) { + testNavigation(project, modulePath); + return; + } + } + } + + // Fallback: Test navigation to aiken/math + testNavigation(project, "aiken/math"); + } + + private void testNavigation(Project project, String modulePath) { + AikenStdlibService stdlibService = AikenStdlibService.getInstance(project); + File moduleFile = stdlibService.findModuleFile(modulePath); + + if (moduleFile != null && moduleFile.exists()) { + System.out.println("DEBUG: File found: " + moduleFile.getAbsolutePath()); + + // Open the file + ApplicationManager.getApplication().invokeLater(() -> { + VirtualFile virtualFile = VfsUtil.findFileByIoFile(moduleFile, true); + System.out.println("DEBUG: Virtual file created: " + virtualFile); + + if (virtualFile != null) { + System.out.println("DEBUG: Opening file in editor..."); + FileEditorManager.getInstance(project).openFile(virtualFile, true); + Messages.showInfoMessage("Successfully opened: " + moduleFile.getAbsolutePath(), "Success"); + } else { + System.out.println("DEBUG: Failed to create virtual file"); + Messages.showErrorDialog("Could not create virtual file for: " + moduleFile.getAbsolutePath(), "Error"); + } + }); + } else { + System.out.println("DEBUG: File not found or doesn't exist"); + Messages.showErrorDialog("Could not find " + modulePath + " module. Expected at: " + + (moduleFile != null ? moduleFile.getAbsolutePath() : "unknown path"), "Error"); + } + } + + private String extractModuleFromElement(com.intellij.psi.PsiElement element) { + if (element == null) return null; + + System.out.println("DEBUG: Looking for module from element: '" + element.getText() + "'"); + + // First, try to find the specific use statement that contains this element + String containingLine = findContainingUseLine(element); + if (containingLine != null) { + System.out.println("DEBUG: Found containing use line: " + containingLine); + return extractStdlibPath(containingLine); + } + + // Fallback: Walk up the tree to find any import context + com.intellij.psi.PsiElement current = element; + while (current != null) { + String text = current.getText(); + if (text.contains("use ")) { + System.out.println("DEBUG: Found use statement: " + text); + return extractStdlibPath(text); + } + current = current.getParent(); + } + + return null; + } + + private String findContainingUseLine(com.intellij.psi.PsiElement element) { + // Get the file text and find which line contains the cursor + com.intellij.psi.PsiFile file = element.getContainingFile(); + if (file == null) return null; + + String fileText = file.getText(); + int elementOffset = element.getTextOffset(); + + // Find the line containing the element + String[] lines = fileText.split("\n"); + int currentOffset = 0; + + for (String line : lines) { + int lineEnd = currentOffset + line.length(); + if (elementOffset >= currentOffset && elementOffset <= lineEnd) { + // This line contains our element + if (line.trim().startsWith("use ")) { + return line.trim(); + } + break; + } + currentOffset = lineEnd + 1; // +1 for newline + } + + return null; + } + + private String extractStdlibPath(String text) { + System.out.println("DEBUG: Extracting path from: '" + text + "'"); + + // Extract module path from use statements - now generic for any package + if (text.contains("use ")) { + int useIndex = text.indexOf("use "); + String afterUse = text.substring(useIndex + 4).trim(); + + // Remove trailing syntax + if (afterUse.contains(".{")) { + afterUse = afterUse.substring(0, afterUse.indexOf(".{")); + } + if (afterUse.contains(" ")) { + afterUse = afterUse.substring(0, afterUse.indexOf(" ")); + } + if (afterUse.contains("\"")) { + afterUse = afterUse.substring(0, afterUse.indexOf("\"")); + } + + String result = afterUse.trim(); + System.out.println("DEBUG: Extracted module path: '" + result + "'"); + return result; + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenChainAwareCompletionContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenChainAwareCompletionContributor.java new file mode 100644 index 0000000..0075e0c --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenChainAwareCompletionContributor.java @@ -0,0 +1,273 @@ +package com.bloxbean.intelliada.idea.aiken.completion; + +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * Provides chain-aware completion for different Cardano networks + */ +public class AikenChainAwareCompletionContributor extends CompletionContributor { + + // Network-specific constants and configurations + private static final Map NETWORK_CONFIGS = Map.of( + "mainnet", new NetworkConfig( + new String[]{"1344400", "1000000"}, // slot duration, min fee + new String[]{"addr1", "stake1"}, // address prefixes + new String[]{"pool1"}, // pool prefixes + 717 // protocol magic + ), + "testnet", new NetworkConfig( + new String[]{"1000", "1000000"}, // slot duration, min fee + new String[]{"addr_test1", "stake_test1"}, // address prefixes + new String[]{"pool_test1"}, // pool prefixes + 1097911063 // protocol magic + ), + "preview", new NetworkConfig( + new String[]{"1000", "1000000"}, // slot duration, min fee + new String[]{"addr_test1", "stake_test1"}, // address prefixes + new String[]{"pool_test1"}, // pool prefixes + 2 // protocol magic + ), + "preprod", new NetworkConfig( + new String[]{"1000", "1000000"}, // slot duration, min fee + new String[]{"addr_test1", "stake_test1"}, // address prefixes + new String[]{"pool_test1"}, // pool prefixes + 1 // protocol magic + ) + ); + + // Well-known addresses and values for different networks + private static final Map NETWORK_ADDRESSES = Map.of( + "mainnet", new String[]{ + "addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae", + "addr1q8s4ncvh8dw6w8q6k6w4q3rrw2jrcjwvxrk6e2w8q6k6w4q" + }, + "testnet", new String[]{ + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgsxj90mg", + "addr_test1qq8s4ncvh8dw6w8q6k6w4q3rrw2jrcjwvxrk6e2w8q6k6w4q" + } + ); + + // Common Cardano constants + private static final String[] CARDANO_CONSTANTS = { + "ada_only_utxo_cost", "min_ada", "max_tx_size", "max_value_size", + "utxo_cost_per_word", "lovelace_per_ada", "coins_per_utxo_word" + }; + + // Network-specific tokens and assets + private static final Map NETWORK_TOKENS = Map.of( + "mainnet", new String[]{ + "hosky", "djed", "shen", "ada", "agix", "copi", "wmt" + }, + "testnet", new String[]{ + "test_token", "faucet_token", "dummy_asset" + } + ); + + public AikenChainAwareCompletionContributor() { + extend(CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(AikenLanguage.INSTANCE), + new CompletionProvider() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, + @NotNull ProcessingContext context, + @NotNull CompletionResultSet result) { + + // Skip completions if we're in an import export context + if (isInImportExportContext(parameters.getPosition())) { + return; + } + + String detectedNetwork = detectNetwork(parameters.getPosition()); + + addNetworkSpecificCompletions(detectedNetwork, result); + addCardanoConstants(result); + addNetworkAddresses(detectedNetwork, result); + addNetworkTokens(detectedNetwork, result); + } + }); + } + + private void addNetworkSpecificCompletions(@NotNull String network, @NotNull CompletionResultSet result) { + NetworkConfig config = NETWORK_CONFIGS.get(network); + if (config == null) { + config = NETWORK_CONFIGS.get("mainnet"); // Default to mainnet + } + + // Add network configuration values + result.addElement(LookupElementBuilder.create("protocol_magic") + .withTypeText("network constant") + .withTailText(" = " + config.protocolMagic) + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant)); + + // Add address prefixes + for (String prefix : config.addressPrefixes) { + result.addElement(LookupElementBuilder.create(prefix + "_prefix") + .withTypeText("address prefix") + .withTailText(" (" + network + ")") + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant)); + } + + // Add network-specific timing values + result.addElement(LookupElementBuilder.create("slot_duration") + .withTypeText("network timing") + .withTailText(" = " + config.constants[0] + "ms") + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant)); + + result.addElement(LookupElementBuilder.create("min_fee_constant") + .withTypeText("network constant") + .withTailText(" = " + config.constants[1]) + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant)); + } + + private void addCardanoConstants(@NotNull CompletionResultSet result) { + for (String constant : CARDANO_CONSTANTS) { + result.addElement(LookupElementBuilder.create(constant) + .withTypeText("Cardano constant") + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant)); + } + + // Add common values + result.addElement(LookupElementBuilder.create("1000000") + .withTypeText("lovelace") + .withTailText(" (1 ADA)") + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant)); + + result.addElement(LookupElementBuilder.create("2000000") + .withTypeText("lovelace") + .withTailText(" (min UTxO)") + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant)); + } + + private void addNetworkAddresses(@NotNull String network, @NotNull CompletionResultSet result) { + String[] addresses = NETWORK_ADDRESSES.get(network); + if (addresses != null) { + for (int i = 0; i < addresses.length; i++) { + final int index = i; // Make final for lambda + final String address = addresses[i]; // Make final for lambda + result.addElement(LookupElementBuilder.create("sample_address_" + (i + 1)) + .withTypeText("sample address") + .withTailText(" (" + network + ")") + .withIcon(com.intellij.icons.AllIcons.Nodes.Constant) + .withInsertHandler((context, item) -> { + // Replace with actual address + context.getDocument().replaceString( + context.getStartOffset(), + context.getTailOffset(), + "\"" + address + "\"" + ); + })); + } + } + } + + private void addNetworkTokens(@NotNull String network, @NotNull CompletionResultSet result) { + String[] tokens = NETWORK_TOKENS.get(network); + if (tokens != null) { + for (String token : tokens) { + result.addElement(LookupElementBuilder.create(token + "_policy") + .withTypeText("token policy") + .withTailText(" (" + network + ")") + .withIcon(com.intellij.icons.AllIcons.Nodes.Tag)); + + result.addElement(LookupElementBuilder.create(token + "_asset") + .withTypeText("token asset") + .withTailText(" (" + network + ")") + .withIcon(com.intellij.icons.AllIcons.Nodes.Tag)); + } + } + } + + private String detectNetwork(@NotNull PsiElement element) { + // Try to detect network from file content or project configuration + String fileContent = element.getContainingFile().getText(); + + // Look for network indicators in comments or constants + if (fileContent.contains("mainnet") || fileContent.contains("production")) { + return "mainnet"; + } + if (fileContent.contains("testnet")) { + return "testnet"; + } + if (fileContent.contains("preview")) { + return "preview"; + } + if (fileContent.contains("preprod")) { + return "preprod"; + } + + // Look for network-specific address prefixes + if (fileContent.contains("addr_test1")) { + return "testnet"; + } + if (fileContent.contains("addr1")) { + return "mainnet"; + } + + // Default to testnet for development + return "testnet"; + } + + private static class NetworkConfig { + final String[] constants; + final String[] addressPrefixes; + final String[] poolPrefixes; + final int protocolMagic; + + NetworkConfig(String[] constants, String[] addressPrefixes, String[] poolPrefixes, int protocolMagic) { + this.constants = constants; + this.addressPrefixes = addressPrefixes; + this.poolPrefixes = poolPrefixes; + this.protocolMagic = protocolMagic; + } + } + + private boolean isInImportExportContext(@NotNull PsiElement element) { + // Check if we're in an import statement with export braces + String lineText = getCurrentLineText(element); + if (lineText != null) { + String trimmed = lineText.trim(); + // Check if we're inside export braces: "use module.{..." + if (trimmed.startsWith("use ") && trimmed.contains(".{")) { + return true; + } + } + return false; + } + + private String getCurrentLineText(@NotNull PsiElement element) { + try { + var file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenCompletionContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenCompletionContributor.java new file mode 100644 index 0000000..f29a24f --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenCompletionContributor.java @@ -0,0 +1,214 @@ +package com.bloxbean.intelliada.idea.aiken.completion; + +import com.bloxbean.intelliada.idea.aiken.lang.psi.AikenTypes; +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +public class AikenCompletionContributor extends CompletionContributor { + + // Aiken keywords + private static final String[] KEYWORDS = { + "if", "else", "when", "is", "fn", "use", "let", "pub", "type", "opaque", + "const", "todo", "expect", "check", "test", "trace", "fail", "validator", + "and", "or", "as", "via", "benchmark" + }; + + // Validator purpose keywords + private static final String[] VALIDATOR_PURPOSES = { + "mint", "spend", "withdraw", "publish", "vote", "propose" + }; + + // Built-in types + private static final String[] BUILTIN_TYPES = { + "Int", "Bool", "ByteArray", "String", "Data", "List", "Option" + }; + + // Built-in functions + private static final String[] BUILTIN_FUNCTIONS = { + "trace", "fail", "todo", "expect" + }; + + // Boolean literals + private static final String[] BOOLEAN_LITERALS = { + "True", "False" + }; + + public AikenCompletionContributor() { + // Complete keywords and built-ins for Aiken language + extend(CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(AikenLanguage.INSTANCE), + new CompletionProvider() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, + @NotNull ProcessingContext context, + @NotNull CompletionResultSet result) { + // Skip basic completions if we're in an import export context + if (isInImportExportContext(parameters.getPosition())) { + return; + } + + // Use default prefix matching for now - it should work correctly + addKeywordCompletions(result); + addBuiltinFunctionCompletions(result); + addBuiltinTypeCompletions(result); + addBooleanLiteralCompletions(result); + addValidatorPurposeCompletions(result); + } + }); + } + + private void addKeywordCompletions(@NotNull CompletionResultSet result) { + for (String keyword : KEYWORDS) { + result.addElement(LookupElementBuilder.create(keyword) + .withBoldness(true) + .withTypeText("keyword") + .withInsertHandler(new KeywordInsertHandler())); + } + } + + private void addBuiltinTypeCompletions(@NotNull CompletionResultSet result) { + for (String type : BUILTIN_TYPES) { + result.addElement(LookupElementBuilder.create(type) + .withTypeText("type") + .withIcon(com.intellij.icons.AllIcons.Nodes.Class) + .withInsertHandler(new KeywordInsertHandler())); + } + } + + private void addBuiltinFunctionCompletions(@NotNull CompletionResultSet result) { + for (String function : BUILTIN_FUNCTIONS) { + result.addElement(LookupElementBuilder.create(function) + .withTypeText("function") + .withIcon(com.intellij.icons.AllIcons.Nodes.Function) + .withInsertHandler(new KeywordInsertHandler())); + } + } + + private void addBooleanLiteralCompletions(@NotNull CompletionResultSet result) { + for (String literal : BOOLEAN_LITERALS) { + result.addElement(LookupElementBuilder.create(literal) + .withTypeText("boolean") + .withIcon(com.intellij.icons.AllIcons.Nodes.Variable) + .withInsertHandler(new KeywordInsertHandler())); + } + } + + private void addValidatorPurposeCompletions(@NotNull CompletionResultSet result) { + for (String purpose : VALIDATOR_PURPOSES) { + result.addElement(LookupElementBuilder.create(purpose) + .withTypeText("validator purpose") + .withIcon(com.intellij.icons.AllIcons.Nodes.Method) + .withInsertHandler(new KeywordInsertHandler())); + } + } + + private boolean isInImportExportContext(@NotNull PsiElement element) { + // Check if we're in an import statement with export braces + String lineText = getCurrentLineText(element); + if (lineText != null) { + String trimmed = lineText.trim(); + // Check if we're inside export braces: "use module.{..." + if (trimmed.startsWith("use ") && trimmed.contains(".{")) { + return true; + } + } + return false; + } + + private String getCurrentLineText(@NotNull PsiElement element) { + try { + var file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } + + /** + * Custom insert handler for keyword completions to handle proper text replacement + */ + private static class KeywordInsertHandler implements InsertHandler { + @Override + public void handleInsert(@NotNull InsertionContext context, @NotNull LookupElement item) { + Editor editor = context.getEditor(); + Document document = editor.getDocument(); + + int startOffset = context.getStartOffset(); + int tailOffset = context.getTailOffset(); + + // Get the typed prefix + String prefix = findTypedPrefix(context); + String insertText = item.getLookupString(); + + if (prefix != null && insertText.startsWith(prefix)) { + // Calculate the correct replacement range + int prefixStart = startOffset - prefix.length(); + + // Replace the entire prefix with the selected completion + document.replaceString(prefixStart, tailOffset, insertText); + + // Position cursor at the end of the inserted text + editor.getCaretModel().moveToOffset(prefixStart + insertText.length()); + } else { + // Fallback to default behavior + document.replaceString(startOffset, tailOffset, insertText); + } + } + + private String findTypedPrefix(@NotNull InsertionContext context) { + try { + PsiFile file = context.getFile(); + if (file != null) { + String fileText = file.getText(); + int offset = context.getStartOffset(); + + // Look backwards to find the start of the current word + int wordStart = offset; + while (wordStart > 0) { + char c = fileText.charAt(wordStart - 1); + if (!Character.isLetterOrDigit(c) && c != '_') { + break; + } + wordStart--; + } + + if (wordStart < offset) { + return fileText.substring(wordStart, offset); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenCrossFileCompletionContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenCrossFileCompletionContributor.java new file mode 100644 index 0000000..65ac379 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenCrossFileCompletionContributor.java @@ -0,0 +1,175 @@ +package com.bloxbean.intelliada.idea.aiken.completion; + +import com.bloxbean.intelliada.idea.aiken.index.AikenSymbolIndex; +import com.bloxbean.intelliada.idea.aiken.index.AikenSymbolInfo; +import com.bloxbean.intelliada.idea.aiken.index.AikenSymbolType; +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.icons.AllIcons; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.util.Collection; + +public class AikenCrossFileCompletionContributor extends CompletionContributor { + + public AikenCrossFileCompletionContributor() { + extend(CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(AikenLanguage.INSTANCE), + new CompletionProvider() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, + @NotNull ProcessingContext context, + @NotNull CompletionResultSet result) { + + // Skip completions if we're in an import export context + if (isInImportExportContext(parameters.getPosition())) { + return; + } + + // Add public symbols from other files + addPublicSymbolCompletions(parameters, result); + + // Add type constructors from project + addConstructorCompletions(parameters, result); + + // Add validators from project + addValidatorCompletions(parameters, result); + } + }); + } + + private void addPublicSymbolCompletions(@NotNull CompletionParameters parameters, + @NotNull CompletionResultSet result) { + Collection publicSymbols = AikenSymbolIndex.getPublicSymbols(parameters.getPosition().getProject()); + + for (AikenSymbolInfo symbol : publicSymbols) { + // Skip symbols from the same file + if (isSameFile(symbol, parameters)) { + continue; + } + + LookupElementBuilder element = LookupElementBuilder.create(symbol.getName()) + .withTypeText(symbol.getType().getDisplayName()) + .withIcon(getIconForSymbolType(symbol.getType())); + + if (symbol.getSignature() != null && !symbol.getSignature().isEmpty()) { + element = element.withTailText(symbol.getSignature()); + } + + // Add file information + String fileName = extractFileName(symbol.getFilePath()); + element = element.withTailText(" (" + fileName + ")", true); + + result.addElement(element); + } + } + + private void addConstructorCompletions(@NotNull CompletionParameters parameters, + @NotNull CompletionResultSet result) { + Collection constructors = AikenSymbolIndex.getSymbolsByType( + AikenSymbolType.CONSTRUCTOR, parameters.getPosition().getProject()); + + for (AikenSymbolInfo constructor : constructors) { + if (constructor.isPublic() && !isSameFile(constructor, parameters)) { + String parentType = constructor.getSignature(); // Parent type name is stored in signature for constructors + LookupElementBuilder element = LookupElementBuilder.create(constructor.getName()) + .withTypeText("constructor") + .withTailText(" :: " + parentType) + .withIcon(AllIcons.Nodes.Method); + + result.addElement(element); + } + } + } + + private void addValidatorCompletions(@NotNull CompletionParameters parameters, + @NotNull CompletionResultSet result) { + Collection validators = AikenSymbolIndex.getSymbolsByType( + AikenSymbolType.VALIDATOR, parameters.getPosition().getProject()); + + for (AikenSymbolInfo validator : validators) { + if (!isSameFile(validator, parameters)) { + String validatorType = validator.getSignature(); // Validator type is stored in signature + LookupElementBuilder element = LookupElementBuilder.create(validator.getName()) + .withTypeText("validator") + .withTailText(" (" + validatorType + ")") + .withIcon(AllIcons.Nodes.Lambda); + + result.addElement(element); + } + } + } + + private boolean isSameFile(@NotNull AikenSymbolInfo symbol, @NotNull CompletionParameters parameters) { + String currentFilePath = parameters.getOriginalFile().getVirtualFile().getPath(); + return symbol.getFilePath().equals(currentFilePath); + } + + private String extractFileName(@NotNull String filePath) { + int lastSlash = filePath.lastIndexOf('/'); + if (lastSlash != -1) { + return filePath.substring(lastSlash + 1); + } + return filePath; + } + + private Icon getIconForSymbolType(@NotNull AikenSymbolType type) { + return switch (type) { + case FUNCTION -> AllIcons.Nodes.Function; + case TYPE -> AllIcons.Nodes.Class; + case CONSTRUCTOR -> AllIcons.Nodes.Method; + case VALIDATOR -> AllIcons.Nodes.Lambda; + case CONSTANT -> AllIcons.Nodes.Constant; + case VARIABLE -> AllIcons.Nodes.Variable; + case TEST -> AllIcons.Nodes.Test; + case BENCHMARK -> AllIcons.Nodes.Function; // Use Function icon as fallback + }; + } + + private boolean isInImportExportContext(@NotNull PsiElement element) { + // Check if we're in an import statement with export braces + String lineText = getCurrentLineText(element); + if (lineText != null) { + String trimmed = lineText.trim(); + // Check if we're inside export braces: "use module.{..." + if (trimmed.startsWith("use ") && trimmed.contains(".{")) { + return true; + } + } + return false; + } + + private String getCurrentLineText(@NotNull PsiElement element) { + try { + var file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenImportCompletionContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenImportCompletionContributor.java new file mode 100644 index 0000000..8b428ad --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenImportCompletionContributor.java @@ -0,0 +1,497 @@ +package com.bloxbean.intelliada.idea.aiken.completion; + +import com.bloxbean.intelliada.idea.aiken.lang.psi.AikenTypes; +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.bloxbean.intelliada.idea.aiken.lang.psi.impl.AikenPsiImplUtil; +import com.bloxbean.intelliada.idea.aiken.module.pkg.AikenTomlService; +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.HashMap; + +public class AikenImportCompletionContributor extends CompletionContributor { + + // Complete Aiken standard library modules + private static final String[] STDLIB_MODULES = { + // Core Aiken modules + "aiken/builtin", "aiken/cbor", "aiken/fuzz", "aiken/primitive/bytearray", + "aiken/primitive/int", "aiken/primitive/string", + + // Collections + "aiken/collection/dict", "aiken/collection/list", "aiken/collection/pairs", + + // Data structures + "aiken/option", "aiken/result", "aiken/ordering", + + // Utilities + "aiken/string", "aiken/bytearray", "aiken/math", "aiken/interval", + "aiken/time", "aiken/rational", + + // Cryptography + "aiken/crypto", "aiken/hash", "aiken/crypto/bls12_381", "aiken/crypto/ed25519", + + // Cardano-specific modules + "cardano/address", "cardano/assets", "cardano/certificate", "cardano/credential", + "cardano/governance", "cardano/script_context", "cardano/transaction", + "cardano/wallet", "cardano/compatibility" + }; + + // Module-specific exports for completion + private static final Map MODULE_EXPORTS = createModuleExports(); + + private static Map createModuleExports() { + Map exports = new HashMap<>(); + + exports.put("aiken/collection/list", new String[]{ + "length", "map", "filter", "fold", "any", "all", "head", "tail", "take", "drop", + "reverse", "concat", "flatten", "unique", "sort", "at", "push", "indexed_map" + }); + + exports.put("aiken/collection/dict", new String[]{ + "new", "insert", "delete", "get", "has_key", "keys", "values", "to_list", + "from_list", "size", "is_empty", "filter", "map", "fold" + }); + + exports.put("aiken/option", new String[]{ + "Some", "None", "map", "or_else", "and_then", "is_some", "is_none", "choice" + }); + + exports.put("aiken/result", new String[]{ + "Ok", "Error", "map", "map_error", "or_else", "and_then", "is_ok", "is_error" + }); + + exports.put("aiken/crypto", new String[]{ + "VerificationKeyHash", "ScriptHash", "blake2b_256", "blake2b_224", "sha2_256", "sha3_256" + }); + + exports.put("cardano/transaction", new String[]{ + "Transaction", "Input", "Output", "OutputReference", "TxId", "find_input", "find_output", + "find_script_outputs", "find_datum", "find_script", "value_sent_to", "value_sent_to_address" + }); + + exports.put("cardano/address", new String[]{ + "Address", "VerificationKeyCredential", "ScriptCredential", "StakeCredential", + "from_verification_key", "from_script", "payment_credential", "stake_credential" + }); + + exports.put("cardano/assets", new String[]{ + "PolicyId", "AssetName", "Value", "from_lovelace", "from_asset", "quantity_of", + "policies", "tokens", "flatten", "negate", "zero" + }); + + exports.put("cardano/script_context", new String[]{ + "ScriptContext", "ScriptPurpose", "Spend", "Mint", "Withdraw", "Publish", "Vote", "Propose" + }); + + exports.put("aiken/string", new String[]{ + "length", "slice", "drop_prefix", "drop_suffix", "starts_with", "ends_with", + "concat", "join", "split", "to_bytearray", "from_bytearray" + }); + + exports.put("aiken/bytearray", new String[]{ + "length", "slice", "drop", "take", "concat", "push", "compare", "from_string", "to_string" + }); + + exports.put("aiken/math", new String[]{ + "abs", "max", "min", "clamp", "pow", "sqrt", "log", "gcd", "lcm" + }); + + exports.put("aiken/interval", new String[]{ + "Interval", "before", "after", "contains", "is_empty", "intersection", "hull" + }); + + exports.put("aiken/builtin", new String[]{ + "head_list", "tail_list", "null_list", "choose_list", "mk_cons", "mk_nil_data", + "mk_nil_pair_data", "serialise_data", "un_i_data", "un_b_data", "un_map_data", + "un_list_data", "un_constr_data", "equals_data", "less_than_equals_integer", + "less_than_integer", "add_integer", "subtract_integer", "multiply_integer", + "divide_integer", "quotient_integer", "remainder_integer", "mod_integer" + }); + + exports.put("aiken/cbor", new String[]{ + "diagnostic", "bytearray", "int", "simple", "tag", "array", "map", "indefinite_array", + "indefinite_map", "indefinite_bytearray", "indefinite_string" + }); + + exports.put("aiken/fuzz", new String[]{ + "int", "int_between", "bool", "bytearray", "bytearray_between", "list", + "list_between", "constant", "and_then", "map", "one_of", "frequency" + }); + + exports.put("aiken/time", new String[]{ + "now", "after", "before", "between", "PosixTime" + }); + + exports.put("aiken/rational", new String[]{ + "Rational", "from_int", "numerator", "denominator", "add", "subtract", + "multiply", "divide", "negate", "abs", "compare", "zero", "one" + }); + + exports.put("aiken/ordering", new String[]{ + "Less", "Equal", "Greater", "compare" + }); + + exports.put("aiken/collection/pairs", new String[]{ + "new", "get_1st", "get_2nd", "map_1st", "map_2nd", "swap" + }); + + exports.put("aiken/primitive/bytearray", new String[]{ + "length", "take", "drop", "slice", "push", "from_string", "to_string", + "compare", "concat", "is_empty" + }); + + exports.put("aiken/primitive/int", new String[]{ + "max", "min", "abs", "compare", "to_string", "from_string" + }); + + exports.put("aiken/primitive/string", new String[]{ + "length", "slice", "take", "drop", "concat", "starts_with", "ends_with", + "contains", "to_bytearray", "from_bytearray", "to_lower", "to_upper" + }); + + exports.put("aiken/crypto/ed25519", new String[]{ + "VerificationKey", "Signature", "verify" + }); + + exports.put("cardano/credential", new String[]{ + "Credential", "VerificationKey", "Script", "from_verification_key", "from_script" + }); + + exports.put("cardano/wallet", new String[]{ + "from_verification_key", "from_script", "with_stake_credential" + }); + + exports.put("cardano/compatibility", new String[]{ + "output_reference", "resolved_input" + }); + + return exports; + } + + public AikenImportCompletionContributor() { + // Complete import-related items for Aiken language + extend(CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(AikenLanguage.INSTANCE), + new CompletionProvider() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, + @NotNull ProcessingContext context, + @NotNull CompletionResultSet result) { + PsiElement element = parameters.getPosition(); + + // Get the current line text to check for import context + String lineText = getCurrentLineText(element); + + if (lineText != null && lineText.trim().startsWith("use ")) { + if (lineText.contains(".{")) { + // Inside export braces - provide specific exports + String moduleName = extractModuleName(lineText); + addSpecificExportCompletions(moduleName, result); + } else { + // Module path completion + addModuleCompletions(parameters, result); + } + } else if (shouldProvideImportCompletions(element)) { + // Fallback for PSI-based detection + addModuleCompletions(parameters, result); + } + } + }); + } + + private void addModuleCompletions(@NotNull CompletionParameters parameters, + @NotNull CompletionResultSet result) { + String importText = getImportText(parameters.getPosition()); + + // Always add basic completions first + result.addElement(LookupElementBuilder.create("aiken/collection/list") + .withTypeText("stdlib module") + .withIcon(com.intellij.icons.AllIcons.Nodes.Module) + .withInsertHandler(new ModuleInsertHandler())); + + result.addElement(LookupElementBuilder.create("aiken/collection/dict") + .withTypeText("stdlib module") + .withIcon(com.intellij.icons.AllIcons.Nodes.Module) + .withInsertHandler(new ModuleInsertHandler())); + + result.addElement(LookupElementBuilder.create("cardano/transaction") + .withTypeText("stdlib module") + .withIcon(com.intellij.icons.AllIcons.Nodes.Module) + .withInsertHandler(new ModuleInsertHandler())); + + // Add standard library modules + for (String module : STDLIB_MODULES) { + // Filter based on what's already typed + if (importText == null || importText.isEmpty() || module.startsWith(importText)) { + result.addElement(LookupElementBuilder.create(module) + .withTypeText("stdlib module") + .withIcon(com.intellij.icons.AllIcons.Nodes.Module) + .withInsertHandler(new ModuleInsertHandler())); + } + } + + // Add namespace completions + result.addElement(LookupElementBuilder.create("aiken/") + .withTypeText("stdlib namespace") + .withIcon(com.intellij.icons.AllIcons.Nodes.ModuleGroup)); + result.addElement(LookupElementBuilder.create("cardano/") + .withTypeText("cardano namespace") + .withIcon(com.intellij.icons.AllIcons.Nodes.ModuleGroup)); + result.addElement(LookupElementBuilder.create("aiken/collection/") + .withTypeText("collections namespace") + .withIcon(com.intellij.icons.AllIcons.Nodes.ModuleGroup)); + } + + private void addSpecificExportCompletions(@NotNull String moduleName, @NotNull CompletionResultSet result) { + if (moduleName != null && MODULE_EXPORTS.containsKey(moduleName)) { + // Only show exports specific to this module + String[] exports = MODULE_EXPORTS.get(moduleName); + for (String export : exports) { + if (Character.isUpperCase(export.charAt(0))) { + // Types and constructors + result.addElement(LookupElementBuilder.create(export) + .withTypeText("type") + .withTailText(" from " + moduleName) + .withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + } else { + // Functions and values + result.addElement(LookupElementBuilder.create(export) + .withTypeText("function") + .withTailText(" from " + moduleName) + .withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } + } + } + // Remove fallback - if we don't know the module, don't guess + } + + + private String extractModuleName(@NotNull String lineText) { + // Extract module name from "use cardano/transaction.{" -> "cardano/transaction" + String trimmed = lineText.trim(); + if (trimmed.startsWith("use ")) { + String afterUse = trimmed.substring(4).trim(); + int braceIndex = afterUse.indexOf(".{"); + if (braceIndex > 0) { + return afterUse.substring(0, braceIndex).trim(); + } + } + return null; + } + + private void addExportCompletions(@NotNull CompletionParameters parameters, + @NotNull CompletionResultSet result) { + // This method is now handled by addSpecificExportCompletions + // No fallback completions - only show what we know + } + + private void addTypeExportCompletions(@NotNull CompletionParameters parameters, + @NotNull CompletionResultSet result) { + // This method is now handled by addSpecificExportCompletions + // No additional implementation needed + } + + /** + * Simplified import context detection + */ + private boolean shouldProvideImportCompletions(@NotNull PsiElement element) { + // Check if we're inside a complete import statement + if (AikenPsiImplUtil.isInImportContext(element)) { + return true; + } + + // Get some context around the current position + String beforeText = getTextBefore(element, 30); + if (beforeText != null) { + // Look for "use " followed by potential module path + if (beforeText.matches(".*\\buse\\s+[a-zA-Z0-9_/]*$")) { + return true; + } + } + + // Check current line + String lineText = getLineText(element); + if (lineText != null) { + String trimmed = lineText.trim(); + if (trimmed.startsWith("use ")) { + return true; + } + } + + return false; + } + + /** + * Get the import text being typed + */ + private String getImportText(@NotNull PsiElement element) { + String lineText = getLineText(element); + if (lineText != null) { + String trimmed = lineText.trim(); + if (trimmed.startsWith("use ")) { + return trimmed.substring(4); // Remove "use " prefix + } + } + return null; + } + + /** + * Get the text of the current line + */ + private String getLineText(@NotNull PsiElement element) { + try { + PsiElement file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore errors + } + return null; + } + + /** + * Get text before the current element position + */ + private String getTextBefore(@NotNull PsiElement element, int length) { + try { + PsiElement file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + int start = Math.max(0, offset - length); + return fileText.substring(start, offset); + } + } catch (Exception e) { + // Ignore errors + } + return null; + } + + private String getCurrentLineText(@NotNull PsiElement element) { + try { + var file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } + + /** + * Custom insert handler for module completions to handle proper text replacement + */ + private static class ModuleInsertHandler implements InsertHandler { + @Override + public void handleInsert(@NotNull InsertionContext context, @NotNull LookupElement item) { + Editor editor = context.getEditor(); + Document document = editor.getDocument(); + PsiFile file = context.getFile(); + + int startOffset = context.getStartOffset(); + int tailOffset = context.getTailOffset(); + + // Get the line text to find the "use " prefix + String lineText = getCurrentLineText(context); + if (lineText != null && lineText.trim().startsWith("use ")) { + // Find the position after "use " + int useIndex = lineText.indexOf("use "); + if (useIndex != -1) { + // Calculate the absolute position after "use " + int lineStart = startOffset; + String beforeCursor = lineText.substring(0, lineText.length() - (tailOffset - startOffset)); + while (lineStart > 0 && !document.getText().substring(lineStart - 1, lineStart).equals("\n")) { + lineStart--; + } + + int useEndPos = lineStart + useIndex + 4; // 4 = length of "use " + + // Replace everything from after "use " to the current cursor position + String insertText = item.getLookupString(); + document.replaceString(useEndPos, tailOffset, insertText); + + // Position cursor at the end of the inserted text + editor.getCaretModel().moveToOffset(useEndPos + insertText.length()); + return; + } + } + + // Fallback to default behavior + document.replaceString(startOffset, tailOffset, item.getLookupString()); + } + + private String getCurrentLineText(@NotNull InsertionContext context) { + try { + PsiFile file = context.getFile(); + if (file != null) { + String fileText = file.getText(); + int offset = context.getStartOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenSimpleImportCompletionContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenSimpleImportCompletionContributor.java new file mode 100644 index 0000000..990cb6c --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenSimpleImportCompletionContributor.java @@ -0,0 +1,262 @@ +package com.bloxbean.intelliada.idea.aiken.completion; + +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +/** + * Simplified import completion contributor for debugging + */ +public class AikenSimpleImportCompletionContributor extends CompletionContributor { + + public AikenSimpleImportCompletionContributor() { + extend(CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(AikenLanguage.INSTANCE), + new CompletionProvider() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, + @NotNull ProcessingContext context, + @NotNull CompletionResultSet result) { + + // Get context around cursor + String lineText = getCurrentLineText(parameters); + + // If line contains "use " then add import completions + if (lineText != null && lineText.trim().startsWith("use ")) { + if (lineText.contains(".{")) { + // Inside export braces - provide exports + String moduleName = extractModuleName(lineText); + addExportCompletions(moduleName, result); + } else { + // Add common stdlib modules + String[] commonModules = { + "aiken/collection/list", "aiken/collection/dict", "aiken/option", "aiken/result", + "aiken/string", "aiken/bytearray", "aiken/math", "aiken/crypto", "aiken/hash", + "aiken/interval", "aiken/time", "aiken/builtin", "aiken/cbor", "aiken/fuzz", + "aiken/primitive/bytearray", "aiken/primitive/int", "aiken/primitive/string", + "aiken/collection/pairs", "aiken/ordering", "aiken/rational", + "aiken/crypto/bls12_381", "aiken/crypto/ed25519", + "cardano/address", "cardano/assets", "cardano/certificate", "cardano/credential", + "cardano/governance", "cardano/script_context", "cardano/transaction", + "cardano/wallet", "cardano/compatibility" + }; + + for (String module : commonModules) { + result.addElement(LookupElementBuilder.create(module) + .withTypeText("stdlib") + .withIcon(com.intellij.icons.AllIcons.Nodes.Module) + .withInsertHandler(new ModuleInsertHandler())); + } + } + } + } + }); + } + + private String getCurrentLineText(@NotNull CompletionParameters parameters) { + try { + var element = parameters.getPosition(); + var file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } + + private String extractModuleName(String lineText) { + // Extract module name from "use cardano/transaction.{" -> "cardano/transaction" + String trimmed = lineText.trim(); + if (trimmed.startsWith("use ")) { + String afterUse = trimmed.substring(4).trim(); + int braceIndex = afterUse.indexOf(".{"); + if (braceIndex > 0) { + return afterUse.substring(0, braceIndex).trim(); + } + } + return null; + } + + private void addExportCompletions(String moduleName, @NotNull CompletionResultSet result) { + if ("cardano/transaction".equals(moduleName)) { + // Cardano transaction exports + result.addElement(LookupElementBuilder.create("Transaction") + .withTypeText("type").withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + result.addElement(LookupElementBuilder.create("Input") + .withTypeText("type").withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + result.addElement(LookupElementBuilder.create("Output") + .withTypeText("type").withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + result.addElement(LookupElementBuilder.create("OutputReference") + .withTypeText("type").withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + result.addElement(LookupElementBuilder.create("TxId") + .withTypeText("type").withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + result.addElement(LookupElementBuilder.create("find_input") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("find_output") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } else if ("aiken/collection/list".equals(moduleName)) { + // Aiken list exports + result.addElement(LookupElementBuilder.create("map") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("filter") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("fold") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("length") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("head") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("tail") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } else if ("aiken/crypto".equals(moduleName)) { + // Aiken crypto exports + result.addElement(LookupElementBuilder.create("VerificationKeyHash") + .withTypeText("type").withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + result.addElement(LookupElementBuilder.create("ScriptHash") + .withTypeText("type").withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + result.addElement(LookupElementBuilder.create("blake2b_256") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } else if ("aiken/math".equals(moduleName)) { + // Aiken math exports + result.addElement(LookupElementBuilder.create("abs") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("max") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("min") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("clamp") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("pow") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } else if ("aiken/string".equals(moduleName)) { + // Aiken string exports + result.addElement(LookupElementBuilder.create("length") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("slice") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("concat") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("starts_with") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } else if ("aiken/option".equals(moduleName)) { + // Aiken option exports + result.addElement(LookupElementBuilder.create("Some") + .withTypeText("constructor").withIcon(com.intellij.icons.AllIcons.Nodes.Method)); + result.addElement(LookupElementBuilder.create("None") + .withTypeText("constructor").withIcon(com.intellij.icons.AllIcons.Nodes.Method)); + result.addElement(LookupElementBuilder.create("map") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("or_else") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } else if ("aiken/result".equals(moduleName)) { + // Aiken result exports + result.addElement(LookupElementBuilder.create("Ok") + .withTypeText("constructor").withIcon(com.intellij.icons.AllIcons.Nodes.Method)); + result.addElement(LookupElementBuilder.create("Error") + .withTypeText("constructor").withIcon(com.intellij.icons.AllIcons.Nodes.Method)); + result.addElement(LookupElementBuilder.create("map") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + result.addElement(LookupElementBuilder.create("is_ok") + .withTypeText("function").withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } + // Remove generic fallback - only show exports we know about + } + + /** + * Custom insert handler for module completions to handle proper text replacement + */ + private static class ModuleInsertHandler implements InsertHandler { + @Override + public void handleInsert(@NotNull InsertionContext context, @NotNull LookupElement item) { + Editor editor = context.getEditor(); + Document document = editor.getDocument(); + PsiFile file = context.getFile(); + + int startOffset = context.getStartOffset(); + int tailOffset = context.getTailOffset(); + + // Get the line text to find the "use " prefix + String lineText = getCurrentLineText(context); + if (lineText != null && lineText.trim().startsWith("use ")) { + // Find the position after "use " + int useIndex = lineText.indexOf("use "); + if (useIndex != -1) { + // Calculate the absolute position after "use " + int lineStart = startOffset; + while (lineStart > 0 && !document.getText().substring(lineStart - 1, lineStart).equals("\n")) { + lineStart--; + } + + int useEndPos = lineStart + useIndex + 4; // 4 = length of "use " + + // Replace everything from after "use " to the current cursor position + String insertText = item.getLookupString(); + document.replaceString(useEndPos, tailOffset, insertText); + + // Position cursor at the end of the inserted text + editor.getCaretModel().moveToOffset(useEndPos + insertText.length()); + return; + } + } + + // Fallback to default behavior + document.replaceString(startOffset, tailOffset, item.getLookupString()); + } + + private String getCurrentLineText(@NotNull InsertionContext context) { + try { + PsiFile file = context.getFile(); + if (file != null) { + String fileText = file.getText(); + int offset = context.getStartOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenTypeAwareCompletionContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenTypeAwareCompletionContributor.java new file mode 100644 index 0000000..0910fe4 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenTypeAwareCompletionContributor.java @@ -0,0 +1,342 @@ +package com.bloxbean.intelliada.idea.aiken.completion; + +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.bloxbean.intelliada.idea.aiken.lang.psi.*; +import com.bloxbean.intelliada.idea.aiken.lang.psi.impl.AikenPsiImplUtil; +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +public class AikenTypeAwareCompletionContributor extends CompletionContributor { + + // Common type constructors and their fields + private static final Map TYPE_CONSTRUCTORS = Map.of( + "Option", new String[]{"Some", "None"}, + "Result", new String[]{"Ok", "Error"}, + "List", new String[]{"[]", "::"}, + "Bool", new String[]{"True", "False"} + ); + + // Standard types and their common methods/fields + private static final Map TYPE_METHODS = Map.of( + "List", new String[]{"length", "head", "tail", "map", "filter", "fold", "any", "all"}, + "Option", new String[]{"map", "or_else", "and_then", "is_some", "is_none"}, + "Result", new String[]{"map", "map_error", "or_else", "and_then", "is_ok", "is_error"}, + "String", new String[]{"length", "slice", "drop_prefix", "drop_suffix"}, + "ByteArray", new String[]{"length", "slice", "take", "drop"} + ); + + // Validator context-specific completions + private static final Map VALIDATOR_COMPLETIONS = Map.of( + "spend", new String[]{"datum", "redeemer", "context", "input", "output"}, + "mint", new String[]{"redeemer", "context", "policy_id", "asset_name"}, + "withdraw", new String[]{"redeemer", "context", "stake_credential"}, + "publish", new String[]{"certificate", "redeemer", "context"}, + "vote", new String[]{"voter", "vote", "redeemer", "context"}, + "propose", new String[]{"proposal", "redeemer", "context"} + ); + + public AikenTypeAwareCompletionContributor() { + extend(CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(AikenLanguage.INSTANCE), + new CompletionProvider() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, + @NotNull ProcessingContext context, + @NotNull CompletionResultSet result) { + PsiElement element = parameters.getPosition(); + + // Skip completions if we're in an import export context + if (isInImportExportContext(element)) { + return; + } + + addTypeConstructorCompletions(element, result); + addMethodCompletions(element, result); + addValidatorContextCompletions(element, result); + addLocalVariableCompletions(element, result); + addFunctionCompletions(element, result); + addTypeCompletions(element, result); + } + }); + } + + private void addTypeConstructorCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + // Find if we're in a context where a type constructor would be appropriate + PsiElement parent = element.getParent(); + if (parent instanceof AikenVariableValue || parent instanceof AikenFunctionCallParam) { + for (Map.Entry entry : TYPE_CONSTRUCTORS.entrySet()) { + String typeName = entry.getKey(); + for (String constructor : entry.getValue()) { + result.addElement(LookupElementBuilder.create(constructor) + .withTypeText(typeName + " constructor") + .withIcon(com.intellij.icons.AllIcons.Nodes.Method) + .withTailText(" :: " + typeName)); + } + } + } + } + + private void addMethodCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + // Look for dot notation (e.g., "list.|") + PsiElement prev = element.getPrevSibling(); + if (prev != null && ".".equals(prev.getText())) { + PsiElement identifier = prev.getPrevSibling(); + if (identifier != null) { + String varName = identifier.getText(); + String inferredType = inferTypeFromContext(identifier); + + if (inferredType != null && TYPE_METHODS.containsKey(inferredType)) { + for (String method : TYPE_METHODS.get(inferredType)) { + result.addElement(LookupElementBuilder.create(method) + .withTypeText(inferredType + " method") + .withIcon(com.intellij.icons.AllIcons.Nodes.Method)); + } + } + } + } + } + + private void addValidatorContextCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + AikenValidatorStatement validator = PsiTreeUtil.getParentOfType(element, AikenValidatorStatement.class); + if (validator != null) { + String validatorType = getValidatorType(validator); + if (validatorType != null && VALIDATOR_COMPLETIONS.containsKey(validatorType)) { + for (String completion : VALIDATOR_COMPLETIONS.get(validatorType)) { + result.addElement(LookupElementBuilder.create(completion) + .withTypeText(validatorType + " validator context") + .withIcon(com.intellij.icons.AllIcons.Nodes.Parameter)); + } + } + } + } + + private void addLocalVariableCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + // Find all let bindings and function parameters in scope + AikenFunctionStatement function = PsiTreeUtil.getParentOfType(element, AikenFunctionStatement.class); + if (function != null) { + // Add function parameters + addFunctionParameterCompletions(function, result); + } + + // Add let bindings from current scope + addLetBindingCompletions(element, result); + } + + private void addFunctionCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + // Get all functions from current file + List functions = AikenPsiImplUtil.getFunctions(element); + for (AikenFunctionStatement function : functions) { + String functionName = getFunctionName(function); + if (functionName != null && !functionName.isEmpty()) { + String signature = buildFunctionSignature(function); + result.addElement(LookupElementBuilder.create(functionName) + .withTypeText("function") + .withTailText(signature) + .withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } + } + } + + private void addTypeCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + // Get all type definitions from current file + List types = AikenPsiImplUtil.getTypes(element); + for (AikenTypeStatement type : types) { + String typeName = getTypeName(type); + if (typeName != null && !typeName.isEmpty()) { + result.addElement(LookupElementBuilder.create(typeName) + .withTypeText("custom type") + .withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + } + } + } + + private String inferTypeFromContext(@NotNull PsiElement element) { + // Simple type inference based on naming conventions and context + String text = element.getText(); + + // Common naming patterns + if (text.contains("list") || text.endsWith("s")) return "List"; + if (text.contains("option") || text.contains("maybe")) return "Option"; + if (text.contains("result")) return "Result"; + if (text.contains("string") || text.contains("name")) return "String"; + if (text.contains("bytes") || text.contains("hash")) return "ByteArray"; + + // Look at assignment context + PsiElement parent = element.getParent(); + if (parent instanceof AikenVariableStatement) { + AikenVariableValue value = PsiTreeUtil.findChildOfType(parent, AikenVariableValue.class); + if (value != null) { + String valueText = value.getText(); + if (valueText.startsWith("[")) return "List"; + if (valueText.startsWith("Some(") || valueText.equals("None")) return "Option"; + if (valueText.startsWith("Ok(") || valueText.startsWith("Error(")) return "Result"; + if (valueText.startsWith("\"")) return "String"; + if (valueText.startsWith("#\"")) return "ByteArray"; + } + } + + return null; + } + + private String getValidatorType(@NotNull AikenValidatorStatement validator) { + // Extract validator type from the validator statement + String text = validator.getText(); + for (String type : VALIDATOR_COMPLETIONS.keySet()) { + if (text.contains(type)) { + return type; + } + } + return "spend"; // Default to spend validator + } + + private void addFunctionParameterCompletions(@NotNull AikenFunctionStatement function, @NotNull CompletionResultSet result) { + // Extract parameter names from function signature + String text = function.getText(); + // Simple regex to extract parameter names (this could be improved with proper PSI navigation) + if (text.contains("(") && text.contains(")")) { + int start = text.indexOf("(") + 1; + int end = text.indexOf(")", start); + if (end > start) { + String params = text.substring(start, end); + String[] paramParts = params.split(","); + for (String param : paramParts) { + param = param.trim(); + if (param.contains(":")) { + String paramName = param.substring(0, param.indexOf(":")).trim(); + if (!paramName.isEmpty()) { + result.addElement(LookupElementBuilder.create(paramName) + .withTypeText("parameter") + .withIcon(com.intellij.icons.AllIcons.Nodes.Parameter)); + } + } + } + } + } + } + + private void addLetBindingCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + // Find all let statements in the current scope + PsiElement current = element; + while (current != null) { + if (current instanceof AikenVariableStatement) { + String varName = getVariableName((AikenVariableStatement) current); + if (varName != null && !varName.isEmpty()) { + result.addElement(LookupElementBuilder.create(varName) + .withTypeText("local variable") + .withIcon(com.intellij.icons.AllIcons.Nodes.Variable)); + } + } + current = current.getPrevSibling(); + } + } + + private String getFunctionName(@NotNull AikenFunctionStatement function) { + // Extract function name from PSI + String text = function.getText(); + if (text.startsWith("fn ") || text.startsWith("pub fn ")) { + String[] parts = text.split("\\s+"); + for (int i = 0; i < parts.length - 1; i++) { + if ("fn".equals(parts[i])) { + return parts[i + 1].split("\\(")[0]; + } + } + } + return null; + } + + private String getTypeName(@NotNull AikenTypeStatement type) { + // Extract type name from PSI + String text = type.getText(); + if (text.startsWith("type ") || text.startsWith("pub type ")) { + String[] parts = text.split("\\s+"); + for (int i = 0; i < parts.length - 1; i++) { + if ("type".equals(parts[i])) { + return parts[i + 1].split("[<{]")[0]; + } + } + } + return null; + } + + private String getVariableName(@NotNull AikenVariableStatement variable) { + // Extract variable name from let statement + String text = variable.getText(); + if (text.startsWith("let ")) { + String[] parts = text.split("\\s+"); + if (parts.length > 1) { + return parts[1].split("=")[0].trim(); + } + } + return null; + } + + private String buildFunctionSignature(@NotNull AikenFunctionStatement function) { + // Build a simple function signature for display + String text = function.getText(); + int paramStart = text.indexOf("("); + int paramEnd = text.indexOf(")", paramStart); + int returnStart = text.indexOf("->", paramEnd); + + if (paramStart != -1 && paramEnd != -1) { + String params = text.substring(paramStart, paramEnd + 1); + if (returnStart != -1) { + int returnEnd = text.indexOf("{", returnStart); + if (returnEnd != -1) { + String returnType = text.substring(returnStart, returnEnd).trim(); + return params + " " + returnType; + } + } + return params; + } + return ""; + } + + private boolean isInImportExportContext(@NotNull PsiElement element) { + // Check if we're in an import statement with export braces + String lineText = getCurrentLineText(element); + if (lineText != null) { + String trimmed = lineText.trim(); + // Check if we're inside export braces: "use module.{..." + if (trimmed.startsWith("use ") && trimmed.contains(".{")) { + return true; + } + } + return false; + } + + private String getCurrentLineText(@NotNull PsiElement element) { + try { + var file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenValidatorCompletionContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenValidatorCompletionContributor.java new file mode 100644 index 0000000..072af60 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/completion/AikenValidatorCompletionContributor.java @@ -0,0 +1,243 @@ +package com.bloxbean.intelliada.idea.aiken.completion; + +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.bloxbean.intelliada.idea.aiken.lang.psi.AikenValidatorStatement; +import com.intellij.codeInsight.completion.*; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +public class AikenValidatorCompletionContributor extends CompletionContributor { + + // Validator purpose-specific completions + private static final Map VALIDATOR_CONTEXTS = Map.of( + "spend", new ValidatorContext( + new String[]{"datum", "redeemer", "context"}, + new String[]{"ScriptContext", "UTxO", "TxInfo", "Input", "Output"}, + new String[]{"find_input", "find_output", "value_sent_to", "value_sent_to_address"} + ), + "mint", new ValidatorContext( + new String[]{"redeemer", "context"}, + new String[]{"ScriptContext", "TxInfo", "MintingPolicy", "CurrencySymbol"}, + new String[]{"own_currency_symbol", "quantity_of", "value_minted"} + ), + "withdraw", new ValidatorContext( + new String[]{"redeemer", "context"}, + new String[]{"ScriptContext", "StakeCredential", "Certificate"}, + new String[]{"find_certificate", "own_stake_credential"} + ), + "publish", new ValidatorContext( + new String[]{"certificate", "redeemer", "context"}, + new String[]{"ScriptContext", "Certificate", "ProposalProcedure"}, + new String[]{"find_certificate", "certificate_purpose"} + ), + "vote", new ValidatorContext( + new String[]{"voter", "vote", "redeemer", "context"}, + new String[]{"ScriptContext", "Voter", "Vote", "GovernanceAction"}, + new String[]{"find_vote", "voter_purpose"} + ), + "propose", new ValidatorContext( + new String[]{"proposal", "redeemer", "context"}, + new String[]{"ScriptContext", "ProposalProcedure", "GovernanceAction"}, + new String[]{"find_proposal", "proposal_purpose"} + ) + ); + + // Common ScriptContext fields and methods + private static final String[] SCRIPT_CONTEXT_FIELDS = { + "tx_info", "purpose", "redeemer" + }; + + private static final String[] TX_INFO_FIELDS = { + "inputs", "outputs", "fee", "mint", "certificates", "withdrawals", + "valid_range", "signatories", "redeemers", "data", "id" + }; + + // Cardano-specific types and their methods + private static final Map CARDANO_TYPE_METHODS = Map.of( + "Value", new String[]{"from_lovelace", "to_dict", "quantity_of", "policies", "tokens"}, + "Address", new String[]{"payment_credential", "stake_credential", "from_script", "from_verification_key"}, + "UTxO", new String[]{"address", "value", "datum", "reference_script"}, + "TxInfo", new String[]{"find_input", "find_output", "find_datum", "find_script"}, + "Interval", new String[]{"contains", "before", "after", "is_empty"} + ); + + public AikenValidatorCompletionContributor() { + extend(CompletionType.BASIC, + PlatformPatterns.psiElement().withLanguage(AikenLanguage.INSTANCE), + new CompletionProvider() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, + @NotNull ProcessingContext context, + @NotNull CompletionResultSet result) { + PsiElement element = parameters.getPosition(); + + // Skip completions if we're in an import export context + if (isInImportExportContext(element)) { + return; + } + + AikenValidatorStatement validator = PsiTreeUtil.getParentOfType(element, AikenValidatorStatement.class); + + if (validator != null) { + String validatorType = extractValidatorType(validator); + addValidatorSpecificCompletions(validatorType, result); + addScriptContextCompletions(result); + addCardanoTypeCompletions(element, result); + } + } + }); + } + + private void addValidatorSpecificCompletions(@NotNull String validatorType, @NotNull CompletionResultSet result) { + ValidatorContext context = VALIDATOR_CONTEXTS.get(validatorType); + if (context == null) { + context = VALIDATOR_CONTEXTS.get("spend"); // Default to spend + } + + // Add parameter names + for (String param : context.parameters) { + result.addElement(LookupElementBuilder.create(param) + .withTypeText(validatorType + " parameter") + .withIcon(com.intellij.icons.AllIcons.Nodes.Parameter)); + } + + // Add relevant types + for (String type : context.types) { + result.addElement(LookupElementBuilder.create(type) + .withTypeText("Cardano type") + .withIcon(com.intellij.icons.AllIcons.Nodes.Class)); + } + + // Add utility functions + for (String function : context.functions) { + result.addElement(LookupElementBuilder.create(function) + .withTypeText(validatorType + " utility") + .withIcon(com.intellij.icons.AllIcons.Nodes.Function)); + } + } + + private void addScriptContextCompletions(@NotNull CompletionResultSet result) { + // Add ScriptContext fields + for (String field : SCRIPT_CONTEXT_FIELDS) { + result.addElement(LookupElementBuilder.create(field) + .withTypeText("ScriptContext field") + .withIcon(com.intellij.icons.AllIcons.Nodes.Field)); + } + + // Add TxInfo fields + for (String field : TX_INFO_FIELDS) { + result.addElement(LookupElementBuilder.create(field) + .withTypeText("TxInfo field") + .withIcon(com.intellij.icons.AllIcons.Nodes.Field)); + } + } + + private void addCardanoTypeCompletions(@NotNull PsiElement element, @NotNull CompletionResultSet result) { + // Look for dot notation to suggest type-specific methods + PsiElement prev = element.getPrevSibling(); + if (prev != null && ".".equals(prev.getText())) { + PsiElement identifier = prev.getPrevSibling(); + if (identifier != null) { + String varName = identifier.getText(); + String inferredType = inferCardanoType(varName); + + if (inferredType != null && CARDANO_TYPE_METHODS.containsKey(inferredType)) { + for (String method : CARDANO_TYPE_METHODS.get(inferredType)) { + result.addElement(LookupElementBuilder.create(method) + .withTypeText(inferredType + " method") + .withIcon(com.intellij.icons.AllIcons.Nodes.Method)); + } + } + } + } + } + + private String extractValidatorType(@NotNull AikenValidatorStatement validator) { + String text = validator.getText(); + + // Look for validator purpose keywords + for (String purpose : VALIDATOR_CONTEXTS.keySet()) { + if (text.contains(purpose + "(") || text.contains(purpose + " (")) { + return purpose; + } + } + + return "spend"; // Default + } + + private String inferCardanoType(@NotNull String varName) { + // Infer Cardano types based on common naming patterns + String lowerName = varName.toLowerCase(); + + if (lowerName.contains("context") || lowerName.equals("ctx")) return "ScriptContext"; + if (lowerName.contains("value") || lowerName.contains("amount")) return "Value"; + if (lowerName.contains("address") || lowerName.contains("addr")) return "Address"; + if (lowerName.contains("utxo") || lowerName.contains("output") || lowerName.contains("input")) return "UTxO"; + if (lowerName.contains("tx") && lowerName.contains("info")) return "TxInfo"; + if (lowerName.contains("interval") || lowerName.contains("range")) return "Interval"; + if (lowerName.contains("datum")) return "Data"; + if (lowerName.contains("redeemer")) return "Data"; + + return null; + } + + private boolean isInImportExportContext(@NotNull PsiElement element) { + // Check if we're in an import statement with export braces + String lineText = getCurrentLineText(element); + if (lineText != null) { + String trimmed = lineText.trim(); + // Check if we're inside export braces: "use module.{..." + if (trimmed.startsWith("use ") && trimmed.contains(".{")) { + return true; + } + } + return false; + } + + private String getCurrentLineText(@NotNull PsiElement element) { + try { + var file = element.getContainingFile(); + if (file != null) { + String fileText = file.getText(); + int offset = element.getTextOffset(); + + // Find start of line + int lineStart = offset; + while (lineStart > 0 && fileText.charAt(lineStart - 1) != '\n') { + lineStart--; + } + + // Find end of line + int lineEnd = offset; + while (lineEnd < fileText.length() && fileText.charAt(lineEnd) != '\n') { + lineEnd++; + } + + if (lineStart <= lineEnd) { + return fileText.substring(lineStart, lineEnd); + } + } + } catch (Exception e) { + // Ignore + } + return null; + } + + private static class ValidatorContext { + final String[] parameters; + final String[] types; + final String[] functions; + + ValidatorContext(String[] parameters, String[] types, String[] functions) { + this.parameters = parameters; + this.types = types; + this.functions = functions; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolIndex.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolIndex.java new file mode 100644 index 0000000..846f081 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolIndex.java @@ -0,0 +1,285 @@ +package com.bloxbean.intelliada.idea.aiken.index; + +import com.bloxbean.intelliada.idea.aiken.lang.psi.*; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.psi.search.FileTypeIndex; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.indexing.*; +import com.intellij.util.io.DataExternalizer; +import com.intellij.util.io.EnumeratorStringDescriptor; +import com.intellij.util.io.KeyDescriptor; +import org.jetbrains.annotations.NotNull; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.util.*; + +public class AikenSymbolIndex extends FileBasedIndexExtension { + public static final ID NAME = ID.create("aiken.symbol.index"); + + @NotNull + @Override + public ID getName() { + return NAME; + } + + @NotNull + @Override + public DataIndexer getIndexer() { + return new DataIndexer() { + @NotNull + @Override + public Map map(@NotNull FileContent inputData) { + Map result = new HashMap<>(); + + PsiFile psiFile = inputData.getPsiFile(); + if (!(psiFile instanceof AikenFile)) { + return result; + } + + // Index functions + Collection functions = PsiTreeUtil.findChildrenOfType(psiFile, AikenFunctionStatement.class); + for (AikenFunctionStatement function : functions) { + String name = extractFunctionName(function); + if (name != null) { + boolean isPublic = isPublicFunction(function); + String signature = extractFunctionSignature(function); + AikenSymbolInfo info = new AikenSymbolInfo(name, AikenSymbolType.FUNCTION, + inputData.getFile().getPath(), isPublic, signature); + result.put(name, info); + } + } + + // Index types + Collection types = PsiTreeUtil.findChildrenOfType(psiFile, AikenTypeStatement.class); + for (AikenTypeStatement type : types) { + String name = extractTypeName(type); + if (name != null) { + boolean isPublic = isPublicType(type); + List constructors = extractTypeConstructors(type); + AikenSymbolInfo info = new AikenSymbolInfo(name, AikenSymbolType.TYPE, + inputData.getFile().getPath(), isPublic, constructors.toString()); + result.put(name, info); + + // Index constructors + for (String constructor : constructors) { + AikenSymbolInfo constructorInfo = new AikenSymbolInfo(constructor, AikenSymbolType.CONSTRUCTOR, + inputData.getFile().getPath(), isPublic, name); + result.put(constructor, constructorInfo); + } + } + } + + // Index validators + Collection validators = PsiTreeUtil.findChildrenOfType(psiFile, AikenValidatorStatement.class); + for (AikenValidatorStatement validator : validators) { + String name = extractValidatorName(validator); + if (name != null) { + String validatorType = extractValidatorType(validator); + AikenSymbolInfo info = new AikenSymbolInfo(name, AikenSymbolType.VALIDATOR, + inputData.getFile().getPath(), true, validatorType); + result.put(name, info); + } + } + + // Index constants + Collection constants = PsiTreeUtil.findChildrenOfType(psiFile, AikenConstantStatement.class); + for (AikenConstantStatement constant : constants) { + String name = extractConstantName(constant); + if (name != null) { + boolean isPublic = isPublicConstant(constant); + AikenSymbolInfo info = new AikenSymbolInfo(name, AikenSymbolType.CONSTANT, + inputData.getFile().getPath(), isPublic, ""); + result.put(name, info); + } + } + + return result; + } + }; + } + + @NotNull + @Override + public KeyDescriptor getKeyDescriptor() { + return EnumeratorStringDescriptor.INSTANCE; + } + + @NotNull + @Override + public DataExternalizer getValueExternalizer() { + return new DataExternalizer() { + @Override + public void save(@NotNull DataOutput out, AikenSymbolInfo value) throws IOException { + out.writeUTF(value.getName()); + out.writeInt(value.getType().ordinal()); + out.writeUTF(value.getFilePath()); + out.writeBoolean(value.isPublic()); + out.writeUTF(value.getSignature() != null ? value.getSignature() : ""); + } + + @Override + public AikenSymbolInfo read(@NotNull DataInput in) throws IOException { + String name = in.readUTF(); + AikenSymbolType type = AikenSymbolType.values()[in.readInt()]; + String filePath = in.readUTF(); + boolean isPublic = in.readBoolean(); + String signature = in.readUTF(); + return new AikenSymbolInfo(name, type, filePath, isPublic, signature.isEmpty() ? null : signature); + } + }; + } + + @Override + public int getVersion() { + return 1; + } + + @NotNull + @Override + public FileBasedIndex.InputFilter getInputFilter() { + return new DefaultFileTypeSpecificInputFilter(com.bloxbean.intelliada.idea.aiken.lang.AikenFileType.INSTANCE); + } + + @Override + public boolean dependsOnFileContent() { + return true; + } + + // Utility methods for symbol retrieval + public static Collection getAllSymbols(@NotNull Project project) { + return FileBasedIndex.getInstance().getAllKeys(NAME, project).stream() + .flatMap(key -> FileBasedIndex.getInstance().getValues(NAME, key, GlobalSearchScope.projectScope(project)).stream()) + .toList(); + } + + public static Collection getSymbolsByName(@NotNull String name, @NotNull Project project) { + return FileBasedIndex.getInstance().getValues(NAME, name, GlobalSearchScope.projectScope(project)); + } + + public static Collection getPublicSymbols(@NotNull Project project) { + return getAllSymbols(project).stream() + .filter(AikenSymbolInfo::isPublic) + .toList(); + } + + public static Collection getSymbolsByType(@NotNull AikenSymbolType type, @NotNull Project project) { + return getAllSymbols(project).stream() + .filter(symbol -> symbol.getType() == type) + .toList(); + } + + // Helper methods for extraction + private String extractFunctionName(AikenFunctionStatement function) { + String text = function.getText(); + if (text.contains("fn ")) { + String[] parts = text.split("\\s+"); + for (int i = 0; i < parts.length - 1; i++) { + if ("fn".equals(parts[i])) { + return parts[i + 1].split("\\(")[0]; + } + } + } + return null; + } + + private String extractTypeName(AikenTypeStatement type) { + String text = type.getText(); + if (text.contains("type ")) { + String[] parts = text.split("\\s+"); + for (int i = 0; i < parts.length - 1; i++) { + if ("type".equals(parts[i])) { + return parts[i + 1].split("[<{]")[0]; + } + } + } + return null; + } + + private String extractValidatorName(AikenValidatorStatement validator) { + String text = validator.getText(); + if (text.contains("validator ")) { + String[] parts = text.split("\\s+"); + for (int i = 0; i < parts.length - 1; i++) { + if ("validator".equals(parts[i])) { + return parts[i + 1].split("[({]")[0]; + } + } + } + return null; + } + + private String extractConstantName(AikenConstantStatement constant) { + String text = constant.getText(); + if (text.contains("const ")) { + String[] parts = text.split("\\s+"); + for (int i = 0; i < parts.length - 1; i++) { + if ("const".equals(parts[i])) { + return parts[i + 1].split("=")[0].trim(); + } + } + } + return null; + } + + private boolean isPublicFunction(AikenFunctionStatement function) { + return function.getText().contains("pub fn"); + } + + private boolean isPublicType(AikenTypeStatement type) { + return type.getText().contains("pub type"); + } + + private boolean isPublicConstant(AikenConstantStatement constant) { + return constant.getText().contains("pub const"); + } + + private String extractFunctionSignature(AikenFunctionStatement function) { + String text = function.getText(); + int start = text.indexOf("("); + int end = text.indexOf(")", start); + if (start != -1 && end != -1) { + return text.substring(start, end + 1); + } + return ""; + } + + private String extractValidatorType(AikenValidatorStatement validator) { + String text = validator.getText(); + // Look for validator purposes: spend, mint, withdraw, etc. + String[] purposes = {"spend", "mint", "withdraw", "publish", "vote", "propose"}; + for (String purpose : purposes) { + if (text.contains(purpose)) { + return purpose; + } + } + return "spend"; // default + } + + private List extractTypeConstructors(AikenTypeStatement type) { + List constructors = new ArrayList<>(); + String text = type.getText(); + + // Simple extraction of constructor names (could be improved) + if (text.contains("{") && text.contains("}")) { + String body = text.substring(text.indexOf("{") + 1, text.lastIndexOf("}")); + String[] lines = body.split("\n"); + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty() && Character.isUpperCase(line.charAt(0))) { + String constructor = line.split("[({\\s]")[0]; + if (!constructor.isEmpty()) { + constructors.add(constructor); + } + } + } + } + + return constructors; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolInfo.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolInfo.java new file mode 100644 index 0000000..cd99880 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolInfo.java @@ -0,0 +1,86 @@ +package com.bloxbean.intelliada.idea.aiken.index; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Information about an Aiken symbol for indexing and completion + */ +public class AikenSymbolInfo { + private final String name; + private final AikenSymbolType type; + private final String filePath; + private final boolean isPublic; + private final String signature; + + public AikenSymbolInfo(@NotNull String name, + @NotNull AikenSymbolType type, + @NotNull String filePath, + boolean isPublic, + @Nullable String signature) { + this.name = name; + this.type = type; + this.filePath = filePath; + this.isPublic = isPublic; + this.signature = signature; + } + + @NotNull + public String getName() { + return name; + } + + @NotNull + public AikenSymbolType getType() { + return type; + } + + @NotNull + public String getFilePath() { + return filePath; + } + + public boolean isPublic() { + return isPublic; + } + + @Nullable + public String getSignature() { + return signature; + } + + @Override + public String toString() { + return "AikenSymbolInfo{" + + "name='" + name + '\'' + + ", type=" + type + + ", filePath='" + filePath + '\'' + + ", isPublic=" + isPublic + + ", signature='" + signature + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + AikenSymbolInfo that = (AikenSymbolInfo) o; + + if (isPublic != that.isPublic) return false; + if (!name.equals(that.name)) return false; + if (type != that.type) return false; + if (!filePath.equals(that.filePath)) return false; + return signature != null ? signature.equals(that.signature) : that.signature == null; + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + type.hashCode(); + result = 31 * result + filePath.hashCode(); + result = 31 * result + (isPublic ? 1 : 0); + result = 31 * result + (signature != null ? signature.hashCode() : 0); + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolType.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolType.java new file mode 100644 index 0000000..cf218c3 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/index/AikenSymbolType.java @@ -0,0 +1,30 @@ +package com.bloxbean.intelliada.idea.aiken.index; + +/** + * Types of symbols that can be indexed in Aiken files + */ +public enum AikenSymbolType { + FUNCTION("function"), + TYPE("type"), + CONSTRUCTOR("constructor"), + VALIDATOR("validator"), + CONSTANT("constant"), + VARIABLE("variable"), + TEST("test"), + BENCHMARK("benchmark"); + + private final String displayName; + + AikenSymbolType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + @Override + public String toString() { + return displayName; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/Aiken.bnf b/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/Aiken.bnf index ee66619..d7f3e46 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/Aiken.bnf +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/Aiken.bnf @@ -40,6 +40,7 @@ tokens = [ OR = "or" AS = "as" VIA = "via" + BENCHMARK = "benchmark" MINT = "mint" SPEND = "spend" @@ -59,7 +60,9 @@ tokens = [ DOC_COMMENT = "regexp:///([^\n]*)?" MODULE_COMMENT = "regexp:////([^\n]*)?" - NUMBER = "regexp:[0-9]*" + NUMBER = "regexp:[0-9]+" + HEX_NUMBER = "regexp:0x[0-9a-fA-F]+" + BYTE_STRING = "regexp:#\"([^\\\\\\\\\\\\\\\\\\\"\\\\\\\\]|\\\\\\\\\\\\\\\\[^efnrt\\\\\\\"\\\\\\\\\\\\\\\\])*\"" STRING_CONTENT = "regexp:\"([^\\\\\\\"]|\\\\[^efnrt\\\"\\\\])+\"" // TODO improve matching for string content // Chars @@ -71,16 +74,27 @@ tokens = [ RPAREN = ')' COLON = ':' + SEMICOLON = ';' + QUESTION = '?' + HASH = '#' COMMA = ',' EQ = '=' EQEQ = '==' + NE = '!=' + LE = '<=' + GE = '>=' BANG = '!' PLUS = '+' MINUS = '-' + PLUSDOT = '+.' + MINUSDOT = '-.' + STARDOT = '*.' + SLASHDOT = '/.' + PERCENT = '%' OR = '||' AND = '&&' @@ -97,10 +111,12 @@ tokens = [ FAT_ARROW = '=>' ARROW = '->' + BACK_ARROW = '<-' QUOTE = '"' PIPE = '|>' + BITWISE_OR = '|' ] } @@ -110,7 +126,11 @@ private topLevelDefinition ::= | typeStatement | validatorStatement | functionStatement + | testStatement + | benchmarkStatement | COMMENT + | DOC_COMMENT + | MODULE_COMMENT | ([PUB] constantStatement) private innerDefinition ::= @@ -145,7 +165,31 @@ variableStatement ::= LET IDENTIFIER EQ variableValue constantStatement ::= CONST IDENTIFIER EQ variableValue // TODO parse value inside string -variableValue ::= functionCall | UPPER_IDENTIFIER | typeIdentifier | IDENTIFIER | NUMBER | STRING_CONTENT +variableValue ::= whenExpression | ifExpression | functionCall | listExpression | tupleExpression | recordExpression | UPPER_IDENTIFIER | typeIdentifier | IDENTIFIER | NUMBER | HEX_NUMBER | BYTE_STRING | STRING_CONTENT | booleanLiteral + +booleanLiteral ::= "True" | "False" + +listExpression ::= LBRACK [variableValue (COMMA variableValue)*] RBRACK + +tupleExpression ::= LPAREN variableValue (COMMA variableValue)+ RPAREN + +recordExpression ::= UPPER_IDENTIFIER LBRACE [recordField (COMMA recordField)*] RBRACE +recordField ::= IDENTIFIER COLON variableValue + +ifExpression ::= IF variableValue LBRACE [innerDefinition*] RBRACE [ELSE LBRACE [innerDefinition*] RBRACE] + +whenExpression ::= WHEN variableValue IS LBRACE whenClause* RBRACE +whenClause ::= pattern ARROW variableValue + +pattern ::= IDENTIFIER | UPPER_IDENTIFIER | listPattern | tuplePattern | recordPattern | literalPattern +listPattern ::= LBRACK [pattern (COMMA pattern)*] RBRACK +tuplePattern ::= LPAREN pattern (COMMA pattern)+ RPAREN +recordPattern ::= UPPER_IDENTIFIER LBRACE [patternField (COMMA patternField)*] RBRACE +patternField ::= IDENTIFIER [COLON pattern] +literalPattern ::= NUMBER | HEX_NUMBER | STRING_CONTENT | BYTE_STRING | booleanLiteral + +testStatement ::= TEST IDENTIFIER LBRACE [innerDefinition*] RBRACE +benchmarkStatement ::= BENCHMARK IDENTIFIER LBRACE [innerDefinition*] RBRACE functionCall ::= [IDENTIFIER DOT] IDENTIFIER LPAREN [functionCallParam*] RPAREN [PIPE functionCall] functionCallParam ::= ((IDENTIFIER COLON variableValue) | variableValue)[COMMA] diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/_AikenLexer.flex b/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/_AikenLexer.flex index 7a59415..80bb331 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/_AikenLexer.flex +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/grammar/_AikenLexer.flex @@ -30,7 +30,9 @@ UPPER_IDENTIFIER=[A-Z][_0-9a-zA-Z]* COMMENT="//"([^\n]*)? DOC_COMMENT="///"([^\n]*)? MODULE_COMMENT="////"([^\n]*)? -NUMBER=[0-9]* +NUMBER=[0-9]+ +HEX_NUMBER=0x[0-9a-fA-F]+ +BYTE_STRING=#\"([^\\\\\\\\\\\\\\\\\\\"\\\\\\\\]|\\\\\\\\\\\\\\\\[^efnrt\\\\\\\"\\\\\\\\\\\\\\\\])*\" STRING_CONTENT=\"([^\\\\\\\"]|\\\\[^efnrt\\\"\\\\])+\" %% @@ -60,6 +62,7 @@ STRING_CONTENT=\"([^\\\\\\\"]|\\\\[^efnrt\\\"\\\\])+\" "or" { return OR; } "as" { return AS; } "via" { return VIA; } + "benchmark" { return BENCHMARK; } "{" { return LBRACE; } @@ -69,12 +72,23 @@ STRING_CONTENT=\"([^\\\\\\\"]|\\\\[^efnrt\\\"\\\\])+\" "(" { return LPAREN; } ")" { return RPAREN; } ":" { return COLON; } + ";" { return SEMICOLON; } + "?" { return QUESTION; } + "#" { return HASH; } "," { return COMMA; } "=" { return EQ; } "==" { return EQEQ; } + "!=" { return NE; } + "<=" { return LE; } + ">=" { return GE; } "!" { return BANG; } "+" { return PLUS; } "-" { return MINUS; } + "+." { return PLUSDOT; } + "-." { return MINUSDOT; } + "*." { return STARDOT; } + "/." { return SLASHDOT; } + "%" { return PERCENT; } "||" { return OR; } "&&" { return AND; } "<" { return LT; } @@ -86,15 +100,19 @@ STRING_CONTENT=\"([^\\\\\\\"]|\\\\[^efnrt\\\"\\\\])+\" ".." { return DOTDOT; } "=>" { return FAT_ARROW; } "->" { return ARROW; } + "<-" { return BACK_ARROW; } "\"" { return QUOTE; } "|>" { return PIPE; } + "|" { return BITWISE_OR; } {IDENTIFIER} { return IDENTIFIER; } {UPPER_IDENTIFIER} { return UPPER_IDENTIFIER; } {COMMENT} { return COMMENT; } {DOC_COMMENT} { return DOC_COMMENT; } {MODULE_COMMENT} { return MODULE_COMMENT; } + {HEX_NUMBER} { return HEX_NUMBER; } {NUMBER} { return NUMBER; } + {BYTE_STRING} { return BYTE_STRING; } {STRING_CONTENT} { return STRING_CONTENT; } } diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/psi/impl/AikenPsiImplUtil.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/psi/impl/AikenPsiImplUtil.java index dae66bd..cdb41ab 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/psi/impl/AikenPsiImplUtil.java +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/lang/psi/impl/AikenPsiImplUtil.java @@ -1,4 +1,88 @@ package com.bloxbean.intelliada.idea.aiken.lang.psi.impl; +import com.bloxbean.intelliada.idea.aiken.lang.psi.*; +import com.intellij.psi.PsiElement; +import com.intellij.psi.util.PsiTreeUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + public class AikenPsiImplUtil { + + /** + * Get the name of an identifier element + */ + @Nullable + public static String getName(@NotNull PsiElement element) { + // For now, just return the text content + return element.getText(); + } + + /** + * Get all imports in the current file + */ + @NotNull + public static List getImports(@NotNull PsiElement context) { + AikenFile file = (AikenFile) context.getContainingFile(); + return PsiTreeUtil.findChildrenOfType(file, AikenImportStatement.class) + .stream().toList(); + } + + /** + * Get all function definitions in the current file + */ + @NotNull + public static List getFunctions(@NotNull PsiElement context) { + AikenFile file = (AikenFile) context.getContainingFile(); + return PsiTreeUtil.findChildrenOfType(file, AikenFunctionStatement.class) + .stream().toList(); + } + + /** + * Get all type definitions in the current file + */ + @NotNull + public static List getTypes(@NotNull PsiElement context) { + AikenFile file = (AikenFile) context.getContainingFile(); + return PsiTreeUtil.findChildrenOfType(file, AikenTypeStatement.class) + .stream().toList(); + } + + /** + * Get all validators in the current file + */ + @NotNull + public static List getValidators(@NotNull PsiElement context) { + AikenFile file = (AikenFile) context.getContainingFile(); + return PsiTreeUtil.findChildrenOfType(file, AikenValidatorStatement.class) + .stream().toList(); + } + + /** + * Check if the element is inside a validator context + */ + public static boolean isInValidatorContext(@NotNull PsiElement element) { + return PsiTreeUtil.getParentOfType(element, AikenValidatorStatement.class) != null; + } + + /** + * Check if the element is inside an import statement + */ + public static boolean isInImportContext(@NotNull PsiElement element) { + return PsiTreeUtil.getParentOfType(element, AikenImportStatement.class) != null; + } + + /** + * Get the module path from an import statement + */ + @Nullable + public static String getModulePath(@NotNull AikenImportStatement importStatement) { + AikenImportStatementElement element = importStatement.getImportStatementElement(); + if (element != null) { + return element.getText(); + } + return null; + } } diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/navigation/AikenGotoDeclarationHandler.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/navigation/AikenGotoDeclarationHandler.java new file mode 100644 index 0000000..5928906 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/navigation/AikenGotoDeclarationHandler.java @@ -0,0 +1,293 @@ +package com.bloxbean.intelliada.idea.aiken.navigation; + +import com.bloxbean.intelliada.idea.aiken.service.AikenStdlibService; +import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler; +import com.intellij.openapi.actionSystem.DataContext; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.OpenFileDescriptor; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.Nullable; + +import java.io.File; + +/** + * Direct navigation handler for Aiken imports - bypasses reference resolution + */ +public class AikenGotoDeclarationHandler implements GotoDeclarationHandler { + + @Override + public PsiElement @Nullable [] getGotoDeclarationTargets(@Nullable PsiElement sourceElement, + int offset, + Editor editor) { + System.out.println("DEBUG: AikenGotoDeclarationHandler called for element: " + + (sourceElement != null ? sourceElement.getText() : "null")); + + if (sourceElement == null) return null; + + // Only handle .ak files + PsiFile file = sourceElement.getContainingFile(); + if (file == null || !file.getName().endsWith(".ak")) { + return null; + } + + System.out.println("DEBUG: Processing Aiken file: " + file.getName()); + + // Extract module path and specific function name from the import context + ImportInfo importInfo = extractImportInfoFromContext(sourceElement); + if (importInfo.modulePath == null) { + System.out.println("DEBUG: No module path found"); + return null; + } + + System.out.println("DEBUG: Extracted module path: " + importInfo.modulePath); + System.out.println("DEBUG: Target function/type: " + importInfo.targetName); + + // Find the module file + AikenStdlibService stdlibService = AikenStdlibService.getInstance(sourceElement.getProject()); + File moduleFile = stdlibService.findModuleFile(importInfo.modulePath); + + if (moduleFile != null && moduleFile.exists()) { + System.out.println("DEBUG: Found module file: " + moduleFile.getAbsolutePath()); + + // Convert to PSI element + VirtualFile virtualFile = VfsUtil.findFileByIoFile(moduleFile, true); + if (virtualFile != null) { + PsiManager psiManager = PsiManager.getInstance(sourceElement.getProject()); + PsiFile psiFile = psiManager.findFile(virtualFile); + if (psiFile != null) { + System.out.println("DEBUG: Successfully created PSI file"); + + // If we have a specific target name, try to find it within the file + if (importInfo.targetName != null) { + PsiElement specificTarget = findSpecificTargetElement(psiFile, importInfo.targetName); + if (specificTarget != null) { + System.out.println("DEBUG: Found specific target element: " + importInfo.targetName); + return new PsiElement[] { specificTarget }; + } + } + + // Fallback to file-level navigation + System.out.println("DEBUG: Using file-level navigation"); + return new PsiElement[] { psiFile }; + } + } + } + + System.out.println("DEBUG: No navigation target found"); + return null; + } + + private static class ImportInfo { + String modulePath; + String targetName; // specific function/type name, null for module-level imports + + ImportInfo(String modulePath, String targetName) { + this.modulePath = modulePath; + this.targetName = targetName; + } + } + + private ImportInfo extractImportInfoFromContext(PsiElement element) { + // Get the file text and check if this element is part of a use statement + PsiFile file = element.getContainingFile(); + if (file == null) return new ImportInfo(null, null); + + String fileText = file.getText(); + int elementOffset = element.getTextOffset(); + String elementText = element.getText(); + + // Find the line containing the element + String[] lines = fileText.split("\n"); + int currentOffset = 0; + + for (String line : lines) { + int lineEnd = currentOffset + line.length(); + if (elementOffset >= currentOffset && elementOffset <= lineEnd) { + // This line contains our element - check if it's a use statement + String trimmedLine = line.trim(); + if (trimmedLine.startsWith("use ")) { + return extractImportInfoFromLine(trimmedLine, elementText); + } + break; + } + currentOffset = lineEnd + 1; // +1 for newline + } + + return new ImportInfo(null, null); + } + + private ImportInfo extractImportInfoFromLine(String line, String elementText) { + System.out.println("DEBUG: Extracting import info from line: '" + line + "', element: '" + elementText + "'"); + + // Extract module path first + String modulePath = extractStdlibPath(line); + if (modulePath == null) { + return new ImportInfo(null, null); + } + + // Check if the element text corresponds to a specific function/type import + String targetName = null; + + // Check if this is a specific import (has .{...}) + if (line.contains(".{") && line.contains("}")) { + int startBrace = line.indexOf(".{"); + int endBrace = line.indexOf("}", startBrace); + if (startBrace != -1 && endBrace != -1) { + String importList = line.substring(startBrace + 2, endBrace); + // Check if our element text is one of the imported names + String[] imports = importList.split(","); + for (String importName : imports) { + String cleanImport = importName.trim(); + if (cleanImport.equals(elementText)) { + targetName = elementText; + break; + } + } + } + } + + System.out.println("DEBUG: Extracted - modulePath: '" + modulePath + "', targetName: '" + targetName + "'"); + return new ImportInfo(modulePath, targetName); + } + + private PsiElement findSpecificTargetElement(PsiFile psiFile, String targetName) { + System.out.println("DEBUG: Searching for target '" + targetName + "' in file: " + psiFile.getName()); + + String fileContent = psiFile.getText(); + + // Search for function definitions: "pub fn targetName(" or "fn targetName(" + String[] patterns = { + "pub fn " + targetName + "\\s*\\(", + "fn " + targetName + "\\s*\\(", + "pub type " + targetName + "\\s*[={]", + "type " + targetName + "\\s*[={]", + "pub const " + targetName + "\\s*[=:]", + "const " + targetName + "\\s*[=:]" + }; + + for (String pattern : patterns) { + int index = findPatternIndex(fileContent, pattern); + if (index != -1) { + System.out.println("DEBUG: Found target using pattern: " + pattern + " at index: " + index); + + // Find the exact position of the target name + int targetNameIndex = findTargetNameInPattern(fileContent, targetName, index); + if (targetNameIndex != -1) { + // Get PSI element at the target name position + PsiElement elementAtOffset = psiFile.findElementAt(targetNameIndex); + if (elementAtOffset != null) { + System.out.println("DEBUG: Found PSI element at target name: " + elementAtOffset.getText()); + + // Walk up to find a meaningful parent element (usually the function/type declaration) + PsiElement parent = elementAtOffset; + int maxWalkUp = 5; // Limit to prevent infinite loops + while (parent != null && maxWalkUp > 0) { + String parentText = parent.getText(); + // Look for a parent that contains the full declaration + if (parentText.contains(targetName) && + (parentText.contains("fn ") || parentText.contains("type ") || parentText.contains("const "))) { + System.out.println("DEBUG: Using parent element: " + parent.getClass().getSimpleName()); + return parent; + } + parent = parent.getParent(); + maxWalkUp--; + if (parent == psiFile) break; // Don't go beyond the file + } + + // Fallback to the original element + return elementAtOffset; + } + } + } + } + + System.out.println("DEBUG: Target '" + targetName + "' not found in file"); + return null; + } + + private int findTargetNameInPattern(String content, String targetName, int patternStart) { + // Find the actual position of the target name within the matched pattern + try { + // Look for the target name starting from the pattern match + String substring = content.substring(patternStart, Math.min(content.length(), patternStart + 100)); + int nameIndex = substring.indexOf(targetName); + if (nameIndex != -1) { + return patternStart + nameIndex; + } + } catch (Exception e) { + System.out.println("DEBUG: Error finding target name position: " + e.getMessage()); + } + return -1; + } + + private int findPatternIndex(String content, String regex) { + try { + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(regex); + java.util.regex.Matcher matcher = pattern.matcher(content); + if (matcher.find()) { + return matcher.start(); + } + } catch (Exception e) { + System.out.println("DEBUG: Regex error for pattern '" + regex + "': " + e.getMessage()); + } + return -1; + } + + private String extractModulePathFromContext(PsiElement element) { + // Get the file text and check if this element is part of a use statement + PsiFile file = element.getContainingFile(); + if (file == null) return null; + + String fileText = file.getText(); + int elementOffset = element.getTextOffset(); + + // Find the line containing the element + String[] lines = fileText.split("\n"); + int currentOffset = 0; + + for (String line : lines) { + int lineEnd = currentOffset + line.length(); + if (elementOffset >= currentOffset && elementOffset <= lineEnd) { + // This line contains our element - check if it's a use statement + String trimmedLine = line.trim(); + if (trimmedLine.startsWith("use ")) { + return extractStdlibPath(trimmedLine); + } + break; + } + currentOffset = lineEnd + 1; // +1 for newline + } + + return null; + } + + private String extractStdlibPath(String text) { + // Extract module path from use statements - generic for any package + if (text.contains("use ")) { + int useIndex = text.indexOf("use "); + String afterUse = text.substring(useIndex + 4).trim(); + + // Remove trailing syntax + if (afterUse.contains(".{")) { + afterUse = afterUse.substring(0, afterUse.indexOf(".{")); + } + if (afterUse.contains(" ")) { + afterUse = afterUse.substring(0, afterUse.indexOf(" ")); + } + if (afterUse.contains("\"")) { + afterUse = afterUse.substring(0, afterUse.indexOf("\"")); + } + + String result = afterUse.trim(); + System.out.println("DEBUG: Extracted module path: '" + result + "'"); + return result; + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/navigation/AikenStdlibLineMarkerProvider.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/navigation/AikenStdlibLineMarkerProvider.java new file mode 100644 index 0000000..30cbd69 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/navigation/AikenStdlibLineMarkerProvider.java @@ -0,0 +1,95 @@ +package com.bloxbean.intelliada.idea.aiken.navigation; + +import com.bloxbean.intelliada.idea.aiken.service.AikenStdlibService; +import com.intellij.codeInsight.daemon.LineMarkerInfo; +import com.intellij.codeInsight.daemon.LineMarkerProvider; +import com.intellij.codeInsight.navigation.NavigationGutterIconBuilder; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.editor.markup.GutterIconRenderer; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.util.Collection; +import java.util.List; + +/** + * Provides line markers for stdlib module imports to enable navigation + */ +public class AikenStdlibLineMarkerProvider implements LineMarkerProvider { + + @Override + public @Nullable LineMarkerInfo getLineMarkerInfo(@NotNull PsiElement element) { + // Check if this element is a stdlib module import + String text = element.getText(); + + if (isStdlibModuleImport(element, text)) { + String modulePath = extractModulePath(text); + if (modulePath != null) { + AikenStdlibService stdlibService = AikenStdlibService.getInstance(element.getProject()); + File moduleFile = stdlibService.findModuleFile(modulePath); + + if (moduleFile != null && moduleFile.exists()) { + return NavigationGutterIconBuilder.create(AllIcons.Gutter.ExtAnnotation) + .setTarget(element) + .setTooltipText("Go to " + modulePath + " source") + .createLineMarkerInfo(element); + } + } + } + + return null; + } + + private boolean isStdlibModuleImport(PsiElement element, String text) { + // Check if this is part of a use statement + PsiElement parent = element.getParent(); + while (parent != null) { + String parentText = parent.getText(); + if (parentText.contains("use ") && (text.contains("aiken/") || text.contains("cardano/"))) { + return true; + } + parent = parent.getParent(); + } + return false; + } + + private String extractModulePath(String text) { + // Remove quotes if present + if (text.startsWith("\"") && text.endsWith("\"")) { + text = text.substring(1, text.length() - 1); + } + + // Extract module path + if (text.contains("aiken/") || text.contains("cardano/")) { + // Handle cases like "aiken/math" or "use aiken/math.{abs}" + int aikenIndex = text.indexOf("aiken/"); + int cardanoIndex = text.indexOf("cardano/"); + + int startIndex = -1; + if (aikenIndex != -1 && cardanoIndex != -1) { + startIndex = Math.min(aikenIndex, cardanoIndex); + } else if (aikenIndex != -1) { + startIndex = aikenIndex; + } else if (cardanoIndex != -1) { + startIndex = cardanoIndex; + } + + if (startIndex != -1) { + String modulePath = text.substring(startIndex); + // Remove any trailing syntax like ".{" or spaces + if (modulePath.contains(".{")) { + modulePath = modulePath.substring(0, modulePath.indexOf(".{")); + } + if (modulePath.contains(" ")) { + modulePath = modulePath.substring(0, modulePath.indexOf(" ")); + } + return modulePath.trim(); + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibFunctionReference.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibFunctionReference.java new file mode 100644 index 0000000..dfeee24 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibFunctionReference.java @@ -0,0 +1,143 @@ +package com.bloxbean.intelliada.idea.aiken.reference; + +import com.bloxbean.intelliada.idea.aiken.service.AikenStdlibService; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.*; +import com.intellij.util.IncorrectOperationException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; + +/** + * Reference implementation for Aiken standard library function calls + */ +public class AikenStdlibFunctionReference extends PsiReferenceBase { + + private final String functionName; + private final String modulePath; + + public AikenStdlibFunctionReference(@NotNull PsiElement element) { + super(element); + String[] moduleAndFunction = extractModuleAndFunction(element); + this.modulePath = moduleAndFunction[0]; + this.functionName = moduleAndFunction[1]; + } + + @Override + public @Nullable PsiElement resolve() { + if (modulePath == null || functionName == null) { + return null; + } + + // Use the stdlib service to find the function in the module file + AikenStdlibService stdlibService = AikenStdlibService.getInstance(getElement().getProject()); + PsiElement functionElement = stdlibService.findFunctionInModule(modulePath, functionName); + + return functionElement; + } + + @Override + public PsiElement handleElementRename(@NotNull String newElementName) throws IncorrectOperationException { + // For now, we don't support renaming stdlib functions + throw new IncorrectOperationException("Cannot rename standard library functions"); + } + + @Override + public @NotNull String getCanonicalText() { + return functionName != null ? functionName : ""; + } + + @Override + public @NotNull TextRange getRangeInElement() { + return TextRange.from(0, getElement().getTextLength()); + } + + private String[] extractModuleAndFunction(PsiElement element) { + String text = element.getText(); + PsiElement parent = element.getParent(); + + // Look for qualified function calls like "list.map", "math.abs", etc. + if (parent != null) { + String parentText = parent.getText(); + + // Check for patterns like "module.function" + if (parentText.contains(".")) { + String[] parts = parentText.split("\\."); + if (parts.length >= 2) { + String moduleAlias = parts[0].trim(); + String functionName = parts[1].trim(); + + // Map common aliases to full module paths + String fullModulePath = mapAliasToModulePath(moduleAlias); + + return new String[]{fullModulePath, functionName}; + } + } + } + + // Look for import context to determine module + String contextModule = findModuleFromImportContext(element); + if (contextModule != null) { + return new String[]{contextModule, text}; + } + + return new String[]{null, null}; + } + + private String mapAliasToModulePath(String alias) { + // Map common aliases to full module paths + return switch (alias) { + case "list" -> "aiken/collection/list"; + case "dict" -> "aiken/collection/dict"; + case "math" -> "aiken/math"; + case "string" -> "aiken/string"; + case "option" -> "aiken/option"; + case "result" -> "aiken/result"; + case "crypto" -> "aiken/crypto"; + case "address" -> "cardano/address"; + case "transaction" -> "cardano/transaction"; + case "assets" -> "cardano/assets"; + default -> alias; // Return as-is for unknown aliases + }; + } + + private String findModuleFromImportContext(PsiElement element) { + // Walk up the PSI tree to find import statements + PsiElement current = element; + while (current != null) { + if (current instanceof PsiFile) { + // Search for import statements in the file + String fileText = current.getText(); + // Look for patterns like "use aiken/math.{abs, max}" + if (fileText.contains("use ")) { + String[] lines = fileText.split("\n"); + for (String line : lines) { + if (line.trim().startsWith("use ") && line.contains(".{")) { + String modulePath = extractModulePathFromImport(line); + if (modulePath != null && line.contains(element.getText())) { + return modulePath; + } + } + } + } + break; + } + current = current.getParent(); + } + return null; + } + + private String extractModulePathFromImport(String importLine) { + // Extract module path from "use aiken/math.{abs, max}" -> "aiken/math" + String trimmed = importLine.trim(); + if (trimmed.startsWith("use ")) { + String afterUse = trimmed.substring(4).trim(); + int braceIndex = afterUse.indexOf(".{"); + if (braceIndex > 0) { + return afterUse.substring(0, braceIndex).trim(); + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibModuleReference.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibModuleReference.java new file mode 100644 index 0000000..543d9ee --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibModuleReference.java @@ -0,0 +1,183 @@ +package com.bloxbean.intelliada.idea.aiken.reference; + +import com.bloxbean.intelliada.idea.aiken.service.AikenStdlibService; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.*; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.util.IncorrectOperationException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; + +/** + * Reference implementation for Aiken standard library module imports + */ +public class AikenStdlibModuleReference extends PsiReferenceBase { + + private final String modulePath; + + public AikenStdlibModuleReference(@NotNull PsiElement element) { + super(element); + this.modulePath = extractModulePath(element); + System.out.println("DEBUG: AikenStdlibModuleReference created for element '" + element.getText() + "' with modulePath: " + modulePath); + } + + @Override + public @Nullable PsiElement resolve() { + System.out.println("DEBUG: AikenStdlibModuleReference.resolve() called for element: '" + getElement().getText() + "'"); + if (modulePath == null) { + System.out.println("DEBUG: modulePath is null for element: " + getElement().getText()); + return null; + } + + System.out.println("DEBUG: Resolving module path: " + modulePath); + + // Use the stdlib service to find the module file + AikenStdlibService stdlibService = AikenStdlibService.getInstance(getElement().getProject()); + File moduleFile = stdlibService.findModuleFile(modulePath); + + System.out.println("DEBUG: Found module file: " + (moduleFile != null ? moduleFile.getAbsolutePath() : "null")); + + if (moduleFile != null && moduleFile.exists()) { + // Convert file to PSI element + PsiManager psiManager = PsiManager.getInstance(getElement().getProject()); + com.intellij.openapi.vfs.VirtualFile virtualFile = com.intellij.openapi.vfs.VfsUtil.findFileByIoFile(moduleFile, true); + System.out.println("DEBUG: Virtual file: " + virtualFile); + if (virtualFile != null) { + PsiFile psiFile = psiManager.findFile(virtualFile); + System.out.println("DEBUG: PSI file: " + psiFile); + return psiFile; + } + } + + return null; + } + + @Override + public PsiElement handleElementRename(@NotNull String newElementName) throws IncorrectOperationException { + // For now, we don't support renaming stdlib modules + throw new IncorrectOperationException("Cannot rename standard library modules"); + } + + @Override + public @NotNull String getCanonicalText() { + return modulePath != null ? modulePath : ""; + } + + @Override + public @NotNull TextRange getRangeInElement() { + return TextRange.from(0, getElement().getTextLength()); + } + + private String extractModulePath(PsiElement element) { + String text = element.getText(); + System.out.println("DEBUG: Extracting module path from text: '" + text + "'"); + + // Remove quotes if present + if (text.startsWith("\"") && text.endsWith("\"")) { + text = text.substring(1, text.length() - 1); + } + + // First, try to find the specific use statement that contains this element + String containingLine = findContainingUseLine(element); + if (containingLine != null) { + System.out.println("DEBUG: Found containing use line: " + containingLine); + return extractStdlibPath(containingLine); + } + + // Fallback: Direct module path + if (looksLikeModulePath(text)) { + String extracted = extractStdlibPath(text); + System.out.println("DEBUG: Extracted direct path: " + extracted); + return extracted; + } + + // Look in surrounding context for use statements + PsiElement parent = element.getParent(); + while (parent != null) { + String parentText = parent.getText(); + if (parentText.contains("use ")) { + String extracted = extractStdlibPath(parentText); + System.out.println("DEBUG: Extracted from parent: " + extracted); + return extracted; + } + parent = parent.getParent(); + } + + System.out.println("DEBUG: No module path found"); + return null; + } + + private String findContainingUseLine(PsiElement element) { + // Get the file text and find which line contains the cursor + PsiFile file = element.getContainingFile(); + if (file == null) return null; + + String fileText = file.getText(); + int elementOffset = element.getTextOffset(); + + // Find the line containing the element + String[] lines = fileText.split("\n"); + int currentOffset = 0; + + for (String line : lines) { + int lineEnd = currentOffset + line.length(); + if (elementOffset >= currentOffset && elementOffset <= lineEnd) { + // This line contains our element + if (line.trim().startsWith("use ")) { + return line.trim(); + } + break; + } + currentOffset = lineEnd + 1; // +1 for newline + } + + return null; + } + + private boolean looksLikeModulePath(String text) { + // Check if text looks like a module path (could be from any package) + return text.contains("/") || // paths like "aiken/math", "cocktail/vodka_inputs" + text.matches("^[a-zA-Z][a-zA-Z0-9_]*$"); // simple names like "mocktail" + } + + private String extractStdlibPath(String text) { + // Extract module path from use statements - now generic for any package + if (text.contains("use ")) { + int useIndex = text.indexOf("use "); + String afterUse = text.substring(useIndex + 4).trim(); + + // Remove trailing syntax + if (afterUse.contains(".{")) { + afterUse = afterUse.substring(0, afterUse.indexOf(".{")); + } + if (afterUse.contains(" ")) { + afterUse = afterUse.substring(0, afterUse.indexOf(" ")); + } + if (afterUse.contains("\"")) { + afterUse = afterUse.substring(0, afterUse.indexOf("\"")); + } + return afterUse.trim(); + } + + // Fallback: try to find any module-like path + String cleaned = text.trim(); + if (cleaned.contains(".{")) { + cleaned = cleaned.substring(0, cleaned.indexOf(".{")); + } + if (cleaned.contains(" ")) { + cleaned = cleaned.substring(0, cleaned.indexOf(" ")); + } + if (cleaned.contains("\"")) { + cleaned = cleaned.substring(0, cleaned.indexOf("\"")); + } + + // Check if it looks like a module path (contains letters/numbers/slashes) + if (cleaned.matches("[a-zA-Z0-9_/\\-]+")) { + return cleaned; + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibReferenceContributor.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibReferenceContributor.java new file mode 100644 index 0000000..e3eecb5 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/reference/AikenStdlibReferenceContributor.java @@ -0,0 +1,197 @@ +package com.bloxbean.intelliada.idea.aiken.reference; + +import com.bloxbean.intelliada.idea.aiken.lang.AikenLanguage; +import com.intellij.openapi.util.TextRange; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.*; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; + +/** + * Reference contributor for Aiken standard library modules and functions + */ +public class AikenStdlibReferenceContributor extends PsiReferenceContributor { + + @Override + public void registerReferenceProviders(@NotNull PsiReferenceRegistrar registrar) { + // Register reference provider for ALL elements in Aiken files - we'll filter in the provider + registrar.registerReferenceProvider( + PlatformPatterns.psiElement(), + new AikenStdlibReferenceProvider() + ); + } + + private static class AikenStdlibReferenceProvider extends PsiReferenceProvider { + @Override + public PsiReference @NotNull [] getReferencesByElement(@NotNull PsiElement element, + @NotNull ProcessingContext context) { + // Only process Aiken files + PsiFile file = element.getContainingFile(); + if (file == null || !file.getName().endsWith(".ak")) { + return PsiReference.EMPTY_ARRAY; + } + + String text = element.getText(); + System.out.println("DEBUG: AikenStdlibReferenceContributor checking element: '" + text + "', class: " + element.getClass().getSimpleName() + ", file: " + file.getName()); + + // Be very aggressive about detecting potential references + // Check if element is in import context first (highest priority) + if (isInStdlibImportContext(element)) { + System.out.println("DEBUG: Found element in stdlib import context: " + text); + AikenStdlibModuleReference ref = new AikenStdlibModuleReference(element); + System.out.println("DEBUG: Created reference: " + ref); + return new PsiReference[] { ref }; + } + + // Check direct module reference (any text that looks like a module path) + if (looksLikeModulePath(text)) { + System.out.println("DEBUG: Found direct module reference in: " + text); + AikenStdlibModuleReference ref = new AikenStdlibModuleReference(element); + System.out.println("DEBUG: Created reference: " + ref); + return new PsiReference[] { ref }; + } + + // Check if this could be part of a module path even if not obvious + if (couldBePartOfModulePath(element)) { + System.out.println("DEBUG: Element could be part of module path: " + text); + AikenStdlibModuleReference ref = new AikenStdlibModuleReference(element); + System.out.println("DEBUG: Created reference: " + ref); + return new PsiReference[] { ref }; + } + + System.out.println("DEBUG: No reference created for: " + text); + return PsiReference.EMPTY_ARRAY; + } + + private boolean isInStdlibImportContext(PsiElement element) { + // Get the file text and check if this element is part of a use statement + PsiFile file = element.getContainingFile(); + if (file == null) return false; + + String fileText = file.getText(); + int elementOffset = element.getTextOffset(); + + // Find the line containing the element + String[] lines = fileText.split("\n"); + int currentOffset = 0; + + for (String line : lines) { + int lineEnd = currentOffset + line.length(); + if (elementOffset >= currentOffset && elementOffset <= lineEnd) { + // This line contains our element - check if it's a use statement + String trimmedLine = line.trim(); + if (trimmedLine.startsWith("use ")) { + return true; + } + break; + } + currentOffset = lineEnd + 1; // +1 for newline + } + + // Fallback: Walk up the tree looking for use statements + PsiElement current = element; + while (current != null) { + String text = current.getText(); + if (text.contains("use ")) { + return true; + } + current = current.getParent(); + } + return false; + } + + private boolean looksLikeModulePath(String text) { + // Check if text looks like a module path (could be from any package) + text = text.trim(); + + // Paths with slashes like "aiken/math", "cocktail/vodka_inputs" + if (text.contains("/")) { + return true; + } + + // Simple module names like "mocktail" - but be more careful + // Must be alphanumeric with underscores, no spaces, and reasonable length + if (text.matches("^[a-zA-Z][a-zA-Z0-9_]{1,30}$")) { + return true; + } + + return false; + } + + private boolean couldBePartOfModulePath(PsiElement element) { + String text = element.getText().trim(); + + // Check if element text could be a module name part + if (text.matches("^[a-zA-Z][a-zA-Z0-9_]*$") && text.length() > 1) { + // Check if this element is on a line that contains "use" + PsiFile file = element.getContainingFile(); + if (file == null) return false; + + String fileText = file.getText(); + int elementOffset = element.getTextOffset(); + + // Find the line containing the element + String[] lines = fileText.split("\n"); + int currentOffset = 0; + + for (String line : lines) { + int lineEnd = currentOffset + line.length(); + if (elementOffset >= currentOffset && elementOffset <= lineEnd) { + // This line contains our element + return line.contains("use "); + } + currentOffset = lineEnd + 1; // +1 for newline + } + } + + return false; + } + + private boolean isStdlibModuleReference(String text) { + // Check if this is a stdlib module path (keeping for compatibility) + return text.contains("aiken/") || text.contains("cardano/"); + } + + private boolean isStdlibFunctionReference(PsiElement element) { + // Check if this is a qualified function call like "list.map" or "math.abs" + String text = element.getText(); + PsiElement parent = element.getParent(); + + // Look for patterns like "module.function" in the context + if (parent != null) { + String parentText = parent.getText(); + if (parentText.contains(".")) { + // Check for common stdlib module aliases + return parentText.matches(".*\\b(list|dict|math|string|option|result|crypto|address|transaction|assets)\\.\\w+.*"); + } + } + + // Also check if this element is in an import context with specific exports + return isInImportExportContext(element); + } + + private boolean isInImportExportContext(PsiElement element) { + // Walk up the tree to find import statements + PsiElement current = element; + while (current != null) { + if (current instanceof PsiFile) { + String fileText = current.getText(); + // Look for import statements that include this element + if (fileText.contains("use ") && fileText.contains(".{")) { + // Check if this element is part of an import export list + String elementText = element.getText(); + String[] lines = fileText.split("\n"); + for (String line : lines) { + if (line.contains("use ") && line.contains(".{") && line.contains(elementText)) { + return true; + } + } + } + break; + } + current = current.getParent(); + } + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/service/AikenPackageService.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/service/AikenPackageService.java new file mode 100644 index 0000000..79df949 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/service/AikenPackageService.java @@ -0,0 +1,231 @@ +package com.bloxbean.intelliada.idea.aiken.service; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Stream; + +/** + * Service to discover and manage Aiken packages dynamically + * Supports both stdlib (aiken-lang-stdlib) and third-party packages + */ +public class AikenPackageService { + + private static final String BUILD_PACKAGES_PATH = "build/packages"; + private static final String STDLIB_PACKAGE_NAME = "aiken-lang-stdlib"; + + private final Project project; + private Map packageRegistry = new HashMap<>(); + private boolean registryInitialized = false; + + public AikenPackageService(Project project) { + this.project = project; + } + + public static AikenPackageService getInstance(Project project) { + return project.getService(AikenPackageService.class); + } + + /** + * Find a module file by import path (e.g., "aiken/math", "mocktail", "sidan/utils") + */ + @Nullable + public File findModuleFile(@NotNull String importPath) { + ensureRegistryInitialized(); + + System.out.println("DEBUG: Looking for import path: " + importPath); + + // Try different strategies to resolve the import path + + // Strategy 1: Direct package match (e.g., "mocktail" -> look in all packages for mocktail.ak) + if (!importPath.contains("/")) { + File moduleFile = findDirectModule(importPath); + if (moduleFile != null) { + System.out.println("DEBUG: Found direct module: " + moduleFile.getAbsolutePath()); + return moduleFile; + } + } + + // Strategy 2: Stdlib path (e.g., "aiken/math" -> aiken-lang-stdlib/lib/aiken/math.ak) + if (importPath.startsWith("aiken/") || importPath.startsWith("cardano/")) { + File stdlibFile = findStdlibModule(importPath); + if (stdlibFile != null) { + System.out.println("DEBUG: Found stdlib module: " + stdlibFile.getAbsolutePath()); + return stdlibFile; + } + } + + // Strategy 3: Third-party package path (e.g., "sidan/utils" -> look in sidan-lab-* packages) + File thirdPartyFile = findThirdPartyModule(importPath); + if (thirdPartyFile != null) { + System.out.println("DEBUG: Found third-party module: " + thirdPartyFile.getAbsolutePath()); + return thirdPartyFile; + } + + System.out.println("DEBUG: Module not found for import path: " + importPath); + return null; + } + + private File findDirectModule(String moduleName) { + String moduleFileName = moduleName + ".ak"; + + // Search in all package lib directories + for (PackageInfo packageInfo : packageRegistry.values()) { + File moduleFile = new File(packageInfo.libPath, moduleFileName); + if (moduleFile.exists()) { + return moduleFile; + } + + // Also search in subdirectories (e.g., lib/mocktail/mocktail.ak) + File subdirFile = new File(packageInfo.libPath, moduleName + "/" + moduleFileName); + if (subdirFile.exists()) { + return subdirFile; + } + } + + return null; + } + + private File findStdlibModule(String importPath) { + PackageInfo stdlibPackage = packageRegistry.get(STDLIB_PACKAGE_NAME); + if (stdlibPackage == null) { + return null; + } + + String filePath = importPath + ".ak"; + File moduleFile = new File(stdlibPackage.libPath, filePath); + + return moduleFile.exists() ? moduleFile : null; + } + + private File findThirdPartyModule(String importPath) { + // For paths like "sidan/utils", try to find in packages starting with "sidan" + String[] parts = importPath.split("/", 2); + if (parts.length >= 1) { + String packagePrefix = parts[0]; + String remainingPath = parts.length > 1 ? parts[1] : ""; + + // Look for packages that might contain this module + for (Map.Entry entry : packageRegistry.entrySet()) { + String packageName = entry.getKey(); + PackageInfo packageInfo = entry.getValue(); + + // Skip stdlib + if (STDLIB_PACKAGE_NAME.equals(packageName)) { + continue; + } + + // Check if package name contains the prefix (e.g., sidan-lab-vodka contains "sidan") + if (packageName.toLowerCase().contains(packagePrefix.toLowerCase())) { + String filePath = remainingPath.isEmpty() ? + packagePrefix + ".ak" : + remainingPath + ".ak"; + + File moduleFile = new File(packageInfo.libPath, filePath); + if (moduleFile.exists()) { + return moduleFile; + } + + // Also try without the prefix + if (!remainingPath.isEmpty()) { + File altFile = new File(packageInfo.libPath, remainingPath + ".ak"); + if (altFile.exists()) { + return altFile; + } + } + } + } + } + + return null; + } + + private void ensureRegistryInitialized() { + if (!registryInitialized) { + discoverPackages(); + registryInitialized = true; + } + } + + private void discoverPackages() { + String projectPath = project.getBasePath(); + if (projectPath == null) { + System.out.println("DEBUG: Project path is null, cannot discover packages"); + return; + } + + File packagesDir = new File(projectPath, BUILD_PACKAGES_PATH); + if (!packagesDir.exists() || !packagesDir.isDirectory()) { + System.out.println("DEBUG: Packages directory not found: " + packagesDir.getAbsolutePath()); + return; + } + + System.out.println("DEBUG: Discovering packages in: " + packagesDir.getAbsolutePath()); + + File[] packageDirs = packagesDir.listFiles(File::isDirectory); + if (packageDirs != null) { + for (File packageDir : packageDirs) { + String packageName = packageDir.getName(); + File libDir = new File(packageDir, "lib"); + + if (libDir.exists() && libDir.isDirectory()) { + PackageInfo packageInfo = new PackageInfo(packageName, packageDir.getAbsolutePath(), libDir.getAbsolutePath()); + packageRegistry.put(packageName, packageInfo); + System.out.println("DEBUG: Registered package: " + packageName + " -> " + libDir.getAbsolutePath()); + } else { + System.out.println("DEBUG: Package " + packageName + " has no lib directory"); + } + } + } + + System.out.println("DEBUG: Discovered " + packageRegistry.size() + " packages"); + } + + /** + * Get all available packages + */ + public Map getAllPackages() { + ensureRegistryInitialized(); + return new HashMap<>(packageRegistry); + } + + /** + * Refresh the package registry (useful after aiken build) + */ + public void refreshPackages() { + packageRegistry.clear(); + registryInitialized = false; + ensureRegistryInitialized(); + } + + /** + * Information about a discovered package + */ + public static class PackageInfo { + public final String name; + public final String packagePath; + public final String libPath; + + public PackageInfo(String name, String packagePath, String libPath) { + this.name = name; + this.packagePath = packagePath; + this.libPath = libPath; + } + + @Override + public String toString() { + return "PackageInfo{name='" + name + "', libPath='" + libPath + "'}"; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/aiken/service/AikenStdlibService.java b/src/main/java/com/bloxbean/intelliada/idea/aiken/service/AikenStdlibService.java new file mode 100644 index 0000000..342150b --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/aiken/service/AikenStdlibService.java @@ -0,0 +1,163 @@ +package com.bloxbean.intelliada.idea.aiken.service; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.psi.search.FilenameIndex; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.psi.util.PsiTreeUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Service to handle Aiken standard library file resolution and function lookup + */ +public class AikenStdlibService { + + private static final String STDLIB_PATH = "build/packages/aiken-lang-stdlib/lib"; + private static final Pattern FUNCTION_PATTERN = Pattern.compile("^pub fn\\s+(\\w+)\\s*\\(", Pattern.MULTILINE); + + private final Project project; + + public AikenStdlibService(Project project) { + this.project = project; + } + + public static AikenStdlibService getInstance(Project project) { + return project.getService(AikenStdlibService.class); + } + + /** + * Find any module file for the given module path (delegates to package service) + */ + @Nullable + public File findModuleFile(@NotNull String modulePath) { + System.out.println("DEBUG: AikenStdlibService delegating to AikenPackageService for: " + modulePath); + + // Use the new package service for dynamic discovery + AikenPackageService packageService = AikenPackageService.getInstance(project); + return packageService.findModuleFile(modulePath); + } + + /** + * Find a specific function in a stdlib module + */ + @Nullable + public PsiElement findFunctionInModule(@NotNull String modulePath, @NotNull String functionName) { + File moduleFile = findModuleFile(modulePath); + if (moduleFile == null || !moduleFile.exists()) { + return null; + } + + try { + // Read the file content + String content = Files.readString(moduleFile.toPath()); + + // Find the function definition + int functionLine = findFunctionLine(content, functionName); + if (functionLine != -1) { + // Convert file to PSI and find the element at the specific line + VirtualFile virtualFile = VfsUtil.findFileByIoFile(moduleFile, true); + if (virtualFile != null) { + PsiManager psiManager = PsiManager.getInstance(project); + PsiFile psiFile = psiManager.findFile(virtualFile); + if (psiFile != null) { + return findElementAtLine(psiFile, functionLine); + } + } + } + } catch (IOException e) { + // Handle error silently + } + + return null; + } + + /** + * Find the line number where a function is defined + */ + private int findFunctionLine(@NotNull String content, @NotNull String functionName) { + String[] lines = content.split("\n"); + Pattern functionPattern = Pattern.compile("^pub fn\\s+" + Pattern.quote(functionName) + "\\s*\\("); + + for (int i = 0; i < lines.length; i++) { + if (functionPattern.matcher(lines[i]).find()) { + return i + 1; // Line numbers are 1-based + } + } + + return -1; + } + + /** + * Find PSI element at a specific line number + */ + @Nullable + private PsiElement findElementAtLine(@NotNull PsiFile psiFile, int lineNumber) { + String[] lines = psiFile.getText().split("\n"); + if (lineNumber <= 0 || lineNumber > lines.length) { + return null; + } + + // Calculate the offset of the line + int offset = 0; + for (int i = 0; i < lineNumber - 1; i++) { + offset += lines[i].length() + 1; // +1 for newline + } + + // Find the element at that offset + PsiElement element = psiFile.findElementAt(offset); + if (element != null) { + // Try to find the function declaration element + PsiElement parent = element.getParent(); + while (parent != null) { + String parentText = parent.getText(); + if (parentText.contains("pub fn")) { + return parent; + } + parent = parent.getParent(); + } + return element; + } + + return null; + } + + /** + * Get all available stdlib modules + */ + @NotNull + public List getAllStdlibModules() { + return List.of( + "aiken/builtin", "aiken/cbor", "aiken/fuzz", "aiken/primitive/bytearray", + "aiken/primitive/int", "aiken/primitive/string", + "aiken/collection/dict", "aiken/collection/list", "aiken/collection/pairs", + "aiken/option", "aiken/result", "aiken/ordering", + "aiken/string", "aiken/bytearray", "aiken/math", "aiken/interval", + "aiken/time", "aiken/rational", + "aiken/crypto", "aiken/hash", "aiken/crypto/bls12_381", "aiken/crypto/ed25519", + "cardano/address", "cardano/assets", "cardano/certificate", "cardano/credential", + "cardano/governance", "cardano/script_context", "cardano/transaction", + "cardano/wallet", "cardano/compatibility" + ); + } + + /** + * Check if a module path is a stdlib module + */ + public boolean isStdlibModule(@NotNull String modulePath) { + return modulePath.startsWith("aiken/") || modulePath.startsWith("cardano/"); + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/configuration/service/ConfigurationHelperService.java b/src/main/java/com/bloxbean/intelliada/idea/configuration/service/ConfigurationHelperService.java index 2d6a7a6..42aa31d 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/configuration/service/ConfigurationHelperService.java +++ b/src/main/java/com/bloxbean/intelliada/idea/configuration/service/ConfigurationHelperService.java @@ -37,6 +37,7 @@ public static RemoteNode createOrUpdateRemoteNodeConfiguration(Project project, node.setProtocolMagic(nodeConfigDialog.getNodeConfigurator().getProtocolMagic()); node.setHeaders(nodeConfigDialog.getNodeConfigurator().getHeaders()); node.setTimeout(nodeConfigDialog.getNodeConfigurator().getTimeout()); + node.setHome(nodeConfigDialog.getNodeConfigurator().getHome()); if (remoteNode == null) { stateService.addRemoteNode(node); diff --git a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.form b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.form index 83ca12c..e71118c 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.form +++ b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.form @@ -12,6 +12,8 @@ + + @@ -20,11 +22,11 @@ - + - - + + @@ -134,6 +136,15 @@ + + + + + + + + + diff --git a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.java b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.java index 2bf4533..3c5e593 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.java +++ b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/DevKitNodeConfigPanel.java @@ -6,6 +6,10 @@ import com.bloxbean.intelliada.idea.core.util.NetworkUrls; import com.bloxbean.intelliada.idea.core.util.Networks; import com.bloxbean.intelliada.idea.core.util.NodeType; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitDownloader; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitLifecycleService; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitProcessManager; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitStatusMonitor; import com.bloxbean.intelliada.idea.nodeint.service.api.LogListener; import com.bloxbean.intelliada.idea.nodeint.service.api.NetworkInfoService; import com.bloxbean.intelliada.idea.nodeint.service.impl.NetworkServiceImpl; @@ -13,7 +17,10 @@ import com.intellij.openapi.progress.ProgressManager; import com.intellij.openapi.progress.Task; import com.intellij.openapi.progress.impl.BackgroundableProcessIndicator; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.ui.ComboBox; +import com.intellij.openapi.ui.TextFieldWithBrowseButton; import com.intellij.openapi.ui.ValidationInfo; import com.intellij.openapi.util.text.StringUtil; import org.jetbrains.annotations.NotNull; @@ -21,9 +28,17 @@ import javax.swing.*; import java.awt.*; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Map; import java.util.UUID; +import static com.bloxbean.intelliada.idea.core.util.CLIProviderUtil.getDevKitScript; +import static com.bloxbean.intelliada.idea.core.util.CLIProviderUtil.getSuggestedCLIFolder; + //Config panel for YaciDevKit api public class DevKitNodeConfigPanel implements NodeConfigurator { private JPanel mainPanel; @@ -34,6 +49,17 @@ public class DevKitNodeConfigPanel implements NodeConfigurator { private JTextField apiEndpointTf; private JLabel connectionTestLabel; private boolean newConfig = true; + + // Local DevKit management components + private JPanel localDevKitPanel; + private JTextField devKitHomeTf; + private TextFieldWithBrowseButton devKitHomeTfWithBrowserBtn; + private JButton installDevKitBtn; + private JButton startDevKitBtn; + private JButton stopDevKitBtn; + private JLabel devKitStatusLabel; + private JLabel devKitVersionLabel; + private DevKitStatusMonitor statusMonitor; public DevKitNodeConfigPanel() { this(null); @@ -47,6 +73,7 @@ public DevKitNodeConfigPanel(RemoteNode node) { private void initialize(RemoteNode node) { handleNodeTypeSelection(); + initializeLocalDevKitComponents(); testConnectionBtn.addActionListener(e -> { testNetworkConnection(); @@ -60,17 +87,23 @@ public void setNodeData(RemoteNode node) { nameTf.setText(node.getName()); nodeTypesCB.setSelectedItem(node.getNodeType()); apiEndpointTf.setText(node.getApiEndpoint()); -// authKey.setText(node.getAuthKey()); -// networkTf.setText(node.getNetwork()); -// networkIdTf.setText(node.getNetworkId()); protocolMagicTf.setText(node.getProtocolMagic()); + + // Set DevKit home if available + if (!StringUtil.isEmpty(node.getHome())) { + devKitHomeTf.setText(node.getHome()); + } } + + // Update local DevKit panel visibility + updateLocalDevKitPanelVisibility(); } private void handleNodeTypeSelection() { nodeTypesCB.addActionListener(e -> { - apiEndpointTf.setText(NetworkUrls.YACI_DEVKIT_BASEURL); - apiEndpointTf.setEnabled(true); + apiEndpointTf.setText(NetworkUrls.YACI_DEVKIT_BASEURL); + apiEndpointTf.setEnabled(true); + updateLocalDevKitPanelVisibility(); }); } @@ -200,5 +233,249 @@ public void warn(String msg) { private void createUIComponents() { // TODO: place custom component creation code here nodeTypesCB = new ComboBox(new NodeType[]{NodeType.YaciDevKit}); + + // Create local DevKit components (but don't add to mainPanel yet) + createLocalDevKitComponentsOnly(); + } + + private void createLocalDevKitComponentsOnly() { + devKitHomeTf = new JTextField(); + devKitHomeTfWithBrowserBtn = new TextFieldWithBrowseButton(devKitHomeTf, e -> { + JFileChooser fc = new JFileChooser(); + fc.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES); + fc.showDialog(mainPanel, "Select"); + File file = fc.getSelectedFile(); + if (file == null) { + return; + } + + String suggestedFolder = getSuggestedCLIFolder(file.getAbsolutePath()); + devKitHomeTf.setText(suggestedFolder); + checkDevKitInstallation(); + }); + + installDevKitBtn = new JButton("Install DevKit"); + startDevKitBtn = new JButton("Start DevKit"); + stopDevKitBtn = new JButton("Stop DevKit"); + devKitStatusLabel = new JLabel("Status: Not configured"); + devKitVersionLabel = new JLabel("Version: N/A"); + } + + private void initializeLocalDevKitComponents() { + // Set up the local DevKit panel that was created by the form + if (localDevKitPanel != null) { + localDevKitPanel.setLayout(new BoxLayout(localDevKitPanel, BoxLayout.Y_AXIS)); + localDevKitPanel.setBorder(BorderFactory.createTitledBorder("Local DevKit Management")); + + // Add components to local DevKit panel + JPanel pathPanel = new JPanel(new BorderLayout()); + pathPanel.add(new JLabel("DevKit Home:"), BorderLayout.WEST); + pathPanel.add(devKitHomeTfWithBrowserBtn, BorderLayout.CENTER); + localDevKitPanel.add(pathPanel); + + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + buttonPanel.add(installDevKitBtn); + buttonPanel.add(startDevKitBtn); + buttonPanel.add(stopDevKitBtn); + localDevKitPanel.add(buttonPanel); + + JPanel statusPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + statusPanel.add(devKitStatusLabel); + statusPanel.add(devKitVersionLabel); + localDevKitPanel.add(statusPanel); + + localDevKitPanel.setVisible(false); // Initially hidden + } + + devKitHomeTf.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent e) { + checkDevKitInstallation(); + } + }); + + installDevKitBtn.addActionListener(e -> { + String path = devKitHomeTf.getText(); + if (StringUtil.isEmpty(path)) { + devKitStatusLabel.setText("Please select a home directory first"); + devKitStatusLabel.setForeground(Color.RED); + return; + } + + Path installDir = Paths.get(path + File.separator + "yaci-devkit"); + DevKitDownloader devKitDownloader = new DevKitDownloader(installDir); + devKitDownloader.installSDK(); + }); + + startDevKitBtn.addActionListener(e -> { + Project project = getCurrentProject(); + if (project != null) { + String devKitHome = devKitHomeTf.getText(); + if (StringUtil.isEmpty(devKitHome)) { + devKitStatusLabel.setText("Please configure DevKit home directory first"); + devKitStatusLabel.setForeground(Color.RED); + return; + } + + DevKitLifecycleService.getInstance().startDevKit(project, devKitHome) + .thenAccept(success -> { + SwingUtilities.invokeLater(() -> { + if (success) { + updateDevKitStatus(); + updateButtonStates(); + } + }); + }); + } + }); + + stopDevKitBtn.addActionListener(e -> { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService.getInstance().stopDevKit(project) + .thenAccept(success -> { + SwingUtilities.invokeLater(() -> { + if (success) { + updateDevKitStatus(); + updateButtonStates(); + } + }); + }); + } + }); + + initializeStatusMonitoring(); + } + + private void updateLocalDevKitPanelVisibility() { + boolean isLocalhost = isLocalhostUrl(apiEndpointTf.getText()); + localDevKitPanel.setVisible(isLocalhost); + + if (isLocalhost) { + updateDevKitStatus(); + updateButtonStates(); + } + } + + private boolean isLocalhostUrl(String url) { + return !StringUtil.isEmpty(url) && + (url.contains("localhost") || url.contains("127.0.0.1")); + } + + private void checkDevKitInstallation() { + String homePath = devKitHomeTf.getText(); + if (StringUtil.isEmpty(homePath)) { + devKitVersionLabel.setText("Version: N/A"); + return; + } + + File devKitScript = new File(homePath + File.separator + "yaci-devkit" + File.separator + getDevKitScript()); + if (devKitScript.exists()) { + try { + String version = com.bloxbean.intelliada.idea.core.util.CLIProviderUtil.getVersionString(homePath + File.separator + "yaci-devkit"); + devKitVersionLabel.setText("Version: " + version); + devKitVersionLabel.setForeground(Color.BLACK); + } catch (Exception e) { + devKitVersionLabel.setText("Version: Error reading version"); + devKitVersionLabel.setForeground(Color.RED); + } + } else { + devKitVersionLabel.setText("Version: DevKit not found"); + devKitVersionLabel.setForeground(Color.RED); + } + } + + private void updateDevKitStatus() { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService service = DevKitLifecycleService.getInstance(); + DevKitProcessManager.DevKitStatus status = service.getDevKitStatus(project); + + String statusText = getStatusDisplayText(status); + devKitStatusLabel.setText("Status: " + statusText); + devKitStatusLabel.setForeground(getStatusColor(status)); + } + } + + private void updateButtonStates() { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService service = DevKitLifecycleService.getInstance(); + DevKitProcessManager.DevKitStatus status = service.getDevKitStatus(project); + + startDevKitBtn.setEnabled(status == DevKitProcessManager.DevKitStatus.STOPPED); + stopDevKitBtn.setEnabled(status == DevKitProcessManager.DevKitStatus.RUNNING); + } + } + + private void initializeStatusMonitoring() { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService service = DevKitLifecycleService.getInstance(); + statusMonitor = service.getStatusMonitor(project); + + if (statusMonitor != null) { + statusMonitor.addStatusChangeListener(new DevKitStatusMonitor.StatusChangeListener() { + @Override + public void onStatusChanged(DevKitProcessManager.DevKitStatus oldStatus, DevKitProcessManager.DevKitStatus newStatus) { + SwingUtilities.invokeLater(() -> { + updateDevKitStatus(); + updateButtonStates(); + }); + } + + @Override + public void onHealthChanged(boolean healthy) { + SwingUtilities.invokeLater(() -> updateDevKitStatus()); + } + }); + } + } + } + + private String getStatusDisplayText(DevKitProcessManager.DevKitStatus status) { + switch (status) { + case STOPPED: + return "Stopped"; + case STARTING: + return "Starting..."; + case RUNNING: + return "Running"; + case STOPPING: + return "Stopping..."; + case ERROR: + return "Error"; + default: + return "Unknown"; + } + } + + private Color getStatusColor(DevKitProcessManager.DevKitStatus status) { + switch (status) { + case RUNNING: + return Color.GREEN.darker(); + case STARTING: + case STOPPING: + return Color.ORANGE.darker(); + case ERROR: + return Color.RED; + case STOPPED: + default: + return Color.GRAY; + } + } + + private Project getCurrentProject() { + Project[] projects = ProjectManager.getInstance().getOpenProjects(); + return projects.length > 0 ? projects[0] : null; + } + + public String getDevKitHome() { + return devKitHomeTf.getText(); + } + + @Override + public String getHome() { + return getDevKitHome(); } } diff --git a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/LocalYaciDevKitConfigPanel.java b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/LocalYaciDevKitConfigPanel.java index 6f2414b..762da20 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/LocalYaciDevKitConfigPanel.java +++ b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/LocalYaciDevKitConfigPanel.java @@ -4,7 +4,12 @@ import com.bloxbean.intelliada.idea.core.util.CLIProviderUtil; import com.bloxbean.intelliada.idea.core.util.NodeType; import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitDownloader; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitLifecycleService; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitProcessManager; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitStatusMonitor; import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.ui.TextFieldWithBrowseButton; import com.intellij.openapi.util.text.StringUtil; @@ -32,6 +37,10 @@ public class LocalYaciDevKitConfigPanel implements NodeConfigurator { private JTextField restEndpointTf; private JTextField protocolMagicTf; private JTextField homeTf; + private JButton startDevKitBtn; + private JButton stopDevKitBtn; + private JLabel statusLabel; + private DevKitStatusMonitor statusMonitor; public LocalYaciDevKitConfigPanel() { this(null); @@ -56,21 +65,41 @@ public void focusLost(FocusEvent e) { }); initializeListeners(); + initializeStatusMonitoring(); } private void initializeListeners() { installDevKitBtn.addActionListener(e -> { - //Install Yaci DevKit - //TODO - //Download the latest Yaci Devkit zip and extract it to the selected folder - //fomr https://github.com/bloxbean/yaci-devkit/releases - String path = homeTf.getText(); - Path installDir = Paths.get(path + File.separator + "yaci-devkit"); DevKitDownloader devKitDownloader = new DevKitDownloader(installDir); devKitDownloader.installSDK(); }); + + startDevKitBtn.addActionListener(e -> { + Project project = getCurrentProject(); + if (project != null) { + String devKitHome = homeTf.getText(); + DevKitLifecycleService.getInstance().startDevKit(project, devKitHome) + .thenAccept(success -> { + if (success) { + SwingUtilities.invokeLater(() -> updateButtonStates()); + } + }); + } + }); + + stopDevKitBtn.addActionListener(e -> { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService.getInstance().stopDevKit(project) + .thenAccept(success -> { + if (success) { + SwingUtilities.invokeLater(() -> updateButtonStates()); + } + }); + } + }); } public JPanel getMainPanel() { @@ -201,4 +230,96 @@ public JTextField getHomeTf() { return homeTf; } + private void initializeStatusMonitoring() { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService service = DevKitLifecycleService.getInstance(); + statusMonitor = service.getStatusMonitor(project); + + if (statusMonitor != null) { + statusMonitor.addStatusChangeListener(new DevKitStatusMonitor.StatusChangeListener() { + @Override + public void onStatusChanged(DevKitProcessManager.DevKitStatus oldStatus, DevKitProcessManager.DevKitStatus newStatus) { + SwingUtilities.invokeLater(() -> updateStatusDisplay()); + } + + @Override + public void onHealthChanged(boolean healthy) { + SwingUtilities.invokeLater(() -> updateStatusDisplay()); + } + }); + } + } + + updateStatusDisplay(); + updateButtonStates(); + } + + private void updateStatusDisplay() { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService service = DevKitLifecycleService.getInstance(); + DevKitProcessManager.DevKitStatus status = service.getDevKitStatus(project); + + if (statusLabel != null) { + String displayText = getStatusDisplayText(status); + statusLabel.setText(displayText); + statusLabel.setForeground(getStatusColor(status)); + } + } + } + + private void updateButtonStates() { + Project project = getCurrentProject(); + if (project != null) { + DevKitLifecycleService service = DevKitLifecycleService.getInstance(); + DevKitProcessManager.DevKitStatus status = service.getDevKitStatus(project); + + if (startDevKitBtn != null) { + startDevKitBtn.setEnabled(status == DevKitProcessManager.DevKitStatus.STOPPED); + } + + if (stopDevKitBtn != null) { + stopDevKitBtn.setEnabled(status == DevKitProcessManager.DevKitStatus.RUNNING); + } + } + } + + private String getStatusDisplayText(DevKitProcessManager.DevKitStatus status) { + switch (status) { + case STOPPED: + return "Stopped"; + case STARTING: + return "Starting..."; + case RUNNING: + return "Running"; + case STOPPING: + return "Stopping..."; + case ERROR: + return "Error"; + default: + return "Unknown"; + } + } + + private Color getStatusColor(DevKitProcessManager.DevKitStatus status) { + switch (status) { + case RUNNING: + return Color.GREEN.darker(); + case STARTING: + case STOPPING: + return Color.ORANGE.darker(); + case ERROR: + return Color.RED; + case STOPPED: + default: + return Color.GRAY; + } + } + + private Project getCurrentProject() { + Project[] projects = ProjectManager.getInstance().getOpenProjects(); + return projects.length > 0 ? projects[0] : null; + } + } diff --git a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/NodeConfigurator.java b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/NodeConfigurator.java index eff8aa6..9ac4cfa 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/NodeConfigurator.java +++ b/src/main/java/com/bloxbean/intelliada/idea/configuration/ui/NodeConfigurator.java @@ -26,4 +26,8 @@ public interface NodeConfigurator { Map getHeaders(); public int getTimeout(); + + default String getHome() { + return null; + } } diff --git a/src/main/java/com/bloxbean/intelliada/idea/core/util/NodeType.java b/src/main/java/com/bloxbean/intelliada/idea/core/util/NodeType.java index 37ab9a6..b729443 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/core/util/NodeType.java +++ b/src/main/java/com/bloxbean/intelliada/idea/core/util/NodeType.java @@ -13,7 +13,9 @@ public enum NodeType { KOIOS_PREPROD("Koios Preprod"), KOIOS_MAINNET("Koios Mainnet"), KOIOS_CUSTOM("Koios Custom"), - YaciDevKit("Yaci DevKit"); + YaciDevKit("Yaci DevKit"), + LocalYaciDevKit("Local Yaci DevKit"), + Yano("Yano Devnet"); private String displayName; diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/action/DeployValidatorAction.java b/src/main/java/com/bloxbean/intelliada/idea/julc/action/DeployValidatorAction.java new file mode 100644 index 0000000..13698fb --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/action/DeployValidatorAction.java @@ -0,0 +1,196 @@ +package com.bloxbean.intelliada.idea.julc.action; + +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.bloxbean.intelliada.idea.julc.service.BlueprintLoadService; +import com.bloxbean.intelliada.idea.julc.service.PlutusBlueprint; +import com.bloxbean.intelliada.idea.toolwindow.CardanoConsole; +import com.bloxbean.intelliada.idea.util.IdeaUtil; +import com.bloxbean.cardano.client.address.AddressProvider; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.plutus.spec.PlutusV3Script; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.openapi.ui.Messages; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.awt.*; +import java.util.List; + +/** + * Action to deploy a compiled julc validator to the devnet. + * Loads validators from the CIP-57 blueprint and shows a dialog + * for selecting the validator, configuring datum, and submitting. + */ +public class DeployValidatorAction extends AnAction { + + @Override + public void update(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + e.getPresentation().setVisible(false); + return; + } + JulcTomlService tomlService = JulcTomlService.getInstance(project); + boolean isJulc = tomlService != null && tomlService.isJulcProject(); + e.getPresentation().setVisible(isJulc); + e.getPresentation().setEnabled(isJulc); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return; + + PlutusBlueprint blueprint = BlueprintLoadService.getInstance(project).loadBlueprint(); + if (blueprint == null || blueprint.getValidators() == null || blueprint.getValidators().isEmpty()) { + Messages.showWarningDialog(project, + "No compiled validators found. Run 'julc build' or './gradlew build' first.", + "Deploy Validator"); + return; + } + + DeployDialog dialog = new DeployDialog(project, blueprint.getValidators()); + if (dialog.showAndGet()) { + PlutusBlueprint.ValidatorInfo selected = dialog.getSelectedValidator(); + if (selected == null) return; + + try { + PlutusV3Script script = PlutusV3Script.builder() + .cborHex(selected.getCompiledCode()) + .build(); + + String scriptAddress = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32(); + + CardanoConsole console = CardanoConsole.getConsole(project); + console.clearAndshow(); + console.showInfoMessage("[Deploy] Validator: " + selected.getTitle()); + console.showInfoMessage("[Deploy] Script Hash: " + selected.getHash()); + console.showInfoMessage("[Deploy] Script Address: " + scriptAddress); + console.showInfoMessage("[Deploy] Size: " + selected.getSizeBytes() + " bytes"); + console.showSuccessMessage("Script address computed. Use the transaction panel to lock funds at this address."); + + IdeaUtil.showNotification(project, "Deploy Validator", + "Script address: " + scriptAddress, NotificationType.INFORMATION, null); + + } catch (Exception ex) { + CardanoConsole console = CardanoConsole.getConsole(project); + console.showErrorMessage("Failed to compute script address: " + ex.getMessage()); + } + } + } + + @NotNull + @Override + public ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + /** + * Dialog for selecting a validator to deploy. + */ + private static class DeployDialog extends DialogWrapper { + private final List validators; + private JComboBox validatorCombo; + private JLabel hashLabel; + private JLabel sizeLabel; + private JLabel addressLabel; + + protected DeployDialog(@Nullable Project project, List validators) { + super(project); + this.validators = validators; + init(); + setTitle("Deploy Validator"); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setPreferredSize(new Dimension(600, 250)); + GridBagConstraints c = new GridBagConstraints(); + c.anchor = GridBagConstraints.WEST; + c.insets = new Insets(4, 4, 4, 4); + + // Validator selector + c.gridx = 0; c.gridy = 0; + panel.add(new JLabel("Validator:"), c); + + validatorCombo = new JComboBox<>(); + for (PlutusBlueprint.ValidatorInfo v : validators) { + validatorCombo.addItem(v); + } + validatorCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean sel, boolean focus) { + super.getListCellRendererComponent(list, value, index, sel, focus); + if (value instanceof PlutusBlueprint.ValidatorInfo v) { + setText(v.getTitle() + " (" + v.getSizeBytes() + " bytes)"); + } + return this; + } + }); + validatorCombo.addActionListener(e -> updateDetails()); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0; + panel.add(validatorCombo, c); + + // Script hash + c.gridy = 1; c.gridx = 0; c.fill = GridBagConstraints.NONE; c.weightx = 0; + panel.add(new JLabel("Script Hash:"), c); + hashLabel = new JLabel("-"); + hashLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11)); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(hashLabel, c); + + // Size + c.gridy = 2; c.gridx = 0; c.fill = GridBagConstraints.NONE; + panel.add(new JLabel("Size:"), c); + sizeLabel = new JLabel("-"); + c.gridx = 1; + panel.add(sizeLabel, c); + + // Script address + c.gridy = 3; c.gridx = 0; + panel.add(new JLabel("Script Address:"), c); + addressLabel = new JLabel("-"); + addressLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 10)); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(addressLabel, c); + + // Parameterized notice + c.gridy = 4; c.gridx = 0; c.gridwidth = 2; + JLabel notice = new JLabel("Note: For parameterized validators (@Param), apply parameters before deployment."); + notice.setForeground(Color.GRAY); + panel.add(notice, c); + + updateDetails(); + return panel; + } + + private void updateDetails() { + PlutusBlueprint.ValidatorInfo v = getSelectedValidator(); + if (v == null) return; + + hashLabel.setText(v.getHash()); + sizeLabel.setText(v.getSizeBytes() + " bytes"); + + try { + PlutusV3Script script = PlutusV3Script.builder() + .cborHex(v.getCompiledCode()) + .build(); + String addr = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32(); + addressLabel.setText(addr); + } catch (Exception ex) { + addressLabel.setText("(unable to compute)"); + } + } + + public PlutusBlueprint.ValidatorInfo getSelectedValidator() { + return (PlutusBlueprint.ValidatorInfo) validatorCombo.getSelectedItem(); + } + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/action/JulcActionGroup.java b/src/main/java/com/bloxbean/intelliada/idea/julc/action/JulcActionGroup.java new file mode 100644 index 0000000..2a4f606 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/action/JulcActionGroup.java @@ -0,0 +1,35 @@ +package com.bloxbean.intelliada.idea.julc.action; + +import com.bloxbean.intelliada.idea.julc.common.JulcIcons; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +public class JulcActionGroup extends DefaultActionGroup { + + @Override + public void update(@NotNull AnActionEvent event) { + Project project = event.getProject(); + if (project == null) { + event.getPresentation().setVisible(false); + return; + } + + boolean isJulcProject = ReadAction.nonBlocking(() -> { + JulcTomlService tomlService = JulcTomlService.getInstance(project); + return tomlService != null && tomlService.isJulcProject(); + }).executeSynchronously(); + + event.getPresentation().setVisible(isJulcProject); + event.getPresentation().setIcon(JulcIcons.JULC_ICON); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcApiAnnotator.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcApiAnnotator.java new file mode 100644 index 0000000..f6bff51 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcApiAnnotator.java @@ -0,0 +1,136 @@ +package com.bloxbean.intelliada.idea.julc.annotator; + +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.Annotator; +import com.intellij.lang.annotation.HighlightSeverity; +import com.intellij.psi.*; +import org.jetbrains.annotations.NotNull; + +import java.util.Set; + +/** + * PSI-based annotator that flags imports and constructor calls for APIs + * not available on-chain in julc validators. + * + * Two validation layers in IntelliAda: + * 1. julc-compiler bridge (SubsetValidator via shadow JAR) — language subset checks + * 2. This annotator (PSI-based) — API blocklist + @OnchainLibrary checks + * + * Only activates for Java files in julc projects containing validator annotations. + */ +public class JulcApiAnnotator implements Annotator { + + private static final Set BLOCKED_PACKAGES = Set.of( + "java.io", "java.net", "java.nio", "java.sql", "java.awt", + "javax.swing", "javax.net", "javax.crypto", + "java.util.concurrent", "java.util.stream", "java.util.regex", + "java.lang.reflect", "java.lang.invoke", "java.text", "java.time" + ); + + private static final Set BLOCKED_CLASSES = Set.of( + "java.lang.Thread", "java.lang.Runtime", "java.lang.System", + "java.lang.ProcessBuilder", "java.lang.ClassLoader", + "java.util.HashMap", "java.util.LinkedHashMap", "java.util.TreeMap", + "java.util.HashSet", "java.util.LinkedHashSet", "java.util.TreeSet", + "java.util.ArrayList", "java.util.LinkedList", "java.util.ArrayDeque", + "java.util.Collections", "java.util.Arrays", + "java.util.Scanner", "java.util.Random" + ); + + private static final Set ALLOWED_CLASSES = Set.of( + "java.math.BigInteger", "java.lang.String", "java.lang.Boolean", + "java.lang.Integer", "java.lang.Long", "java.lang.Byte", + "java.lang.Object", "java.lang.Comparable", "java.lang.Record", + "java.util.Optional", "java.util.List", "java.util.Map" + ); + + private static final Set JULC_ANNOTATION_NAMES = Set.of( + "Validator", "SpendingValidator", "MintingValidator", "MultiValidator", + "WithdrawValidator", "CertifyingValidator", "VotingValidator", + "ProposingValidator", "OnchainLibrary" + ); + + @Override + public void annotate(@NotNull PsiElement element, @NotNull AnnotationHolder holder) { + // Only in julc projects + JulcTomlService tomlService = JulcTomlService.getInstance(element.getProject()); + if (tomlService == null || !tomlService.isJulcProject()) return; + + // Only in validator files + if (!isInJulcValidatorFile(element)) return; + + if (element instanceof PsiImportStatement importStmt) { + checkImport(importStmt, holder); + } + } + + private void checkImport(PsiImportStatement importStmt, AnnotationHolder holder) { + PsiJavaCodeReferenceElement ref = importStmt.getImportReference(); + if (ref == null) return; + + String qualifiedName = ref.getQualifiedName(); + if (qualifiedName == null) return; + + // Only check java.* and javax.* imports + if (!qualifiedName.startsWith("java.") && !qualifiedName.startsWith("javax.")) return; + + // Allow explicitly permitted classes + if (ALLOWED_CLASSES.contains(qualifiedName)) return; + + // Check blocked specific classes + if (BLOCKED_CLASSES.contains(qualifiedName)) { + holder.newAnnotation(HighlightSeverity.ERROR, + "julc: '" + qualifiedName + "' is not available on-chain") + .tooltip("Not available on-chain

" + + "" + qualifiedName + " cannot be used in julc validators.
" + + getSuggestionHtml(qualifiedName) + "") + .create(); + return; + } + + // Check blocked packages + for (String blockedPkg : BLOCKED_PACKAGES) { + if (qualifiedName.startsWith(blockedPkg + ".") || qualifiedName.equals(blockedPkg)) { + holder.newAnnotation(HighlightSeverity.ERROR, + "julc: '" + blockedPkg + ".*' is not available on-chain") + .tooltip("Not available on-chain

" + + "The " + blockedPkg + " package is not available in the Plutus VM.
" + + getSuggestionHtml(qualifiedName) + "") + .create(); + return; + } + } + } + + private String getSuggestionHtml(String className) { + if (className.contains("HashMap") || className.contains("TreeMap")) + return "Use: julc Map type from julc-ledger-api"; + if (className.contains("ArrayList") || className.contains("LinkedList")) + return "Use: julc List type from julc-ledger-api"; + if (className.contains("HashSet") || className.contains("TreeSet")) + return "Use: List with contains() — sets not available on-chain"; + if (className.contains("io")) + return "Note: File I/O is not available on-chain"; + if (className.contains("Thread") || className.contains("concurrent")) + return "Note: Threading not available — validators are single-threaded"; + if (className.contains("System")) + return "Use: Builtins.trace() for debugging instead of System.out"; + if (className.contains("stream")) + return "Use: julc ListsLib (map, filter, foldl) instead of streams"; + if (className.contains("Random")) + return "Note: Use on-chain randomness from block hash instead"; + return ""; + } + + private boolean isInJulcValidatorFile(PsiElement element) { + PsiFile file = element.getContainingFile(); + if (file == null) return false; + String text = file.getText(); + if (text == null) return false; + for (String ann : JULC_ANNOTATION_NAMES) { + if (text.contains("@" + ann)) return true; + } + return false; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcExternalAnnotator.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcExternalAnnotator.java new file mode 100644 index 0000000..c0ab70b --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcExternalAnnotator.java @@ -0,0 +1,134 @@ +package com.bloxbean.intelliada.idea.julc.annotator; + +import com.bloxbean.intelliada.idea.julc.annotator.fix.ReplaceNullWithOptionalFix; +import com.bloxbean.intelliada.idea.julc.annotator.validate.JulcCompilerBridge; +import com.bloxbean.intelliada.idea.julc.annotator.validate.JulcDiagnostic; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.ExternalAnnotator; +import com.intellij.lang.annotation.HighlightSeverity; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * External annotator that runs julc's SubsetValidator via the runtime classloader bridge. + * + * Validates language subset: try/catch, null, arrays, this/super, float/double, + * method references, functional interface calls, unreachable code, etc. + * + * Uses the real julc-compiler SubsetValidator loaded from the bundled shadow JAR + * (julc-compiler-all.jar) via JulcCompilerBridge. + * + * Only activates for .java files in julc projects containing validator annotations. + */ +public class JulcExternalAnnotator extends ExternalAnnotator> { + + private static final Logger LOG = Logger.getInstance(JulcExternalAnnotator.class); + + public static class Input { + final String text; + final String fileName; + + Input(String text, String fileName) { + this.text = text; + this.fileName = fileName; + } + } + + @Override + public @Nullable Input collectInformation(@NotNull PsiFile file) { + if (file.getVirtualFile() == null || !"java".equalsIgnoreCase(file.getVirtualFile().getExtension())) { + return null; + } + + JulcTomlService tomlService = JulcTomlService.getInstance(file.getProject()); + if (tomlService == null || !tomlService.isJulcProject()) { + return null; + } + + String text = file.getText(); + if (text == null || text.isBlank()) { + return null; + } + + if (!containsJulcAnnotation(text)) { + return null; + } + + return new Input(text, file.getVirtualFile().getName()); + } + + @Override + public @Nullable List doAnnotate(Input input) { + if (input == null) return null; + + // Use real julc SubsetValidator via runtime classloader bridge + if (JulcCompilerBridge.isAvailable()) { + List diagnostics = JulcCompilerBridge.validate(input.text); + if (!diagnostics.isEmpty()) { + return diagnostics; + } + } else { + LOG.debug("julc-compiler bridge not available — subset validation skipped"); + } + + return Collections.emptyList(); + } + + @Override + public void apply(@NotNull PsiFile file, List diagnostics, @NotNull AnnotationHolder holder) { + if (diagnostics == null || diagnostics.isEmpty()) return; + + Document document = PsiDocumentManager.getInstance(file.getProject()).getDocument(file); + if (document == null) return; + + for (JulcDiagnostic diag : diagnostics) { + int line = diag.line() - 1; + if (line < 0 || line >= document.getLineCount()) continue; + + int lineStart = document.getLineStartOffset(line); + int lineEnd = document.getLineEndOffset(line); + int col = Math.max(0, diag.column() - 1); + int rangeStart = Math.min(lineStart + col, lineEnd); + + TextRange range = new TextRange(rangeStart, lineEnd); + + HighlightSeverity severity = switch (diag.level()) { + case ERROR -> HighlightSeverity.ERROR; + case WARNING -> HighlightSeverity.WARNING; + default -> HighlightSeverity.WEAK_WARNING; + }; + + String tooltip = diag.message(); + if (diag.hasSuggestion()) { + tooltip += "\n\nSuggestion: " + diag.suggestion(); + } + + var builder = holder.newAnnotation(severity, diag.message()) + .range(range) + .tooltip(tooltip); + + if (diag.message().contains("null is not supported")) { + builder = builder.withFix(new ReplaceNullWithOptionalFix(range)); + } + + builder.create(); + } + } + + private static boolean containsJulcAnnotation(String text) { + return text.contains("@Validator") || text.contains("@SpendingValidator") + || text.contains("@MintingValidator") || text.contains("@MultiValidator") + || text.contains("@WithdrawValidator") || text.contains("@CertifyingValidator") + || text.contains("@VotingValidator") || text.contains("@ProposingValidator") + || text.contains("@OnchainLibrary"); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcLineMarkerProvider.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcLineMarkerProvider.java new file mode 100644 index 0000000..ca9f8a3 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcLineMarkerProvider.java @@ -0,0 +1,127 @@ +package com.bloxbean.intelliada.idea.julc.annotator; + +import com.bloxbean.intelliada.idea.julc.common.JulcIcons; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.codeInsight.daemon.GutterIconNavigationHandler; +import com.intellij.codeInsight.daemon.LineMarkerInfo; +import com.intellij.codeInsight.daemon.LineMarkerProvider; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.editor.markup.GutterIconRenderer; +import com.intellij.openapi.actionSystem.ActionManager; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DataContext; +import com.intellij.openapi.actionSystem.impl.SimpleDataContext; +import com.intellij.psi.*; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.util.Set; + +/** + * Gutter icon provider for julc constructs. + * Shows icons next to @Validator classes, @Entrypoint methods, @Param fields, and @OnchainLibrary classes. + * + * Only activates in julc projects. + */ +public class JulcLineMarkerProvider implements LineMarkerProvider { + + private static final Set VALIDATOR_ANNOTATIONS = Set.of( + "Validator", "SpendingValidator", "MintingValidator", "MultiValidator", + "WithdrawValidator", "CertifyingValidator", "VotingValidator", "ProposingValidator" + ); + + @Override + public LineMarkerInfo getLineMarkerInfo(@NotNull PsiElement element) { + // IntelliJ convention: only process leaf elements (PsiIdentifier) + if (!(element instanceof PsiIdentifier)) return null; + + // Only in julc projects + JulcTomlService tomlService = JulcTomlService.getInstance(element.getProject()); + if (tomlService == null || !tomlService.isJulcProject()) return null; + + PsiElement parent = element.getParent(); + + // @Validator / @SpendingValidator / etc. on a class + if (parent instanceof PsiClass psiClass && element.equals(psiClass.getNameIdentifier())) { + if (hasAnyAnnotation(psiClass, VALIDATOR_ANNOTATIONS)) { + return createMarker(element, AllIcons.Nodes.Deploy, + "julc Validator — Click to build", + (e, elt) -> triggerBuild(elt)); + } + if (hasAnnotation(psiClass, "OnchainLibrary")) { + return createMarker(element, AllIcons.Nodes.Library, + "julc On-chain Library — Click to build", + (e, elt) -> triggerBuild(elt)); + } + } + + // @Entrypoint on a method + if (parent instanceof PsiMethod psiMethod && element.equals(psiMethod.getNameIdentifier())) { + if (hasAnnotation(psiMethod, "Entrypoint")) { + return createMarker(element, AllIcons.RunConfigurations.TestState.Run, + "julc Entrypoint — Click to build", + (e, elt) -> triggerBuild(elt)); + } + } + + // @Param on a field + if (parent instanceof PsiField psiField && element.equals(psiField.getNameIdentifier())) { + if (hasAnnotation(psiField, "Param")) { + return createMarker(element, AllIcons.Nodes.Parameter, + "julc @Param — compile-time parameter baked into script hash", + (e, elt) -> triggerBuild(elt)); + } + } + + return null; + } + + private void triggerBuild(PsiElement element) { + AnAction buildAction = ActionManager.getInstance() + .getAction("com.bloxbean.intelliada.idea.julc.compile.action.JulcBuildAction"); + if (buildAction != null) { + DataContext dataContext = SimpleDataContext.getProjectContext(element.getProject()); + AnActionEvent event = AnActionEvent.createFromAnAction(buildAction, null, "JulcGutter", dataContext); + buildAction.actionPerformed(event); + } + } + + private LineMarkerInfo createMarker(PsiElement element, Icon icon, String tooltip, + GutterIconNavigationHandler handler) { + return new LineMarkerInfo<>( + element, + element.getTextRange(), + icon, + e -> tooltip, + handler, + GutterIconRenderer.Alignment.LEFT, + () -> tooltip + ); + } + + private boolean hasAnyAnnotation(PsiModifierListOwner owner, Set annotationNames) { + PsiModifierList modifierList = owner.getModifierList(); + if (modifierList == null) return false; + + for (PsiAnnotation ann : modifierList.getAnnotations()) { + String name = ann.getQualifiedName(); + if (name == null) continue; + for (String target : annotationNames) { + if (name.endsWith(target)) return true; + } + } + return false; + } + + private boolean hasAnnotation(PsiModifierListOwner owner, String annotationSimpleName) { + PsiModifierList modifierList = owner.getModifierList(); + if (modifierList == null) return false; + + for (PsiAnnotation ann : modifierList.getAnnotations()) { + String name = ann.getQualifiedName(); + if (name != null && name.endsWith(annotationSimpleName)) return true; + } + return false; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcOnchainLibraryAnnotator.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcOnchainLibraryAnnotator.java new file mode 100644 index 0000000..8631639 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/JulcOnchainLibraryAnnotator.java @@ -0,0 +1,193 @@ +package com.bloxbean.intelliada.idea.julc.annotator; + +import com.bloxbean.intelliada.idea.julc.annotator.fix.AddOnchainLibraryFix; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.Annotator; +import com.intellij.lang.annotation.HighlightSeverity; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ProjectFileIndex; +import com.intellij.openapi.vfs.JarFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.*; +import com.intellij.psi.util.PsiTreeUtil; +import org.jetbrains.annotations.NotNull; + +import java.util.Set; + +/** + * PSI-based annotator that checks cross-file @OnchainLibrary requirements in julc validators. + * + * When a validator calls a method on another class, that class must be: + * 1. Annotated with @OnchainLibrary (project source) + * 2. From a julc library JAR (has META-INF/plutus-sources/ inside) + * 3. A julc core/stdlib/ledger type + * 4. An allowed Java type (BigInteger, String, etc.) + * + * This annotator uses IntelliJ PSI for cross-file resolution. + * Only activates for Java files in julc projects containing validator annotations. + */ +public class JulcOnchainLibraryAnnotator implements Annotator { + private static final Logger LOG = Logger.getInstance(JulcOnchainLibraryAnnotator.class); + + private static final String ONCHAIN_LIBRARY_FQN = "com.bloxbean.cardano.julc.stdlib.annotation.OnchainLibrary"; + + private static final Set JULC_ANNOTATION_NAMES = Set.of( + "Validator", "SpendingValidator", "MintingValidator", "MultiValidator", + "WithdrawValidator", "CertifyingValidator", "VotingValidator", + "ProposingValidator", "OnchainLibrary" + ); + + private static final Set ALLOWED_JAVA_PREFIXES = Set.of( + "java.math.", "java.lang.String", "java.lang.Boolean", + "java.lang.Integer", "java.lang.Long", "java.lang.Byte", + "java.lang.Object", "java.lang.Comparable", "java.lang.Record", + "java.util.Optional", "java.util.List", "java.util.Map" + ); + + @Override + public void annotate(@NotNull PsiElement element, @NotNull AnnotationHolder holder) { + // Only process method call expressions + if (!(element instanceof PsiMethodCallExpression call)) return; + + // Only in julc projects + Project project = element.getProject(); + JulcTomlService tomlService = JulcTomlService.getInstance(project); + if (tomlService == null || !tomlService.isJulcProject()) return; + + // Only in files that contain julc validator annotations + if (!isInJulcValidatorFile(element)) return; + + PsiMethod method = call.resolveMethod(); + if (method == null) return; + + PsiClass targetClass = method.getContainingClass(); + if (targetClass == null) return; + + String qualifiedName = targetClass.getQualifiedName(); + if (qualifiedName == null) return; + + // Always allow julc types + if (qualifiedName.startsWith("com.bloxbean.cardano.julc.")) return; + + // Always allow permitted Java types + if (isAllowedJavaType(qualifiedName)) return; + + // Same class references are fine + PsiClass thisClass = PsiTreeUtil.getParentOfType(element, PsiClass.class); + if (targetClass.equals(thisClass)) return; + + // Check based on source location + if (isProjectSource(targetClass, project)) { + // Project source class — must have @OnchainLibrary + if (!hasOnchainLibraryAnnotation(targetClass)) { + holder.newAnnotation(HighlightSeverity.ERROR, + "julc: '" + targetClass.getName() + "' needs @OnchainLibrary annotation for on-chain use") + .tooltip("julc compilation will fail

" + + "Class " + qualifiedName + " is called from a validator but is not annotated with @OnchainLibrary.
" + + "julc will not compile this class for on-chain use.

" + + "Fix: Add @OnchainLibrary annotation to " + targetClass.getName() + "") + .withFix(new AddOnchainLibraryFix(targetClass)) + .create(); + } + } else if (isFromJar(targetClass)) { + // JAR class — check if the JAR is a valid julc library + if (!isJulcLibraryJar(targetClass)) { + holder.newAnnotation(HighlightSeverity.WARNING, + "julc: '" + targetClass.getName() + "' is from a non-julc library and may not be available on-chain") + .tooltip("Possibly unavailable on-chain

" + + "Class " + qualifiedName + " comes from a JAR that doesn't contain julc on-chain sources " + + "(no META-INF/plutus-sources/).
It may not be available in the Plutus VM.") + .create(); + } + } + } + + private boolean isInJulcValidatorFile(PsiElement element) { + PsiFile file = element.getContainingFile(); + if (file == null) return false; + + // Quick text check first (fast path) + String text = file.getText(); + if (text == null) return false; + + for (String ann : JULC_ANNOTATION_NAMES) { + if (text.contains("@" + ann)) return true; + } + return false; + } + + private boolean isProjectSource(PsiClass psiClass, Project project) { + VirtualFile vFile = getVirtualFile(psiClass); + if (vFile == null) return false; + + ProjectFileIndex fileIndex = ProjectFileIndex.getInstance(project); + return fileIndex.isInSourceContent(vFile); + } + + private boolean isFromJar(PsiClass psiClass) { + VirtualFile vFile = getVirtualFile(psiClass); + if (vFile == null) return false; + + return vFile.getFileSystem() instanceof JarFileSystem + || vFile.getPath().contains(".jar!"); + } + + private boolean hasOnchainLibraryAnnotation(PsiClass psiClass) { + PsiAnnotation[] annotations = psiClass.getAnnotations(); + for (PsiAnnotation ann : annotations) { + String name = ann.getQualifiedName(); + if (ONCHAIN_LIBRARY_FQN.equals(name)) return true; + // Also check simple name (in case import is present) + if (name != null && name.endsWith("OnchainLibrary")) return true; + } + return false; + } + + private boolean isJulcLibraryJar(PsiClass psiClass) { + VirtualFile vFile = getVirtualFile(psiClass); + if (vFile == null) return false; + + // Navigate to the JAR root + VirtualFile jarRoot = null; + + if (vFile.getFileSystem() instanceof JarFileSystem jarFs) { + jarRoot = jarFs.getLocalByEntry(vFile); + if (jarRoot != null) { + jarRoot = jarFs.getJarRootForLocalFile(jarRoot); + } + } + + // Alternative: parse the path to find the jar root + if (jarRoot == null) { + String path = vFile.getPath(); + int jarSep = path.indexOf(".jar!"); + if (jarSep > 0) { + String jarPath = path.substring(0, jarSep + 4); // include .jar + VirtualFile localFile = com.intellij.openapi.vfs.LocalFileSystem.getInstance() + .findFileByPath(jarPath); + if (localFile != null) { + jarRoot = JarFileSystem.getInstance().getJarRootForLocalFile(localFile); + } + } + } + + if (jarRoot == null) return false; + + // Check for plutus-sources index + return jarRoot.findFileByRelativePath("META-INF/plutus-sources/index.txt") != null; + } + + private boolean isAllowedJavaType(String qualifiedName) { + for (String prefix : ALLOWED_JAVA_PREFIXES) { + if (qualifiedName.startsWith(prefix) || qualifiedName.equals(prefix)) return true; + } + return false; + } + + private VirtualFile getVirtualFile(PsiClass psiClass) { + PsiFile containingFile = psiClass.getContainingFile(); + return containingFile != null ? containingFile.getVirtualFile() : null; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/fix/AddOnchainLibraryFix.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/fix/AddOnchainLibraryFix.java new file mode 100644 index 0000000..28975e1 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/fix/AddOnchainLibraryFix.java @@ -0,0 +1,77 @@ +package com.bloxbean.intelliada.idea.julc.annotator.fix; + +import com.intellij.codeInsight.intention.IntentionAction; +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.*; +import com.intellij.psi.codeStyle.JavaCodeStyleManager; +import com.intellij.util.IncorrectOperationException; +import org.jetbrains.annotations.NotNull; + +/** + * Quick fix that adds @OnchainLibrary annotation to a class referenced from a julc validator. + * Triggered from JulcOnchainLibraryAnnotator when a project class is missing the annotation. + */ +public class AddOnchainLibraryFix implements IntentionAction { + + private static final String ONCHAIN_LIBRARY_FQN = "com.bloxbean.cardano.julc.stdlib.annotation.OnchainLibrary"; + + private final SmartPsiElementPointer targetClassPointer; + private final String className; + + public AddOnchainLibraryFix(@NotNull PsiClass targetClass) { + this.targetClassPointer = SmartPointerManager.createPointer(targetClass); + this.className = targetClass.getName(); + } + + @Override + public @NotNull String getText() { + return "Add @OnchainLibrary to '" + className + "'"; + } + + @Override + public @NotNull String getFamilyName() { + return "julc"; + } + + @Override + public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) { + PsiClass targetClass = targetClassPointer.getElement(); + return targetClass != null && targetClass.isValid(); + } + + @Override + public void invoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException { + PsiClass targetClass = targetClassPointer.getElement(); + if (targetClass == null || !targetClass.isValid()) return; + + PsiFile targetFile = targetClass.getContainingFile(); + if (targetFile == null) return; + + // Add annotation + PsiElementFactory factory = JavaPsiFacade.getElementFactory(project); + PsiAnnotation annotation = factory.createAnnotationFromText("@OnchainLibrary", targetClass); + + PsiModifierList modifierList = targetClass.getModifierList(); + if (modifierList != null) { + modifierList.addBefore(annotation, modifierList.getFirstChild()); + } + + // Add import + if (targetFile instanceof PsiJavaFile javaFile) { + JavaCodeStyleManager.getInstance(project).addImport(javaFile, + JavaPsiFacade.getInstance(project).findClass(ONCHAIN_LIBRARY_FQN, targetClass.getResolveScope())); + } + } + + @Override + public boolean startInWriteAction() { + return true; + } + + @Override + public @NotNull IntentionPreviewInfo generatePreview(@NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) { + return IntentionPreviewInfo.EMPTY; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/fix/ReplaceNullWithOptionalFix.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/fix/ReplaceNullWithOptionalFix.java new file mode 100644 index 0000000..8bb5ae5 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/fix/ReplaceNullWithOptionalFix.java @@ -0,0 +1,68 @@ +package com.bloxbean.intelliada.idea.julc.annotator.fix; + +import com.intellij.codeInsight.intention.IntentionAction; +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import com.intellij.util.IncorrectOperationException; +import org.jetbrains.annotations.NotNull; + +/** + * Quick fix that replaces 'null' with 'Optional.empty()' in julc validators. + * Triggered from JulcExternalAnnotator when null literal is detected. + */ +public class ReplaceNullWithOptionalFix implements IntentionAction { + + private final TextRange range; + + public ReplaceNullWithOptionalFix(@NotNull TextRange range) { + this.range = range; + } + + @Override + public @NotNull String getText() { + return "Replace 'null' with 'Optional.empty()'"; + } + + @Override + public @NotNull String getFamilyName() { + return "julc"; + } + + @Override + public boolean isAvailable(@NotNull Project project, Editor editor, PsiFile file) { + if (editor == null || file == null) return false; + Document doc = editor.getDocument(); + if (range.getEndOffset() > doc.getTextLength()) return false; + + String text = doc.getText(range).trim(); + return text.contains("null"); + } + + @Override + public void invoke(@NotNull Project project, Editor editor, PsiFile file) throws IncorrectOperationException { + Document doc = editor.getDocument(); + String lineText = doc.getText(range); + + // Find the exact 'null' within the range and replace + int nullOffset = lineText.indexOf("null"); + if (nullOffset >= 0) { + int start = range.getStartOffset() + nullOffset; + int end = start + 4; // "null".length() + doc.replaceString(start, end, "Optional.empty()"); + } + } + + @Override + public boolean startInWriteAction() { + return true; + } + + @Override + public @NotNull IntentionPreviewInfo generatePreview(@NotNull Project project, @NotNull Editor editor, @NotNull PsiFile file) { + return IntentionPreviewInfo.EMPTY; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcCompilerBridge.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcCompilerBridge.java new file mode 100644 index 0000000..71b3b29 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcCompilerBridge.java @@ -0,0 +1,211 @@ +package com.bloxbean.intelliada.idea.julc.annotator.validate; + +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.extensions.PluginId; + +import java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Runtime bridge to julc-compiler via isolated URLClassLoader. + * + * The julc-compiler shadow JAR is bundled in lib/julc/ (Java 24+ bytecode). + * The plugin compiles at Java 21 but JBR 25 at runtime can execute it. + * + * Users can override with newer JARs by placing them in ~/.intelliada/julc-libs/ + * + * Falls back gracefully to the local JavaParser-based validators if JARs are missing + * or if the classloader fails (e.g., running on JBR 21). + */ +public class JulcCompilerBridge { + private static final Logger LOG = Logger.getInstance(JulcCompilerBridge.class); + private static final String PLUGIN_ID = "com.bloxbean.intelliada"; + private static final String COMPILER_JAR = "julc-compiler-all.jar"; + private static final String USER_LIB_DIR = System.getProperty("user.home") + + File.separator + ".intelliada" + File.separator + "julc-libs"; + + private static volatile boolean initialized = false; + private static volatile boolean available = false; + static URLClassLoader julcClassLoader; // Package-visible for JulcVmBridge + + // Reflected classes and methods + private static Class staticJavaParserClass; + private static Class subsetValidatorClass; + private static Class compilerDiagnosticClass; + private static Method parseMethod; + private static Method validateMethod; + private static Method levelMethod; + private static Method messageMethod; + private static Method lineMethod; + private static Method columnMethod; + private static Method suggestionMethod; + private static Method hasSuggestionMethod; + + /** + * Initialize the bridge. Safe to call multiple times. + */ + public static synchronized void initialize() { + if (initialized) return; + initialized = true; + + try { + Path compilerJar = resolveJarPath(COMPILER_JAR); + if (compilerJar == null) { + LOG.info("julc-compiler-all.jar not found. Using local validators."); + return; + } + + LOG.info("Loading julc-compiler from: " + compilerJar); + + // Create isolated classloader (parent = null to avoid conflicts) + julcClassLoader = new URLClassLoader( + new URL[]{compilerJar.toUri().toURL()}, + null // isolated from plugin classloader + ); + + // Load classes via reflection + staticJavaParserClass = julcClassLoader.loadClass("com.github.javaparser.StaticJavaParser"); + subsetValidatorClass = julcClassLoader.loadClass("com.bloxbean.cardano.julc.compiler.validate.SubsetValidator"); + compilerDiagnosticClass = julcClassLoader.loadClass("com.bloxbean.cardano.julc.compiler.error.CompilerDiagnostic"); + + // Cache methods + parseMethod = staticJavaParserClass.getMethod("parse", String.class); + validateMethod = subsetValidatorClass.getMethod("validate", julcClassLoader.loadClass("com.github.javaparser.ast.CompilationUnit")); + levelMethod = compilerDiagnosticClass.getMethod("level"); + messageMethod = compilerDiagnosticClass.getMethod("message"); + lineMethod = compilerDiagnosticClass.getMethod("line"); + columnMethod = compilerDiagnosticClass.getMethod("column"); + suggestionMethod = compilerDiagnosticClass.getMethod("suggestion"); + hasSuggestionMethod = compilerDiagnosticClass.getMethod("hasSuggestion"); + + available = true; + LOG.info("julc-compiler bridge initialized successfully"); + + } catch (Exception e) { + LOG.info("julc-compiler bridge not available (expected on JBR < 25): " + e.getMessage()); + available = false; + } + } + + /** + * Validate source code using the real julc SubsetValidator. + * Returns empty list if bridge is not available. + */ + public static List validate(String source) { + if (!available) return List.of(); + + try { + // Parse source with JavaParser (in julc classloader) + Object compilationUnit = parseMethod.invoke(null, source); + + // Create SubsetValidator and call validate() + Object validator = subsetValidatorClass.getDeclaredConstructor().newInstance(); + @SuppressWarnings("unchecked") + List diagnostics = (List) validateMethod.invoke(validator, compilationUnit); + + // Map CompilerDiagnostic → JulcDiagnostic + List results = new ArrayList<>(); + for (Object diag : diagnostics) { + Object level = levelMethod.invoke(diag); + String message = (String) messageMethod.invoke(diag); + int line = (int) lineMethod.invoke(diag); + int column = (int) columnMethod.invoke(diag); + String suggestion = (String) suggestionMethod.invoke(diag); + boolean hasSugg = (boolean) hasSuggestionMethod.invoke(diag); + + JulcDiagnostic.Level diagLevel = mapLevel(level.toString()); + results.add(new JulcDiagnostic(diagLevel, message, "", line, column, + hasSugg ? suggestion : null)); + } + return results; + + } catch (Exception e) { + LOG.debug("julc SubsetValidator call failed: " + e.getMessage()); + return List.of(); + } + } + + public static boolean isAvailable() { + if (!initialized) initialize(); + return available; + } + + /** + * Resolve JAR path: check user override dir first, then plugin lib/julc/. + * Package-visible so JulcVmBridge can reuse. + */ + static Path resolveJarPath(String jarName) { + // 1. Check user override: ~/.intelliada/julc-libs/ + Path userJar = Path.of(USER_LIB_DIR, jarName); + if (Files.exists(userJar)) { + LOG.info("Using user-provided julc JAR: " + userJar); + return userJar; + } + + // 2. Check plugin bundle via PluginManagerCore + try { + IdeaPluginDescriptor plugin = PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID)); + if (plugin != null) { + Path pluginPath = plugin.getPluginPath(); + LOG.info("julc bridge: plugin path = " + pluginPath); + + // julc-runtime/ is outside lib/ to avoid IntelliJ classloader scanning + Path pluginJar = pluginPath.resolve("julc-runtime").resolve(jarName); + if (Files.exists(pluginJar)) { + return pluginJar; + } + + // Legacy paths + pluginJar = pluginPath.resolve("lib").resolve("julc").resolve(jarName); + if (Files.exists(pluginJar)) { + return pluginJar; + } + } + } catch (Exception e) { + LOG.info("julc bridge: plugin path resolution error: " + e.getMessage()); + } + + // 3. Locate via classloader — find our own JAR and look for julc-runtime/ next to lib/ + try { + var url = JulcCompilerBridge.class.getProtectionDomain().getCodeSource().getLocation(); + if (url != null) { + Path ourJar = Path.of(url.toURI()); + // Our class is in plugins/intelliada/lib/intelliada-xxx.jar + // Shadow JARs are in plugins/intelliada/julc-runtime/ + Path pluginDir = ourJar.getParent().getParent(); // plugins/intelliada/ + Path pluginJar = pluginDir.resolve("julc-runtime").resolve(jarName); + LOG.info("julc bridge: classloader-based check: " + pluginJar); + if (Files.exists(pluginJar)) { + return pluginJar; + } + } + } catch (Exception e) { + LOG.info("julc bridge: classloader resolution error: " + e.getMessage()); + } + + // 4. Check working directory (development mode) + Path devJar = Path.of("julc-runtime", jarName); + if (Files.exists(devJar)) { + return devJar; + } + + LOG.info("julc bridge: " + jarName + " not found in any location"); + return null; + } + + private static JulcDiagnostic.Level mapLevel(String level) { + return switch (level) { + case "ERROR" -> JulcDiagnostic.Level.ERROR; + case "WARNING" -> JulcDiagnostic.Level.WARNING; + default -> JulcDiagnostic.Level.INFO; + }; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcDiagnostic.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcDiagnostic.java new file mode 100644 index 0000000..5f15d6f --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcDiagnostic.java @@ -0,0 +1,41 @@ +package com.bloxbean.intelliada.idea.julc.annotator.validate; + +/** + * A diagnostic message with source location and optional suggestion. + * Adapted from julc-compiler's CompilerDiagnostic for use in IntelliAda. + * This class can be removed once julc-compiler is available as a JVM 21 compatible dependency. + */ +public class JulcDiagnostic { + + public enum Level { ERROR, WARNING, INFO } + + private final Level level; + private final String message; + private final String fileName; + private final int line; + private final int column; + private final String suggestion; + + public JulcDiagnostic(Level level, String message, String fileName, int line, int column, String suggestion) { + this.level = level; + this.message = message; + this.fileName = fileName; + this.line = line; + this.column = column; + this.suggestion = suggestion; + } + + public JulcDiagnostic(Level level, String message, String fileName, int line, int column) { + this(level, message, fileName, line, column, null); + } + + public Level level() { return level; } + public String message() { return message; } + public String fileName() { return fileName; } + public int line() { return line; } + public int column() { return column; } + public String suggestion() { return suggestion; } + + public boolean isError() { return level == Level.ERROR; } + public boolean hasSuggestion() { return suggestion != null && !suggestion.isEmpty(); } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcVmBridge.java b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcVmBridge.java new file mode 100644 index 0000000..d080de2 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/annotator/validate/JulcVmBridge.java @@ -0,0 +1,322 @@ +package com.bloxbean.intelliada.idea.julc.annotator.validate; + +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.extensions.PluginId; + +import java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Runtime bridge to julc-compiler (full compilation) and julc-vm (local evaluation). + * + * Uses isolated URLClassLoaders to load Java 24+ bytecode shadow JARs at runtime on JBR 25. + * Provides: + * 1. Full compilation: source → UPLC Program (script hash, size, diagnostics) + * 2. Local evaluation: Program → EvalResult (success/failure, budget, traces) + * + * Shadow JARs are loaded from: + * - ~/.intelliada/julc-libs/ (user override, checked first) + * - Plugin lib/julc/ (bundled default) + */ +public class JulcVmBridge { + private static final Logger LOG = Logger.getInstance(JulcVmBridge.class); + private static final String PLUGIN_ID = "com.bloxbean.intelliada"; + private static final String COMPILER_JAR = "julc-compiler-all.jar"; + private static final String VM_JAR = "julc-vm-java-all.jar"; + private static final String USER_LIB_DIR = System.getProperty("user.home") + + File.separator + ".intelliada" + File.separator + "julc-libs"; + + private static volatile boolean initialized = false; + private static volatile boolean compilerAvailable = false; + private static volatile boolean vmAvailable = false; + private static URLClassLoader compilerClassLoader; + private static URLClassLoader vmClassLoader; + + // Compiler reflection + private static Class julcCompilerClass; + private static Method compileMethod; // compile(String source) -> CompileResult + private static Method hasErrorsMethod; + private static Method diagnosticsMethod; + private static Method scriptSizeBytesMethod; + private static Method programMethod; + private static Method isParameterizedMethod; + private static Method uplcFormattedMethod; + + // VM reflection + private static Class julcVmClass; + private static Method evaluateMethod; + private static Method vmCreateMethod; + + /** + * Compile result accessible from Java 21 code. + */ + public static class CompileInfo { + public final boolean hasErrors; + public final List diagnostics; + public final int scriptSizeBytes; + public final boolean parameterized; + public final String uplcText; + public final Object program; // opaque handle for VM evaluation + + public CompileInfo(boolean hasErrors, List diagnostics, + int scriptSizeBytes, boolean parameterized, String uplcText, Object program) { + this.hasErrors = hasErrors; + this.diagnostics = diagnostics; + this.scriptSizeBytes = scriptSizeBytes; + this.parameterized = parameterized; + this.uplcText = uplcText; + this.program = program; + } + } + + /** + * Evaluation result accessible from Java 21 code. + */ + public static class EvalInfo { + public final boolean success; + public final long cpuSteps; + public final long memoryUnits; + public final List traces; + public final String errorMessage; + + public EvalInfo(boolean success, long cpuSteps, long memoryUnits, List traces, String errorMessage) { + this.success = success; + this.cpuSteps = cpuSteps; + this.memoryUnits = memoryUnits; + this.traces = traces; + this.errorMessage = errorMessage; + } + } + + public static synchronized void initialize() { + if (initialized) return; + initialized = true; + + // Ensure JulcCompilerBridge is initialized first (we reuse its classloader) + JulcCompilerBridge.initialize(); + + initCompiler(); + initVm(); + } + + private static void initCompiler() { + try { + // Reuse JulcCompilerBridge's classloader to share types (especially Program) + compilerClassLoader = JulcCompilerBridge.julcClassLoader; + if (compilerClassLoader == null) { + // Fallback: create our own classloader + Path jar = resolveJar(COMPILER_JAR); + if (jar == null) { + LOG.info("julc-compiler-all.jar not found for VmBridge"); + return; + } + compilerClassLoader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null); + } + + julcCompilerClass = compilerClassLoader.loadClass("com.bloxbean.cardano.julc.compiler.JulcCompiler"); + Class compileResultClass = compilerClassLoader.loadClass("com.bloxbean.cardano.julc.compiler.CompileResult"); + + compileMethod = julcCompilerClass.getMethod("compile", String.class); + hasErrorsMethod = compileResultClass.getMethod("hasErrors"); + diagnosticsMethod = compileResultClass.getMethod("diagnostics"); + scriptSizeBytesMethod = compileResultClass.getMethod("scriptSizeBytes"); + programMethod = compileResultClass.getMethod("program"); + isParameterizedMethod = compileResultClass.getMethod("isParameterized"); + uplcFormattedMethod = compileResultClass.getMethod("uplcFormatted"); + + compilerAvailable = true; + LOG.info("julc VmBridge compiler initialized (shared classloader: " + (JulcCompilerBridge.julcClassLoader != null) + ")"); + } catch (Exception e) { + LOG.info("julc VmBridge compiler not available: " + e.getMessage()); + } + } + + private static void initVm() { + try { + Path jar = resolveJar(VM_JAR); + if (jar == null) { + LOG.info("julc-vm-java-all.jar not found"); + return; + } + + // VM classloader needs compiler classloader as parent for shared types (Program, Term, etc.) + if (compilerClassLoader == null) { + LOG.info("VM requires compiler classloader"); + return; + } + + vmClassLoader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, compilerClassLoader); + + julcVmClass = vmClassLoader.loadClass("com.bloxbean.cardano.julc.vm.JulcVm"); + vmCreateMethod = julcVmClass.getMethod("create"); + + Class programClass = compilerClassLoader.loadClass("com.bloxbean.cardano.julc.core.Program"); + evaluateMethod = julcVmClass.getMethod("evaluate", programClass); + + vmAvailable = true; + LOG.info("julc-vm bridge initialized from: " + jar); + } catch (Exception e) { + LOG.info("julc-vm bridge not available: " + e.getMessage()); + } + } + + /** + * Compile Java source to UPLC using the real julc compiler. + */ + public static CompileInfo compile(String source) { + if (!initialized) initialize(); + if (!compilerAvailable) return null; + + try { + // Create compiler with default stdlib lookup + Object compiler = julcCompilerClass.getDeclaredConstructor().newInstance(); + Object result = compileMethod.invoke(compiler, source); + + boolean hasErrors = (boolean) hasErrorsMethod.invoke(result); + + // Extract diagnostics + @SuppressWarnings("unchecked") + List rawDiags = (List) diagnosticsMethod.invoke(result); + List diagnostics = mapDiagnostics(rawDiags); + + int sizeBytes = 0; + boolean parameterized = false; + String uplcText = null; + Object program = null; + + if (!hasErrors) { + sizeBytes = (int) scriptSizeBytesMethod.invoke(result); + parameterized = (boolean) isParameterizedMethod.invoke(result); + uplcText = (String) uplcFormattedMethod.invoke(result); + program = programMethod.invoke(result); + } + + LOG.info("julc VmBridge compile: hasErrors=" + hasErrors + ", size=" + sizeBytes + + ", sourceLen=" + source.length() + + ", program=" + (program != null ? program.getClass().getName() : "null")); + return new CompileInfo(hasErrors, diagnostics, sizeBytes, parameterized, uplcText, program); + } catch (Exception e) { + LOG.warn("julc compile failed: " + e.getMessage(), e); + return null; + } + } + + /** + * Evaluate a compiled program using the julc VM. + */ + public static EvalInfo evaluate(Object program) { + if (!initialized) initialize(); + LOG.info("julc VmBridge evaluate: vmAvailable=" + vmAvailable + ", program=" + (program != null ? program.getClass().getName() : "null")); + if (!vmAvailable || program == null) return null; + + try { + // Set thread context classloader so ServiceLoader.load() finds JulcVmProvider + ClassLoader originalCL = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(vmClassLoader); + Object vm; + try { + vm = vmCreateMethod.invoke(null); + } finally { + Thread.currentThread().setContextClassLoader(originalCL); + } + Object evalResult = evaluateMethod.invoke(vm, program); + + String className = evalResult.getClass().getSimpleName(); + boolean success = "Success".equals(className); + + long cpu = 0; + long mem = 0; + List traces = new ArrayList<>(); + String errorMessage = null; + + // Extract consumed budget + try { + Method consumedMethod = evalResult.getClass().getMethod("consumed"); + Object budget = consumedMethod.invoke(evalResult); + if (budget != null) { + Method cpuMethod = budget.getClass().getMethod("cpuSteps"); + Method memMethod = budget.getClass().getMethod("memoryUnits"); + cpu = (long) cpuMethod.invoke(budget); + mem = (long) memMethod.invoke(budget); + } + } catch (Exception ex) { + LOG.debug("Budget extraction failed: " + ex.getMessage()); + } + + // Extract traces + try { + Method tracesMethod = evalResult.getClass().getMethod("traces"); + @SuppressWarnings("unchecked") + List traceList = (List) tracesMethod.invoke(evalResult); + if (traceList != null) traces.addAll(traceList); + } catch (Exception ex) { /* traces not available */ } + + // Extract error message for failures + if (!success) { + try { + Method msgMethod = evalResult.getClass().getMethod("message"); + errorMessage = (String) msgMethod.invoke(evalResult); + } catch (Exception ex) { + errorMessage = className; + } + } + + return new EvalInfo(success, cpu, mem, traces, errorMessage); + } catch (Exception e) { + LOG.warn("julc VM evaluation failed: " + e.getMessage(), e); + return null; + } + } + + public static boolean isCompilerAvailable() { + if (!initialized) initialize(); + return compilerAvailable; + } + + public static boolean isVmAvailable() { + if (!initialized) initialize(); + return vmAvailable; + } + + private static List mapDiagnostics(List rawDiags) { + List results = new ArrayList<>(); + for (Object diag : rawDiags) { + try { + Method levelM = diag.getClass().getMethod("level"); + Method msgM = diag.getClass().getMethod("message"); + Method lineM = diag.getClass().getMethod("line"); + Method colM = diag.getClass().getMethod("column"); + Method suggM = diag.getClass().getMethod("suggestion"); + Method hasSuggM = diag.getClass().getMethod("hasSuggestion"); + + String level = levelM.invoke(diag).toString(); + String message = (String) msgM.invoke(diag); + int line = (int) lineM.invoke(diag); + int column = (int) colM.invoke(diag); + boolean hasSugg = (boolean) hasSuggM.invoke(diag); + String suggestion = hasSugg ? (String) suggM.invoke(diag) : null; + + results.add(new JulcDiagnostic( + "ERROR".equals(level) ? JulcDiagnostic.Level.ERROR : + "WARNING".equals(level) ? JulcDiagnostic.Level.WARNING : JulcDiagnostic.Level.INFO, + message, "", line, column, suggestion)); + } catch (Exception e) { + LOG.debug("Failed to map diagnostic: " + e.getMessage()); + } + } + return results; + } + + private static Path resolveJar(String jarName) { + // Use the same resolution logic as JulcCompilerBridge + return JulcCompilerBridge.resolveJarPath(jarName); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/blueprint/JulcBlueprintAction.java b/src/main/java/com/bloxbean/intelliada/idea/julc/blueprint/JulcBlueprintAction.java new file mode 100644 index 0000000..ee70e18 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/blueprint/JulcBlueprintAction.java @@ -0,0 +1,56 @@ +package com.bloxbean.intelliada.idea.julc.blueprint; + +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.DialogWrapper; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +public class JulcBlueprintAction extends AnAction { + + @Override + public void update(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + e.getPresentation().setVisible(false); + return; + } + JulcTomlService tomlService = JulcTomlService.getInstance(project); + boolean isJulc = tomlService != null && tomlService.isJulcProject(); + e.getPresentation().setVisible(isJulc); + e.getPresentation().setEnabled(isJulc); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return; + + DialogWrapper dialog = new DialogWrapper(project) { + { + init(); + setTitle("julc Blueprint Viewer"); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + JulcBlueprintPanel panel = new JulcBlueprintPanel(project); + JPanel main = panel.getMainPanel(); + main.setPreferredSize(new java.awt.Dimension(800, 500)); + return main; + } + }; + dialog.show(); + } + + @NotNull + @Override + public ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/blueprint/JulcBlueprintPanel.java b/src/main/java/com/bloxbean/intelliada/idea/julc/blueprint/JulcBlueprintPanel.java new file mode 100644 index 0000000..7e4198e --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/blueprint/JulcBlueprintPanel.java @@ -0,0 +1,142 @@ +package com.bloxbean.intelliada.idea.julc.blueprint; + +import com.bloxbean.intelliada.idea.julc.service.BlueprintLoadService; +import com.bloxbean.intelliada.idea.julc.service.PlutusBlueprint; +import com.intellij.openapi.project.Project; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; + +/** + * Panel showing compiled validators from the CIP-57 blueprint. + * Displays: title, script hash, size, compiled code. + */ +public class JulcBlueprintPanel { + private final Project project; + private JPanel mainPanel; + private DefaultTableModel tableModel; + private JTable validatorTable; + private JTextArea uplcTextArea; + private PlutusBlueprint blueprint; + + public JulcBlueprintPanel(Project project) { + this.project = project; + initComponents(); + loadBlueprint(); + } + + public JPanel getMainPanel() { + return mainPanel; + } + + private void initComponents() { + mainPanel = new JPanel(new BorderLayout(5, 5)); + mainPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); + + // Validator table + tableModel = new DefaultTableModel(new String[]{"Validator", "Script Hash", "Size (bytes)", "Parameterized"}, 0) { + @Override + public boolean isCellEditable(int row, int column) { return false; } + }; + validatorTable = new JTable(tableModel); + validatorTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + validatorTable.getSelectionModel().addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + showSelectedValidator(); + } + }); + + JScrollPane tableScroll = new JScrollPane(validatorTable); + tableScroll.setPreferredSize(new Dimension(600, 200)); + + // UPLC text area + uplcTextArea = new JTextArea(); + uplcTextArea.setEditable(false); + uplcTextArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + JScrollPane textScroll = new JScrollPane(uplcTextArea); + textScroll.setPreferredSize(new Dimension(600, 200)); + + // Split pane + JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, tableScroll, textScroll); + splitPane.setResizeWeight(0.4); + mainPanel.add(splitPane, BorderLayout.CENTER); + + // Buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton copyHashBtn = new JButton("Copy Script Hash"); + copyHashBtn.addActionListener(e -> copySelectedColumn(1)); + buttonPanel.add(copyHashBtn); + + JButton copyCodeBtn = new JButton("Copy Compiled Code"); + copyCodeBtn.addActionListener(e -> copyCompiledCode()); + buttonPanel.add(copyCodeBtn); + + JButton refreshBtn = new JButton("Refresh"); + refreshBtn.addActionListener(e -> loadBlueprint()); + buttonPanel.add(refreshBtn); + + mainPanel.add(buttonPanel, BorderLayout.SOUTH); + } + + private void loadBlueprint() { + tableModel.setRowCount(0); + uplcTextArea.setText(""); + + blueprint = BlueprintLoadService.getInstance(project).loadBlueprint(); + if (blueprint == null) { + uplcTextArea.setText("No blueprint found. Build the project first (julc build / ./gradlew build)."); + return; + } + + for (PlutusBlueprint.ValidatorInfo v : blueprint.getValidators()) { + tableModel.addRow(new Object[]{ + v.getTitle(), + v.getHash(), + v.getSizeBytes(), + v.isParameterized() ? "Yes" : "No" + }); + } + + if (blueprint.getPreamble() != null) { + String title = blueprint.getPreamble().getTitle(); + String version = blueprint.getPreamble().getVersion(); + if (!title.isEmpty()) { + uplcTextArea.setText("Project: " + title + " v" + version + + "\nValidators: " + blueprint.getValidators().size() + + "\n\nSelect a validator to view compiled code."); + } + } + } + + private void showSelectedValidator() { + int row = validatorTable.getSelectedRow(); + if (row < 0 || blueprint == null) return; + + PlutusBlueprint.ValidatorInfo v = blueprint.getValidators().get(row); + StringBuilder sb = new StringBuilder(); + sb.append("Validator: ").append(v.getTitle()).append("\n"); + sb.append("Script Hash: ").append(v.getHash()).append("\n"); + sb.append("Size: ").append(v.getSizeBytes()).append(" bytes\n"); + sb.append("Parameterized: ").append(v.isParameterized()).append("\n\n"); + sb.append("--- Compiled Code (CBOR hex) ---\n"); + sb.append(v.getCompiledCode()); + uplcTextArea.setText(sb.toString()); + uplcTextArea.setCaretPosition(0); + } + + private void copySelectedColumn(int col) { + int row = validatorTable.getSelectedRow(); + if (row < 0) return; + String value = String.valueOf(tableModel.getValueAt(row, col)); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(value), null); + } + + private void copyCompiledCode() { + int row = validatorTable.getSelectedRow(); + if (row < 0 || blueprint == null) return; + String code = blueprint.getValidators().get(row).getCompiledCode(); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(code), null); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/common/JulcIcons.java b/src/main/java/com/bloxbean/intelliada/idea/julc/common/JulcIcons.java new file mode 100644 index 0000000..86ba147 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/common/JulcIcons.java @@ -0,0 +1,9 @@ +package com.bloxbean.intelliada.idea.julc.common; + +import com.intellij.openapi.util.IconLoader; + +import javax.swing.*; + +public class JulcIcons { + public static final Icon JULC_ICON = IconLoader.getIcon("/icons/julc_module.png", JulcIcons.class); +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/compile/JulcCompileService.java b/src/main/java/com/bloxbean/intelliada/idea/julc/compile/JulcCompileService.java new file mode 100644 index 0000000..707c176 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/compile/JulcCompileService.java @@ -0,0 +1,189 @@ +package com.bloxbean.intelliada.idea.julc.compile; + +import com.bloxbean.intelliada.idea.aiken.compile.CompilationResultListener; +import com.bloxbean.intelliada.idea.aiken.compile.CompileException; +import com.bloxbean.intelliada.idea.julc.configuration.JulcConfigurationHelperService; +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcProjectState; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.OSProcessHandler; +import com.intellij.execution.process.ProcessEvent; +import com.intellij.execution.process.ProcessListener; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Compile service for julc projects. + * Detects project type (basic/gradle/maven) and invokes the appropriate build command. + */ +public class JulcCompileService { + private static final Logger LOG = Logger.getInstance(JulcCompileService.class); + + private final Project project; + private final String cwd; + private final JulcProjectState.ProjectType projectType; + + public JulcCompileService(@NotNull Project project) { + this.project = project; + this.cwd = project.getBasePath(); + + JulcTomlService tomlService = JulcTomlService.getInstance(project); + this.projectType = tomlService.detectProjectType(); + } + + /** + * Builds the julc project. + */ + public void compile(CompilationResultListener listener) { + List cmd = buildCompileCommand(); + if (cmd == null) { + listener.error("Could not determine julc project type. Ensure julc.toml, build.gradle, or pom.xml exists."); + return; + } + + executeCommand(cmd, "Build", listener); + } + + /** + * Runs tests for the julc project. + */ + public void check(CompilationResultListener listener) { + List cmd = buildCheckCommand(); + if (cmd == null) { + listener.error("Could not determine julc project type for testing."); + return; + } + + executeCommand(cmd, "Test", listener); + } + + private List buildCompileCommand() { + if (projectType == null) return null; + + List cmd = new ArrayList<>(); + switch (projectType) { + case basic -> { + JulcSDK sdk = JulcConfigurationHelperService.getCompilerLocalSDK(project); + if (sdk == null) { + return null; + } + cmd.addAll(sdk.getJulcCommand()); + cmd.add("build"); + } + case gradle -> { + cmd.add(getGradleWrapper()); + cmd.add("clean"); + cmd.add("build"); + cmd.add("-x"); + cmd.add("test"); + } + case maven -> { + cmd.add(getMavenWrapper()); + cmd.add("compile"); + } + } + return cmd; + } + + private List buildCheckCommand() { + if (projectType == null) return null; + + List cmd = new ArrayList<>(); + switch (projectType) { + case basic -> { + JulcSDK sdk = JulcConfigurationHelperService.getCompilerLocalSDK(project); + if (sdk == null) { + return null; + } + cmd.addAll(sdk.getJulcCommand()); + cmd.add("check"); + } + case gradle -> { + cmd.add(getGradleWrapper()); + cmd.add("test"); + } + case maven -> { + cmd.add(getMavenWrapper()); + cmd.add("test"); + } + } + return cmd; + } + + private void executeCommand(List cmd, String taskName, CompilationResultListener listener) { + OSProcessHandler handler; + try { + handler = new OSProcessHandler( + new GeneralCommandLine(cmd).withWorkDirectory(cwd) + ); + } catch (ExecutionException ex) { + listener.error(taskName + " failed: " + ex.getMessage()); + listener.onFailure(cwd, ex); + return; + } + + listener.info("Running julc " + taskName.toLowerCase() + "..."); + listener.attachProcess(handler); + + handler.addProcessListener(new ProcessListener() { + @Override + public void startNotified(@NotNull ProcessEvent event) {} + + @Override + public void processTerminated(@NotNull ProcessEvent event) { + if (event.getExitCode() == 0 || (SystemInfo.isWindows && event.getExitCode() <= 0)) { + listener.info(taskName + " successful."); + // Refresh VFS so blueprint files are visible to Deploy Validator + refreshBuildOutput(); + listener.onSuccessful(cwd); + } else { + listener.error(taskName + " failed."); + listener.onFailure(cwd, new CompileException("julc " + taskName.toLowerCase() + " process failed.")); + } + } + + @Override + public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType) {} + }); + } + + private void refreshBuildOutput() { + VirtualFile buildDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(cwd + "/build"); + if (buildDir != null) { + VfsUtil.markDirtyAndRefresh(false, true, true, buildDir); + } + // Also refresh target/ for Maven projects + VirtualFile targetDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(cwd + "/target"); + if (targetDir != null) { + VfsUtil.markDirtyAndRefresh(false, true, true, targetDir); + } + } + + private String getGradleWrapper() { + String wrapper = SystemInfo.isWindows ? "gradlew.bat" : "./gradlew"; + if (new File(cwd, SystemInfo.isWindows ? "gradlew.bat" : "gradlew").exists()) { + return wrapper; + } + return "gradle"; + } + + private String getMavenWrapper() { + String wrapper = SystemInfo.isWindows ? "mvnw.cmd" : "./mvnw"; + if (new File(cwd, SystemInfo.isWindows ? "mvnw.cmd" : "mvnw").exists()) { + return wrapper; + } + return "mvn"; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/compile/action/JulcBuildAction.java b/src/main/java/com/bloxbean/intelliada/idea/julc/compile/action/JulcBuildAction.java new file mode 100644 index 0000000..f17f6b4 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/compile/action/JulcBuildAction.java @@ -0,0 +1,132 @@ +package com.bloxbean.intelliada.idea.julc.compile.action; + +import com.bloxbean.intelliada.idea.aiken.compile.CompilationResultListener; +import com.bloxbean.intelliada.idea.julc.compile.JulcCompileService; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.bloxbean.intelliada.idea.toolwindow.CardanoConsole; +import com.bloxbean.intelliada.idea.util.IdeaUtil; +import com.intellij.execution.process.OSProcessHandler; +import com.intellij.icons.AllIcons; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.progress.impl.BackgroundableProcessIndicator; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.util.IncorrectOperationException; +import org.jetbrains.annotations.NotNull; + +import java.io.File; + +public class JulcBuildAction extends AnAction { + private static final Logger LOG = Logger.getInstance(JulcBuildAction.class); + + public JulcBuildAction() { + super(AllIcons.Actions.Compile); + } + + @Override + public void update(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + e.getPresentation().setVisible(false); + return; + } + + JulcTomlService tomlService = JulcTomlService.getInstance(project); + boolean isJulc = tomlService != null && tomlService.isJulcProject(); + e.getPresentation().setVisible(isJulc); + e.getPresentation().setEnabled(isJulc); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = CommonDataKeys.PROJECT.getData(e.getDataContext()); + if (project == null) return; + + FileDocumentManager.getInstance().saveAllDocuments(); + + final CardanoConsole console = CardanoConsole.getConsole(project); + console.clearAndshow(); + + final String projectDir = project.getBasePath(); + final VirtualFile folderToRefresh = projectDir != null + ? VfsUtil.findFileByIoFile(new File(projectDir), true) : null; + + CompilationResultListener listener = createListener(project, console, folderToRefresh); + + Task.Backgroundable task = new Task.Backgroundable(project, "julc Build") { + @Override + public void run(@NotNull ProgressIndicator indicator) { + JulcCompileService compileService = new JulcCompileService(project); + console.showInfoMessage("Start julc build..."); + compileService.compile(listener); + } + }; + + ProgressManager.getInstance().runProcessWithProgressAsynchronously(task, new BackgroundableProcessIndicator(task)); + } + + @NotNull + @Override + public ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + static CompilationResultListener createListener(Project project, CardanoConsole console, VirtualFile folderToRefresh) { + return new CompilationResultListener() { + @Override + public void attachProcess(OSProcessHandler handler) { + ApplicationManager.getApplication().invokeLater(() -> { + try { + console.getView().attachToProcess(handler); + } catch (IncorrectOperationException ex) { + console.showInfoMessage(ex.getMessage()); + console.dispose(); + console.getView().attachToProcess(handler); + } + handler.startNotify(); + }); + } + + @Override + public void error(String message) { + console.showErrorMessage(message); + } + + @Override + public void info(String message) { + console.showInfoMessage(message); + } + + @Override + public void warn(String msg) { + console.showWarningMessage(msg); + } + + @Override + public void onSuccessful(String sourceFile) { + console.showSuccessMessage("Build Successful"); + if (folderToRefresh != null) { + folderToRefresh.refresh(false, false); + } + IdeaUtil.showNotification(project, "julc Build", "Build was successful", NotificationType.INFORMATION, null); + } + + @Override + public void onFailure(String sourceFile, Throwable t) { + console.showErrorMessage(String.format("Build failed for %s", sourceFile), t); + IdeaUtil.showNotification(project, "julc Build", "Build failed", NotificationType.ERROR, null); + } + }; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/compile/action/JulcCheckAction.java b/src/main/java/com/bloxbean/intelliada/idea/julc/compile/action/JulcCheckAction.java new file mode 100644 index 0000000..8d76610 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/compile/action/JulcCheckAction.java @@ -0,0 +1,71 @@ +package com.bloxbean.intelliada.idea.julc.compile.action; + +import com.bloxbean.intelliada.idea.aiken.compile.CompilationResultListener; +import com.bloxbean.intelliada.idea.julc.compile.JulcCompileService; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.bloxbean.intelliada.idea.toolwindow.CardanoConsole; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.progress.impl.BackgroundableProcessIndicator; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +public class JulcCheckAction extends AnAction { + private static final Logger LOG = Logger.getInstance(JulcCheckAction.class); + + public JulcCheckAction() { + super(AllIcons.RunConfigurations.TestState.Run); + } + + @Override + public void update(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + e.getPresentation().setVisible(false); + return; + } + + JulcTomlService tomlService = JulcTomlService.getInstance(project); + boolean isJulc = tomlService != null && tomlService.isJulcProject(); + e.getPresentation().setVisible(isJulc); + e.getPresentation().setEnabled(isJulc); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = CommonDataKeys.PROJECT.getData(e.getDataContext()); + if (project == null) return; + + FileDocumentManager.getInstance().saveAllDocuments(); + + final CardanoConsole console = CardanoConsole.getConsole(project); + console.clearAndshow(); + + CompilationResultListener listener = JulcBuildAction.createListener(project, console, null); + + Task.Backgroundable task = new Task.Backgroundable(project, "julc Tests") { + @Override + public void run(@NotNull ProgressIndicator indicator) { + JulcCompileService compileService = new JulcCompileService(project); + console.showInfoMessage("Running julc tests..."); + compileService.check(listener); + } + }; + + ProgressManager.getInstance().runProcessWithProgressAsynchronously(task, new BackgroundableProcessIndicator(task)); + } + + @NotNull + @Override + public ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcConfigurationAction.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcConfigurationAction.java new file mode 100644 index 0000000..6148f6b --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcConfigurationAction.java @@ -0,0 +1,32 @@ +package com.bloxbean.intelliada.idea.julc.configuration; + +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcProjectState; +import com.bloxbean.intelliada.idea.julc.configuration.ui.JulcProjectConfigurationDialog; +import com.bloxbean.intelliada.idea.util.IdeaUtil; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +public class JulcConfigurationAction extends AnAction { + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return; + + JulcProjectState projectState = JulcProjectState.getInstance(project); + if (projectState == null) { + IdeaUtil.showNotification(project, "julc project configuration", + "Unable to configure julc project", NotificationType.ERROR, null); + return; + } + + JulcProjectConfigurationDialog dialog = new JulcProjectConfigurationDialog(project); + boolean ok = dialog.showAndGet(); + if (ok) { + dialog.save(project); + } + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcConfigurationHelperService.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcConfigurationHelperService.java new file mode 100644 index 0000000..2b1e401 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcConfigurationHelperService.java @@ -0,0 +1,127 @@ +package com.bloxbean.intelliada.idea.julc.configuration; + +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcProjectState; +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcSDKState; +import com.bloxbean.intelliada.idea.julc.configuration.ui.JulcSDKDialog; +import com.bloxbean.intelliada.idea.julc.messaging.JulcProjectConfigChangeNotifier; +import com.bloxbean.intelliada.idea.julc.messaging.JulcSDKChangeNotifier; +import com.bloxbean.intelliada.idea.julc.util.JulcSdkUtil; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.util.text.StringUtil; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; + +public class JulcConfigurationHelperService { + private static final Logger LOG = Logger.getInstance(JulcConfigurationHelperService.class); + + public static JulcSDK getCompilerLocalSDK(Project project) { + JulcProjectState projectState = JulcProjectState.getInstance(project); + JulcProjectState.ConfigType compilerType = projectState.getState().getSdkType(); + String compilerId = projectState.getState().getSdkId(); + + // 1. Check explicitly configured SDK + if (JulcProjectState.ConfigType.local_sdk == compilerType + && !StringUtil.isEmpty(compilerId)) { + List sdks = JulcSDKState.getInstance().getSdks(); + for (JulcSDK sdk : sdks) { + if (compilerId.equals(sdk.getId())) { + return sdk; + } + } + } + + // 2. Check ~/.julc/bin/julc + String julcExe = JulcSdkUtil.getJulcExecutable(); + String julcHome = System.getProperty("user.home") + File.separator + ".julc" + File.separator + "bin"; + if (Path.of(julcHome, julcExe).toFile().exists()) { + return new JulcSDK("default", "julc", julcHome, "0.0"); + } + + // 3. Check ~/.julc/ (download may extract with subdirectories) + String julcBaseDir = System.getProperty("user.home") + File.separator + ".julc"; + String foundBinDir = JulcDownloader.findJulcBinDir(Path.of(julcBaseDir)); + if (foundBinDir != null) { + return new JulcSDK("default", "julc", foundBinDir, "0.0"); + } + + // 4. Check system PATH + String pathResult = findOnSystemPath(julcExe); + if (pathResult != null) { + return new JulcSDK("system", "julc (system)", pathResult, "0.0"); + } + + return null; + } + + /** + * Check if julc is available on the system PATH. + * Returns the directory containing the executable, or null. + */ + private static String findOnSystemPath(String executable) { + try { + String cmd = SystemInfo.isWindows ? "where" : "which"; + Process process = new ProcessBuilder(cmd, executable) + .redirectErrorStream(true) + .start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line = reader.readLine(); + int exitCode = process.waitFor(); + if (exitCode == 0 && line != null && !line.isBlank()) { + // "which julc" returns full path like /usr/local/bin/julc + return new File(line.trim()).getParent(); + } + } + } catch (Exception e) { + LOG.debug("Could not check system PATH for " + executable, e); + } + return null; + } + + public static JulcSDK createOrUpdateSDKConfiguration(Project project, JulcSDK existingSdk) { + JulcSDKState stateService = JulcSDKState.getInstance(); + JulcSDKDialog sdkDialog = new JulcSDKDialog(project, existingSdk); + boolean ok = sdkDialog.showAndGet(); + if (ok) { + JulcSDK sdk = new JulcSDK(); + + if (existingSdk == null) { + sdk.setId(UUID.randomUUID().toString()); + } else { + sdk.setId(existingSdk.getId()); + } + + sdk.setPath(sdkDialog.getPath()); + sdk.setName(sdkDialog.getName()); + sdk.setVersion(sdkDialog.getVersion()); + + if (existingSdk == null) { + stateService.addSdk(sdk); + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(JulcSDKChangeNotifier.CHANGE_JULC_SDK_TOPIC) + .sdkAdded(sdk); + } else { + stateService.updateSdk(sdk); + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(JulcSDKChangeNotifier.CHANGE_JULC_SDK_TOPIC) + .sdkUpdated(sdk); + } + + return sdk; + } + return null; + } + + public static void notifyProjectConfigChange(Project project) { + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(JulcProjectConfigChangeNotifier.CHANGE_JULC_PROJECT_CONFIG_TOPIC) + .configUpdated(project); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcDownloader.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcDownloader.java new file mode 100644 index 0000000..95ae310 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcDownloader.java @@ -0,0 +1,258 @@ +package com.bloxbean.intelliada.idea.julc.configuration; + +import com.intellij.notification.Notification; +import com.intellij.notification.NotificationType; +import com.intellij.notification.Notifications; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.util.SystemInfo; +import org.jetbrains.annotations.NotNull; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Downloads julc CLI from GitHub releases. + * Platform-aware: selects the correct zip for macOS/Linux/Windows. + */ +public class JulcDownloader { + private static final Logger LOG = Logger.getInstance(JulcDownloader.class); + private static final String GITHUB_API_URL = "https://api.github.com/repos/bloxbean/julc/releases/latest"; + + private final Path installDir; + private Runnable onComplete; + + public JulcDownloader(Path installDir) { + this.installDir = installDir; + } + + public void setOnComplete(Runnable onComplete) { + this.onComplete = onComplete; + } + + public void install() { + Task.Backgroundable task = new Task.Backgroundable(null, "Downloading julc CLI...", true) { + @Override + public void run(@NotNull ProgressIndicator indicator) { + try { + indicator.setText("Fetching latest julc release..."); + indicator.setFraction(0.1); + + String downloadUrl = fetchDownloadUrl(); + if (downloadUrl == null) { + showNotification("julc Install", "No julc release found for this platform.", NotificationType.ERROR); + return; + } + + indicator.setText("Downloading julc CLI..."); + indicator.setFraction(0.3); + + Files.createDirectories(installDir); + Path zipPath = installDir.resolve("julc.zip"); + downloadFile(downloadUrl, zipPath, indicator); + + indicator.setText("Extracting julc..."); + indicator.setFraction(0.8); + + unzip(zipPath, installDir); + Files.deleteIfExists(zipPath); + + // Set executable permissions on Unix + if (!SystemInfo.isWindows) { + findAndSetExecutable(installDir); + } + + indicator.setFraction(1.0); + showNotification("julc Install", "julc CLI installed at " + installDir, NotificationType.INFORMATION); + + if (onComplete != null) { + onComplete.run(); + } + + } catch (Exception e) { + LOG.error("Failed to install julc CLI", e); + showNotification("julc Install", "Failed: " + e.getMessage(), NotificationType.ERROR); + } + } + }; + + ProgressManager.getInstance().run(task); + } + + private String fetchDownloadUrl() throws IOException { + URL url = new URL(GITHUB_API_URL); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/vnd.github.v3+json"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(30000); + + if (conn.getResponseCode() != 200) { + throw new IOException("GitHub API returned " + conn.getResponseCode()); + } + + String body; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + body = sb.toString(); + } finally { + conn.disconnect(); + } + + JSONObject release = new JSONObject(body); + JSONArray assets = release.getJSONArray("assets"); + String platformSuffix = getPlatformSuffix(); + + LOG.info("Looking for julc asset with platform suffix: " + platformSuffix); + + for (int i = 0; i < assets.length(); i++) { + JSONObject asset = assets.getJSONObject(i); + String name = asset.getString("name"); + String assetUrl = asset.getString("browser_download_url"); + + LOG.info(" Asset: " + name); + + // Match julc-{version}-{platform}.zip but NOT julc-playground-* + if (name.startsWith("julc-") && !name.contains("playground") + && name.contains(platformSuffix) && name.endsWith(".zip")) { + LOG.info(" -> Selected: " + assetUrl); + return assetUrl; + } + } + + return null; + } + + private String getPlatformSuffix() { + if (SystemInfo.isMac) { + return "macos-aarch64"; + } else if (SystemInfo.isLinux) { + return "linux-x86_64"; + } else if (SystemInfo.isWindows) { + return "windows-x86_64"; + } + return "linux-x86_64"; + } + + private void downloadFile(String urlStr, Path target, ProgressIndicator indicator) throws IOException { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + + // GitHub redirects browser_download_url — follow manually if needed + int status = conn.getResponseCode(); + if (status == 302 || status == 301) { + String redirect = conn.getHeaderField("Location"); + conn.disconnect(); + conn = (HttpURLConnection) new URL(redirect).openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + } + + long totalSize = conn.getContentLengthLong(); + long downloaded = 0; + + try (InputStream is = conn.getInputStream(); + OutputStream os = Files.newOutputStream(target)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + os.write(buffer, 0, bytesRead); + downloaded += bytesRead; + if (totalSize > 0) { + indicator.setFraction(0.3 + 0.5 * ((double) downloaded / totalSize)); + indicator.setText2(String.format("%.1f MB / %.1f MB", + downloaded / (1024.0 * 1024.0), totalSize / (1024.0 * 1024.0))); + } + } + } finally { + conn.disconnect(); + } + } + + private void unzip(Path zipPath, Path destDir) throws IOException { + try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipPath.toFile()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + Path targetPath = destDir.resolve(entry.getName()).normalize(); + if (!targetPath.startsWith(destDir)) { + throw new IOException("Zip entry outside target dir: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(targetPath); + } else { + Files.createDirectories(targetPath.getParent()); + try (OutputStream os = Files.newOutputStream(targetPath)) { + zis.transferTo(os); + } + } + zis.closeEntry(); + } + } + } + + /** + * Recursively find and set executable on julc binary. + */ + private void findAndSetExecutable(Path dir) { + try { + Files.walk(dir, 3) + .filter(p -> { + String name = p.getFileName().toString(); + return name.equals("julc") || name.equals("gradlew") || name.equals("mvnw"); + }) + .forEach(p -> p.toFile().setExecutable(true)); + } catch (IOException e) { + LOG.warn("Could not set executable permissions", e); + } + } + + /** + * Find the julc binary path after extraction. + * Returns the directory containing the julc executable, or null. + */ + public static String findJulcBinDir(Path installDir) { + String julcName = SystemInfo.isWindows ? "julc.exe" : "julc"; + + // Direct: installDir/bin/julc + Path direct = installDir.resolve("bin").resolve(julcName); + if (Files.exists(direct)) { + return installDir.resolve("bin").toString(); + } + + // Subdirectory: installDir/julc-*/bin/julc + try { + return Files.walk(installDir, 4) + .filter(p -> p.getFileName().toString().equals(julcName) + && !p.toString().endsWith(".zip") + && Files.isRegularFile(p)) + .map(p -> p.getParent().toString()) + .findFirst() + .orElse(null); + } catch (IOException e) { + return null; + } + } + + private void showNotification(String title, String content, NotificationType type) { + Notifications.Bus.notify(new Notification("julc", title, content, type)); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcSDK.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcSDK.java new file mode 100644 index 0000000..00c1090 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/JulcSDK.java @@ -0,0 +1,63 @@ +package com.bloxbean.intelliada.idea.julc.configuration; + +import com.intellij.openapi.util.SystemInfo; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Data +@AllArgsConstructor +public class JulcSDK { + private String id; + private String name; + private String path; + private String version; + + public JulcSDK() { + id = ""; + name = ""; + path = ""; + version = ""; + } + + public void updateValues(JulcSDK sdk) { + if (sdk == null) return; + this.setName(sdk.getName()); + this.setPath(sdk.getPath()); + this.setVersion(sdk.getVersion()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + JulcSDK sdk = (JulcSDK) o; + return id.equals(sdk.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public String toString() { + return name; + } + + public List getJulcCommand() { + List cmd = new ArrayList<>(); + cmd.add(getPath() + File.separator + getJulcExecutable()); + return cmd; + } + + private String getJulcExecutable() { + String julcCmd = "julc"; + if (SystemInfo.isWindows) + julcCmd = "julc.exe"; + return julcCmd; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/service/JulcProjectState.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/service/JulcProjectState.java new file mode 100644 index 0000000..7e62a01 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/service/JulcProjectState.java @@ -0,0 +1,68 @@ +package com.bloxbean.intelliada.idea.julc.configuration.service; + +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; + +@State( + name = "com.bloxbean.julc.JulcProjectState", + reloadable = true, + storages = {@Storage("julc-project.xml")} +) +public class JulcProjectState implements PersistentStateComponent { + private static final Logger LOG = Logger.getInstance(JulcProjectState.class); + + public enum ConfigType {local_sdk} + + public enum ProjectType {basic, gradle, maven} + + public static JulcProjectState getInstance(Project project) { + return project.getService(JulcProjectState.class); + } + + public static class State { + private ConfigType sdkType; + private String sdkId; + private ProjectType projectType; + + public ConfigType getSdkType() { + return sdkType; + } + + public void setSdkType(ConfigType sdkType) { + this.sdkType = sdkType; + } + + public String getSdkId() { + return sdkId; + } + + public void setSdkId(String sdkId) { + this.sdkId = sdkId; + } + + public ProjectType getProjectType() { + return projectType; + } + + public void setProjectType(ProjectType projectType) { + this.projectType = projectType; + } + } + + private State state = new State(); + + public State getState() { + return state; + } + + public void loadState(State state) { + this.state = state; + } + + public void setState(State state) { + this.state = state; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/service/JulcSDKState.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/service/JulcSDKState.java new file mode 100644 index 0000000..193217d --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/service/JulcSDKState.java @@ -0,0 +1,87 @@ +package com.bloxbean.intelliada.idea.julc.configuration.service; + +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.text.StringUtil; +import org.jdom.Element; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +@State( + name = "com.bloxbean.julc.JulcSDKState", + storages = {@Storage("julc-sdks.xml")} +) +public class JulcSDKState implements PersistentStateComponent { + private static final Logger LOG = Logger.getInstance(JulcSDKState.class); + + public static JulcSDKState getInstance() { + return ApplicationManager.getApplication().getService(JulcSDKState.class); + } + + private List sdks; + + public JulcSDKState() { + this.sdks = new ArrayList<>(); + } + + @Nullable + @Override + public Element getState() { + Element state = new Element("sdks"); + for (JulcSDK sdk : sdks) { + Element entry = new Element("sdk"); + entry.setAttribute("id", sdk.getId()); + entry.setAttribute("name", sdk.getName()); + entry.setAttribute("path", StringUtil.notNullize(sdk.getPath())); + entry.setAttribute("version", StringUtil.notNullize(sdk.getVersion())); + state.addContent(entry); + } + return state; + } + + @Override + public void loadState(@NotNull Element elm) { + List list = new ArrayList<>(); + for (Element child : elm.getChildren("sdk")) { + String id = child.getAttributeValue("id"); + String name = child.getAttributeValue("name"); + String path = child.getAttributeValue("path"); + String version = child.getAttributeValue("version"); + list.add(new JulcSDK(id, name, path, version)); + } + setSdks(list); + } + + public List getSdks() { + return sdks; + } + + public void addSdk(JulcSDK sdk) { + sdks.add(sdk); + } + + public void updateSdk(JulcSDK sdk) { + for (JulcSDK existing : sdks) { + if (existing.getId() != null && existing.getId().equals(sdk.getId())) { + existing.updateValues(sdk); + break; + } + } + } + + public void removeSdk(JulcSDK sdk) { + if (sdks == null || sdk == null) return; + sdks.remove(sdk); + } + + private void setSdks(List list) { + sdks = list; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcProjectConfig.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcProjectConfig.java new file mode 100644 index 0000000..8e6e88b --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcProjectConfig.java @@ -0,0 +1,163 @@ +package com.bloxbean.intelliada.idea.julc.configuration.ui; + +import com.bloxbean.intelliada.idea.julc.configuration.JulcConfigurationHelperService; +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcProjectState; +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcSDKState; +import com.bloxbean.intelliada.idea.common.Tuple; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; +import com.intellij.openapi.ui.ValidationInfo; +import com.intellij.openapi.util.text.StringUtil; + +import javax.swing.*; +import java.awt.*; +import java.util.List; + +public class JulcProjectConfig { + private JPanel mainPanel; + private JComboBox localSDKCB; + private JButton newSDKBtn; + private JButton sdkDetailBtn; + + private final JulcSDK emptySDK = new JulcSDK(); + private boolean configChanged; + + public JulcProjectConfig(Project project) { + initComponents(); + initializeData(); + attachHandlers(project); + setCurrentSelection(project); + listenSelectionChange(); + } + + private void initComponents() { + mainPanel = new JPanel(new BorderLayout()); + JPanel formPanel = new JPanel(new GridBagLayout()); + formPanel.setBorder(BorderFactory.createTitledBorder("julc Configuration")); + + GridBagConstraints c = new GridBagConstraints(); + c.insets = new java.awt.Insets(4, 4, 4, 4); + c.anchor = GridBagConstraints.WEST; + + c.gridx = 0; c.gridy = 0; + formPanel.add(new JLabel("julc SDK"), c); + + localSDKCB = new JComboBox<>(); + localSDKCB.setMinimumSize(new Dimension(350, 27)); + localSDKCB.setPreferredSize(new Dimension(350, 27)); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0; + formPanel.add(localSDKCB, c); + + newSDKBtn = new JButton("New"); + c.gridx = 2; c.fill = GridBagConstraints.NONE; c.weightx = 0; + formPanel.add(newSDKBtn, c); + + sdkDetailBtn = new JButton("Details"); + c.gridx = 3; + formPanel.add(sdkDetailBtn, c); + + mainPanel.add(formPanel, BorderLayout.NORTH); + mainPanel.setMinimumSize(new Dimension(650, 100)); + mainPanel.setPreferredSize(new Dimension(650, 100)); + } + + private void initializeData() { + populateAvailableSDKs(); + } + + private void setCurrentSelection(Project project) { + JulcProjectState projectState = JulcProjectState.getInstance(project); + JulcProjectState.State state = projectState.getState(); + + if (JulcProjectState.ConfigType.local_sdk == state.getSdkType()) { + setSelectedSDK(localSDKCB, state.getSdkId()); + } + } + + private void setSelectedSDK(JComboBox cb, String id) { + for (int i = 0; i < cb.getItemCount(); i++) { + JulcSDK sdk = cb.getItemAt(i); + if (sdk == null) continue; + if (sdk.getId() != null && sdk.getId().equals(id)) { + cb.setSelectedIndex(i); + break; + } + } + } + + private void populateAvailableSDKs() { + List sdks = JulcSDKState.getInstance().getSdks(); + localSDKCB.removeAllItems(); + localSDKCB.addItem(emptySDK); + if (sdks != null) { + for (JulcSDK sdk : sdks) { + localSDKCB.addItem(sdk); + } + } + } + + private void attachHandlers(Project project) { + newSDKBtn.addActionListener(e -> { + JulcSDK sdk = JulcConfigurationHelperService.createOrUpdateSDKConfiguration(project, null); + if (sdk != null) { + populateAvailableSDKs(); + setSelectedSDK(localSDKCB, sdk.getId()); + } + }); + + sdkDetailBtn.addActionListener(e -> { + JulcSDK sdk = (JulcSDK) localSDKCB.getSelectedItem(); + if (sdk == null || StringUtil.isEmpty(sdk.getId())) { + Messages.showWarningDialog("Please select a julc SDK first to see the details", ""); + return; + } + JulcSDK updatedSDK = JulcConfigurationHelperService.createOrUpdateSDKConfiguration(project, sdk); + if (updatedSDK != null) { + updateSDKInComboBox(localSDKCB, updatedSDK); + } + }); + } + + private void listenSelectionChange() { + localSDKCB.addActionListener(e -> configChanged = true); + } + + public boolean isConfigChanged() { + return configChanged; + } + + private void updateSDKInComboBox(JComboBox cb, JulcSDK updatedSDK) { + for (int i = 0; i < cb.getItemCount(); i++) { + JulcSDK sdk = cb.getItemAt(i); + if (sdk == null || StringUtil.isEmpty(sdk.getId())) continue; + if (sdk.getId().equals(updatedSDK.getId())) { + sdk.updateValues(updatedSDK); + break; + } + } + } + + public Tuple getSdkId() { + JulcSDK sdk = (JulcSDK) localSDKCB.getSelectedItem(); + if (sdk != null) + return new Tuple<>(JulcProjectState.ConfigType.local_sdk, sdk.getId()); + return null; + } + + public void updateDataToState(JulcProjectState.State state) { + Tuple setting = getSdkId(); + if (setting != null) { + state.setSdkType(setting._1()); + state.setSdkId(setting._2()); + } + } + + public JPanel getMainPanel() { + return mainPanel; + } + + public ValidationInfo doValidate() { + return null; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcProjectConfigurationDialog.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcProjectConfigurationDialog.java new file mode 100644 index 0000000..cebe642 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcProjectConfigurationDialog.java @@ -0,0 +1,56 @@ +package com.bloxbean.intelliada.idea.julc.configuration.ui; + +import com.bloxbean.intelliada.idea.julc.configuration.JulcConfigurationHelperService; +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcProjectState; +import com.bloxbean.intelliada.idea.util.IdeaUtil; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.openapi.ui.ValidationInfo; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +public class JulcProjectConfigurationDialog extends DialogWrapper { + + private final JulcProjectConfig julcProjectConfig; + + public JulcProjectConfigurationDialog(@Nullable Project project) { + super(project); + julcProjectConfig = new JulcProjectConfig(project); + init(); + setTitle("julc Project - Configuration"); + } + + public void save(Project project) { + JulcProjectState projectState = JulcProjectState.getInstance(project); + if (projectState == null) { + IdeaUtil.showNotification(project, "julc configuration", + "Unable to save julc configuration for the project", NotificationType.ERROR, null); + return; + } + + JulcProjectState.State state = projectState.getState(); + if (state != null) { + julcProjectConfig.updateDataToState(state); + projectState.setState(state); + + if (julcProjectConfig.isConfigChanged()) { + JulcConfigurationHelperService.notifyProjectConfigChange(project); + } + } else { + IdeaUtil.showNotification(project, "julc project configuration", + "Unable to save julc configuration for the project !!!", NotificationType.ERROR, null); + } + } + + @Override + protected @Nullable ValidationInfo doValidate() { + return julcProjectConfig.doValidate(); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + return julcProjectConfig.getMainPanel(); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcSDKDialog.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcSDKDialog.java new file mode 100644 index 0000000..1506e72 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcSDKDialog.java @@ -0,0 +1,57 @@ +package com.bloxbean.intelliada.idea.julc.configuration.ui; + +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.openapi.ui.ValidationInfo; +import com.intellij.openapi.util.text.StringUtil; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +public class JulcSDKDialog extends DialogWrapper { + + private final JulcSDKPanel sdkPanel; + + public JulcSDKDialog(Project project) { + this(project, null); + } + + public JulcSDKDialog(Project project, JulcSDK sdk) { + super(project); + sdkPanel = new JulcSDKPanel(sdk); + init(); + setTitle("julc SDK"); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + return sdkPanel.getMainPanel(); + } + + public String getPath() { + return sdkPanel.getPath(); + } + + public String getName() { + return sdkPanel.getName(); + } + + public String getVersion() { + return sdkPanel.getVersion(); + } + + @Override + protected @Nullable ValidationInfo doValidate() { + if (StringUtil.isEmpty(sdkPanel.getName())) { + return new ValidationInfo("Invalid Name", sdkPanel.getNameTf()); + } + if (StringUtil.isEmpty(sdkPanel.getPath())) { + return new ValidationInfo("Invalid julc Path", sdkPanel.getPathTf()); + } + if (StringUtil.isEmpty(sdkPanel.getVersion())) { + return new ValidationInfo("Invalid Version Number or Version could not be determined", sdkPanel.getVersionTf()); + } + return null; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcSDKPanel.java b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcSDKPanel.java new file mode 100644 index 0000000..a75586c --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/configuration/ui/JulcSDKPanel.java @@ -0,0 +1,209 @@ +package com.bloxbean.intelliada.idea.julc.configuration.ui; + +import com.bloxbean.intelliada.idea.julc.configuration.JulcDownloader; +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.bloxbean.intelliada.idea.julc.util.JulcSdkUtil; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.ui.TextFieldWithBrowseButton; +import com.intellij.openapi.util.text.StringUtil; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.io.File; +import java.nio.file.Path; + +public class JulcSDKPanel { + private static final Logger LOG = Logger.getInstance(JulcSDKPanel.class); + + private JTextField versionTf; + private JPanel mainPanel; + private JTextField nameTf; + private TextFieldWithBrowseButton pathTfWithBrowserBtn; + private JLabel errorMsgLabel; + private JTextField pathTf; + private JButton downloadBtn; + + public JulcSDKPanel() { + this(null); + } + + public JulcSDKPanel(JulcSDK sdk) { + super(); + initComponents(); + + if (sdk != null) { + nameTf.setText(sdk.getName()); + pathTf.setText(sdk.getPath()); + versionTf.setText(sdk.getVersion()); + } + + pathTf.setToolTipText("Folder where the 'julc' binary is available (e.g., the 'bin' directory)."); + + pathTf.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent e) { + checkExecutable(); + } + }); + } + + private void initComponents() { + mainPanel = new JPanel(new BorderLayout()); + JPanel formPanel = new JPanel(new GridBagLayout()); + formPanel.setBorder(BorderFactory.createTitledBorder("julc SDK")); + + GridBagConstraints labelC = new GridBagConstraints(); + labelC.anchor = GridBagConstraints.WEST; + labelC.insets = new Insets(4, 4, 4, 4); + + GridBagConstraints fieldC = new GridBagConstraints(); + fieldC.fill = GridBagConstraints.HORIZONTAL; + fieldC.weightx = 1.0; + fieldC.insets = new Insets(4, 4, 4, 4); + + // Name + nameTf = new JTextField("julc"); + labelC.gridy = 0; labelC.gridx = 0; + formPanel.add(new JLabel("Name"), labelC); + fieldC.gridy = 0; fieldC.gridx = 1; fieldC.gridwidth = 2; + formPanel.add(nameTf, fieldC); + + // Path with browser button + pathTf = new JTextField(); + pathTfWithBrowserBtn = new TextFieldWithBrowseButton(pathTf, e -> { + JFileChooser fc = new JFileChooser(); + fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + fc.showDialog(mainPanel, "Select"); + File file = fc.getSelectedFile(); + if (file == null) return; + pathTf.setText(file.getAbsolutePath()); + checkExecutable(); + }); + labelC.gridy = 1; fieldC.gridy = 1; fieldC.gridwidth = 1; + formPanel.add(new JLabel("julc Exec Path"), labelC); + formPanel.add(pathTfWithBrowserBtn, fieldC); + + // Download button + downloadBtn = new JButton("Download julc"); + downloadBtn.addActionListener(e -> downloadJulc()); + GridBagConstraints btnC = new GridBagConstraints(); + btnC.gridx = 2; btnC.gridy = 1; + btnC.insets = new Insets(4, 4, 4, 4); + formPanel.add(downloadBtn, btnC); + + // Version + versionTf = new JTextField(); + versionTf.setEditable(false); + labelC.gridy = 2; fieldC.gridy = 2; fieldC.gridwidth = 2; + formPanel.add(new JLabel("julc Version"), labelC); + formPanel.add(versionTf, fieldC); + + // Error label + errorMsgLabel = new JLabel(""); + fieldC.gridy = 3; + formPanel.add(errorMsgLabel, fieldC); + + mainPanel.add(formPanel, BorderLayout.NORTH); + mainPanel.setPreferredSize(new Dimension(750, 300)); + mainPanel.setMinimumSize(new Dimension(750, 300)); + } + + private void downloadJulc() { + // Choose install directory + JFileChooser fc = new JFileChooser(); + fc.setDialogTitle("Select julc installation directory"); + fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + String defaultDir = System.getProperty("user.home") + File.separator + ".julc"; + fc.setSelectedFile(new File(defaultDir)); + + if (fc.showSaveDialog(mainPanel) != JFileChooser.APPROVE_OPTION) return; + + Path installDir = fc.getSelectedFile().toPath(); + JulcDownloader downloader = new JulcDownloader(installDir); + + // After download, auto-detect the binary path + downloader.setOnComplete(() -> { + String binDir = JulcDownloader.findJulcBinDir(installDir); + if (binDir != null) { + SwingUtilities.invokeLater(() -> { + pathTf.setText(binDir); + checkExecutable(); + }); + } + }); + + downloadBtn.setEnabled(false); + downloadBtn.setText("Downloading..."); + downloader.install(); + + // Re-enable after a delay (download runs in background) + new Timer(2000, e -> { + downloadBtn.setEnabled(true); + downloadBtn.setText("Download julc"); + ((Timer)e.getSource()).stop(); + }).start(); + } + + public JPanel getMainPanel() { + return mainPanel; + } + + public String getPath() { + return pathTf.getText(); + } + + public String getName() { + return nameTf.getText(); + } + + public String getVersion() { + return versionTf.getText(); + } + + public JTextField getVersionTf() { + return versionTf; + } + + public JTextField getNameTf() { + return nameTf; + } + + public JTextField getPathTf() { + return pathTf; + } + + private void checkExecutable() { + errorMsgLabel.setText(""); + versionTf.setText(""); + + if (!new File(pathTf.getText() + File.separator + JulcSdkUtil.getJulcExecutable()).exists()) { + versionTf.setText(""); + printError("'julc' was not found. Please make sure 'julc' is available under the selected folder."); + return; + } + + String version; + try { + version = JulcSdkUtil.getVersionString(pathTf.getText()); + } catch (Exception exception) { + versionTf.setText(""); + printError(exception.getMessage()); + return; + } + + if (StringUtil.isEmpty(version)) { + versionTf.setText(""); + printError("Invalid julc binary folder. Version could not be determined."); + } else { + versionTf.setText(version); + errorMsgLabel.setText(""); + } + } + + private void printError(String msg) { + errorMsgLabel.setText(msg); + errorMsgLabel.setForeground(Color.red); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcScriptSizeAnnotator.java b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcScriptSizeAnnotator.java new file mode 100644 index 0000000..ff82136 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcScriptSizeAnnotator.java @@ -0,0 +1,133 @@ +package com.bloxbean.intelliada.idea.julc.editor; + +import com.bloxbean.intelliada.idea.julc.annotator.validate.JulcVmBridge; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.Annotator; +import com.intellij.lang.annotation.HighlightSeverity; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.psi.*; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Shows compiled script size next to @Validator classes. + * Compiles the validator in the background and displays size with color coding: + * - Green: < 8KB + * - Yellow: 8-14KB + * - Red: > 14KB (approaching 16KB limit) + */ +public class JulcScriptSizeAnnotator implements Annotator { + private static final Logger LOG = Logger.getInstance(JulcScriptSizeAnnotator.class); + + private static final Set VALIDATOR_ANNOTATIONS = Set.of( + "Validator", "SpendingValidator", "MintingValidator", "MultiValidator", + "WithdrawValidator", "CertifyingValidator", "VotingValidator", "ProposingValidator" + ); + + // Cache: file path -> last compile info (avoid recompiling on every keystroke) + private static final Map sizeCache = new ConcurrentHashMap<>(); + + private record CachedSize(long timestamp, int sizeBytes, boolean hasErrors, String errorMsg) {} + + @Override + public void annotate(@NotNull PsiElement element, @NotNull AnnotationHolder holder) { + if (!(element instanceof PsiIdentifier)) return; + PsiElement parent = element.getParent(); + if (!(parent instanceof PsiClass psiClass)) return; + if (!element.equals(psiClass.getNameIdentifier())) return; + + if (!hasValidatorAnnotation(psiClass)) return; + + JulcTomlService tomlService = JulcTomlService.getInstance(element.getProject()); + if (tomlService == null || !tomlService.isJulcProject()) return; + + if (!JulcVmBridge.isCompilerAvailable()) return; + + // Get the full file text for compilation + PsiFile file = element.getContainingFile(); + if (file == null) return; + String source = file.getText(); + String filePath = file.getVirtualFile() != null ? file.getVirtualFile().getPath() : ""; + + // Check cache (avoid recompiling if source hasn't changed recently) + CachedSize cached = sizeCache.get(filePath); + long now = System.currentTimeMillis(); + if (cached != null && (now - cached.timestamp) < 3000) { + applyAnnotation(element, holder, cached); + return; + } + + // Compile and get size + try { + JulcVmBridge.CompileInfo info = JulcVmBridge.compile(source); + if (info != null) { + CachedSize newCached; + if (info.hasErrors) { + String errorMsg = info.diagnostics.isEmpty() ? "Compilation error" + : info.diagnostics.get(0).message(); + newCached = new CachedSize(now, 0, true, errorMsg); + } else { + newCached = new CachedSize(now, info.scriptSizeBytes, false, null); + } + sizeCache.put(filePath, newCached); + applyAnnotation(element, holder, newCached); + } + } catch (Exception e) { + LOG.debug("Script size compilation failed: " + e.getMessage()); + } + } + + private void applyAnnotation(PsiElement element, AnnotationHolder holder, CachedSize info) { + if (info.hasErrors) { + return; // Don't show size if compilation failed — SubsetValidator handles errors + } + + String sizeText = formatSize(info.sizeBytes); + HighlightSeverity severity; + String color; + + if (info.sizeBytes > 14 * 1024) { + severity = HighlightSeverity.WARNING; + color = "#CC0000"; + } else if (info.sizeBytes > 8 * 1024) { + severity = HighlightSeverity.WEAK_WARNING; + color = "#CC8800"; + } else { + severity = HighlightSeverity.INFORMATION; + color = "#008800"; + } + + holder.newAnnotation(severity, "julc: Script size " + sizeText) + .tooltip("Compiled Script Size: " + sizeText + "
" + + "" + sizeText + " / 16 KB limit

" + + "On-chain scripts must be under 16 KB (FLAT-encoded).
" + + (info.sizeBytes > 14 * 1024 ? "⚠️ Approaching limit — consider optimizing" : + info.sizeBytes > 8 * 1024 ? "Script is moderately sized" : + "✅ Script size is healthy") + "") + .afterEndOfLine() + .create(); + } + + private String formatSize(int bytes) { + if (bytes < 1024) return bytes + " B"; + double kb = bytes / 1024.0; + return kb < 10 ? String.format("%.1f KB", kb) : String.format("%.0f KB", kb); + } + + private boolean hasValidatorAnnotation(PsiClass psiClass) { + PsiModifierList mods = psiClass.getModifierList(); + if (mods == null) return false; + for (PsiAnnotation ann : mods.getAnnotations()) { + String name = ann.getQualifiedName(); + if (name == null) continue; + for (String target : VALIDATOR_ANNOTATIONS) { + if (name.endsWith(target)) return true; + } + } + return false; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcScriptSizeNotificationProvider.java b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcScriptSizeNotificationProvider.java new file mode 100644 index 0000000..0284aa1 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcScriptSizeNotificationProvider.java @@ -0,0 +1,72 @@ +package com.bloxbean.intelliada.idea.julc.editor; + +import com.bloxbean.intelliada.idea.julc.annotator.validate.JulcVmBridge; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.openapi.fileEditor.FileEditor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.ui.EditorNotificationPanel; +import com.intellij.ui.EditorNotificationProvider; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.util.function.Function; + +/** + * Shows a warning banner at the top of the editor when a julc validator's + * compiled script size approaches the 16 KB on-chain limit. + */ +public class JulcScriptSizeNotificationProvider implements EditorNotificationProvider { + + private static final int WARNING_THRESHOLD = 12 * 1024; // 12 KB + private static final int DANGER_THRESHOLD = 15 * 1024; // 15 KB + + @Override + public @Nullable Function collectNotificationData( + @NotNull Project project, @NotNull VirtualFile file) { + + if (!"java".equalsIgnoreCase(file.getExtension())) return null; + + JulcTomlService tomlService = JulcTomlService.getInstance(project); + if (tomlService == null || !tomlService.isJulcProject()) return null; + + if (!JulcVmBridge.isCompilerAvailable()) return null; + + // Quick check: does the file contain validator annotations? + try { + String content = new String(file.contentsToByteArray()); + if (!content.contains("@Validator") && !content.contains("@SpendingValidator") + && !content.contains("@MintingValidator") && !content.contains("@MultiValidator")) { + return null; + } + + // Compile to check size + JulcVmBridge.CompileInfo info = JulcVmBridge.compile(content); + if (info == null || info.hasErrors || info.scriptSizeBytes < WARNING_THRESHOLD) { + return null; + } + + int sizeBytes = info.scriptSizeBytes; + String sizeText = formatSize(sizeBytes); + + return fileEditor -> { + EditorNotificationPanel panel = new EditorNotificationPanel( + sizeBytes > DANGER_THRESHOLD ? EditorNotificationPanel.Status.Error + : EditorNotificationPanel.Status.Warning); + panel.setText("julc: Script size " + sizeText + " / 16 KB — " + + (sizeBytes > DANGER_THRESHOLD ? "critically close to limit!" : "consider optimizing")); + panel.createActionLabel("Dismiss", () -> panel.setVisible(false)); + return panel; + }; + } catch (Exception e) { + return null; + } + } + + private String formatSize(int bytes) { + if (bytes < 1024) return bytes + " B"; + double kb = bytes / 1024.0; + return kb < 10 ? String.format("%.1f KB", kb) : String.format("%.0f KB", kb); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcToolWindowFactory.java b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcToolWindowFactory.java new file mode 100644 index 0000000..7c5ef7f --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcToolWindowFactory.java @@ -0,0 +1,40 @@ +package com.bloxbean.intelliada.idea.julc.editor; + +import com.bloxbean.intelliada.idea.julc.repl.JulcReplPanel; +import com.bloxbean.intelliada.idea.julc.trace.JulcExecutionTracePanel; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import org.jetbrains.annotations.NotNull; + +/** + * Single julc tool window with tabs: + * - Trace: compile + evaluate + budget + traces + * - UPLC: generated UPLC preview + * - REPL: interactive julc REPL + */ +public class JulcToolWindowFactory implements ToolWindowFactory, DumbAware { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + ContentFactory cf = ContentFactory.getInstance(); + + // Trace tab (default) + JulcExecutionTracePanel tracePanel = new JulcExecutionTracePanel(project); + Content traceContent = cf.createContent(tracePanel.getMainPanel(), "Trace", false); + toolWindow.getContentManager().addContent(traceContent); + + // UPLC tab + JulcUplcPreviewPanel uplcPanel = new JulcUplcPreviewPanel(project); + Content uplcContent = cf.createContent(uplcPanel.getMainPanel(), "UPLC", false); + toolWindow.getContentManager().addContent(uplcContent); + + // REPL tab + JulcReplPanel replPanel = new JulcReplPanel(project); + Content replContent = cf.createContent(replPanel.getMainPanel(), "REPL", false); + toolWindow.getContentManager().addContent(replContent); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcUplcPreviewPanel.java b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcUplcPreviewPanel.java new file mode 100644 index 0000000..6e4d7ed --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcUplcPreviewPanel.java @@ -0,0 +1,155 @@ +package com.bloxbean.intelliada.idea.julc.editor; + +import com.bloxbean.intelliada.idea.julc.annotator.validate.JulcVmBridge; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.editor.event.DocumentListener; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.awt.*; +import java.util.Timer; +import java.util.TimerTask; + +/** + * UPLC Preview panel — shows generated UPLC alongside the Java source. + * Updates on file save with a debounce delay. + */ +public class JulcUplcPreviewPanel { + private static final Logger LOG = Logger.getInstance(JulcUplcPreviewPanel.class); + + private final Project project; + private JPanel mainPanel; + private JTextArea uplcTextArea; + private JLabel statusLabel; + private Timer debounceTimer; + + public JulcUplcPreviewPanel(Project project) { + this.project = project; + initComponents(); + listenForFileChanges(); + } + + public JPanel getMainPanel() { + return mainPanel; + } + + private void initComponents() { + mainPanel = new JPanel(new BorderLayout()); + + // Status bar at top + JPanel topPanel = new JPanel(new BorderLayout()); + statusLabel = new JLabel("Open a julc validator file to see UPLC output"); + statusLabel.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8)); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.ITALIC, 11f)); + + JButton refreshBtn = new JButton("Refresh"); + refreshBtn.addActionListener(e -> compileCurrentFile()); + topPanel.add(statusLabel, BorderLayout.CENTER); + topPanel.add(refreshBtn, BorderLayout.EAST); + mainPanel.add(topPanel, BorderLayout.NORTH); + + // UPLC text area + uplcTextArea = new JTextArea(); + uplcTextArea.setEditable(false); + uplcTextArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + uplcTextArea.setBackground(new Color(30, 30, 30)); + uplcTextArea.setForeground(new Color(200, 200, 200)); + uplcTextArea.setCaretColor(Color.WHITE); + mainPanel.add(new JScrollPane(uplcTextArea), BorderLayout.CENTER); + } + + private void listenForFileChanges() { + project.getMessageBus().connect().subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new FileEditorManagerListener() { + @Override + public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + if ("java".equalsIgnoreCase(file.getExtension())) { + scheduleCompile(file); + } + } + }); + } + + private void scheduleCompile(VirtualFile file) { + if (debounceTimer != null) debounceTimer.cancel(); + debounceTimer = new Timer(); + debounceTimer.schedule(new TimerTask() { + @Override + public void run() { + compileFile(file); + } + }, 500); + } + + private void compileCurrentFile() { + VirtualFile[] files = FileEditorManager.getInstance(project).getSelectedFiles(); + if (files.length > 0 && "java".equalsIgnoreCase(files[0].getExtension())) { + compileFile(files[0]); + } + } + + private void compileFile(VirtualFile file) { + if (!JulcVmBridge.isCompilerAvailable()) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("julc compiler not available"); + uplcTextArea.setText(""); + }); + return; + } + + JulcTomlService tomlService = JulcTomlService.getInstance(project); + if (tomlService == null || !tomlService.isJulcProject()) return; + + ApplicationManager.getApplication().runReadAction(() -> { + Document doc = FileDocumentManager.getInstance().getDocument(file); + if (doc == null) return; + String source = doc.getText(); + + if (!source.contains("@Validator") && !source.contains("@SpendingValidator") + && !source.contains("@MintingValidator") && !source.contains("@MultiValidator")) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("Not a julc validator file"); + uplcTextArea.setText(""); + }); + return; + } + + JulcVmBridge.CompileInfo info = JulcVmBridge.compile(source); + SwingUtilities.invokeLater(() -> { + if (info == null) { + statusLabel.setText("Compilation returned null"); + uplcTextArea.setText(""); + } else if (info.hasErrors) { + statusLabel.setText("Compilation errors (" + info.diagnostics.size() + ")"); + StringBuilder sb = new StringBuilder("Compilation errors:\n\n"); + for (var diag : info.diagnostics) { + sb.append("Line ").append(diag.line()).append(": ").append(diag.message()).append("\n"); + } + uplcTextArea.setText(sb.toString()); + } else { + statusLabel.setText("✅ " + file.getName() + " — " + formatSize(info.scriptSizeBytes) + + (info.parameterized ? " (parameterized)" : "")); + statusLabel.setForeground(info.scriptSizeBytes > 14 * 1024 ? Color.RED + : info.scriptSizeBytes > 8 * 1024 ? new Color(200, 150, 0) + : new Color(0, 128, 0)); + uplcTextArea.setText(info.uplcText != null ? info.uplcText : "(no UPLC output)"); + uplcTextArea.setCaretPosition(0); + } + }); + }); + } + + private String formatSize(int bytes) { + if (bytes < 1024) return bytes + " B"; + double kb = bytes / 1024.0; + return kb < 10 ? String.format("%.1f KB", kb) : String.format("%.0f KB", kb); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcUplcPreviewToolWindowFactory.java b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcUplcPreviewToolWindowFactory.java new file mode 100644 index 0000000..eaf7d58 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/editor/JulcUplcPreviewToolWindowFactory.java @@ -0,0 +1,18 @@ +package com.bloxbean.intelliada.idea.julc.editor; + +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import org.jetbrains.annotations.NotNull; + +public class JulcUplcPreviewToolWindowFactory implements ToolWindowFactory, DumbAware { + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + JulcUplcPreviewPanel panel = new JulcUplcPreviewPanel(project); + Content content = ContentFactory.getInstance().createContent(panel.getMainPanel(), "UPLC", false); + toolWindow.getContentManager().addContent(content); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/messaging/JulcProjectConfigChangeNotifier.java b/src/main/java/com/bloxbean/intelliada/idea/julc/messaging/JulcProjectConfigChangeNotifier.java new file mode 100644 index 0000000..8d1c26e --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/messaging/JulcProjectConfigChangeNotifier.java @@ -0,0 +1,11 @@ +package com.bloxbean.intelliada.idea.julc.messaging; + +import com.intellij.openapi.project.Project; +import com.intellij.util.messages.Topic; + +public interface JulcProjectConfigChangeNotifier { + Topic CHANGE_JULC_PROJECT_CONFIG_TOPIC + = Topic.create("JulcProjectConfigurationTopic", JulcProjectConfigChangeNotifier.class); + + void configUpdated(Project project); +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/messaging/JulcSDKChangeNotifier.java b/src/main/java/com/bloxbean/intelliada/idea/julc/messaging/JulcSDKChangeNotifier.java new file mode 100644 index 0000000..29a6c1e --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/messaging/JulcSDKChangeNotifier.java @@ -0,0 +1,12 @@ +package com.bloxbean.intelliada.idea.julc.messaging; + +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.intellij.util.messages.Topic; + +public interface JulcSDKChangeNotifier { + Topic CHANGE_JULC_SDK_TOPIC = Topic.create("JulcSDKTopic", JulcSDKChangeNotifier.class); + + void sdkAdded(JulcSDK sdk); + void sdkUpdated(JulcSDK sdk); + void sdkDeleted(JulcSDK sdk); +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/module/JulcModuleBuilder.java b/src/main/java/com/bloxbean/intelliada/idea/julc/module/JulcModuleBuilder.java new file mode 100644 index 0000000..749c03e --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/module/JulcModuleBuilder.java @@ -0,0 +1,465 @@ +package com.bloxbean.intelliada.idea.julc.module; + +import com.bloxbean.intelliada.idea.julc.common.JulcIcons; +import com.bloxbean.intelliada.idea.julc.configuration.JulcConfigurationHelperService; +import com.bloxbean.intelliada.idea.julc.configuration.JulcDownloader; +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcSDKState; +import com.bloxbean.intelliada.idea.julc.util.JulcSdkUtil; +import com.bloxbean.intelliada.idea.util.IdeaUtil; +import com.intellij.ide.util.projectWizard.ModuleBuilder; +import com.intellij.ide.util.projectWizard.ModuleBuilderListener; +import com.intellij.ide.util.projectWizard.ModuleWizardStep; +import com.intellij.ide.util.projectWizard.WizardInputField; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.externalSystem.importing.ImportSpecBuilder; +import com.intellij.openapi.externalSystem.model.ProjectSystemId; +import com.intellij.openapi.externalSystem.service.project.manage.ExternalProjectsManagerImpl; +import com.intellij.openapi.externalSystem.util.ExternalSystemUtil; +import com.intellij.openapi.module.Module; +import com.intellij.openapi.module.ModuleType; +import com.intellij.openapi.module.ModuleTypeManager; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ContentEntry; +import com.intellij.openapi.roots.ModifiableRootModel; +import com.intellij.openapi.roots.ui.configuration.ModulesProvider; +import com.intellij.openapi.ui.TextFieldWithBrowseButton; +import com.intellij.openapi.util.NlsContexts; +import com.intellij.openapi.util.Pair; +import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.awt.*; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +public class JulcModuleBuilder extends ModuleBuilder implements ModuleBuilderListener { + private static final Logger LOG = Logger.getInstance(JulcModuleBuilder.class); + private static final ProjectSystemId GRADLE_SYSTEM_ID = new ProjectSystemId("GRADLE"); + + private JulcSdkInputField sdkField; + private TemplateInputField templateField; + private TextInputField groupField; + private TextInputField packageField; + + public JulcModuleBuilder() { + addListener(this); + } + + @Override + public String getBuilderId() { return "Julc"; } + + @Override + public Icon getNodeIcon() { return JulcIcons.JULC_ICON; } + + @Override + public String getDescription() { return "julc - Write Cardano Smart Contracts in Java"; } + + @Override + public String getPresentableName() { return "julc"; } + + @Override + public String getGroupName() { return "julc"; } + + @Override + public ModuleWizardStep[] createWizardSteps(com.intellij.ide.util.projectWizard.WizardContext wizardContext, ModulesProvider modulesProvider) { + return new ModuleWizardStep[]{}; + } + + @Override + public void moduleCreated(@NotNull Module module) { + Project project = module.getProject(); + String basePath = project.getBasePath(); + if (basePath == null) return; + + ApplicationManager.getApplication().invokeLater(() -> { + VirtualFile baseDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(basePath); + if (baseDir != null) { + VfsUtil.markDirtyAndRefresh(false, true, true, baseDir); + } + + try { + VirtualFile buildFile = LocalFileSystem.getInstance().refreshAndFindFileByPath(basePath + "/build.gradle"); + if (buildFile != null) { + ExternalProjectsManagerImpl.getInstance(project).runWhenInitialized(() -> { + try { + ExternalSystemUtil.refreshProject(basePath, new ImportSpecBuilder(project, GRADLE_SYSTEM_ID)); + LOG.info("Triggered Gradle import for julc project at " + basePath); + } catch (Exception e) { + LOG.warn("Gradle import trigger failed", e); + } + }); + } + } catch (Exception e) { + LOG.debug("Could not trigger Gradle import", e); + } + }); + } + + @Override + public void setupRootModel(@NotNull ModifiableRootModel rootModel) throws ConfigurationException { + rootModel.inheritSdk(); + + String moduleName = rootModel.getModule().getName().toLowerCase(); + String template = templateField != null ? templateField.getValue() : "gradle"; + String group = groupField != null ? groupField.getValue() : "com.example"; + String pkg = packageField != null ? packageField.getValue() : group; + + // Get SDK from the wizard field + JulcSDK sdk = sdkField != null ? sdkField.getResolvedSDK() : null; + if (sdk == null) { + sdk = JulcConfigurationHelperService.getCompilerLocalSDK(rootModel.getProject()); + } + + if (sdk == null) { + throw new ConfigurationException( + "julc CLI is not configured. Please select or download a julc SDK in the wizard.", + "julc SDK Required"); + } + + Project project = rootModel.getProject(); + String basePath = project.getBasePath(); + + // Save SDK to state for future use + final JulcSDK finalSdk = sdk; + ApplicationManager.getApplication().runWriteAction(() -> { + scaffoldWithCli(finalSdk, basePath, moduleName, template, group, pkg, project); + }); + + ContentEntry contentEntry = doAddContentEntry(rootModel); + if (contentEntry != null) { + List> sourcePaths = getSourcePaths(template); + for (Pair sourcePath : sourcePaths) { + new File(sourcePath.first).mkdirs(); + VirtualFile sourceRoot = LocalFileSystem.getInstance() + .refreshAndFindFileByPath(FileUtil.toSystemIndependentName(sourcePath.first)); + if (sourceRoot != null) { + contentEntry.addSourceFolder(sourceRoot, false, sourcePath.second); + } + } + } + } + + private void scaffoldWithCli(JulcSDK sdk, String basePath, String moduleName, + String template, String group, String pkg, Project project) { + List commands = sdk.getJulcCommand(); + commands.add("new"); + commands.add(moduleName); + commands.add("--template"); + commands.add(template); + commands.add("--group"); + commands.add(group); + commands.add("--package"); + commands.add(pkg); + + try { + var tempDir = Files.createTempDirectory("julc").toFile(); + ProcessBuilder pb = new ProcessBuilder(commands); + pb.directory(tempDir); + pb.redirectErrorStream(true); + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + IdeaUtil.showNotification(project, "Project creation", + "julc new exited with code " + exitCode, NotificationType.WARNING, null); + } + + File srcDir = new File(tempDir, moduleName); + if (srcDir.exists()) { + FileUtil.copyDirContent(srcDir, new File(basePath)); + } else { + FileUtil.copyDirContent(tempDir, new File(basePath)); + } + } catch (Exception e) { + IdeaUtil.showNotification(project, "Project creation", + "Failed to create julc project: " + e.getMessage(), + NotificationType.ERROR, null); + LOG.error("julc project creation failed", e); + } + } + + @Override + protected List> getAdditionalFields() { + if (sdkField == null) { + sdkField = new JulcSdkInputField("julcSdk"); + } + if (templateField == null) { + templateField = new TemplateInputField("template", "gradle"); + } + if (groupField == null) { + groupField = new TextInputField("group", "com.example", "Group ID"); + } + if (packageField == null) { + packageField = new TextInputField("package", "com.example", "Package"); + } + return Arrays.asList(sdkField, templateField, groupField, packageField); + } + + @Override + public ModuleType getModuleType() { + return ModuleTypeManager.getInstance().getDefaultModuleType(); + } + + private List> getSourcePaths(String template) { + List> paths = new ArrayList<>(); + String base = getContentEntryPath(); + + if ("basic".equals(template)) { + @NonNls String srcPath = base + File.separator + "src"; + new File(srcPath).mkdirs(); + paths.add(Pair.create(srcPath, "")); + } else { + @NonNls String mainPath = base + File.separator + "src" + File.separator + "main" + File.separator + "java"; + @NonNls String testPath = base + File.separator + "src" + File.separator + "test" + File.separator + "java"; + new File(mainPath).mkdirs(); + new File(testPath).mkdirs(); + paths.add(Pair.create(mainPath, "")); + } + return paths; + } + + // ========== Custom Wizard Fields ========== + + /** + * julc SDK selector field shown in the project wizard. + * User must select or download a julc SDK before proceeding. + */ + static class JulcSdkInputField extends WizardInputField { + private JPanel panel; + private JTextField pathTf; + private JLabel statusLabel; + private JulcSDK resolvedSdk; + + protected JulcSdkInputField(String id) { + super(id, ""); + buildPanel(); + // Try to auto-detect existing SDK + autoDetect(); + } + + private void buildPanel() { + panel = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(2, 2, 2, 2); + c.anchor = GridBagConstraints.WEST; + + pathTf = new JTextField(25); + TextFieldWithBrowseButton browse = new TextFieldWithBrowseButton(pathTf, e -> { + JFileChooser fc = new JFileChooser(); + fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + fc.setDialogTitle("Select folder containing 'julc' executable"); + if (fc.showDialog(panel, "Select") == JFileChooser.APPROVE_OPTION) { + pathTf.setText(fc.getSelectedFile().getAbsolutePath()); + validatePath(); + } + }); + c.gridx = 0; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0; + panel.add(browse, c); + + JButton downloadBtn = new JButton("Download"); + downloadBtn.addActionListener(e -> downloadJulc()); + c.gridx = 1; c.fill = GridBagConstraints.NONE; c.weightx = 0; + panel.add(downloadBtn, c); + + statusLabel = new JLabel(" "); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.ITALIC, 11f)); + c.gridx = 0; c.gridy = 1; c.gridwidth = 2; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(statusLabel, c); + + pathTf.addActionListener(e -> validatePath()); + pathTf.addFocusListener(new java.awt.event.FocusAdapter() { + @Override + public void focusLost(java.awt.event.FocusEvent e) { + validatePath(); + } + }); + } + + private void autoDetect() { + // Check existing SDKs + List sdks = JulcSDKState.getInstance().getSdks(); + if (!sdks.isEmpty()) { + JulcSDK sdk = sdks.get(0); + pathTf.setText(sdk.getPath()); + resolvedSdk = sdk; + statusLabel.setText("Using: " + sdk.getName() + " (" + sdk.getVersion() + ")"); + statusLabel.setForeground(new Color(0, 128, 0)); + return; + } + + // Check common locations + String[] fallbacks = { + System.getProperty("user.home") + "/.julc/bin", + "/usr/local/bin" + }; + String exe = JulcSdkUtil.getJulcExecutable(); + for (String dir : fallbacks) { + if (new File(dir, exe).exists()) { + pathTf.setText(dir); + validatePath(); + return; + } + } + + // Check ~/.intelliada/julc-cli/ (our download location) + String downloadBase = System.getProperty("user.home") + "/.intelliada/julc-cli"; + String foundDir = JulcDownloader.findJulcBinDir(Path.of(downloadBase)); + if (foundDir != null) { + pathTf.setText(foundDir); + validatePath(); + return; + } + + statusLabel.setText("No julc found. Browse to installation or click Download."); + statusLabel.setForeground(Color.GRAY); + } + + private void validatePath() { + String path = pathTf.getText().trim(); + if (path.isEmpty()) { + resolvedSdk = null; + statusLabel.setText("Select julc installation path"); + statusLabel.setForeground(Color.GRAY); + return; + } + + String exe = JulcSdkUtil.getJulcExecutable(); + if (!new File(path, exe).exists()) { + // Maybe path includes the executable itself + File f = new File(path); + if (f.isFile() && f.getName().startsWith("julc")) { + path = f.getParent(); + pathTf.setText(path); + } + if (!new File(path, exe).exists()) { + resolvedSdk = null; + statusLabel.setText("'julc' not found in this directory"); + statusLabel.setForeground(Color.RED); + return; + } + } + + // Try to get version + String version = "unknown"; + try { + version = JulcSdkUtil.getVersionString(path); + } catch (Exception ex) { + // ignore + } + + final String resolvedPath = path; + resolvedSdk = new JulcSDK(UUID.randomUUID().toString(), "julc", resolvedPath, version != null ? version : "unknown"); + + // Save to SDK state for persistence + JulcSDKState state = JulcSDKState.getInstance(); + boolean exists = state.getSdks().stream().anyMatch(s -> s.getPath().equals(resolvedPath)); + if (!exists) { + state.addSdk(resolvedSdk); + } + + statusLabel.setText("julc " + (version != null ? version : "") + " — " + resolvedPath); + statusLabel.setForeground(new Color(0, 128, 0)); + } + + private void downloadJulc() { + String defaultDir = System.getProperty("user.home") + File.separator + ".intelliada" + File.separator + "julc-cli"; + JFileChooser fc = new JFileChooser(); + fc.setDialogTitle("Select download directory for julc CLI"); + fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + fc.setSelectedFile(new File(defaultDir)); + + if (fc.showSaveDialog(panel) != JFileChooser.APPROVE_OPTION) return; + + Path installDir = fc.getSelectedFile().toPath(); + statusLabel.setText("Downloading julc..."); + statusLabel.setForeground(new Color(200, 150, 0)); + + JulcDownloader downloader = new JulcDownloader(installDir); + downloader.setOnComplete(() -> { + String binDir = JulcDownloader.findJulcBinDir(installDir); + if (binDir != null) { + SwingUtilities.invokeLater(() -> { + pathTf.setText(binDir); + validatePath(); + }); + } else { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("Download complete. Browse to the julc executable directory."); + statusLabel.setForeground(new Color(200, 150, 0)); + }); + } + }); + downloader.install(); + } + + public JulcSDK getResolvedSDK() { + return resolvedSdk; + } + + @Override + public @NlsContexts.Label String getLabel() { return "julc SDK"; } + + @Override + public JPanel getComponent() { return panel; } + + @Override + public String getValue() { + return pathTf.getText(); + } + } + + static class TemplateInputField extends WizardInputField> { + private final JComboBox combo; + + protected TemplateInputField(String id, String defaultValue) { + super(id, defaultValue); + combo = new JComboBox<>(new String[]{"gradle", "maven", "basic"}); + combo.setSelectedItem(defaultValue); + } + + @Override + public @NlsContexts.Label String getLabel() { return "Template"; } + + @Override + public JComboBox getComponent() { return combo; } + + @Override + public String getValue() { return (String) combo.getSelectedItem(); } + } + + static class TextInputField extends WizardInputField { + private final JTextField textField; + private final String label; + + protected TextInputField(String id, String defaultValue, String label) { + super(id, defaultValue); + this.label = label; + textField = new JTextField(defaultValue); + } + + @Override + public @NlsContexts.Label String getLabel() { return label; } + + @Override + public JTextField getComponent() { return textField; } + + @Override + public String getValue() { return textField.getText(); } + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/module/pkg/JulcTomlService.java b/src/main/java/com/bloxbean/intelliada/idea/julc/module/pkg/JulcTomlService.java new file mode 100644 index 0000000..84b3a19 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/module/pkg/JulcTomlService.java @@ -0,0 +1,96 @@ +package com.bloxbean.intelliada.idea.julc.module.pkg; + +import com.bloxbean.intelliada.idea.julc.configuration.service.JulcProjectState; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Project service that detects julc project type (basic, gradle, maven). + */ +public class JulcTomlService { + private static final Logger LOG = Logger.getInstance(JulcTomlService.class); + + public static final String JULC_TOML = "julc.toml"; + + private final Project project; + + public JulcTomlService(Project project) { + this.project = project; + } + + public static JulcTomlService getInstance(Project project) { + return project.getService(JulcTomlService.class); + } + + /** + * Returns true if this project is any kind of julc project (basic, gradle, or maven). + */ + public boolean isJulcProject() { + return isBasicJulcProject() || isGradleJulcProject() || isMavenJulcProject(); + } + + /** + * Returns true if a julc.toml file exists at project root (basic julc project). + */ + public boolean isBasicJulcProject() { + VirtualFile baseDir = project.getBaseDir(); + if (baseDir == null) return false; + return baseDir.findChild(JULC_TOML) != null; + } + + /** + * Returns true if this is a Gradle project with julc dependencies. + */ + public boolean isGradleJulcProject() { + VirtualFile baseDir = project.getBaseDir(); + if (baseDir == null) return false; + + VirtualFile buildGradle = baseDir.findChild("build.gradle"); + if (buildGradle == null) { + buildGradle = baseDir.findChild("build.gradle.kts"); + } + if (buildGradle == null) return false; + + return containsJulcReference(buildGradle); + } + + /** + * Returns true if this is a Maven project with julc dependencies. + */ + public boolean isMavenJulcProject() { + VirtualFile baseDir = project.getBaseDir(); + if (baseDir == null) return false; + + VirtualFile pomXml = baseDir.findChild("pom.xml"); + if (pomXml == null) return false; + + return containsJulcReference(pomXml); + } + + /** + * Detects the project type for configuration purposes. + */ + public JulcProjectState.ProjectType detectProjectType() { + if (isGradleJulcProject()) return JulcProjectState.ProjectType.gradle; + if (isMavenJulcProject()) return JulcProjectState.ProjectType.maven; + if (isBasicJulcProject()) return JulcProjectState.ProjectType.basic; + return null; + } + + private boolean containsJulcReference(VirtualFile file) { + try { + String content = new String(file.contentsToByteArray(), StandardCharsets.UTF_8); + return content.contains("julc-stdlib") + || content.contains("julc-annotation-processor") + || content.contains("com.bloxbean.cardano.julc") + || content.contains("julc-compiler"); + } catch (IOException e) { + LOG.warn("Could not read " + file.getPath(), e); + return false; + } + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/module/project/JulcProjectOpenProcessor.java b/src/main/java/com/bloxbean/intelliada/idea/julc/module/project/JulcProjectOpenProcessor.java new file mode 100644 index 0000000..263ef9b --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/module/project/JulcProjectOpenProcessor.java @@ -0,0 +1,90 @@ +package com.bloxbean.intelliada.idea.julc.module.project; + +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.ide.impl.ProjectUtil; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ContentEntry; +import com.intellij.openapi.roots.ModifiableRootModel; +import com.intellij.openapi.roots.ModuleRootManager; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.projectImport.ProjectOpenProcessor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +/** + * Detects julc projects on open and configures source roots. + */ +public class JulcProjectOpenProcessor extends ProjectOpenProcessor { + private static final Logger LOG = Logger.getInstance(JulcProjectOpenProcessor.class); + + @Override + public @NotNull String getName() { + return "julc"; + } + + @Override + public @Nullable Icon getIcon() { + return null; + } + + @Override + public boolean canOpenProject(@NotNull VirtualFile file) { + VirtualFile dir = file.isDirectory() ? file : file.getParent(); + if (dir == null) return false; + + // Basic julc project detection: julc.toml exists + VirtualFile julcToml = dir.findChild(JulcTomlService.JULC_TOML); + return julcToml != null; + } + + @Override + public @Nullable Project doOpenProject(@NotNull VirtualFile virtualFile, @Nullable Project projectToClose, boolean forceOpenInNewFrame) { + VirtualFile dir = virtualFile.isDirectory() ? virtualFile : virtualFile.getParent(); + if (dir == null) return null; + + Project project = ProjectUtil.openOrImport(dir.toNioPath(), projectToClose, forceOpenInNewFrame); + if (project == null) return null; + + configureSourceRoots(project, dir); + return project; + } + + private void configureSourceRoots(Project project, VirtualFile projectDir) { + try { + var modules = ModuleRootManager.getInstance( + com.intellij.openapi.module.ModuleManager.getInstance(project).getModules()[0] + ); + ModifiableRootModel model = modules.getModifiableModel(); + + ContentEntry[] entries = model.getContentEntries(); + if (entries.length > 0) { + ContentEntry entry = entries[0]; + + // Mark src/ as source root for basic julc projects + VirtualFile srcDir = projectDir.findChild("src"); + if (srcDir != null) { + entry.addSourceFolder(srcDir, false); + } + + // Mark test/ as test root + VirtualFile testDir = projectDir.findChild("test"); + if (testDir != null) { + entry.addSourceFolder(testDir, true); + } + + // Exclude build/ directory + VirtualFile buildDir = projectDir.findChild("build"); + if (buildDir != null) { + entry.addExcludeFolder(buildDir); + } + } + + model.commit(); + } catch (Exception e) { + LOG.warn("Could not configure source roots for julc project", e); + } + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/repl/JulcReplPanel.java b/src/main/java/com/bloxbean/intelliada/idea/julc/repl/JulcReplPanel.java new file mode 100644 index 0000000..a242c9f --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/repl/JulcReplPanel.java @@ -0,0 +1,199 @@ +package com.bloxbean.intelliada.idea.julc.repl; + +import com.bloxbean.intelliada.idea.julc.configuration.JulcConfigurationHelperService; +import com.bloxbean.intelliada.idea.julc.configuration.JulcSDK; +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.OSProcessHandler; +import com.intellij.execution.process.ProcessAdapter; +import com.intellij.execution.process.ProcessEvent; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Interactive julc REPL panel. + * Manages a julc repl subprocess and provides input/output UI. + */ +public class JulcReplPanel { + private static final Logger LOG = Logger.getInstance(JulcReplPanel.class); + private static final String ANSI_ESCAPE_REGEX = "\u001B\\[[;\\d]*[A-Za-z]"; + + private final Project project; + private JPanel mainPanel; + private JTextArea outputArea; + private JTextField inputField; + private JButton sendBtn; + private JButton restartBtn; + + private OSProcessHandler processHandler; + private OutputStream processStdin; + private final List history = new ArrayList<>(); + private int historyIndex = -1; + + public JulcReplPanel(Project project) { + this.project = project; + initComponents(); + startRepl(); + } + + public JPanel getMainPanel() { + return mainPanel; + } + + private void initComponents() { + mainPanel = new JPanel(new BorderLayout(2, 2)); + + // Output area + outputArea = new JTextArea(); + outputArea.setEditable(false); + outputArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + outputArea.setBackground(new Color(30, 30, 30)); + outputArea.setForeground(new Color(200, 200, 200)); + outputArea.setCaretColor(Color.WHITE); + JScrollPane scrollPane = new JScrollPane(outputArea); + mainPanel.add(scrollPane, BorderLayout.CENTER); + + // Input panel + JPanel inputPanel = new JPanel(new BorderLayout(2, 0)); + + JLabel promptLabel = new JLabel("julc> "); + promptLabel.setFont(new Font(Font.MONOSPACED, Font.BOLD, 13)); + inputPanel.add(promptLabel, BorderLayout.WEST); + + inputField = new JTextField(); + inputField.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + inputField.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + sendInput(); + } else if (e.getKeyCode() == KeyEvent.VK_UP) { + navigateHistory(-1); + } else if (e.getKeyCode() == KeyEvent.VK_DOWN) { + navigateHistory(1); + } + } + }); + inputPanel.add(inputField, BorderLayout.CENTER); + + JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 2, 0)); + sendBtn = new JButton("Send"); + sendBtn.addActionListener(e -> sendInput()); + btnPanel.add(sendBtn); + + restartBtn = new JButton("Restart"); + restartBtn.addActionListener(e -> restartRepl()); + btnPanel.add(restartBtn); + + inputPanel.add(btnPanel, BorderLayout.EAST); + mainPanel.add(inputPanel, BorderLayout.SOUTH); + } + + private void startRepl() { + JulcSDK sdk = JulcConfigurationHelperService.getCompilerLocalSDK(project); + if (sdk == null) { + appendOutput("julc SDK not configured. Please configure it via julc > Configuration.\n"); + inputField.setEnabled(false); + sendBtn.setEnabled(false); + return; + } + + try { + // Check if julc version supports repl command (added in 0.1.0-pre11+) + List cmd = sdk.getJulcCommand(); + cmd.add("repl"); + + GeneralCommandLine commandLine = new GeneralCommandLine(cmd); + commandLine.setWorkDirectory(project.getBasePath()); + commandLine.setRedirectErrorStream(true); + + processHandler = new OSProcessHandler(commandLine); + processStdin = processHandler.getProcess().getOutputStream(); + + processHandler.addProcessListener(new ProcessAdapter() { + @Override + public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType) { + String text = stripAnsi(event.getText()); + SwingUtilities.invokeLater(() -> appendOutput(text)); + } + + @Override + public void processTerminated(@NotNull ProcessEvent event) { + SwingUtilities.invokeLater(() -> { + appendOutput("\n[REPL process exited with code " + event.getExitCode() + "]\n"); + inputField.setEnabled(false); + sendBtn.setEnabled(false); + }); + } + }); + + processHandler.startNotify(); + inputField.setEnabled(true); + sendBtn.setEnabled(true); + inputField.requestFocusInWindow(); + + } catch (ExecutionException e) { + appendOutput("Failed to start julc REPL: " + e.getMessage() + "\n"); + LOG.warn("Failed to start julc REPL", e); + } + } + + private void sendInput() { + String input = inputField.getText(); + if (input.isEmpty()) return; + + appendOutput("julc> " + input + "\n"); + history.add(input); + historyIndex = history.size(); + inputField.setText(""); + + if (processStdin != null) { + try { + processStdin.write((input + "\n").getBytes(StandardCharsets.UTF_8)); + processStdin.flush(); + } catch (Exception e) { + appendOutput("[Error sending input: " + e.getMessage() + "]\n"); + } + } + } + + private void restartRepl() { + if (processHandler != null && !processHandler.isProcessTerminated()) { + processHandler.destroyProcess(); + } + outputArea.setText(""); + startRepl(); + } + + private void navigateHistory(int direction) { + if (history.isEmpty()) return; + historyIndex += direction; + historyIndex = Math.max(0, Math.min(historyIndex, history.size())); + + if (historyIndex < history.size()) { + inputField.setText(history.get(historyIndex)); + } else { + inputField.setText(""); + } + } + + private void appendOutput(String text) { + outputArea.append(text); + outputArea.setCaretPosition(outputArea.getDocument().getLength()); + } + + private String stripAnsi(String text) { + return text.replaceAll(ANSI_ESCAPE_REGEX, ""); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/repl/JulcReplToolWindowFactory.java b/src/main/java/com/bloxbean/intelliada/idea/julc/repl/JulcReplToolWindowFactory.java new file mode 100644 index 0000000..8642acc --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/repl/JulcReplToolWindowFactory.java @@ -0,0 +1,20 @@ +package com.bloxbean.intelliada.idea.julc.repl; + +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import org.jetbrains.annotations.NotNull; + +public class JulcReplToolWindowFactory implements ToolWindowFactory, DumbAware { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + JulcReplPanel replPanel = new JulcReplPanel(project); + Content content = ContentFactory.getInstance().createContent( + replPanel.getMainPanel(), "REPL", false); + toolWindow.getContentManager().addContent(content); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/run/JulcRunValidatorAction.java b/src/main/java/com/bloxbean/intelliada/idea/julc/run/JulcRunValidatorAction.java new file mode 100644 index 0000000..2cc9518 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/run/JulcRunValidatorAction.java @@ -0,0 +1,157 @@ +package com.bloxbean.intelliada.idea.julc.run; + +import com.bloxbean.intelliada.idea.julc.annotator.validate.JulcVmBridge; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.bloxbean.intelliada.idea.toolwindow.CardanoConsole; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; + +/** + * "Run Validator" action — compiles and evaluates a julc validator locally. + * Available from julc context menu and gutter icon. + * Uses julc-compiler + julc-vm via runtime classloader bridge. + */ +public class JulcRunValidatorAction extends AnAction { + private static final Logger LOG = Logger.getInstance(JulcRunValidatorAction.class); + + @Override + public void update(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + e.getPresentation().setVisible(false); + return; + } + JulcTomlService tomlService = JulcTomlService.getInstance(project); + boolean visible = tomlService != null && tomlService.isJulcProject() && JulcVmBridge.isCompilerAvailable(); + e.getPresentation().setVisible(visible); + e.getPresentation().setEnabled(visible); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return; + + PsiFile psiFile = e.getData(CommonDataKeys.PSI_FILE); + if (psiFile == null) return; + + String source = psiFile.getText(); + if (source == null || source.isBlank()) return; + + CardanoConsole console = CardanoConsole.getConsole(project); + console.clearAndshow(); + + ProgressManager.getInstance().run(new Task.Backgroundable(project, "Running julc Validator...") { + @Override + public void run(@NotNull ProgressIndicator indicator) { + indicator.setText("Compiling validator..."); + indicator.setFraction(0.2); + + JulcVmBridge.CompileInfo compileInfo = JulcVmBridge.compile(source); + if (compileInfo == null) { + console.showErrorMessage("[julc Run] Compilation returned null — bridge may not be available"); + return; + } + + if (compileInfo.hasErrors) { + console.showErrorMessage("[julc Run] Compilation failed:"); + for (var diag : compileInfo.diagnostics) { + console.showErrorMessage(" " + diag.line() + ":" + diag.column() + " " + diag.message()); + } + return; + } + + console.showInfoMessage("[julc Run] Compilation successful"); + console.showInfoMessage("[julc Run] Script size: " + formatSize(compileInfo.scriptSizeBytes)); + if (compileInfo.parameterized) { + console.showWarningMessage("[julc Run] Validator is parameterized (@Param) — evaluation uses default parameters"); + } + + if (compileInfo.scriptSizeBytes > 14 * 1024) { + console.showWarningMessage("[julc Run] ⚠️ Script size " + formatSize(compileInfo.scriptSizeBytes) + + " is approaching the 16 KB on-chain limit!"); + } + + // Evaluate + indicator.setText("Evaluating validator..."); + indicator.setFraction(0.6); + + if (!JulcVmBridge.isVmAvailable()) { + console.showWarningMessage("[julc Run] VM not available — showing compile results only"); + showUplcPreview(console, compileInfo); + return; + } + + JulcVmBridge.EvalInfo evalInfo = JulcVmBridge.evaluate(compileInfo.program); + if (evalInfo == null) { + console.showWarningMessage("[julc Run] Evaluation returned null"); + showUplcPreview(console, compileInfo); + return; + } + + indicator.setFraction(1.0); + + // Show results + console.showInfoMessage(""); + if (evalInfo.success) { + console.showSuccessMessage("[julc Run] ✅ PASS"); + } else { + console.showErrorMessage("[julc Run] ❌ FAIL" + + (evalInfo.errorMessage != null ? ": " + evalInfo.errorMessage : "")); + } + + console.showInfoMessage("[julc Run] Budget: CPU " + formatNumber(evalInfo.cpuSteps) + + " | Memory " + formatNumber(evalInfo.memoryUnits)); + + // Show traces + if (!evalInfo.traces.isEmpty()) { + console.showInfoMessage("[julc Run] Traces:"); + for (String trace : evalInfo.traces) { + console.showInfoMessage(" → " + trace); + } + } + + showUplcPreview(console, compileInfo); + } + }); + } + + private void showUplcPreview(CardanoConsole console, JulcVmBridge.CompileInfo info) { + if (info.uplcText != null) { + console.showInfoMessage(""); + console.showInfoMessage("[julc Run] UPLC output:"); + // Show first 500 chars to avoid flooding console + String preview = info.uplcText.length() > 500 + ? info.uplcText.substring(0, 500) + "... (" + info.uplcText.length() + " chars total)" + : info.uplcText; + console.showInfoMessage(preview); + } + } + + private String formatSize(int bytes) { + if (bytes < 1024) return bytes + " B"; + double kb = bytes / 1024.0; + return kb < 10 ? String.format("%.1f KB", kb) : String.format("%.0f KB", kb); + } + + private String formatNumber(long n) { + if (n < 1000) return String.valueOf(n); + if (n < 1_000_000) return String.format("%.1fK", n / 1000.0); + return String.format("%.2fM", n / 1_000_000.0); + } + + @NotNull + @Override + public ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/service/BlueprintLoadService.java b/src/main/java/com/bloxbean/intelliada/idea/julc/service/BlueprintLoadService.java new file mode 100644 index 0000000..abd0b75 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/service/BlueprintLoadService.java @@ -0,0 +1,141 @@ +package com.bloxbean.intelliada.idea.julc.service; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Loads compiled validator blueprints from julc build output. + * Supports both CLI output (build/plutus/plutus.json) and + * annotation processor output (META-INF/plutus/*.plutus.json). + */ +public class BlueprintLoadService { + private static final Logger LOG = Logger.getInstance(BlueprintLoadService.class); + + private final Project project; + + public BlueprintLoadService(Project project) { + this.project = project; + } + + public static BlueprintLoadService getInstance(Project project) { + return new BlueprintLoadService(project); + } + + /** + * Load all validators from the project's build output. + */ + public PlutusBlueprint loadBlueprint() { + VirtualFile baseDir = project.getBaseDir(); + if (baseDir == null) return null; + + // Try CLI output: build/plutus/plutus.json + VirtualFile cliBlueprint = baseDir.findFileByRelativePath("build/plutus/plutus.json"); + if (cliBlueprint != null) { + return parseBlueprintFile(cliBlueprint); + } + + // Try Gradle annotation processor output: build/classes/java/main/META-INF/plutus/ + VirtualFile apDir = baseDir.findFileByRelativePath("build/classes/java/main/META-INF/plutus"); + if (apDir != null && apDir.isDirectory()) { + return loadFromAnnotationProcessorOutput(apDir); + } + + // Try Maven annotation processor output: target/classes/META-INF/plutus/ + VirtualFile mavenDir = baseDir.findFileByRelativePath("target/classes/META-INF/plutus"); + if (mavenDir != null && mavenDir.isDirectory()) { + return loadFromAnnotationProcessorOutput(mavenDir); + } + + return null; + } + + /** + * Load validators from a CIP-57 plutus.json blueprint file. + */ + private PlutusBlueprint parseBlueprintFile(VirtualFile file) { + try { + String content = new String(file.contentsToByteArray(), StandardCharsets.UTF_8); + JSONObject json = new JSONObject(content); + + PlutusBlueprint blueprint = new PlutusBlueprint(); + + // Parse preamble + JSONObject preamble = json.optJSONObject("preamble"); + if (preamble != null) { + blueprint.setPreamble(new PlutusBlueprint.Preamble( + preamble.optString("title", ""), + preamble.optString("version", "") + )); + } + + // Parse validators + JSONArray validators = json.optJSONArray("validators"); + List validatorList = new ArrayList<>(); + if (validators != null) { + for (int i = 0; i < validators.length(); i++) { + JSONObject v = validators.getJSONObject(i); + String compiledCode = v.optString("compiledCode", ""); + validatorList.add(new PlutusBlueprint.ValidatorInfo( + v.optString("title", "unknown"), + v.optString("hash", ""), + compiledCode, + compiledCode.length() / 2, // hex bytes + false + )); + } + } + blueprint.setValidators(validatorList); + return blueprint; + + } catch (Exception e) { + LOG.warn("Failed to parse blueprint: " + file.getPath(), e); + return null; + } + } + + /** + * Load individual .plutus.json files from annotation processor output. + */ + private PlutusBlueprint loadFromAnnotationProcessorOutput(VirtualFile dir) { + PlutusBlueprint blueprint = new PlutusBlueprint(); + blueprint.setPreamble(new PlutusBlueprint.Preamble("", "")); + List validators = new ArrayList<>(); + + for (VirtualFile child : dir.getChildren()) { + if (child.getName().endsWith(".plutus.json")) { + try { + String content = new String(child.contentsToByteArray(), StandardCharsets.UTF_8); + JSONObject json = new JSONObject(content); + + String compiledCode = json.optString("compiledCode", + json.optString("cborHex", "")); + String title = child.getNameWithoutExtension().replace(".plutus", ""); + + validators.add(new PlutusBlueprint.ValidatorInfo( + title, + json.optString("hash", json.optString("scriptHash", "")), + compiledCode, + compiledCode.length() / 2, + json.optBoolean("parameterized", false) + )); + } catch (IOException e) { + LOG.warn("Failed to parse " + child.getPath(), e); + } + } + } + + if (validators.isEmpty()) return null; + + blueprint.setValidators(validators); + return blueprint; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/service/PlutusBlueprint.java b/src/main/java/com/bloxbean/intelliada/idea/julc/service/PlutusBlueprint.java new file mode 100644 index 0000000..279fd51 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/service/PlutusBlueprint.java @@ -0,0 +1,33 @@ +package com.bloxbean.intelliada.idea.julc.service; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +/** + * CIP-57 Plutus Blueprint model. + * Parsed from build/plutus/plutus.json or META-INF/plutus/*.plutus.json + */ +@Data +public class PlutusBlueprint { + private Preamble preamble; + private List validators; + + @Data + @AllArgsConstructor + public static class Preamble { + private String title; + private String version; + } + + @Data + @AllArgsConstructor + public static class ValidatorInfo { + private String title; + private String hash; + private String compiledCode; + private long sizeBytes; + private boolean parameterized; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcBudgetTracker.java b/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcBudgetTracker.java new file mode 100644 index 0000000..1b1275a --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcBudgetTracker.java @@ -0,0 +1,62 @@ +package com.bloxbean.intelliada.idea.julc.trace; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks budget changes between evaluations to show deltas. + * "Budget: CPU 1,234,567 (+50,234 from last run)" + */ +public class JulcBudgetTracker { + + private static final Map snapshots = new ConcurrentHashMap<>(); + + public record BudgetSnapshot(long cpu, long memory, int scriptSizeBytes, long timestamp) {} + + public record BudgetDelta(long cpuDelta, long memDelta, int sizeDelta, BudgetSnapshot current, BudgetSnapshot previous) { + public boolean hasChanged() { + return cpuDelta != 0 || memDelta != 0 || sizeDelta != 0; + } + + public String formatCpuDelta() { + return formatDelta(cpuDelta); + } + + public String formatMemDelta() { + return formatDelta(memDelta); + } + + public String formatSizeDelta() { + if (sizeDelta == 0) return ""; + return (sizeDelta > 0 ? "+" : "") + sizeDelta + " B"; + } + + private String formatDelta(long delta) { + if (delta == 0) return ""; + String sign = delta > 0 ? "+" : ""; + if (Math.abs(delta) < 1000) return sign + delta; + if (Math.abs(delta) < 1_000_000) return String.format("%s%.1fK", sign, delta / 1000.0); + return String.format("%s%.2fM", sign, delta / 1_000_000.0); + } + } + + public static BudgetDelta record(String filePath, long cpu, long memory, int scriptSizeBytes) { + BudgetSnapshot current = new BudgetSnapshot(cpu, memory, scriptSizeBytes, System.currentTimeMillis()); + BudgetSnapshot previous = snapshots.put(filePath, current); + + if (previous == null) { + return new BudgetDelta(0, 0, 0, current, null); + } + + return new BudgetDelta( + cpu - previous.cpu, + memory - previous.memory, + scriptSizeBytes - previous.scriptSizeBytes, + current, previous + ); + } + + public static BudgetSnapshot getLastSnapshot(String filePath) { + return snapshots.get(filePath); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcExecutionTracePanel.java b/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcExecutionTracePanel.java new file mode 100644 index 0000000..ef70765 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcExecutionTracePanel.java @@ -0,0 +1,267 @@ +package com.bloxbean.intelliada.idea.julc.trace; + +import com.bloxbean.intelliada.idea.julc.annotator.validate.JulcVmBridge; +import com.bloxbean.intelliada.idea.julc.module.pkg.JulcTomlService; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; + +/** + * Execution trace panel showing: + * - Compile result (script size, parameterized) + * - Evaluation result (pass/fail, budget) + * - User trace messages + * - Budget delta from last run + * - Budget hotspot summary + */ +public class JulcExecutionTracePanel { + private static final Logger LOG = Logger.getInstance(JulcExecutionTracePanel.class); + + private final Project project; + private JPanel mainPanel; + private JLabel resultLabel; + private JLabel budgetLabel; + private JLabel sizeLabel; + private JLabel deltaLabel; + private JTextArea traceArea; + private DefaultTableModel hotspotModel; + + public JulcExecutionTracePanel(Project project) { + this.project = project; + initComponents(); + } + + public JPanel getMainPanel() { + return mainPanel; + } + + private void initComponents() { + mainPanel = new JPanel(new BorderLayout(5, 5)); + + // Top: Run button + status + JPanel topPanel = new JPanel(new BorderLayout()); + JButton runBtn = new JButton("Run & Trace Current Validator"); + runBtn.addActionListener(e -> runCurrentFile()); + topPanel.add(runBtn, BorderLayout.WEST); + + JPanel statusPanel = new JPanel(new GridLayout(2, 2, 10, 2)); + statusPanel.setBorder(BorderFactory.createEmptyBorder(4, 10, 4, 4)); + + resultLabel = new JLabel("—"); + resultLabel.setFont(resultLabel.getFont().deriveFont(Font.BOLD, 14f)); + budgetLabel = new JLabel("Budget: —"); + budgetLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + sizeLabel = new JLabel("Size: —"); + deltaLabel = new JLabel(""); + deltaLabel.setFont(deltaLabel.getFont().deriveFont(Font.ITALIC, 11f)); + + statusPanel.add(resultLabel); + statusPanel.add(sizeLabel); + statusPanel.add(budgetLabel); + statusPanel.add(deltaLabel); + + topPanel.add(statusPanel, BorderLayout.CENTER); + mainPanel.add(topPanel, BorderLayout.NORTH); + + // Center: Split pane — traces + hotspots + JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + splitPane.setResizeWeight(0.6); + + // Trace messages + traceArea = new JTextArea(); + traceArea.setEditable(false); + traceArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + traceArea.setBackground(new Color(30, 30, 30)); + traceArea.setForeground(new Color(200, 200, 200)); + JPanel tracePanel = new JPanel(new BorderLayout()); + tracePanel.setBorder(BorderFactory.createTitledBorder("Execution Trace")); + tracePanel.add(new JScrollPane(traceArea), BorderLayout.CENTER); + splitPane.setTopComponent(tracePanel); + + // Budget hotspots + hotspotModel = new DefaultTableModel(new String[]{"Operation", "CPU Steps", "% of Total"}, 0) { + @Override + public boolean isCellEditable(int row, int column) { return false; } + }; + JTable hotspotTable = new JTable(hotspotModel); + JPanel hotspotPanel = new JPanel(new BorderLayout()); + hotspotPanel.setBorder(BorderFactory.createTitledBorder("Budget Hotspots")); + hotspotPanel.add(new JScrollPane(hotspotTable), BorderLayout.CENTER); + splitPane.setBottomComponent(hotspotPanel); + + mainPanel.add(splitPane, BorderLayout.CENTER); + } + + private void runCurrentFile() { + VirtualFile[] files = FileEditorManager.getInstance(project).getSelectedFiles(); + if (files.length == 0) { + resultLabel.setText("No file open"); + resultLabel.setForeground(Color.GRAY); + return; + } + + VirtualFile file = files[0]; + if (!"java".equalsIgnoreCase(file.getExtension())) { + resultLabel.setText("Not a Java file"); + resultLabel.setForeground(Color.GRAY); + return; + } + + JulcTomlService tomlService = JulcTomlService.getInstance(project); + if (tomlService == null || !tomlService.isJulcProject()) { + resultLabel.setText("Not a julc project"); + resultLabel.setForeground(Color.GRAY); + return; + } + + // Read document text on EDT (has unsaved edits) before spawning background task + Document doc = FileDocumentManager.getInstance().getDocument(file); + final String source = doc != null ? doc.getText() : null; + + if (source == null || source.isBlank()) { + resultLabel.setText("Cannot read file content"); + resultLabel.setForeground(Color.RED); + return; + } + + resultLabel.setText("Compiling..."); + resultLabel.setForeground(new Color(200, 150, 0)); + traceArea.setText(""); + hotspotModel.setRowCount(0); + + ProgressManager.getInstance().run(new Task.Backgroundable(project, "Running julc Validator with Trace...") { + @Override + public void run(@NotNull ProgressIndicator indicator) { + if (!JulcVmBridge.isCompilerAvailable()) { + SwingUtilities.invokeLater(() -> { + resultLabel.setText("Cannot compile — julc compiler not available"); + resultLabel.setForeground(Color.RED); + }); + return; + } + + // Compile + indicator.setText("Compiling..."); + JulcVmBridge.CompileInfo compileInfo = JulcVmBridge.compile(source); + if (compileInfo == null) { + SwingUtilities.invokeLater(() -> { + resultLabel.setText("❌ Compilation returned null"); + resultLabel.setForeground(Color.RED); + }); + return; + } + + if (compileInfo.hasErrors) { + SwingUtilities.invokeLater(() -> { + resultLabel.setText("❌ Compilation failed"); + resultLabel.setForeground(Color.RED); + StringBuilder sb = new StringBuilder("Compilation errors:\n"); + for (var d : compileInfo.diagnostics) { + sb.append(" Line ").append(d.line()).append(": ").append(d.message()).append("\n"); + } + traceArea.setText(sb.toString()); + }); + return; + } + + // Evaluate + indicator.setText("Evaluating..."); + JulcVmBridge.EvalInfo evalInfo = null; + if (JulcVmBridge.isVmAvailable()) { + evalInfo = JulcVmBridge.evaluate(compileInfo.program); + } + + // Record budget delta + String filePath = file.getPath(); + long cpu = evalInfo != null ? evalInfo.cpuSteps : 0; + long mem = evalInfo != null ? evalInfo.memoryUnits : 0; + JulcBudgetTracker.BudgetDelta delta = JulcBudgetTracker.record(filePath, cpu, mem, compileInfo.scriptSizeBytes); + + // Update UI on EDT + final JulcVmBridge.EvalInfo finalEval = evalInfo; + SwingUtilities.invokeLater(() -> updateUI(compileInfo, finalEval, delta)); + } + }); + } + + private void updateUI(JulcVmBridge.CompileInfo compileInfo, JulcVmBridge.EvalInfo evalInfo, + JulcBudgetTracker.BudgetDelta delta) { + // Result + if (evalInfo != null) { + if (evalInfo.success) { + resultLabel.setText("✅ PASS"); + resultLabel.setForeground(new Color(0, 128, 0)); + } else { + resultLabel.setText("❌ FAIL" + (evalInfo.errorMessage != null ? ": " + evalInfo.errorMessage : "")); + resultLabel.setForeground(Color.RED); + } + budgetLabel.setText("Budget: CPU " + formatNumber(evalInfo.cpuSteps) + " | Memory " + formatNumber(evalInfo.memoryUnits)); + } else { + resultLabel.setText("Compiled (VM not available)"); + resultLabel.setForeground(new Color(200, 150, 0)); + budgetLabel.setText("Budget: —"); + } + + // Size + sizeLabel.setText("Size: " + formatSize(compileInfo.scriptSizeBytes) + + (compileInfo.parameterized ? " (parameterized)" : "")); + + // Delta + if (delta.hasChanged()) { + StringBuilder db = new StringBuilder(); + if (!delta.formatCpuDelta().isEmpty()) db.append("CPU ").append(delta.formatCpuDelta()).append(" "); + if (!delta.formatMemDelta().isEmpty()) db.append("Mem ").append(delta.formatMemDelta()).append(" "); + if (!delta.formatSizeDelta().isEmpty()) db.append("Size ").append(delta.formatSizeDelta()); + deltaLabel.setText("Δ " + db.toString().trim()); + deltaLabel.setForeground(delta.cpuDelta() > 0 ? Color.RED : new Color(0, 128, 0)); + } else if (delta.previous() == null) { + deltaLabel.setText("(first run)"); + deltaLabel.setForeground(Color.GRAY); + } else { + deltaLabel.setText("(no change)"); + deltaLabel.setForeground(Color.GRAY); + } + + // Traces + StringBuilder traces = new StringBuilder(); + if (evalInfo != null && !evalInfo.traces.isEmpty()) { + traces.append("User Traces (Builtins.trace):\n"); + for (String t : evalInfo.traces) { + traces.append(" → ").append(t).append("\n"); + } + traces.append("\n"); + } + + if (compileInfo.uplcText != null) { + traces.append("UPLC Output:\n"); + traces.append(compileInfo.uplcText); + } + + traceArea.setText(traces.toString()); + traceArea.setCaretPosition(0); + } + + private String formatSize(int bytes) { + if (bytes < 1024) return bytes + " B"; + double kb = bytes / 1024.0; + return kb < 10 ? String.format("%.1f KB", kb) : String.format("%.0f KB", kb); + } + + private String formatNumber(long n) { + if (n < 1000) return String.valueOf(n); + if (n < 1_000_000) return String.format("%.1fK", n / 1000.0); + return String.format("%.2fM", n / 1_000_000.0); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcTraceToolWindowFactory.java b/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcTraceToolWindowFactory.java new file mode 100644 index 0000000..a976f9a --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/trace/JulcTraceToolWindowFactory.java @@ -0,0 +1,18 @@ +package com.bloxbean.intelliada.idea.julc.trace; + +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import org.jetbrains.annotations.NotNull; + +public class JulcTraceToolWindowFactory implements ToolWindowFactory, DumbAware { + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + JulcExecutionTracePanel panel = new JulcExecutionTracePanel(project); + Content content = ContentFactory.getInstance().createContent(panel.getMainPanel(), "Trace", false); + toolWindow.getContentManager().addContent(content); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/julc/util/JulcSdkUtil.java b/src/main/java/com/bloxbean/intelliada/idea/julc/util/JulcSdkUtil.java new file mode 100644 index 0000000..a0f33ae --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/julc/util/JulcSdkUtil.java @@ -0,0 +1,72 @@ +package com.bloxbean.intelliada.idea.julc.util; + +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; + +public class JulcSdkUtil { + private static final Logger LOG = Logger.getInstance(JulcSdkUtil.class); + + public static String getVersionString(String julcBinFolder) throws Exception { + if (julcBinFolder == null) return null; + + String julcCmd = getJulcExecutable(); + + File file = new File(julcBinFolder); + VirtualFile home = LocalFileSystem.getInstance().findFileByIoFile(file); + if (home != null) { + try { + String result = runAndGetVersion(file.getAbsolutePath() + File.separator + julcCmd, "--version"); + LOG.debug(result); + return result; + } catch (Exception e) { + if (LOG.isDebugEnabled()) { + LOG.error(e); + } + throw e; + } + } + return null; + } + + private static String runAndGetVersion(String program, String command) throws InterruptedException, ExecutionException, IOException { + GeneralCommandLine commandLine = new GeneralCommandLine(program, command); + Process process = commandLine.createProcess(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + String version = null; + int lineCount = 1; + while ((line = reader.readLine()) != null) { + if (lineCount == 1) { + // Expected format: "julc " or just "" + String[] parts = line.split(" "); + version = parts.length > 1 ? parts[1] : parts[0]; + } + lineCount++; + } + + int exitVal = process.waitFor(); + if (exitVal == 0) { + LOG.debug("Getting julc SDK version. Success!"); + return version; + } + } + return null; + } + + public static String getJulcExecutable() { + String julcCmd = "julc"; + if (SystemInfo.isWindows) + julcCmd = "julc.exe"; + return julcCmd; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitLifecycleService.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitLifecycleService.java new file mode 100644 index 0000000..e384ef6 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitLifecycleService.java @@ -0,0 +1,166 @@ +package com.bloxbean.intelliada.idea.nodeint.devkit; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import com.intellij.openapi.project.ProjectManagerListener; +import com.intellij.openapi.startup.StartupActivity; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Service +public final class DevKitLifecycleService implements StartupActivity { + private static final Logger LOG = Logger.getInstance(DevKitLifecycleService.class); + + private final ConcurrentMap processManagers = new ConcurrentHashMap<>(); + private final ConcurrentMap statusMonitors = new ConcurrentHashMap<>(); + + public static DevKitLifecycleService getInstance() { + return ApplicationManager.getApplication().getService(DevKitLifecycleService.class); + } + + @Override + public void runActivity(@NotNull Project project) { + // Initialize project-specific DevKit management + LOG.info("Initializing DevKit lifecycle service for project: " + project.getName()); + + // Register project close listener + project.getMessageBus().connect().subscribe(ProjectManager.TOPIC, new ProjectManagerListener() { + @Override + public void projectClosed(@NotNull Project closedProject) { + if (closedProject.equals(project)) { + cleanupProject(project); + } + } + }); + } + + public CompletableFuture startDevKit(@NotNull Project project, @NotNull String devKitHome) { + return startDevKit(project, devKitHome, false); + } + + public CompletableFuture startDevKit(@NotNull Project project, @NotNull String devKitHome, boolean interactive) { + String projectKey = getProjectKey(project); + + DevKitProcessManager processManager = processManagers.computeIfAbsent(projectKey, + k -> new DevKitProcessManager(devKitHome)); + + // Start status monitoring + DevKitStatusMonitor statusMonitor = statusMonitors.computeIfAbsent(projectKey, + k -> new DevKitStatusMonitor(processManager)); + statusMonitor.startMonitoring(); + + LOG.info("Starting DevKit for project: " + project.getName() + " at: " + devKitHome); + return processManager.startDevKit(interactive); + } + + public CompletableFuture stopDevKit(@NotNull Project project) { + String projectKey = getProjectKey(project); + DevKitProcessManager processManager = processManagers.get(projectKey); + + if (processManager != null) { + LOG.info("Stopping DevKit for project: " + project.getName()); + + // Stop status monitoring + DevKitStatusMonitor statusMonitor = statusMonitors.get(projectKey); + if (statusMonitor != null) { + statusMonitor.stopMonitoring(); + } + + return processManager.stopDevKit(); + } + + return CompletableFuture.completedFuture(true); + } + + public DevKitProcessManager.DevKitStatus getDevKitStatus(@NotNull Project project) { + String projectKey = getProjectKey(project); + DevKitProcessManager processManager = processManagers.get(projectKey); + + if (processManager != null) { + return processManager.getStatus(); + } + + return DevKitProcessManager.DevKitStatus.STOPPED; + } + + public boolean isDevKitRunning(@NotNull Project project) { + String projectKey = getProjectKey(project); + DevKitProcessManager processManager = processManagers.get(projectKey); + + return processManager != null && processManager.isRunning(); + } + + public boolean isDevKitHealthy(@NotNull Project project) { + String projectKey = getProjectKey(project); + DevKitProcessManager processManager = processManagers.get(projectKey); + + return processManager != null && processManager.isHealthy(); + } + + @Nullable + public DevKitProcessManager getProcessManager(@NotNull Project project) { + String projectKey = getProjectKey(project); + return processManagers.get(projectKey); + } + + @Nullable + public DevKitStatusMonitor getStatusMonitor(@NotNull Project project) { + String projectKey = getProjectKey(project); + return statusMonitors.get(projectKey); + } + + public CompletableFuture restartDevKit(@NotNull Project project) { + LOG.info("Restarting DevKit for project: " + project.getName()); + + return stopDevKit(project).thenCompose(stopped -> { + if (stopped) { + String projectKey = getProjectKey(project); + DevKitProcessManager processManager = processManagers.get(projectKey); + if (processManager != null) { + return processManager.startDevKit(); + } + } + return CompletableFuture.completedFuture(false); + }); + } + + public void cleanupProject(@NotNull Project project) { + String projectKey = getProjectKey(project); + LOG.info("Cleaning up DevKit resources for project: " + project.getName()); + + // Stop status monitoring + DevKitStatusMonitor statusMonitor = statusMonitors.remove(projectKey); + if (statusMonitor != null) { + statusMonitor.stopMonitoring(); + } + + // Stop DevKit process + DevKitProcessManager processManager = processManagers.remove(projectKey); + if (processManager != null) { + processManager.stopDevKit(); + } + } + + public void cleanupAll() { + LOG.info("Cleaning up all DevKit resources"); + + // Stop all status monitors + statusMonitors.values().forEach(DevKitStatusMonitor::stopMonitoring); + statusMonitors.clear(); + + // Stop all DevKit processes + processManagers.values().forEach(DevKitProcessManager::stopDevKit); + processManagers.clear(); + } + + private String getProjectKey(@NotNull Project project) { + return project.getLocationHash(); + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitProcessManager.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitProcessManager.java new file mode 100644 index 0000000..ccc1dc4 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitProcessManager.java @@ -0,0 +1,241 @@ +package com.bloxbean.intelliada.idea.nodeint.devkit; + +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.OSProcessHandler; +import com.intellij.execution.process.ProcessAdapter; +import com.intellij.execution.process.ProcessEvent; +import com.intellij.execution.process.ProcessHandler; +import com.intellij.notification.Notification; +import com.intellij.notification.NotificationType; +import com.intellij.notification.Notifications; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.Key; +import com.intellij.openapi.util.SystemInfo; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class DevKitProcessManager { + private static final Logger LOG = Logger.getInstance(DevKitProcessManager.class); + + public enum DevKitStatus { + STOPPED, + STARTING, + RUNNING, + STOPPING, + ERROR + } + + private final Path devKitHomePath; + private final String baseUrl; + private final int port; + private ProcessHandler processHandler; + private DevKitStatus currentStatus = DevKitStatus.STOPPED; + private final Object statusLock = new Object(); + + public DevKitProcessManager(String devKitHome) { + this(devKitHome, "http://localhost:8080", 8080); + } + + public DevKitProcessManager(String devKitHome, String baseUrl, int port) { + this.devKitHomePath = Paths.get(devKitHome); + this.baseUrl = baseUrl; + this.port = port; + } + + public CompletableFuture startDevKit() { + return startDevKit(false); + } + + public CompletableFuture startDevKit(boolean interactive) { + CompletableFuture future = new CompletableFuture<>(); + + synchronized (statusLock) { + if (currentStatus == DevKitStatus.RUNNING) { + future.complete(true); + return future; + } + + if (currentStatus == DevKitStatus.STARTING) { + future.complete(false); + return future; + } + + currentStatus = DevKitStatus.STARTING; + } + + try { + GeneralCommandLine commandLine = createStartCommand(interactive); + processHandler = new OSProcessHandler(commandLine); + + processHandler.addProcessListener(new ProcessAdapter() { + @Override + public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType) { + String text = event.getText().trim(); + LOG.info("DevKit output: " + text); + + if (text.contains("Started YaciDevKitApplication")) { + synchronized (statusLock) { + currentStatus = DevKitStatus.RUNNING; + } + notifyUser("DevKit Started", "Yaci DevKit is now running on port " + port, NotificationType.INFORMATION); + future.complete(true); + } + } + + @Override + public void processTerminated(@NotNull ProcessEvent event) { + synchronized (statusLock) { + currentStatus = DevKitStatus.STOPPED; + } + LOG.info("DevKit process terminated with exit code: " + event.getExitCode()); + if (!future.isDone()) { + future.complete(false); + } + } + }); + + processHandler.startNotify(); + + // Timeout after 60 seconds if not started + CompletableFuture.delayedExecutor(60, TimeUnit.SECONDS).execute(() -> { + if (!future.isDone()) { + synchronized (statusLock) { + currentStatus = DevKitStatus.ERROR; + } + notifyUser("DevKit Start Failed", "DevKit failed to start within 60 seconds", NotificationType.ERROR); + future.complete(false); + } + }); + + } catch (ExecutionException e) { + LOG.error("Failed to start DevKit", e); + synchronized (statusLock) { + currentStatus = DevKitStatus.ERROR; + } + notifyUser("DevKit Start Failed", "Failed to start DevKit: " + e.getMessage(), NotificationType.ERROR); + future.complete(false); + } + + return future; + } + + public CompletableFuture stopDevKit() { + CompletableFuture future = new CompletableFuture<>(); + + synchronized (statusLock) { + if (currentStatus == DevKitStatus.STOPPED) { + future.complete(true); + return future; + } + + if (processHandler == null || processHandler.isProcessTerminated()) { + currentStatus = DevKitStatus.STOPPED; + future.complete(true); + return future; + } + + currentStatus = DevKitStatus.STOPPING; + } + + try { + // First try graceful shutdown + processHandler.destroyProcess(); + + // Wait for process to terminate + CompletableFuture.delayedExecutor(10, TimeUnit.SECONDS).execute(() -> { + if (processHandler != null && !processHandler.isProcessTerminated()) { + // Force kill if not terminated + processHandler.destroyProcess(); + } + + synchronized (statusLock) { + currentStatus = DevKitStatus.STOPPED; + } + notifyUser("DevKit Stopped", "Yaci DevKit has been stopped", NotificationType.INFORMATION); + future.complete(true); + }); + + } catch (Exception e) { + LOG.error("Failed to stop DevKit", e); + synchronized (statusLock) { + currentStatus = DevKitStatus.ERROR; + } + notifyUser("DevKit Stop Failed", "Failed to stop DevKit: " + e.getMessage(), NotificationType.ERROR); + future.complete(false); + } + + return future; + } + + public DevKitStatus getStatus() { + synchronized (statusLock) { + return currentStatus; + } + } + + public boolean isRunning() { + return getStatus() == DevKitStatus.RUNNING && isHealthy(); + } + + public boolean isHealthy() { + try { + URL url = new URL(baseUrl + "/api/v1/health"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + int responseCode = connection.getResponseCode(); + return responseCode == 200; + } catch (IOException e) { + LOG.debug("Health check failed", e); + return false; + } + } + + private GeneralCommandLine createStartCommand(boolean interactive) { + GeneralCommandLine commandLine = new GeneralCommandLine(); + + String scriptName = getDevKitScriptName(); + File scriptFile = devKitHomePath.resolve("yaci-devkit").resolve(scriptName).toFile(); + + if (!scriptFile.exists()) { + throw new IllegalStateException("DevKit script not found: " + scriptFile.getAbsolutePath()); + } + + commandLine.setExePath(scriptFile.getAbsolutePath()); + commandLine.setWorkDirectory(devKitHomePath.resolve("yaci-devkit").toFile()); + + if (interactive) { + commandLine.addParameter("up"); + commandLine.addParameter("--interactive"); + } else { + commandLine.addParameter("up"); + } + + return commandLine; + } + + private String getDevKitScriptName() { + return SystemInfo.isWindows ? "devkit.bat" : "devkit.sh"; + } + + private void notifyUser(String title, String message, NotificationType type) { + Notifications.Bus.notify(new Notification("DevKit", title, message, type)); + } + + @Nullable + public ProcessHandler getProcessHandler() { + return processHandler; + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitStatusMonitor.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitStatusMonitor.java new file mode 100644 index 0000000..5861eeb --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/devkit/DevKitStatusMonitor.java @@ -0,0 +1,145 @@ +package com.bloxbean.intelliada.idea.nodeint.devkit; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.Disposer; +import com.intellij.util.Alarm; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +public class DevKitStatusMonitor { + private static final Logger LOG = Logger.getInstance(DevKitStatusMonitor.class); + private static final int MONITORING_INTERVAL_MS = 5000; // 5 seconds + + public interface StatusChangeListener { + void onStatusChanged(DevKitProcessManager.DevKitStatus oldStatus, DevKitProcessManager.DevKitStatus newStatus); + void onHealthChanged(boolean healthy); + } + + private final DevKitProcessManager processManager; + private final Alarm alarm; + private final AtomicBoolean isMonitoring = new AtomicBoolean(false); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + + private DevKitProcessManager.DevKitStatus lastStatus = DevKitProcessManager.DevKitStatus.STOPPED; + private boolean lastHealthy = false; + + public DevKitStatusMonitor(@NotNull DevKitProcessManager processManager) { + this.processManager = processManager; + this.alarm = new Alarm(); + } + + public void startMonitoring() { + if (isMonitoring.compareAndSet(false, true)) { + LOG.info("Starting DevKit status monitoring"); + scheduleNextCheck(); + } + } + + public void stopMonitoring() { + if (isMonitoring.compareAndSet(true, false)) { + LOG.info("Stopping DevKit status monitoring"); + alarm.cancelAllRequests(); + Disposer.dispose(alarm); + } + } + + public void addStatusChangeListener(@NotNull StatusChangeListener listener) { + listeners.add(listener); + } + + public void removeStatusChangeListener(@NotNull StatusChangeListener listener) { + listeners.remove(listener); + } + + public DevKitProcessManager.DevKitStatus getCurrentStatus() { + return processManager.getStatus(); + } + + public boolean isCurrentlyHealthy() { + return processManager.isHealthy(); + } + + private void scheduleNextCheck() { + if (isMonitoring.get()) { + alarm.addRequest(this::performStatusCheck, MONITORING_INTERVAL_MS); + } + } + + private void performStatusCheck() { + try { + DevKitProcessManager.DevKitStatus currentStatus = processManager.getStatus(); + boolean currentHealthy = processManager.isHealthy(); + + // Check for status changes + if (currentStatus != lastStatus) { + LOG.debug("DevKit status changed from " + lastStatus + " to " + currentStatus); + notifyStatusChange(lastStatus, currentStatus); + lastStatus = currentStatus; + } + + // Check for health changes + if (currentHealthy != lastHealthy) { + LOG.debug("DevKit health changed from " + lastHealthy + " to " + currentHealthy); + notifyHealthChange(currentHealthy); + lastHealthy = currentHealthy; + } + + // Handle special cases + if (currentStatus == DevKitProcessManager.DevKitStatus.RUNNING && !currentHealthy) { + LOG.warn("DevKit is marked as running but health check failed"); + // Could trigger a restart or status correction here + } + + } catch (Exception e) { + LOG.error("Error during DevKit status check", e); + } finally { + scheduleNextCheck(); + } + } + + private void notifyStatusChange(DevKitProcessManager.DevKitStatus oldStatus, DevKitProcessManager.DevKitStatus newStatus) { + for (StatusChangeListener listener : listeners) { + try { + listener.onStatusChanged(oldStatus, newStatus); + } catch (Exception e) { + LOG.error("Error notifying status change listener", e); + } + } + } + + private void notifyHealthChange(boolean healthy) { + for (StatusChangeListener listener : listeners) { + try { + listener.onHealthChanged(healthy); + } catch (Exception e) { + LOG.error("Error notifying health change listener", e); + } + } + } + + public String getStatusDisplayText() { + DevKitProcessManager.DevKitStatus status = getCurrentStatus(); + boolean healthy = isCurrentlyHealthy(); + + switch (status) { + case STOPPED: + return "Stopped"; + case STARTING: + return "Starting..."; + case RUNNING: + return healthy ? "Running" : "Running (Unhealthy)"; + case STOPPING: + return "Stopping..."; + case ERROR: + return "Error"; + default: + return "Unknown"; + } + } + + public boolean isRunningAndHealthy() { + return getCurrentStatus() == DevKitProcessManager.DevKitStatus.RUNNING && isCurrentlyHealthy(); + } +} \ No newline at end of file diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/CardanoServiceFactory.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/CardanoServiceFactory.java index f8417b2..c64b222 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/CardanoServiceFactory.java +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/CardanoServiceFactory.java @@ -20,7 +20,8 @@ public static CardanoAccountService getAccountService(Project project, LogListen throw new TargetNodeNotConfigured("Please select a default node first"); } - if (remoteNode.getNodeType() == NodeType.YaciDevKit) { + if (remoteNode.getNodeType() == NodeType.YaciDevKit + || remoteNode.getNodeType() == NodeType.Yano) { return new YaciAccountServiceImpl(remoteNode, logListener); } else { return new AccountServiceImpl(project, logListener); diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/NodeServiceFactory.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/NodeServiceFactory.java index de7e740..d550a14 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/NodeServiceFactory.java +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/NodeServiceFactory.java @@ -36,7 +36,14 @@ public BackendService getBackendService(RemoteNode remoteNode) { if (backendService != null) return backendService; - if (NodeType.YaciDevKit.equals(remoteNode.getNodeType())) { + if (NodeType.Yano.equals(remoteNode.getNodeType())) { + backendService + = new BFBackendService(remoteNode.getApiEndpoint(), "Yano dummy key"); + backendServiceMap.put(remoteNode.getId(), backendService); + if (LOG.isDebugEnabled()) { + LOG.debug("Backend service created for the node : " + remoteNode); + } + } else if (NodeType.YaciDevKit.equals(remoteNode.getNodeType())) { backendService = new BFBackendService(remoteNode.getApiEndpoint(), "Some dummy key"); backendServiceMap.put(remoteNode.getId(), backendService); diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/impl/yaciprovider/YaciBaseService.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/impl/yaciprovider/YaciBaseService.java index 13ddd66..9caf8e4 100644 --- a/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/impl/yaciprovider/YaciBaseService.java +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/service/impl/yaciprovider/YaciBaseService.java @@ -8,7 +8,10 @@ import com.bloxbean.intelliada.idea.configuration.model.RemoteNode; import com.bloxbean.intelliada.idea.core.util.Network; import com.bloxbean.intelliada.idea.core.util.NetworkUtil; +import com.bloxbean.intelliada.idea.core.util.NodeType; import com.bloxbean.intelliada.idea.nodeint.CardanoNodeConfigurationHelper; +import com.bloxbean.intelliada.idea.nodeint.devkit.DevKitLifecycleService; +import com.bloxbean.intelliada.idea.nodeint.yano.YanoLifecycleService; import com.bloxbean.intelliada.idea.nodeint.exception.ApiCallException; import com.bloxbean.intelliada.idea.nodeint.exception.TargetNodeNotConfigured; import com.bloxbean.intelliada.idea.nodeint.service.NodeServiceFactory; @@ -25,6 +28,7 @@ public class YaciBaseService { protected BackendService backendService; private RemoteNode remoteNode; protected String baseUrl; + private Project project; public YaciBaseService(Project project) throws TargetNodeNotConfigured { this(project, new LogListener() { @@ -61,10 +65,21 @@ public YaciBaseService(Project project, LogListener logListener) throws TargetNo throw new TargetNodeNotConfigured("Please select a default node first"); } + this.project = project; this.logListener = logListener; this.remoteNode = remoteNode; this.baseUrl = this.remoteNode.getApiEndpoint(); + // Auto-start DevKit if it's a LocalYaciDevKit node + if (remoteNode.getNodeType() == NodeType.LocalYaciDevKit) { + ensureDevKitRunning(); + } + + // Auto-start Yano if it's a Yano node + if (remoteNode.getNodeType() == NodeType.Yano) { + ensureYanoRunning(); + } + this.backendService = new BFBackendService(remoteNode.getApiEndpoint(), "Dummy Key"); } @@ -141,4 +156,66 @@ protected RemoteNode getRemoteNode() { protected void clearCachedBackendService() { NodeServiceFactory.getInstance().nodeRemoved(remoteNode); } + + private void ensureYanoRunning() { + if (project != null && remoteNode.getNodeType() == NodeType.Yano) { + YanoLifecycleService service = YanoLifecycleService.getInstance(); + + if (!service.isYanoRunning(project)) { + logListener.info("Starting Yano devnet..."); + String yanoHome = remoteNode.getHome(); + + if (StringUtil.isEmpty(yanoHome)) { + logListener.warn("Yano home directory not configured. Please configure it in node settings."); + return; + } + + service.startYano(project, yanoHome) + .thenAccept(success -> { + if (success) { + logListener.info("Yano devnet started successfully"); + } else { + logListener.error("Failed to start Yano devnet"); + } + }) + .exceptionally(throwable -> { + logListener.error("Error starting Yano devnet", throwable); + return null; + }); + } else { + logListener.info("Yano devnet is already running"); + } + } + } + + private void ensureDevKitRunning() { + if (project != null && remoteNode.getNodeType() == NodeType.LocalYaciDevKit) { + DevKitLifecycleService service = DevKitLifecycleService.getInstance(); + + if (!service.isDevKitRunning(project)) { + logListener.info("Starting local Yaci DevKit..."); + String devKitHome = remoteNode.getHome(); + + if (StringUtil.isEmpty(devKitHome)) { + logListener.warn("DevKit home directory not configured. Please configure it in node settings."); + return; + } + + service.startDevKit(project, devKitHome) + .thenAccept(success -> { + if (success) { + logListener.info("Local Yaci DevKit started successfully"); + } else { + logListener.error("Failed to start local Yaci DevKit"); + } + }) + .exceptionally(throwable -> { + logListener.error("Error starting local Yaci DevKit", throwable); + return null; + }); + } else { + logListener.info("Local Yaci DevKit is already running"); + } + } + } } diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDevnetService.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDevnetService.java new file mode 100644 index 0000000..992f4e1 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDevnetService.java @@ -0,0 +1,234 @@ +package com.bloxbean.intelliada.idea.nodeint.yano; + +import com.bloxbean.intelliada.idea.nodeint.yano.model.*; +import com.intellij.openapi.diagnostic.Logger; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * REST client for Yano devnet operations: + * fund, snapshots, rollback, time advance, epoch shifting, tx evaluation. + */ +public class YanoDevnetService { + private static final Logger LOG = Logger.getInstance(YanoDevnetService.class); + + private final String baseUrl; + private final HttpClient httpClient; + + public YanoDevnetService(String baseUrl) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.httpClient = HttpClient.newBuilder().build(); + } + + // ---- Faucet ---- + + public FundResponse fundAddress(String address, BigDecimal ada) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("address", address); + body.put("ada", ada); + + JSONObject resp = post("/api/v1/devnet/fund", body); + return new FundResponse( + resp.optString("tx_hash"), + resp.optInt("index"), + resp.optLong("lovelace") + ); + } + + // ---- Snapshots ---- + + public SnapshotResponse createSnapshot(String name) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("name", name); + return parseSnapshot(post("/api/v1/devnet/snapshot", body)); + } + + public void restoreSnapshot(String name) throws IOException, InterruptedException { + post("/api/v1/devnet/restore/" + name, null); + } + + public List listSnapshots() throws IOException, InterruptedException { + String json = get("/api/v1/devnet/snapshots"); + JSONArray arr = new JSONArray(json); + List list = new ArrayList<>(); + for (int i = 0; i < arr.length(); i++) { + list.add(parseSnapshot(arr.getJSONObject(i))); + } + return list; + } + + public void deleteSnapshot(String name) throws IOException, InterruptedException { + delete("/api/v1/devnet/snapshot/" + name); + } + + // ---- Rollback ---- + + public RollbackResponse rollbackBySlot(long slot) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("slot", slot); + return parseRollback(post("/api/v1/devnet/rollback", body)); + } + + public RollbackResponse rollbackByBlockNumber(long blockNumber) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("block_number", blockNumber); + return parseRollback(post("/api/v1/devnet/rollback", body)); + } + + public RollbackResponse rollbackByCount(int count) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("count", count); + return parseRollback(post("/api/v1/devnet/rollback", body)); + } + + // ---- Time Advance ---- + + public TimeAdvanceResponse advanceBySlots(int slots) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("slots", slots); + return parseTimeAdvance(post("/api/v1/devnet/time/advance", body)); + } + + public TimeAdvanceResponse advanceBySeconds(int seconds) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("seconds", seconds); + return parseTimeAdvance(post("/api/v1/devnet/time/advance", body)); + } + + public TimeAdvanceResponse advanceByEpochs(int epochs) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("epochs", epochs); + return parseTimeAdvance(post("/api/v1/devnet/time/advance", body)); + } + + // ---- Time Travel (Epoch Shifting) ---- + + public EpochShiftResponse shiftEpochs(int epochs) throws IOException, InterruptedException { + JSONObject body = new JSONObject(); + body.put("epochs", epochs); + JSONObject resp = post("/api/v1/devnet/epochs/shift", body); + return new EpochShiftResponse( + resp.optString("message"), + resp.optLong("shift_millis"), + resp.optString("new_system_start"), + resp.optLong("genesis_slot") + ); + } + + public TimeAdvanceResponse catchUpToWallClock() throws IOException, InterruptedException { + return parseTimeAdvance(post("/api/v1/devnet/epochs/catch-up", null)); + } + + // ---- Transaction Evaluation ---- + + public String evaluateTransaction(String txCborHex) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/api/v1/utils/txs/evaluate")) + .header("Content-Type", "text/plain") + .POST(HttpRequest.BodyPublishers.ofString(txCborHex)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("Evaluation failed: " + response.body()); + } + return response.body(); + } + + // ---- Genesis Download ---- + + public byte[] downloadGenesis() throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/api/v1/devnet/genesis/download")) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() != 200) { + throw new IOException("Genesis download failed: " + response.statusCode()); + } + return response.body(); + } + + // ---- Helpers ---- + + private JSONObject post(String path, JSONObject body) throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json"); + + if (body != null) { + builder.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)); + } else { + builder.POST(HttpRequest.BodyPublishers.noBody()); + } + + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + + String respBody = response.body(); + if (respBody == null || respBody.isBlank()) { + return new JSONObject(); + } + return new JSONObject(respBody); + } + + private String get(String path) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + return response.body(); + } + + private void delete(String path) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .DELETE() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + } + + private SnapshotResponse parseSnapshot(JSONObject obj) { + return new SnapshotResponse( + obj.optString("name"), + obj.optLong("slot"), + obj.optLong("block_number"), + obj.optString("created_at") + ); + } + + private RollbackResponse parseRollback(JSONObject obj) { + return new RollbackResponse( + obj.optString("message"), + obj.optLong("slot"), + obj.optLong("block_number") + ); + } + + private TimeAdvanceResponse parseTimeAdvance(JSONObject obj) { + return new TimeAdvanceResponse( + obj.optString("message"), + obj.optLong("new_slot"), + obj.optLong("new_block_number"), + obj.optInt("blocks_produced") + ); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDownloader.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDownloader.java new file mode 100644 index 0000000..e8495e9 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDownloader.java @@ -0,0 +1,309 @@ +package com.bloxbean.intelliada.idea.nodeint.yano; + +import com.intellij.notification.Notification; +import com.intellij.notification.NotificationType; +import com.intellij.notification.Notifications; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.util.SystemInfo; +import org.json.JSONArray; +import org.json.JSONObject; +import org.jetbrains.annotations.NotNull; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Downloads Yano releases from GitHub. + * Supports version selection and installs to ~/.intelliada/yano/{version}/. + * Prefers native binary for the platform, falls back to JVM JAR. + */ +public class YanoDownloader { + private static final Logger LOG = Logger.getInstance(YanoDownloader.class); + private static final String GITHUB_RELEASES_URL = "https://api.github.com/repos/bloxbean/yano/releases"; + public static final String DEFAULT_INSTALL_BASE = System.getProperty("user.home") + + File.separator + ".intelliada" + File.separator + "yano"; + + private final Path installDir; + private Runnable onComplete; + + public YanoDownloader(Path installDir) { + this.installDir = installDir; + } + + public void setOnComplete(Runnable onComplete) { + this.onComplete = onComplete; + } + + /** + * Fetch available Yano versions from GitHub releases. + */ + public static List fetchAvailableVersions() throws IOException { + String body = httpGetString(GITHUB_RELEASES_URL); + JSONArray releases = new JSONArray(body); + List versions = new ArrayList<>(); + + for (int i = 0; i < releases.length(); i++) { + JSONObject rel = releases.getJSONObject(i); + String tag = rel.getString("tag_name"); + boolean prerelease = rel.optBoolean("prerelease", false); + + // Find platform-specific asset + JSONArray assets = rel.getJSONArray("assets"); + String nativeUrl = null; + String jvmUrl = null; + String platformSuffix = getPlatformSuffix(); + + for (int j = 0; j < assets.length(); j++) { + JSONObject asset = assets.getJSONObject(j); + String name = asset.getString("name"); + String url = asset.getString("browser_download_url"); + long size = asset.getLong("size"); + + if (name.contains("native") && name.contains(platformSuffix) && name.endsWith(".zip")) { + nativeUrl = url; + } else if (!name.contains("native") && name.endsWith(".zip")) { + jvmUrl = url; + } + } + + String downloadUrl = nativeUrl != null ? nativeUrl : jvmUrl; + boolean isNative = nativeUrl != null; + if (downloadUrl != null) { + versions.add(new ReleaseInfo(tag, prerelease, downloadUrl, isNative)); + } + } + return versions; + } + + /** + * Install Yano in a background task with progress. + */ + public void install(String downloadUrl) { + Task.Backgroundable task = new Task.Backgroundable(null, "Downloading Yano...", true) { + @Override + public void run(@NotNull ProgressIndicator indicator) { + try { + indicator.setText("Downloading Yano..."); + indicator.setFraction(0.1); + + Files.createDirectories(installDir); + Path zipPath = installDir.resolve("yano.zip"); + downloadFile(downloadUrl, zipPath, indicator); + + indicator.setText("Extracting Yano..."); + indicator.setFraction(0.8); + + unzip(zipPath, installDir); + Files.deleteIfExists(zipPath); + + if (!SystemInfo.isWindows) { + setExecutableRecursive(installDir); + } + + indicator.setFraction(1.0); + showNotification("Yano installed at " + installDir, NotificationType.INFORMATION); + + if (onComplete != null) { + onComplete.run(); + } + } catch (Exception e) { + LOG.error("Failed to install Yano", e); + showNotification("Failed: " + e.getMessage(), NotificationType.ERROR); + } + } + }; + ProgressManager.getInstance().run(task); + } + + /** + * Find the Yano home directory (containing yano-node.jar or yano-node binary). + */ + public static String findYanoHome(Path installDir) { + // Direct: installDir/yano-node.jar or installDir/yano-node + if (hasYanoBinary(installDir)) { + return installDir.toString(); + } + + // Subdirectory: installDir/yano-*/yano-node.jar + try { + return Files.walk(installDir, 3) + .filter(p -> { + String name = p.getFileName().toString(); + return name.equals("yano-node.jar") || name.equals("yano-node.sh") + || name.equals("yano.sh") || name.equals("yano.jar") + || (name.equals("yano") && Files.isExecutable(p) && !Files.isDirectory(p)); + }) + .map(p -> p.getParent().toString()) + .findFirst() + .orElse(installDir.toString()); + } catch (IOException e) { + return installDir.toString(); + } + } + + /** + * Get the default install path for a specific version. + */ + public static Path getVersionInstallDir(String version) { + return Path.of(DEFAULT_INSTALL_BASE, version); + } + + /** + * Check if a Yano installation exists at the given path. + */ + public static boolean isInstalled(Path dir) { + return hasYanoBinary(dir) || findYanoHome(dir) != null; + } + + private static boolean hasYanoBinary(Path dir) { + return Files.exists(dir.resolve("yano-node.jar")) + || Files.exists(dir.resolve("yano-node")) + || Files.exists(dir.resolve("yano-node.sh")) + || Files.exists(dir.resolve("yano")) + || Files.exists(dir.resolve("yano.sh")) + || Files.exists(dir.resolve("yano.jar")); + } + + private static String getPlatformSuffix() { + if (SystemInfo.isMac) return "macos-arm64"; + if (SystemInfo.isLinux) return "linux-x64"; + if (SystemInfo.isWindows) return "windows-x64"; + return "linux-x64"; + } + + private void downloadFile(String urlStr, Path target, ProgressIndicator indicator) throws IOException { + HttpURLConnection conn = openConnection(urlStr); + long totalSize = conn.getContentLengthLong(); + long downloaded = 0; + + try (InputStream is = conn.getInputStream(); + OutputStream os = Files.newOutputStream(target)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + os.write(buffer, 0, bytesRead); + downloaded += bytesRead; + if (totalSize > 0) { + indicator.setFraction(0.1 + 0.7 * ((double) downloaded / totalSize)); + indicator.setText2(String.format("%.1f MB / %.1f MB", + downloaded / (1024.0 * 1024.0), totalSize / (1024.0 * 1024.0))); + } + } + } finally { + conn.disconnect(); + } + } + + private void unzip(Path zipPath, Path destDir) throws IOException { + try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipPath.toFile()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + Path targetPath = destDir.resolve(entry.getName()).normalize(); + if (!targetPath.startsWith(destDir)) { + throw new IOException("Zip entry outside target dir: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(targetPath); + } else { + Files.createDirectories(targetPath.getParent()); + try (OutputStream os = Files.newOutputStream(targetPath)) { + zis.transferTo(os); + } + } + zis.closeEntry(); + } + } + } + + private void setExecutableRecursive(Path dir) { + try { + Files.walk(dir, 4) + .filter(p -> { + String name = p.getFileName().toString(); + return name.equals("yano-node") || name.equals("yano-node.sh") + || name.equals("yano") || name.equals("yano.sh"); + }) + .forEach(p -> p.toFile().setExecutable(true)); + } catch (IOException e) { + LOG.warn("Could not set executable permissions", e); + } + } + + private static HttpURLConnection openConnection(String urlStr) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setConnectTimeout(15000); + conn.setReadTimeout(120000); + + // Follow GitHub redirects + int status = conn.getResponseCode(); + if (status == 302 || status == 301) { + String redirect = conn.getHeaderField("Location"); + conn.disconnect(); + conn = (HttpURLConnection) new URL(redirect).openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setConnectTimeout(15000); + conn.setReadTimeout(120000); + } + return conn; + } + + private static String httpGetString(String urlStr) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/vnd.github.v3+json"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(30000); + + if (conn.getResponseCode() != 200) { + throw new IOException("GitHub API returned " + conn.getResponseCode()); + } + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) sb.append(line); + return sb.toString(); + } finally { + conn.disconnect(); + } + } + + private void showNotification(String content, NotificationType type) { + Notifications.Bus.notify(new Notification("Yano", "Yano Install", content, type)); + } + + /** + * Release info from GitHub. + */ + public static class ReleaseInfo { + public final String tag; + public final boolean prerelease; + public final String downloadUrl; + public final boolean isNative; + + public ReleaseInfo(String tag, boolean prerelease, String downloadUrl, boolean isNative) { + this.tag = tag; + this.prerelease = prerelease; + this.downloadUrl = downloadUrl; + this.isNative = isNative; + } + + @Override + public String toString() { + return tag + (isNative ? " (native)" : " (JVM)") + (prerelease ? " [pre-release]" : ""); + } + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoLifecycleService.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoLifecycleService.java new file mode 100644 index 0000000..af2a172 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoLifecycleService.java @@ -0,0 +1,98 @@ +package com.bloxbean.intelliada.idea.nodeint.yano; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import com.intellij.openapi.project.ProjectManagerListener; +import com.intellij.openapi.startup.StartupActivity; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Application-level service managing Yano node lifecycle per project. + */ +public class YanoLifecycleService implements StartupActivity { + private static final Logger LOG = Logger.getInstance(YanoLifecycleService.class); + + private final ConcurrentMap processManagers = new ConcurrentHashMap<>(); + private final ConcurrentMap statusMonitors = new ConcurrentHashMap<>(); + + public static YanoLifecycleService getInstance() { + return ApplicationManager.getApplication().getService(YanoLifecycleService.class); + } + + @Override + public void runActivity(@NotNull Project project) { + project.getMessageBus().connect().subscribe( + ProjectManager.TOPIC, + new ProjectManagerListener() { + @Override + public void projectClosed(@NotNull Project closedProject) { + cleanupProject(closedProject); + } + } + ); + } + + public CompletableFuture startYano(@NotNull Project project, @NotNull String yanoHome) { + return startYano(project, yanoHome, 7070); + } + + public CompletableFuture startYano(@NotNull Project project, @NotNull String yanoHome, int port) { + String projectKey = getProjectKey(project); + + YanoProcessManager processManager = processManagers.computeIfAbsent(projectKey, + k -> new YanoProcessManager(yanoHome, port)); + + YanoStatusMonitor statusMonitor = statusMonitors.computeIfAbsent(projectKey, + k -> new YanoStatusMonitor(processManager, project)); + statusMonitor.startMonitoring(); + + return processManager.startYano(); + } + + public CompletableFuture stopYano(@NotNull Project project) { + String projectKey = getProjectKey(project); + YanoProcessManager processManager = processManagers.get(projectKey); + if (processManager != null) { + return processManager.stopYano(); + } + return CompletableFuture.completedFuture(true); + } + + public boolean isYanoRunning(@NotNull Project project) { + String projectKey = getProjectKey(project); + YanoProcessManager pm = processManagers.get(projectKey); + return pm != null && pm.getStatus() == YanoProcessManager.YanoStatus.RUNNING; + } + + public YanoProcessManager getProcessManager(@NotNull Project project) { + return processManagers.get(getProjectKey(project)); + } + + public YanoStatusMonitor getStatusMonitor(@NotNull Project project) { + return statusMonitors.get(getProjectKey(project)); + } + + public void cleanupProject(@NotNull Project project) { + String projectKey = getProjectKey(project); + + YanoStatusMonitor monitor = statusMonitors.remove(projectKey); + if (monitor != null) { + monitor.stopMonitoring(); + } + + YanoProcessManager manager = processManagers.remove(projectKey); + if (manager != null) { + manager.stopYano(); + } + } + + private String getProjectKey(@NotNull Project project) { + return project.getLocationHash(); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoProcessManager.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoProcessManager.java new file mode 100644 index 0000000..3dc07f4 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoProcessManager.java @@ -0,0 +1,271 @@ +package com.bloxbean.intelliada.idea.nodeint.yano; + +import com.intellij.execution.ExecutionException; +import com.intellij.execution.configurations.GeneralCommandLine; +import com.intellij.execution.process.OSProcessHandler; +import com.intellij.execution.process.ProcessAdapter; +import com.intellij.execution.process.ProcessEvent; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.Key; +import com.intellij.openapi.util.SystemInfo; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Manages the Yano devnet node process lifecycle. + * Starts/stops the yano-node JAR or native binary. + */ +public class YanoProcessManager { + private static final Logger LOG = Logger.getInstance(YanoProcessManager.class); + + private static final int HEALTH_POLL_INTERVAL_MS = 500; + private static final int JVM_STARTUP_TIMEOUT_SEC = 60; + private static final int NATIVE_STARTUP_TIMEOUT_SEC = 30; + private static final int SHUTDOWN_TIMEOUT_SEC = 10; + + public enum YanoStatus { + STOPPED, STARTING, RUNNING, STOPPING, ERROR + } + + private final String yanoHome; + private int port = 7070; + + private volatile YanoStatus currentStatus = YanoStatus.STOPPED; + private final Object statusLock = new Object(); + private OSProcessHandler processHandler; + + public YanoProcessManager(String yanoHome) { + this.yanoHome = yanoHome; + } + + public YanoProcessManager(String yanoHome, int port) { + this.yanoHome = yanoHome; + this.port = port; + } + + public YanoStatus getStatus() { + return currentStatus; + } + + public int getPort() { + return port; + } + + public String getBaseUrl() { + return "http://localhost:" + port; + } + + public CompletableFuture startYano() { + synchronized (statusLock) { + if (currentStatus == YanoStatus.RUNNING) { + return CompletableFuture.completedFuture(true); + } + if (currentStatus == YanoStatus.STARTING) { + return CompletableFuture.completedFuture(false); + } + currentStatus = YanoStatus.STARTING; + } + + CompletableFuture future = new CompletableFuture<>(); + + try { + GeneralCommandLine commandLine = createStartCommand(); + processHandler = new OSProcessHandler(commandLine); + + processHandler.addProcessListener(new ProcessAdapter() { + @Override + public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType) { + LOG.debug("Yano: " + event.getText().trim()); + } + + @Override + public void processTerminated(@NotNull ProcessEvent event) { + synchronized (statusLock) { + if (currentStatus != YanoStatus.STOPPING) { + currentStatus = YanoStatus.ERROR; + } else { + currentStatus = YanoStatus.STOPPED; + } + } + if (!future.isDone()) { + future.complete(false); + } + } + }); + + processHandler.startNotify(); + + // Poll health endpoint for startup detection + pollForStartup(future); + + } catch (ExecutionException e) { + LOG.error("Failed to start Yano", e); + synchronized (statusLock) { + currentStatus = YanoStatus.ERROR; + } + future.complete(false); + } + + return future; + } + + public CompletableFuture stopYano() { + CompletableFuture future = new CompletableFuture<>(); + + synchronized (statusLock) { + if (currentStatus == YanoStatus.STOPPED) { + return CompletableFuture.completedFuture(true); + } + currentStatus = YanoStatus.STOPPING; + } + + if (processHandler != null && !processHandler.isProcessTerminated()) { + processHandler.destroyProcess(); + + // Force kill after timeout + CompletableFuture.delayedExecutor(SHUTDOWN_TIMEOUT_SEC, TimeUnit.SECONDS).execute(() -> { + if (processHandler != null && !processHandler.isProcessTerminated()) { + processHandler.destroyProcess(); + } + synchronized (statusLock) { + currentStatus = YanoStatus.STOPPED; + } + if (!future.isDone()) { + future.complete(true); + } + }); + + // Check if already terminated + if (processHandler.isProcessTerminated()) { + synchronized (statusLock) { + currentStatus = YanoStatus.STOPPED; + } + future.complete(true); + } + } else { + synchronized (statusLock) { + currentStatus = YanoStatus.STOPPED; + } + future.complete(true); + } + + return future; + } + + public boolean isHealthy() { + try { + URL url = new URL(getBaseUrl() + "/q/health/ready"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + int responseCode = conn.getResponseCode(); + conn.disconnect(); + return responseCode == 200; + } catch (IOException e) { + return false; + } + } + + public boolean isProcessAlive() { + return processHandler != null && !processHandler.isProcessTerminated(); + } + + private GeneralCommandLine createStartCommand() { + // Resolve actual home (zip may extract into a subdirectory) + String resolvedHome = YanoDownloader.findYanoHome(java.nio.file.Path.of(yanoHome)); + if (resolvedHome == null) resolvedHome = yanoHome; + + File homeDir = new File(resolvedHome); + // Binary names vary: "yano" or "yano-node" for native, "yano.sh" or "yano-node.sh" for script + File nativeBinary = findFile(resolvedHome, "yano", "yano-node"); + File shellScript = findFile(resolvedHome, "yano.sh", "yano-node.sh"); + File jarFile = findFile(resolvedHome, "yano-node.jar", "yano.jar"); + + GeneralCommandLine commandLine; + if (nativeBinary.exists() && nativeBinary.canExecute()) { + commandLine = new GeneralCommandLine(nativeBinary.getAbsolutePath()); + commandLine.addParameter("-Dquarkus.profile=devnet"); + commandLine.addParameter("-Dquarkus.http.port=" + port); + } else if (shellScript.exists() && shellScript.canExecute()) { + commandLine = new GeneralCommandLine(shellScript.getAbsolutePath(), "--devnet"); + commandLine.withEnvironment("JAVA_OPTS", "-Dquarkus.http.port=" + port); + } else if (jarFile.exists()) { + commandLine = new GeneralCommandLine("java"); + commandLine.addParameter("-Dquarkus.profile=devnet"); + commandLine.addParameter("-Dquarkus.http.port=" + port); + commandLine.addParameter("-jar"); + commandLine.addParameter(jarFile.getAbsolutePath()); + } else { + throw new IllegalStateException("No yano-node binary, script, or jar found in " + resolvedHome + + " (searched from " + yanoHome + ")"); + } + + commandLine.setWorkDirectory(resolvedHome); + commandLine.setRedirectErrorStream(true); + LOG.info("Yano start command: " + commandLine.getCommandLineString()); + return commandLine; + } + + private void pollForStartup(CompletableFuture future) { + boolean isNative = new File(yanoHome, isWindows() ? "yano-node.exe" : "yano-node").exists(); + int timeoutSec = isNative ? NATIVE_STARTUP_TIMEOUT_SEC : JVM_STARTUP_TIMEOUT_SEC; + + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "yano-startup-poll"); + t.setDaemon(true); + return t; + }); + + long startTime = System.currentTimeMillis(); + scheduler.scheduleAtFixedRate(() -> { + if (future.isDone()) { + scheduler.shutdown(); + return; + } + + long elapsed = System.currentTimeMillis() - startTime; + if (elapsed > timeoutSec * 1000L) { + LOG.warn("Yano startup timed out after " + timeoutSec + "s"); + synchronized (statusLock) { + currentStatus = YanoStatus.ERROR; + } + future.complete(false); + scheduler.shutdown(); + return; + } + + if (isHealthy()) { + synchronized (statusLock) { + currentStatus = YanoStatus.RUNNING; + } + LOG.info("Yano devnet started successfully on port " + port); + future.complete(true); + scheduler.shutdown(); + } + }, HEALTH_POLL_INTERVAL_MS, HEALTH_POLL_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + + /** + * Find the first existing file matching any of the given names in the directory. + */ + private static File findFile(String dir, String... names) { + for (String name : names) { + File f = new File(dir, name); + if (f.exists()) return f; + } + return new File(dir, names[0]); // Return first as default (for error messages) + } + + private static boolean isWindows() { + return SystemInfo.isWindows; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoStatusMonitor.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoStatusMonitor.java new file mode 100644 index 0000000..129be78 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoStatusMonitor.java @@ -0,0 +1,169 @@ +package com.bloxbean.intelliada.idea.nodeint.yano; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.util.Alarm; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Periodically monitors Yano health and chain tip. + * Notifies listeners on status and chain tip changes. + */ +public class YanoStatusMonitor { + private static final Logger LOG = Logger.getInstance(YanoStatusMonitor.class); + private static final int MONITORING_INTERVAL_MS = 3000; + + private final YanoProcessManager processManager; + private final Alarm alarm; + private final AtomicBoolean isMonitoring = new AtomicBoolean(false); + private final List listeners = new CopyOnWriteArrayList<>(); + + private YanoProcessManager.YanoStatus lastStatus; + private boolean lastHealthy; + private long lastSlot = -1; + private long lastBlockNumber = -1; + + public interface StatusChangeListener { + void onStatusChanged(YanoProcessManager.YanoStatus oldStatus, YanoProcessManager.YanoStatus newStatus); + void onHealthChanged(boolean healthy); + void onChainTipChanged(long slot, long blockNumber); + } + + public YanoStatusMonitor(YanoProcessManager processManager, Disposable parentDisposable) { + this.processManager = processManager; + this.alarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, parentDisposable); + this.lastStatus = processManager.getStatus(); + this.lastHealthy = false; + } + + public void startMonitoring() { + if (isMonitoring.compareAndSet(false, true)) { + scheduleNextCheck(); + } + } + + public void stopMonitoring() { + isMonitoring.set(false); + alarm.cancelAllRequests(); + } + + public void addStatusChangeListener(StatusChangeListener listener) { + listeners.add(listener); + } + + public void removeStatusChangeListener(StatusChangeListener listener) { + listeners.remove(listener); + } + + public long getLastSlot() { + return lastSlot; + } + + public long getLastBlockNumber() { + return lastBlockNumber; + } + + private void scheduleNextCheck() { + if (isMonitoring.get()) { + alarm.addRequest(this::performStatusCheck, MONITORING_INTERVAL_MS); + } + } + + private void performStatusCheck() { + try { + YanoProcessManager.YanoStatus currentStatus = processManager.getStatus(); + boolean currentHealthy = processManager.isHealthy(); + + if (currentStatus != lastStatus) { + YanoProcessManager.YanoStatus old = lastStatus; + lastStatus = currentStatus; + for (StatusChangeListener listener : listeners) { + try { + listener.onStatusChanged(old, currentStatus); + } catch (Exception e) { + LOG.warn("Error notifying status change listener", e); + } + } + } + + if (currentHealthy != lastHealthy) { + lastHealthy = currentHealthy; + for (StatusChangeListener listener : listeners) { + try { + listener.onHealthChanged(currentHealthy); + } catch (Exception e) { + LOG.warn("Error notifying health change listener", e); + } + } + } + + // Fetch chain tip if healthy + if (currentHealthy && currentStatus == YanoProcessManager.YanoStatus.RUNNING) { + fetchChainTip(); + } + } finally { + scheduleNextCheck(); + } + } + + private void fetchChainTip() { + try { + String json = httpGet(processManager.getBaseUrl() + "/api/v1/blocks/latest"); + if (json != null) { + JSONObject block = new JSONObject(json); + long slot = block.optLong("slot", -1); + long blockNumber = block.optLong("height", block.optLong("block_number", -1)); + + if (slot != lastSlot || blockNumber != lastBlockNumber) { + lastSlot = slot; + lastBlockNumber = blockNumber; + for (StatusChangeListener listener : listeners) { + try { + listener.onChainTipChanged(slot, blockNumber); + } catch (Exception e) { + LOG.warn("Error notifying chain tip listener", e); + } + } + } + } + } catch (Exception e) { + LOG.debug("Could not fetch chain tip: " + e.getMessage()); + } + } + + private String httpGet(String urlStr) { + try { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + if (conn.getResponseCode() == 200) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + } + conn.disconnect(); + } catch (IOException e) { + // Expected when node is not running + } + return null; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/EpochShiftResponse.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/EpochShiftResponse.java new file mode 100644 index 0000000..120de4e --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/EpochShiftResponse.java @@ -0,0 +1,13 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class EpochShiftResponse { + private String message; + private long shiftMillis; + private String newSystemStart; + private long genesisSlot; +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/FundResponse.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/FundResponse.java new file mode 100644 index 0000000..4862d92 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/FundResponse.java @@ -0,0 +1,12 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class FundResponse { + private String txHash; + private int index; + private long lovelace; +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/RollbackResponse.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/RollbackResponse.java new file mode 100644 index 0000000..adb0bea --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/RollbackResponse.java @@ -0,0 +1,12 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class RollbackResponse { + private String message; + private long slot; + private long blockNumber; +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/SnapshotResponse.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/SnapshotResponse.java new file mode 100644 index 0000000..27bcc48 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/SnapshotResponse.java @@ -0,0 +1,13 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class SnapshotResponse { + private String name; + private long slot; + private long blockNumber; + private String createdAt; +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/TimeAdvanceResponse.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/TimeAdvanceResponse.java new file mode 100644 index 0000000..4c2abd3 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/model/TimeAdvanceResponse.java @@ -0,0 +1,13 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TimeAdvanceResponse { + private String message; + private long newSlot; + private long newBlockNumber; + private int blocksProduced; +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoDevnetPanel.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoDevnetPanel.java new file mode 100644 index 0000000..ac0b76e --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoDevnetPanel.java @@ -0,0 +1,721 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.ui; + +import com.bloxbean.intelliada.idea.account.model.CardanoAccount; +import com.bloxbean.intelliada.idea.account.service.AccountChooser; +import com.bloxbean.intelliada.idea.nodeint.yano.*; +import com.bloxbean.intelliada.idea.nodeint.yano.model.*; +import com.bloxbean.intelliada.idea.toolwindow.CardanoConsole; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Yano Devnet control panel with tabs for: + * - Setup & Control (download, start, stop with inline status) + * - Fund Account (with account chooser integration) + * - Snapshots + * - Rollback + * - Time Machine (advance + epoch shifting) + * + * Each tab has an inline status label for immediate feedback. + */ +public class YanoDevnetPanel { + private static final Logger LOG = Logger.getInstance(YanoDevnetPanel.class); + private static final int DEFAULT_HTTP_PORT = 8888; + + private final Project project; + private JPanel mainPanel; + + // Setup tab + private JComboBox versionCombo; + private JLabel installStatusLabel; + private JSpinner portSpinner; + private JButton startBtn; + private JButton stopBtn; + private JLabel statusLabel; + private JLabel slotLabel; + private JLabel blockLabel; + + // Fund tab + private JTextField fundAddressTf; + private JSpinner fundAmountSpinner; + private JLabel fundStatusLabel; + + // Snapshot tab + private DefaultTableModel snapshotTableModel; + private JTable snapshotTable; + private JTextField snapshotNameTf; + private JLabel snapshotStatusLabel; + + // Rollback tab + private JSpinner rollbackCountSpinner; + private JLabel rollbackStatusLabel; + + // Time Machine tab + private JSpinner advanceSlotsSpinner; + private JSpinner advanceEpochsSpinner; + private JSpinner shiftEpochsSpinner; + private JLabel timeStatusLabel; + + public YanoDevnetPanel(Project project) { + this.project = project; + initComponents(); + } + + public JPanel getMainPanel() { + return mainPanel; + } + + private void initComponents() { + mainPanel = new JPanel(new BorderLayout()); + + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("Setup", createSetupTab()); + tabbedPane.addTab("Fund", createFundTab()); + tabbedPane.addTab("Snapshots", createSnapshotTab()); + tabbedPane.addTab("Rollback", createRollbackTab()); + tabbedPane.addTab("Time Machine", createTimeMachineTab()); + + mainPanel.add(tabbedPane, BorderLayout.CENTER); + } + + // ========== Setup Tab ========== + + private JPanel createSetupTab() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + GridBagConstraints c = gbc(); + + // Download Section + JLabel downloadTitle = new JLabel("Download & Install"); + downloadTitle.setFont(downloadTitle.getFont().deriveFont(Font.BOLD, 13f)); + c.gridx = 0; c.gridy = 0; c.gridwidth = 3; + panel.add(downloadTitle, c); + + c.gridy = 1; c.gridwidth = 1; + panel.add(new JLabel("Version:"), c); + versionCombo = new JComboBox<>(); + versionCombo.setPreferredSize(new Dimension(300, 27)); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0; + panel.add(versionCombo, c); + JButton fetchBtn = new JButton("Fetch Versions"); + fetchBtn.addActionListener(e -> fetchVersions()); + c.gridx = 2; c.fill = GridBagConstraints.NONE; c.weightx = 0; + panel.add(fetchBtn, c); + + JButton downloadBtn = new JButton("Download & Install"); + downloadBtn.addActionListener(e -> downloadSelected()); + c.gridx = 1; c.gridy = 2; + panel.add(downloadBtn, c); + + installStatusLabel = new JLabel(""); + c.gridx = 0; c.gridy = 3; c.gridwidth = 3; + panel.add(installStatusLabel, c); + + // Separator + c.gridy = 4; c.gridwidth = 3; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(new JSeparator(), c); + + // Run Section + JLabel runTitle = new JLabel("Run Yano Devnet"); + runTitle.setFont(runTitle.getFont().deriveFont(Font.BOLD, 13f)); + c.gridy = 5; c.fill = GridBagConstraints.NONE; + panel.add(runTitle, c); + + c.gridy = 6; c.gridwidth = 1; + panel.add(new JLabel("HTTP Port:"), c); + portSpinner = new JSpinner(new SpinnerNumberModel(DEFAULT_HTTP_PORT, 1024, 65535, 1)); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(portSpinner, c); + + JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + startBtn = new JButton("Start Yano"); + startBtn.addActionListener(e -> doStartYano()); + btnPanel.add(startBtn); + stopBtn = new JButton("Stop Yano"); + stopBtn.setEnabled(false); + stopBtn.addActionListener(e -> doStopYano()); + btnPanel.add(stopBtn); + c.gridx = 0; c.gridy = 7; c.gridwidth = 3; + panel.add(btnPanel, c); + + // Status + c.gridy = 8; c.gridwidth = 3; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(new JSeparator(), c); + + JPanel statusPanel = new JPanel(new GridBagLayout()); + GridBagConstraints sc = gbc(); + statusPanel.add(new JLabel("Status:"), sc); + statusLabel = new JLabel("Stopped"); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.BOLD, 14f)); + statusLabel.setForeground(Color.GRAY); + sc.gridx = 1; sc.gridwidth = 2; + statusPanel.add(statusLabel, sc); + + sc.gridy = 1; sc.gridx = 0; sc.gridwidth = 1; + statusPanel.add(new JLabel("Slot:"), sc); + slotLabel = new JLabel("-"); + slotLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + sc.gridx = 1; + statusPanel.add(slotLabel, sc); + + sc.gridy = 2; sc.gridx = 0; + statusPanel.add(new JLabel("Block:"), sc); + blockLabel = new JLabel("-"); + blockLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + sc.gridx = 1; + statusPanel.add(blockLabel, sc); + + c.gridy = 9; c.gridwidth = 3; + panel.add(statusPanel, c); + + checkInstalledVersions(); + return wrapNorth(panel); + } + + // ========== Fund Tab ========== + + private JPanel createFundTab() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(BorderFactory.createTitledBorder("Fund Account")); + GridBagConstraints c = gbc(); + + c.gridx = 0; c.gridy = 0; + panel.add(new JLabel("Address:"), c); + fundAddressTf = new JTextField(35); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.weightx = 1.0; + panel.add(fundAddressTf, c); + + // Account chooser button - integrates with Cardano Account management + JButton chooseAccountBtn = new JButton("Choose Account"); + chooseAccountBtn.addActionListener(e -> { + CardanoAccount account = AccountChooser.getSelectedAccount(project, false); + if (account != null) { + fundAddressTf.setText(account.getAddress()); + } + }); + c.gridx = 2; c.fill = GridBagConstraints.NONE; c.weightx = 0; + panel.add(chooseAccountBtn, c); + + c.gridy = 1; c.gridx = 0; + panel.add(new JLabel("ADA Amount:"), c); + fundAmountSpinner = new JSpinner(new SpinnerNumberModel(1000, 1, 1000000, 100)); + c.gridx = 1; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(fundAmountSpinner, c); + + JButton fundBtn = new JButton("Fund"); + fundBtn.addActionListener(e -> doFund()); + c.gridx = 1; c.gridy = 2; c.fill = GridBagConstraints.NONE; + panel.add(fundBtn, c); + + // Inline status + fundStatusLabel = createStatusLabel(); + c.gridx = 0; c.gridy = 3; c.gridwidth = 3; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(fundStatusLabel, c); + + return wrapNorth(panel); + } + + // ========== Snapshot Tab ========== + + private JPanel createSnapshotTab() { + JPanel panel = new JPanel(new BorderLayout(5, 5)); + panel.setBorder(BorderFactory.createTitledBorder("Snapshots")); + + snapshotTableModel = new DefaultTableModel(new String[]{"Name", "Slot", "Block", "Created"}, 0) { + @Override public boolean isCellEditable(int row, int column) { return false; } + }; + snapshotTable = new JTable(snapshotTableModel); + panel.add(new JScrollPane(snapshotTable), BorderLayout.CENTER); + + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + snapshotNameTf = new JTextField(15); + buttonPanel.add(new JLabel("Name:")); + buttonPanel.add(snapshotNameTf); + + JButton createBtn = new JButton("Create"); + createBtn.addActionListener(e -> doCreateSnapshot()); + buttonPanel.add(createBtn); + JButton restoreBtn = new JButton("Restore"); + restoreBtn.addActionListener(e -> doRestoreSnapshot()); + buttonPanel.add(restoreBtn); + JButton deleteBtn = new JButton("Delete"); + deleteBtn.addActionListener(e -> doDeleteSnapshot()); + buttonPanel.add(deleteBtn); + JButton refreshBtn = new JButton("Refresh"); + refreshBtn.addActionListener(e -> doRefreshSnapshots()); + buttonPanel.add(refreshBtn); + + JPanel southPanel = new JPanel(new BorderLayout()); + southPanel.add(buttonPanel, BorderLayout.NORTH); + snapshotStatusLabel = createStatusLabel(); + southPanel.add(snapshotStatusLabel, BorderLayout.SOUTH); + panel.add(southPanel, BorderLayout.SOUTH); + + return panel; + } + + // ========== Rollback Tab ========== + + private JPanel createRollbackTab() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(BorderFactory.createTitledBorder("Rollback")); + GridBagConstraints c = gbc(); + + panel.add(new JLabel("Undo Last N Blocks:"), c); + rollbackCountSpinner = new JSpinner(new SpinnerNumberModel(1, 1, 1000, 1)); + c.gridx = 1; + panel.add(rollbackCountSpinner, c); + + JButton rollbackBtn = new JButton("Rollback"); + rollbackBtn.addActionListener(e -> doRollback()); + c.gridy = 1; c.gridx = 1; + panel.add(rollbackBtn, c); + + rollbackStatusLabel = createStatusLabel(); + c.gridx = 0; c.gridy = 2; c.gridwidth = 2; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(rollbackStatusLabel, c); + + return wrapNorth(panel); + } + + // ========== Time Machine Tab ========== + + private JPanel createTimeMachineTab() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(BorderFactory.createTitledBorder("Time Machine")); + GridBagConstraints c = gbc(); + + panel.add(new JLabel("Advance by Slots:"), c); + advanceSlotsSpinner = new JSpinner(new SpinnerNumberModel(10, 1, 100000, 10)); + c.gridx = 1; + panel.add(advanceSlotsSpinner, c); + JButton advanceSlotsBtn = new JButton("Advance"); + advanceSlotsBtn.addActionListener(e -> doAdvanceSlots()); + c.gridx = 2; + panel.add(advanceSlotsBtn, c); + + c.gridy = 1; c.gridx = 0; + panel.add(new JLabel("Advance by Epochs:"), c); + advanceEpochsSpinner = new JSpinner(new SpinnerNumberModel(1, 1, 100, 1)); + c.gridx = 1; + panel.add(advanceEpochsSpinner, c); + JButton advanceEpochsBtn = new JButton("Advance"); + advanceEpochsBtn.addActionListener(e -> doAdvanceEpochs()); + c.gridx = 2; + panel.add(advanceEpochsBtn, c); + + c.gridy = 2; c.gridx = 0; c.gridwidth = 3; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(new JSeparator(), c); + + JLabel ttLabel = new JLabel("Past Time Travel (Epoch Shifting)"); + ttLabel.setFont(ttLabel.getFont().deriveFont(Font.BOLD)); + c.gridy = 3; c.fill = GridBagConstraints.NONE; + panel.add(ttLabel, c); + + c.gridy = 4; c.gridwidth = 1; + panel.add(new JLabel("Shift Back Epochs:"), c); + shiftEpochsSpinner = new JSpinner(new SpinnerNumberModel(5, 1, 1000, 1)); + c.gridx = 1; + panel.add(shiftEpochsSpinner, c); + JButton shiftBtn = new JButton("Shift Genesis"); + shiftBtn.addActionListener(e -> doShiftEpochs()); + c.gridx = 2; + panel.add(shiftBtn, c); + + JButton catchUpBtn = new JButton("Catch Up to Wall Clock"); + catchUpBtn.addActionListener(e -> doCatchUp()); + c.gridy = 5; c.gridx = 0; c.gridwidth = 3; + panel.add(catchUpBtn, c); + + timeStatusLabel = createStatusLabel(); + c.gridy = 6; c.fill = GridBagConstraints.HORIZONTAL; + panel.add(timeStatusLabel, c); + + return wrapNorth(panel); + } + + // ========== Setup Actions ========== + + private void fetchVersions() { + ProgressManager.getInstance().run(new Task.Backgroundable(project, "Fetching Yano versions...") { + @Override + public void run(@NotNull ProgressIndicator indicator) { + try { + List versions = YanoDownloader.fetchAvailableVersions(); + SwingUtilities.invokeLater(() -> { + versionCombo.removeAllItems(); + for (YanoDownloader.ReleaseInfo v : versions) { + versionCombo.addItem(v); + } + if (!versions.isEmpty()) versionCombo.setSelectedIndex(0); + setStatus(installStatusLabel, "Found " + versions.size() + " version(s)", false); + }); + } catch (Exception e) { + SwingUtilities.invokeLater(() -> setStatus(installStatusLabel, "Failed to fetch: " + e.getMessage(), true)); + } + } + }); + } + + private void downloadSelected() { + YanoDownloader.ReleaseInfo selected = (YanoDownloader.ReleaseInfo) versionCombo.getSelectedItem(); + if (selected == null) { + setStatus(installStatusLabel, "Select a version first", true); + return; + } + + Path installDir = YanoDownloader.getVersionInstallDir(selected.tag); + setStatus(installStatusLabel, "Downloading " + selected.tag + "...", false); + log("Downloading Yano " + selected.tag + " to " + installDir); + + YanoDownloader downloader = new YanoDownloader(installDir); + downloader.setOnComplete(() -> SwingUtilities.invokeLater(() -> { + setStatus(installStatusLabel, "Installed: " + selected.tag, false); + installStatusLabel.setForeground(new Color(0, 128, 0)); + log("Yano " + selected.tag + " installed successfully"); + })); + downloader.install(selected.downloadUrl); + } + + private void checkInstalledVersions() { + Path baseDir = Path.of(YanoDownloader.DEFAULT_INSTALL_BASE); + if (Files.exists(baseDir)) { + try { + Files.list(baseDir).filter(Files::isDirectory).findFirst().ifPresent(dir -> { + String home = YanoDownloader.findYanoHome(dir); + if (home != null) { + setStatus(installStatusLabel, "Installed: " + dir.getFileName(), false); + installStatusLabel.setForeground(new Color(0, 128, 0)); + } + }); + } catch (Exception e) { /* ignore */ } + } + } + + private String resolveYanoHome() { + YanoDownloader.ReleaseInfo selected = (YanoDownloader.ReleaseInfo) versionCombo.getSelectedItem(); + if (selected != null) { + Path installDir = YanoDownloader.getVersionInstallDir(selected.tag); + if (Files.exists(installDir)) return YanoDownloader.findYanoHome(installDir); + } + Path baseDir = Path.of(YanoDownloader.DEFAULT_INSTALL_BASE); + if (Files.exists(baseDir)) { + try { + return Files.list(baseDir).filter(Files::isDirectory) + .map(YanoDownloader::findYanoHome) + .filter(h -> h != null).findFirst().orElse(null); + } catch (Exception e) { /* ignore */ } + } + return null; + } + + private void doStartYano() { + String home = resolveYanoHome(); + if (home == null) { + setStatus(installStatusLabel, "No Yano installation found. Download first.", true); + return; + } + int port = (int) portSpinner.getValue(); + startBtn.setEnabled(false); + statusLabel.setText("Starting..."); + statusLabel.setForeground(new Color(200, 150, 0)); + log("Starting Yano from " + home + " on port " + port); + + YanoLifecycleService.getInstance().startYano(project, home, port) + .thenAccept(success -> SwingUtilities.invokeLater(() -> { + if (success) { + statusLabel.setText("Running"); + statusLabel.setForeground(new Color(0, 128, 0)); + stopBtn.setEnabled(true); + log("Yano started on http://localhost:" + port); + initStatusMonitor(); + } else { + statusLabel.setText("Failed"); + statusLabel.setForeground(Color.RED); + startBtn.setEnabled(true); + log("Failed to start Yano"); + } + })) + .exceptionally(t -> { + SwingUtilities.invokeLater(() -> { + statusLabel.setText("Error: " + t.getMessage()); + statusLabel.setForeground(Color.RED); + startBtn.setEnabled(true); + }); + return null; + }); + } + + private void doStopYano() { + stopBtn.setEnabled(false); + statusLabel.setText("Stopping..."); + log("Stopping Yano..."); + + YanoLifecycleService.getInstance().stopYano(project) + .thenAccept(success -> SwingUtilities.invokeLater(() -> { + statusLabel.setText("Stopped"); + statusLabel.setForeground(Color.GRAY); + startBtn.setEnabled(true); + slotLabel.setText("-"); + blockLabel.setText("-"); + log("Yano stopped"); + })); + } + + // ========== Fund Actions ========== + + private void doFund() { + String address = fundAddressTf.getText().trim(); + int amount = (int) fundAmountSpinner.getValue(); + if (address.isEmpty()) { + setStatus(fundStatusLabel, "Please enter or choose an address", true); + return; + } + setStatus(fundStatusLabel, "Funding...", false); + + runAsync("Funding account", () -> { + YanoDevnetService svc = getDevnetService(); + FundResponse resp = svc.fundAddress(address, BigDecimal.valueOf(amount)); + String msg = "Funded " + amount + " ADA | tx: " + resp.getTxHash(); + log(msg); + SwingUtilities.invokeLater(() -> setStatus(fundStatusLabel, msg, false)); + }); + } + + // ========== Snapshot Actions ========== + + private void doCreateSnapshot() { + String name = snapshotNameTf.getText().trim(); + if (name.isEmpty()) { + setStatus(snapshotStatusLabel, "Enter a snapshot name", true); + return; + } + setStatus(snapshotStatusLabel, "Creating snapshot...", false); + + runAsync("Creating snapshot", () -> { + YanoDevnetService svc = getDevnetService(); + SnapshotResponse resp = svc.createSnapshot(name); + String msg = "Snapshot '" + resp.getName() + "' created at slot " + resp.getSlot(); + log(msg); + SwingUtilities.invokeLater(() -> { + setStatus(snapshotStatusLabel, msg, false); + doRefreshSnapshots(); + }); + }); + } + + private void doRestoreSnapshot() { + int row = snapshotTable.getSelectedRow(); + if (row < 0) { + setStatus(snapshotStatusLabel, "Select a snapshot to restore", true); + return; + } + String name = (String) snapshotTableModel.getValueAt(row, 0); + setStatus(snapshotStatusLabel, "Restoring '" + name + "'...", false); + + runAsync("Restoring snapshot", () -> { + getDevnetService().restoreSnapshot(name); + String msg = "Restored snapshot '" + name + "'"; + log(msg); + SwingUtilities.invokeLater(() -> setStatus(snapshotStatusLabel, msg, false)); + }); + } + + private void doDeleteSnapshot() { + int row = snapshotTable.getSelectedRow(); + if (row < 0) return; + String name = (String) snapshotTableModel.getValueAt(row, 0); + + runAsync("Deleting snapshot", () -> { + getDevnetService().deleteSnapshot(name); + log("Deleted snapshot '" + name + "'"); + SwingUtilities.invokeLater(() -> { + setStatus(snapshotStatusLabel, "Deleted '" + name + "'", false); + doRefreshSnapshots(); + }); + }); + } + + private void doRefreshSnapshots() { + runAsync("Refreshing snapshots", () -> { + List snapshots = getDevnetService().listSnapshots(); + SwingUtilities.invokeLater(() -> { + snapshotTableModel.setRowCount(0); + for (SnapshotResponse s : snapshots) { + snapshotTableModel.addRow(new Object[]{s.getName(), s.getSlot(), s.getBlockNumber(), s.getCreatedAt()}); + } + setStatus(snapshotStatusLabel, snapshots.size() + " snapshot(s)", false); + }); + }); + } + + // ========== Rollback Actions ========== + + private void doRollback() { + int count = (int) rollbackCountSpinner.getValue(); + setStatus(rollbackStatusLabel, "Rolling back " + count + " blocks...", false); + + runAsync("Rolling back", () -> { + RollbackResponse resp = getDevnetService().rollbackByCount(count); + String msg = "Rolled back " + count + " blocks -> slot " + resp.getSlot() + ", block " + resp.getBlockNumber(); + log(msg); + SwingUtilities.invokeLater(() -> setStatus(rollbackStatusLabel, msg, false)); + }); + } + + // ========== Time Machine Actions ========== + + private void doAdvanceSlots() { + int slots = (int) advanceSlotsSpinner.getValue(); + setStatus(timeStatusLabel, "Advancing " + slots + " slots...", false); + + runAsync("Advancing time", () -> { + TimeAdvanceResponse resp = getDevnetService().advanceBySlots(slots); + String msg = "Advanced " + resp.getBlocksProduced() + " blocks -> slot " + resp.getNewSlot(); + log(msg); + SwingUtilities.invokeLater(() -> setStatus(timeStatusLabel, msg, false)); + }); + } + + private void doAdvanceEpochs() { + int epochs = (int) advanceEpochsSpinner.getValue(); + setStatus(timeStatusLabel, "Advancing " + epochs + " epoch(s)...", false); + + runAsync("Advancing epochs", () -> { + TimeAdvanceResponse resp = getDevnetService().advanceByEpochs(epochs); + String msg = "Advanced " + epochs + " epoch(s), " + resp.getBlocksProduced() + " blocks -> slot " + resp.getNewSlot(); + log(msg); + SwingUtilities.invokeLater(() -> setStatus(timeStatusLabel, msg, false)); + }); + } + + private void doShiftEpochs() { + int epochs = (int) shiftEpochsSpinner.getValue(); + setStatus(timeStatusLabel, "Shifting genesis back " + epochs + " epochs...", false); + + runAsync("Shifting epochs", () -> { + EpochShiftResponse resp = getDevnetService().shiftEpochs(epochs); + String msg = "Shifted back " + epochs + " epochs. New start: " + resp.getNewSystemStart(); + log(msg); + SwingUtilities.invokeLater(() -> setStatus(timeStatusLabel, msg, false)); + }); + } + + private void doCatchUp() { + setStatus(timeStatusLabel, "Catching up to wall clock...", false); + + runAsync("Catching up", () -> { + TimeAdvanceResponse resp = getDevnetService().catchUpToWallClock(); + String msg = "Caught up: " + resp.getBlocksProduced() + " blocks -> slot " + resp.getNewSlot(); + log(msg); + SwingUtilities.invokeLater(() -> setStatus(timeStatusLabel, msg, false)); + }); + } + + // ========== Status Monitor ========== + + private void initStatusMonitor() { + YanoStatusMonitor monitor = YanoLifecycleService.getInstance().getStatusMonitor(project); + if (monitor != null) { + monitor.addStatusChangeListener(new YanoStatusMonitor.StatusChangeListener() { + @Override + public void onStatusChanged(YanoProcessManager.YanoStatus oldStatus, YanoProcessManager.YanoStatus newStatus) { + SwingUtilities.invokeLater(() -> { + statusLabel.setText(newStatus.name()); + statusLabel.setForeground(newStatus == YanoProcessManager.YanoStatus.RUNNING + ? new Color(0, 128, 0) : Color.GRAY); + startBtn.setEnabled(newStatus != YanoProcessManager.YanoStatus.RUNNING); + stopBtn.setEnabled(newStatus == YanoProcessManager.YanoStatus.RUNNING); + }); + } + + @Override public void onHealthChanged(boolean healthy) {} + + @Override + public void onChainTipChanged(long slot, long blockNumber) { + SwingUtilities.invokeLater(() -> { + slotLabel.setText(String.valueOf(slot)); + blockLabel.setText(String.valueOf(blockNumber)); + }); + } + }); + } + } + + // ========== Helpers ========== + + private YanoDevnetService getDevnetService() { + YanoProcessManager pm = YanoLifecycleService.getInstance().getProcessManager(project); + String baseUrl = pm != null ? pm.getBaseUrl() : "http://localhost:" + portSpinner.getValue(); + return new YanoDevnetService(baseUrl); + } + + private void log(String message) { + CardanoConsole console = CardanoConsole.getConsole(project); + console.showInfoMessage("[Yano] " + message); + } + + private void runAsync(String title, DevnetAction action) { + ProgressManager.getInstance().run(new Task.Backgroundable(project, title) { + @Override + public void run(@NotNull ProgressIndicator indicator) { + try { + action.execute(); + } catch (Exception e) { + LOG.warn("Yano action failed: " + title, e); + String errMsg = "Error: " + e.getMessage(); + log(errMsg); + // Update the relevant status label on error + SwingUtilities.invokeLater(() -> { + if (title.contains("Fund")) setStatus(fundStatusLabel, errMsg, true); + else if (title.contains("snapshot") || title.contains("Snapshot")) setStatus(snapshotStatusLabel, errMsg, true); + else if (title.contains("Rolling") || title.contains("Rollback")) setStatus(rollbackStatusLabel, errMsg, true); + else setStatus(timeStatusLabel, errMsg, true); + }); + } + } + }); + } + + private static void setStatus(JLabel label, String text, boolean isError) { + label.setText(text); + label.setForeground(isError ? Color.RED : new Color(0, 100, 0)); + } + + private static JLabel createStatusLabel() { + JLabel label = new JLabel(" "); + label.setFont(label.getFont().deriveFont(Font.ITALIC, 11f)); + label.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + return label; + } + + @FunctionalInterface + private interface DevnetAction { + void execute() throws Exception; + } + + private static GridBagConstraints gbc() { + GridBagConstraints c = new GridBagConstraints(); + c.anchor = GridBagConstraints.WEST; + c.insets = new Insets(4, 4, 4, 4); + return c; + } + + private static JPanel wrapNorth(JPanel inner) { + JPanel wrapper = new JPanel(new BorderLayout()); + wrapper.add(inner, BorderLayout.NORTH); + return wrapper; + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoDevnetToolWindowFactory.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoDevnetToolWindowFactory.java new file mode 100644 index 0000000..406e026 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoDevnetToolWindowFactory.java @@ -0,0 +1,20 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.ui; + +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import org.jetbrains.annotations.NotNull; + +public class YanoDevnetToolWindowFactory implements ToolWindowFactory, DumbAware { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + YanoDevnetPanel devnetPanel = new YanoDevnetPanel(project); + Content content = ContentFactory.getInstance().createContent( + devnetPanel.getMainPanel(), "Devnet", false); + toolWindow.getContentManager().addContent(content); + } +} diff --git a/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoStatusBarWidgetFactory.java b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoStatusBarWidgetFactory.java new file mode 100644 index 0000000..1e009a4 --- /dev/null +++ b/src/main/java/com/bloxbean/intelliada/idea/nodeint/yano/ui/YanoStatusBarWidgetFactory.java @@ -0,0 +1,128 @@ +package com.bloxbean.intelliada.idea.nodeint.yano.ui; + +import com.bloxbean.intelliada.idea.nodeint.yano.YanoLifecycleService; +import com.bloxbean.intelliada.idea.nodeint.yano.YanoProcessManager; +import com.bloxbean.intelliada.idea.nodeint.yano.YanoStatusMonitor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.StatusBar; +import com.intellij.openapi.wm.StatusBarWidget; +import com.intellij.openapi.wm.StatusBarWidgetFactory; +import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.util.Consumer; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.awt.event.MouseEvent; + +/** + * Status bar widget showing live Yano chain tip when running. + * Click to open the Yano Devnet tool window. + */ +public class YanoStatusBarWidgetFactory implements StatusBarWidgetFactory { + private static final String ID = "YanoDevnetStatus"; + + @Override + public @NotNull @NonNls String getId() { + return ID; + } + + @Override + public @NotNull String getDisplayName() { + return "Yano Devnet"; + } + + @Override + public @NotNull StatusBarWidget createWidget(@NotNull Project project) { + return new YanoStatusWidget(project); + } + + private static class YanoStatusWidget implements StatusBarWidget, StatusBarWidget.TextPresentation { + private final Project project; + private StatusBar statusBar; + private String text = ""; + + YanoStatusWidget(Project project) { + this.project = project; + setupMonitor(); + } + + @Override + public @NotNull @NonNls String ID() { + return ID; + } + + @Override + public void install(@NotNull StatusBar statusBar) { + this.statusBar = statusBar; + } + + @Override + public void dispose() {} + + @Override + public @NotNull StatusBarWidget.WidgetPresentation getPresentation() { + return this; + } + + @Override + public @NotNull String getText() { + return text; + } + + @Override + public float getAlignment() { + return 0; + } + + @Override + public @Nullable String getTooltipText() { + return "Yano Devnet - Click to open control panel"; + } + + @Override + public @Nullable Consumer getClickConsumer() { + return e -> { + ToolWindowManager twm = ToolWindowManager.getInstance(project); + var tw = twm.getToolWindow("Yano Devnet"); + if (tw != null) { + tw.show(); + } + }; + } + + private void setupMonitor() { + YanoLifecycleService lifecycle = YanoLifecycleService.getInstance(); + if (lifecycle == null) return; + + YanoStatusMonitor monitor = lifecycle.getStatusMonitor(project); + if (monitor == null) { + text = ""; + return; + } + + monitor.addStatusChangeListener(new YanoStatusMonitor.StatusChangeListener() { + @Override + public void onStatusChanged(YanoProcessManager.YanoStatus old, YanoProcessManager.YanoStatus newStatus) { + if (newStatus == YanoProcessManager.YanoStatus.RUNNING) { + text = "Yano: running"; + } else if (newStatus == YanoProcessManager.YanoStatus.STARTING) { + text = "Yano: starting..."; + } else { + text = ""; + } + if (statusBar != null) statusBar.updateWidget(ID); + } + + @Override + public void onHealthChanged(boolean healthy) {} + + @Override + public void onChainTipChanged(long slot, long blockNumber) { + text = "Yano: slot " + slot + " | block " + blockNumber; + if (statusBar != null) statusBar.updateWidget(ID); + } + }); + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a5c758d..f5cf257 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -13,6 +13,7 @@ com.intellij.modules.platform + com.intellij.modules.java com.redhat.devtools.lsp4ij com.intellij.modules.lang @@ -74,9 +75,103 @@ language="Aiken" implementationClass="com.redhat.devtools.lsp4ij.features.signatureHelp.LSPParameterInfoHandler"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -123,10 +218,53 @@ description="Format Aiken Code"> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/julc_module.png b/src/main/resources/icons/julc_module.png new file mode 100644 index 0000000..2aae340 Binary files /dev/null and b/src/main/resources/icons/julc_module.png differ diff --git a/src/main/resources/liveTemplates/Aiken.xml b/src/main/resources/liveTemplates/Aiken.xml new file mode 100644 index 0000000..dda0cfb --- /dev/null +++ b/src/main/resources/liveTemplates/Aiken.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/liveTemplates/AikenAdvanced.xml b/src/main/resources/liveTemplates/AikenAdvanced.xml new file mode 100644 index 0000000..dab27e0 --- /dev/null +++ b/src/main/resources/liveTemplates/AikenAdvanced.xml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/liveTemplates/julc.xml b/src/main/resources/liveTemplates/julc.xml new file mode 100644 index 0000000..7ae6384 --- /dev/null +++ b/src/main/resources/liveTemplates/julc.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + diff --git a/src/test/java/com/bloxbean/intelliada/idea/julc/configuration/JulcSDKTest.java b/src/test/java/com/bloxbean/intelliada/idea/julc/configuration/JulcSDKTest.java new file mode 100644 index 0000000..143ba47 --- /dev/null +++ b/src/test/java/com/bloxbean/intelliada/idea/julc/configuration/JulcSDKTest.java @@ -0,0 +1,82 @@ +package com.bloxbean.intelliada.idea.julc.configuration; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class JulcSDKTest { + + @Test + void testDefaultConstructor() { + JulcSDK sdk = new JulcSDK(); + assertEquals("", sdk.getId()); + assertEquals("", sdk.getName()); + assertEquals("", sdk.getPath()); + assertEquals("", sdk.getVersion()); + } + + @Test + void testAllArgsConstructor() { + JulcSDK sdk = new JulcSDK("id1", "My julc", "/usr/local/bin", "0.1.0"); + assertEquals("id1", sdk.getId()); + assertEquals("My julc", sdk.getName()); + assertEquals("/usr/local/bin", sdk.getPath()); + assertEquals("0.1.0", sdk.getVersion()); + } + + @Test + void testUpdateValues() { + JulcSDK sdk = new JulcSDK("id1", "Old", "/old/path", "0.1.0"); + JulcSDK updated = new JulcSDK("id2", "New", "/new/path", "0.2.0"); + + sdk.updateValues(updated); + + // ID should NOT change + assertEquals("id1", sdk.getId()); + // Other fields should update + assertEquals("New", sdk.getName()); + assertEquals("/new/path", sdk.getPath()); + assertEquals("0.2.0", sdk.getVersion()); + } + + @Test + void testUpdateValuesWithNull() { + JulcSDK sdk = new JulcSDK("id1", "Name", "/path", "1.0"); + sdk.updateValues(null); + // Should not change + assertEquals("Name", sdk.getName()); + } + + @Test + void testEqualsAndHashCode() { + JulcSDK sdk1 = new JulcSDK("id1", "SDK1", "/path1", "1.0"); + JulcSDK sdk2 = new JulcSDK("id1", "SDK2", "/path2", "2.0"); + JulcSDK sdk3 = new JulcSDK("id2", "SDK1", "/path1", "1.0"); + + // Same ID -> equal + assertEquals(sdk1, sdk2); + assertEquals(sdk1.hashCode(), sdk2.hashCode()); + + // Different ID -> not equal + assertNotEquals(sdk1, sdk3); + } + + @Test + void testToString() { + JulcSDK sdk = new JulcSDK("id1", "My julc SDK", "/path", "1.0"); + assertEquals("My julc SDK", sdk.toString()); + } + + @Test + void testGetJulcCommand() { + JulcSDK sdk = new JulcSDK("id1", "julc", "/usr/local/bin", "0.1.0"); + List cmd = sdk.getJulcCommand(); + + assertNotNull(cmd); + assertEquals(1, cmd.size()); + assertTrue(cmd.get(0).startsWith("/usr/local/bin")); + assertTrue(cmd.get(0).contains("julc")); + } +} diff --git a/src/test/java/com/bloxbean/intelliada/idea/julc/service/BlueprintLoadServiceTest.java b/src/test/java/com/bloxbean/intelliada/idea/julc/service/BlueprintLoadServiceTest.java new file mode 100644 index 0000000..eff2cbd --- /dev/null +++ b/src/test/java/com/bloxbean/intelliada/idea/julc/service/BlueprintLoadServiceTest.java @@ -0,0 +1,104 @@ +package com.bloxbean.intelliada.idea.julc.service; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for CIP-57 blueprint JSON parsing logic. + * Tests the JSON structure parsing without requiring IntelliJ platform. + */ +class BlueprintLoadServiceTest { + + @Test + void testParseBlueprintJsonStructure() { + JSONObject blueprint = new JSONObject(); + + // Preamble + JSONObject preamble = new JSONObject(); + preamble.put("title", "my-project"); + preamble.put("version", "1.0.0"); + blueprint.put("preamble", preamble); + + // Validators + JSONArray validators = new JSONArray(); + JSONObject validator = new JSONObject(); + validator.put("title", "spending.validate"); + validator.put("hash", "abc123def456"); + validator.put("compiledCode", "59012301000033222220051"); + validators.put(validator); + blueprint.put("validators", validators); + + // Verify preamble parsing + assertEquals("my-project", blueprint.getJSONObject("preamble").getString("title")); + assertEquals("1.0.0", blueprint.getJSONObject("preamble").getString("version")); + + // Verify validators parsing + JSONArray parsedValidators = blueprint.getJSONArray("validators"); + assertEquals(1, parsedValidators.length()); + + JSONObject v = parsedValidators.getJSONObject(0); + assertEquals("spending.validate", v.getString("title")); + assertEquals("abc123def456", v.getString("hash")); + assertEquals("59012301000033222220051", v.getString("compiledCode")); + } + + @Test + void testPlutusBlueprintModel() { + PlutusBlueprint blueprint = new PlutusBlueprint(); + blueprint.setPreamble(new PlutusBlueprint.Preamble("test", "1.0")); + + PlutusBlueprint.ValidatorInfo validator = new PlutusBlueprint.ValidatorInfo( + "my.spend", "abcdef", "590123", 3, false); + + blueprint.setValidators(java.util.List.of(validator)); + + assertEquals("test", blueprint.getPreamble().getTitle()); + assertEquals(1, blueprint.getValidators().size()); + assertEquals("my.spend", blueprint.getValidators().get(0).getTitle()); + assertEquals("abcdef", blueprint.getValidators().get(0).getHash()); + assertEquals(3, blueprint.getValidators().get(0).getSizeBytes()); + assertFalse(blueprint.getValidators().get(0).isParameterized()); + } + + @Test + void testValidatorInfoSizeCalculation() { + // compiledCode is hex, so size in bytes = hex length / 2 + String hexCode = "59012301000033222220051a0b0c0d0e"; + long expectedSize = hexCode.length() / 2; + + PlutusBlueprint.ValidatorInfo info = new PlutusBlueprint.ValidatorInfo( + "test", "hash", hexCode, expectedSize, false); + + assertEquals(16, info.getSizeBytes()); + } + + @Test + void testMultipleValidators() { + PlutusBlueprint blueprint = new PlutusBlueprint(); + blueprint.setPreamble(new PlutusBlueprint.Preamble("multi", "2.0")); + blueprint.setValidators(java.util.List.of( + new PlutusBlueprint.ValidatorInfo("mint", "hash1", "aabb", 2, false), + new PlutusBlueprint.ValidatorInfo("spend", "hash2", "ccdd", 2, false), + new PlutusBlueprint.ValidatorInfo("withdraw", "hash3", "eeff", 2, true) + )); + + assertEquals(3, blueprint.getValidators().size()); + assertTrue(blueprint.getValidators().get(2).isParameterized()); + } + + @Test + void testAnnotationProcessorOutputFormat() { + // Individual .plutus.json file format + JSONObject individual = new JSONObject(); + individual.put("cborHex", "590123abcdef"); + individual.put("scriptHash", "deadbeef"); + individual.put("parameterized", true); + + assertEquals("590123abcdef", individual.optString("cborHex", "")); + assertEquals("deadbeef", individual.optString("scriptHash", "")); + assertTrue(individual.optBoolean("parameterized", false)); + } +} diff --git a/src/test/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDevnetServiceTest.java b/src/test/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDevnetServiceTest.java new file mode 100644 index 0000000..8fab6c4 --- /dev/null +++ b/src/test/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoDevnetServiceTest.java @@ -0,0 +1,191 @@ +package com.bloxbean.intelliada.idea.nodeint.yano; + +import com.bloxbean.intelliada.idea.nodeint.yano.model.*; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for Yano devnet service DTO parsing and request/response structure. + * These tests verify the JSON contract without requiring a running Yano instance. + */ +class YanoDevnetServiceTest { + + @Test + void testFundResponseParsing() { + JSONObject json = new JSONObject(); + json.put("tx_hash", "abc123"); + json.put("index", 0); + json.put("lovelace", 1000000000L); + + FundResponse response = new FundResponse( + json.optString("tx_hash"), + json.optInt("index"), + json.optLong("lovelace") + ); + + assertEquals("abc123", response.getTxHash()); + assertEquals(0, response.getIndex()); + assertEquals(1000000000L, response.getLovelace()); + } + + @Test + void testSnapshotResponseParsing() { + JSONObject json = new JSONObject(); + json.put("name", "before-deploy"); + json.put("slot", 12345L); + json.put("block_number", 100L); + json.put("created_at", "2026-04-10T10:00:00Z"); + + SnapshotResponse response = new SnapshotResponse( + json.optString("name"), + json.optLong("slot"), + json.optLong("block_number"), + json.optString("created_at") + ); + + assertEquals("before-deploy", response.getName()); + assertEquals(12345L, response.getSlot()); + assertEquals(100L, response.getBlockNumber()); + assertEquals("2026-04-10T10:00:00Z", response.getCreatedAt()); + } + + @Test + void testRollbackResponseParsing() { + JSONObject json = new JSONObject(); + json.put("message", "Rolled back successfully"); + json.put("slot", 500L); + json.put("block_number", 50L); + + RollbackResponse response = new RollbackResponse( + json.optString("message"), + json.optLong("slot"), + json.optLong("block_number") + ); + + assertEquals("Rolled back successfully", response.getMessage()); + assertEquals(500L, response.getSlot()); + assertEquals(50L, response.getBlockNumber()); + } + + @Test + void testTimeAdvanceResponseParsing() { + JSONObject json = new JSONObject(); + json.put("message", "Advanced"); + json.put("new_slot", 2000L); + json.put("new_block_number", 200L); + json.put("blocks_produced", 100); + + TimeAdvanceResponse response = new TimeAdvanceResponse( + json.optString("message"), + json.optLong("new_slot"), + json.optLong("new_block_number"), + json.optInt("blocks_produced") + ); + + assertEquals("Advanced", response.getMessage()); + assertEquals(2000L, response.getNewSlot()); + assertEquals(200L, response.getNewBlockNumber()); + assertEquals(100, response.getBlocksProduced()); + } + + @Test + void testEpochShiftResponseParsing() { + JSONObject json = new JSONObject(); + json.put("message", "Shifted"); + json.put("shift_millis", 432000000L); + json.put("new_system_start", "2026-04-05T00:00:00Z"); + json.put("genesis_slot", 0L); + + EpochShiftResponse response = new EpochShiftResponse( + json.optString("message"), + json.optLong("shift_millis"), + json.optString("new_system_start"), + json.optLong("genesis_slot") + ); + + assertEquals("Shifted", response.getMessage()); + assertEquals(432000000L, response.getShiftMillis()); + assertEquals("2026-04-05T00:00:00Z", response.getNewSystemStart()); + assertEquals(0L, response.getGenesisSlot()); + } + + @Test + void testFundRequestBody() { + // Verify the request body structure for funding + JSONObject body = new JSONObject(); + body.put("address", "addr_test1qz..."); + body.put("ada", 1000); + + assertTrue(body.has("address")); + assertTrue(body.has("ada")); + assertEquals("addr_test1qz...", body.getString("address")); + assertEquals(1000, body.getInt("ada")); + } + + @Test + void testRollbackRequestVariants() { + // By slot + JSONObject bySlot = new JSONObject(); + bySlot.put("slot", 100L); + assertTrue(bySlot.has("slot")); + assertFalse(bySlot.has("block_number")); + assertFalse(bySlot.has("count")); + + // By block number + JSONObject byBlock = new JSONObject(); + byBlock.put("block_number", 50L); + assertFalse(byBlock.has("slot")); + assertTrue(byBlock.has("block_number")); + + // By count + JSONObject byCount = new JSONObject(); + byCount.put("count", 10); + assertFalse(byCount.has("slot")); + assertTrue(byCount.has("count")); + } + + @Test + void testTimeAdvanceRequestVariants() { + // By slots + JSONObject bySlots = new JSONObject(); + bySlots.put("slots", 100); + assertTrue(bySlots.has("slots")); + assertFalse(bySlots.has("seconds")); + assertFalse(bySlots.has("epochs")); + + // By epochs + JSONObject byEpochs = new JSONObject(); + byEpochs.put("epochs", 5); + assertTrue(byEpochs.has("epochs")); + } + + @Test + void testSnapshotListParsing() { + JSONArray array = new JSONArray(); + array.put(new JSONObject().put("name", "snap1").put("slot", 100L).put("block_number", 10L).put("created_at", "2026-01-01")); + array.put(new JSONObject().put("name", "snap2").put("slot", 200L).put("block_number", 20L).put("created_at", "2026-01-02")); + + assertEquals(2, array.length()); + assertEquals("snap1", array.getJSONObject(0).getString("name")); + assertEquals("snap2", array.getJSONObject(1).getString("name")); + } + + @Test + void testNodeTypeYanoExists() { + com.bloxbean.intelliada.idea.core.util.NodeType yano = + com.bloxbean.intelliada.idea.core.util.NodeType.Yano; + assertNotNull(yano); + assertEquals("Yano Devnet", yano.getDisplayName()); + } + + @Test + void testNodeTypeLookup() { + com.bloxbean.intelliada.idea.core.util.NodeType result = + com.bloxbean.intelliada.idea.core.util.NodeType.lookupByName("Yano"); + assertNotNull(result); + assertEquals(com.bloxbean.intelliada.idea.core.util.NodeType.Yano, result); + } +} diff --git a/src/test/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoProcessManagerTest.java b/src/test/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoProcessManagerTest.java new file mode 100644 index 0000000..305a67e --- /dev/null +++ b/src/test/java/com/bloxbean/intelliada/idea/nodeint/yano/YanoProcessManagerTest.java @@ -0,0 +1,102 @@ +package com.bloxbean.intelliada.idea.nodeint.yano; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for YanoProcessManager without requiring a running Yano instance. + */ +class YanoProcessManagerTest { + + @Test + void testInitialStatusIsStopped() { + YanoProcessManager manager = new YanoProcessManager("/tmp/yano"); + assertEquals(YanoProcessManager.YanoStatus.STOPPED, manager.getStatus()); + } + + @Test + void testDefaultPort() { + YanoProcessManager manager = new YanoProcessManager("/tmp/yano"); + assertEquals(7070, manager.getPort()); + } + + @Test + void testCustomPort() { + YanoProcessManager manager = new YanoProcessManager("/tmp/yano", 9090); + assertEquals(9090, manager.getPort()); + } + + @Test + void testBaseUrl() { + YanoProcessManager manager = new YanoProcessManager("/tmp/yano", 8080); + assertEquals("http://localhost:8080", manager.getBaseUrl()); + } + + @Test + void testBaseUrlDefaultPort() { + YanoProcessManager manager = new YanoProcessManager("/tmp/yano"); + assertEquals("http://localhost:7070", manager.getBaseUrl()); + } + + @Test + void testIsHealthyWhenNotRunning() { + YanoProcessManager manager = new YanoProcessManager("/tmp/nonexistent"); + assertFalse(manager.isHealthy()); + } + + @Test + void testIsProcessAliveWhenNotStarted() { + YanoProcessManager manager = new YanoProcessManager("/tmp/yano"); + assertFalse(manager.isProcessAlive()); + } + + @Test + void testStopWhenAlreadyStopped(@TempDir Path tempDir) { + YanoProcessManager manager = new YanoProcessManager(tempDir.toString()); + var future = manager.stopYano(); + assertTrue(future.join()); + assertEquals(YanoProcessManager.YanoStatus.STOPPED, manager.getStatus()); + } + + @Test + void testStartWithMissingBinaries(@TempDir Path tempDir) { + // No yano-node.jar or yano-node binary in temp dir + YanoProcessManager manager = new YanoProcessManager(tempDir.toString()); + var future = manager.startYano(); + + // Should fail because neither binary nor jar exists + boolean result = future.join(); + assertFalse(result); + } + + @Test + void testStartWithFakeJar(@TempDir Path tempDir) throws Exception { + // Create a fake yano-node.jar (just an empty file) + File fakeJar = tempDir.resolve("yano-node.jar").toFile(); + fakeJar.createNewFile(); + + YanoProcessManager manager = new YanoProcessManager(tempDir.toString()); + // Start will try to run java -jar on the fake jar, which will fail + var future = manager.startYano(); + + // Should eventually fail because the fake jar isn't runnable + // But it shouldn't throw an exception + assertNotNull(future); + } + + @Test + void testYanoStatusEnumValues() { + YanoProcessManager.YanoStatus[] values = YanoProcessManager.YanoStatus.values(); + assertEquals(5, values.length); + assertNotNull(YanoProcessManager.YanoStatus.STOPPED); + assertNotNull(YanoProcessManager.YanoStatus.STARTING); + assertNotNull(YanoProcessManager.YanoStatus.RUNNING); + assertNotNull(YanoProcessManager.YanoStatus.STOPPING); + assertNotNull(YanoProcessManager.YanoStatus.ERROR); + } +}