diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cfc635a..5f5fda2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "Bash(find:*)" + "Bash(find:*)", + "mcp__github__get_pull_request", + "mcp__github__get_pull_request_status" ], "deny": [] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9cc2107 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,237 @@ +name: CI + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master, develop ] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + test: + name: Test Suite + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + rust: [stable, nightly] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Run tests + run: cargo test --all-features --verbose + + - name: Run tests (no default features) + run: cargo test --no-default-features --verbose + + fmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-clippy-${{ hashFiles('**/Cargo.lock') }} + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + build: + name: Build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-latest + target: x86_64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-release-${{ hashFiles('**/Cargo.lock') }} + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: red-${{ matrix.target }} + path: | + target/${{ matrix.target }}/release/red + target/${{ matrix.target }}/release/red.exe + + plugin-tests: + name: Plugin Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run plugin tests + run: | + cd test-harness + for test in ../examples/*.test.js; do + if [ -f "$test" ]; then + plugin="${test%.test.js}.js" + if [ -f "$plugin" ]; then + echo "Running tests for $(basename $plugin)..." + node test-runner.js "$plugin" "$test" || exit 1 + fi + fi + done + + docs: + name: Documentation + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Build documentation + run: cargo doc --no-deps --all-features + + - name: Check documentation links + run: cargo doc --no-deps --all-features --document-private-items + + security-audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run security audit + uses: rustsec/audit-check@v1.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + msrv: + name: Minimum Supported Rust Version + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Read MSRV from Cargo.toml + id: msrv + run: | + msrv=$(grep -E '^rust-version' Cargo.toml | sed -E 's/.*"([0-9.]+)".*/\1/') + echo "version=$msrv" >> $GITHUB_OUTPUT + + - name: Install MSRV toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.msrv.outputs.version }} + if: steps.msrv.outputs.version != '' + + - name: Check MSRV + run: cargo check --all-features + if: steps.msrv.outputs.version != '' \ No newline at end of file diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml new file mode 100644 index 0000000..f060848 --- /dev/null +++ b/.github/workflows/plugin-check.yml @@ -0,0 +1,196 @@ +name: Plugin System Check + +on: + push: + paths: + - 'src/plugin/**' + - 'examples/**' + - 'test-harness/**' + - 'types/**' + pull_request: + paths: + - 'src/plugin/**' + - 'examples/**' + - 'test-harness/**' + - 'types/**' + +jobs: + plugin-lint: + name: Plugin Linting + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install ESLint + run: | + npm install -g eslint + npm install -g @typescript-eslint/parser @typescript-eslint/eslint-plugin + + - name: Create ESLint config + run: | + cat > .eslintrc.json << 'EOF' + { + "env": { + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "no-console": "off", + "semi": ["error", "always"] + }, + "overrides": [ + { + "files": ["*.ts"], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + } + } + ], + "globals": { + "Deno": "readonly", + "globalThis": "readonly" + } + } + EOF + + - name: Lint example plugins + run: | + for file in examples/*.js; do + if [ -f "$file" ] && [[ ! "$file" =~ \.test\.js$ ]]; then + echo "Linting $file..." + eslint "$file" || true + fi + done + + - name: Lint test harness + run: eslint test-harness/*.js || true + + type-check: + name: TypeScript Type Checking + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install TypeScript + run: npm install -g typescript + + - name: Create tsconfig.json + run: | + cat > tsconfig.json << 'EOF' + { + "compilerOptions": { + "target": "ES2021", + "module": "ES2022", + "lib": ["ES2021"], + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "types": ["./types/red.d.ts"] + }, + "include": [ + "types/**/*", + "examples/*.ts", + "examples/*.js" + ], + "exclude": [ + "examples/*.test.js" + ] + } + EOF + + - name: Type check + run: tsc --noEmit || true + + plugin-test-matrix: + name: Plugin Tests on Multiple Node Versions + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['18', '20', '21'] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Run plugin tests + run: | + cd test-harness + for test in ../examples/*.test.js; do + if [ -f "$test" ]; then + plugin="${test%.test.js}.js" + if [ -f "$plugin" ]; then + echo "Running tests for $(basename $plugin) on Node ${{ matrix.node-version }}..." + node test-runner.js "$plugin" "$test" || exit 1 + fi + fi + done + + validate-examples: + name: Validate Example Plugins + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check plugin structure + run: | + echo "Checking example plugins..." + for plugin in examples/*.js; do + if [ -f "$plugin" ] && [[ ! "$plugin" =~ \.test\.js$ ]]; then + echo "Checking $plugin..." + # Check for required exports + if ! grep -q "export.*function.*activate" "$plugin" && \ + ! grep -q "exports\.activate" "$plugin" && \ + ! grep -q "module\.exports.*=.*{.*activate" "$plugin"; then + echo "ERROR: $plugin missing activate function" + exit 1 + fi + echo "✓ $plugin is valid" + fi + done + + - name: Validate package.json files + run: | + for dir in examples/*/; do + if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then + echo "Validating $dir/package.json..." + node -e "JSON.parse(require('fs').readFileSync('$dir/package.json'))" || exit 1 + echo "✓ $dir/package.json is valid JSON" + fi + done \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..08f1286 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,136 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag_name: + description: 'Tag name for release' + required: true + default: 'v0.1.0' + +jobs: + create-release: + name: Create Release + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + tag_name: ${{ env.TAG_NAME }} + steps: + - name: Set tag name + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV + else + echo "TAG_NAME=${{ github.ref_name }}" >> $GITHUB_ENV + fi + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.TAG_NAME }} + release_name: Red Editor ${{ env.TAG_NAME }} + draft: true + prerelease: false + body: | + # Red Editor ${{ env.TAG_NAME }} + + ## What's Changed + + + ## Installation + + ### macOS + ```bash + curl -L https://github.com/${{ github.repository }}/releases/download/${{ env.TAG_NAME }}/red-x86_64-apple-darwin.tar.gz | tar xz + chmod +x red + sudo mv red /usr/local/bin/ + ``` + + ### Linux + ```bash + curl -L https://github.com/${{ github.repository }}/releases/download/${{ env.TAG_NAME }}/red-x86_64-unknown-linux-gnu.tar.gz | tar xz + chmod +x red + sudo mv red /usr/local/bin/ + ``` + + ### Windows + Download `red-x86_64-pc-windows-msvc.zip` and extract to a directory in your PATH. + + ## Full Changelog + https://github.com/${{ github.repository }}/compare/... + + build-release: + name: Build Release + needs: create-release + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + archive: tar.gz + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + archive: tar.gz + - os: macos-latest + target: x86_64-apple-darwin + archive: tar.gz + - os: macos-latest + target: aarch64-apple-darwin + archive: tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + archive: zip + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl tools + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Prepare release archive (Unix) + if: matrix.os != 'windows-latest' + run: | + mkdir -p release + cp target/${{ matrix.target }}/release/red release/ + cp README.md LICENSE default_config.toml release/ + cd release + tar czf ../red-${{ matrix.target }}.tar.gz * + + - name: Prepare release archive (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path release + Copy-Item target/${{ matrix.target }}/release/red.exe release/ + Copy-Item README.md,LICENSE,default_config.toml release/ + Compress-Archive -Path release/* -DestinationPath red-${{ matrix.target }}.zip + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./red-${{ matrix.target }}.${{ matrix.archive }} + asset_name: red-${{ matrix.target }}.${{ matrix.archive }} + asset_content_type: application/octet-stream \ No newline at end of file diff --git a/.hookman/config.toml b/.hookman/config.toml new file mode 100644 index 0000000..9f7a875 --- /dev/null +++ b/.hookman/config.toml @@ -0,0 +1 @@ +version = "0.1.0" diff --git a/.hookman/hooks/pre-commit.toml b/.hookman/hooks/pre-commit.toml new file mode 100644 index 0000000..7275b8d --- /dev/null +++ b/.hookman/hooks/pre-commit.toml @@ -0,0 +1,5 @@ +hook_type = "pre-commit" + +[[commands]] +id = "check-format" +command = "cargo fmt -- --check" diff --git a/Cargo.lock b/Cargo.lock index dbcb8f5..10f6c47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -36,6 +48,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.15" @@ -87,20 +105,32 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.80" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "ast_node" -version = "0.9.6" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e3e06ec6ac7d893a0db7127d91063ad7d9da8988f8a1a256f03729e6eec026" +checksum = "91fb5864e2f5bf9fd9797b94b2dfd1554d4c3092b535008b27d7e15c86675a2f" dependencies = [ "proc-macro2", "quote", - "swc_macros_common", - "syn 2.0.96", + "swc_macros_common 1.0.0", + "syn", ] [[package]] @@ -111,7 +141,7 @@ checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -122,7 +152,7 @@ checksum = "1b1244b10dcd56c92219da4e14caa97e312079e185f04ba3eea25061561dc0a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -152,15 +182,69 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" +dependencies = [ + "simd-abstraction", +] + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref 0.5.2", + "vsimd", +] + [[package]] name = "better_scoped_tls" -version = "0.1.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "794edcc9b3fb07bb4aecaa11f093fd45663b4feadb782d68303a2268bc2701de" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" dependencies = [ "scoped-tls", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -184,9 +268,21 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] [[package]] name = "block-buffer" @@ -219,20 +315,52 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.96", + "syn", ] [[package]] name = "bumpalo" -version = "3.15.3" +version = "3.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea184aa71bb362a1157c896979544cc23974e08fd265f29ea96b59f0b4a555b" +checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytes" -version = "1.5.0" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "capacity_builder" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2d24a6dcf0cd402a21b65d35340f3a49ff3475dc5fdac91d22d2733e6641c6" +dependencies = [ + "capacity_builder_macros", + "itoa", +] + +[[package]] +name = "capacity_builder_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4a6cae9efc04cc6cbb8faf338d2c497c165c83e74509cf4dbedea948bbf6e5" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "castaway" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] [[package]] name = "cc" @@ -240,6 +368,15 @@ version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3286b845d0fccbdd15af433f61c5970e711987036cb468f437ff6badd70f4e24" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -252,6 +389,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.26" @@ -283,7 +431,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -299,10 +447,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] -name = "convert_case" -version = "0.4.0" +name = "compact_str" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] [[package]] name = "cooked-waker" @@ -335,17 +490,26 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.1", "crossterm_winapi", "futures-core", "libc", - "mio", + "mio 0.8.11", "parking_lot", "signal-hook", "signal-hook-mio", @@ -392,7 +556,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.96", + "syn", ] [[package]] @@ -403,7 +567,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -443,15 +607,19 @@ dependencies = [ [[package]] name = "deno_ast" -version = "1.0.1" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87c67f73e749f78096f517cbb57967d98a8c713b39cf88b1f0b8750a84aa29" +checksum = "0f883bd8eae4dfc8019d925ec3dd04b634b6af9346a5168acc259d55f5f5021d" dependencies = [ - "anyhow", - "base64", + "base64 0.22.1", + "capacity_builder", + "deno_error", "deno_media_type", + "deno_terminal", "dprint-swc-ext", + "percent-encoding", "serde", + "sourcemap 9.2.2", "swc_atoms", "swc_common", "swc_config", @@ -470,20 +638,23 @@ dependencies = [ "swc_ecma_utils", "swc_ecma_visit", "swc_eq_ignore_macros", - "swc_macros_common", + "swc_macros_common 1.0.0", "swc_visit", "swc_visit_macros", "text_lines", + "thiserror 2.0.12", + "unicode-width 0.2.1", "url", ] [[package]] name = "deno_core" -version = "0.264.0" +version = "0.320.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c8dc01fe0c49caf5c784c50958db2d73eb03be62d2d95e3ec83541b64841d8c" +checksum = "f285eed7b072749f9c3a9c4cf2c9ebb06462a2c22afec94892a6684c38f32696" dependencies = [ "anyhow", + "bincode", "bit-set", "bit-vec", "bytes", @@ -493,15 +664,15 @@ dependencies = [ "deno_unsync", "futures", "libc", - "log", "memoffset", "parking_lot", + "percent-encoding", "pin-project", "serde", "serde_json", "serde_v8", "smallvec", - "sourcemap 7.0.1", + "sourcemap 8.0.1", "static_assertions", "tokio", "url", @@ -510,15 +681,36 @@ dependencies = [ [[package]] name = "deno_core_icudata" -version = "0.0.73" +version = "0.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4dccb6147bb3f3ba0c7a48e993bfeb999d2c2e47a81badee80e2b370c8d695" + +[[package]] +name = "deno_error" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13951ea98c0a4c372f162d669193b4c9d991512de9f2381dd161027f34b26b1" +checksum = "612ec3fc481fea759141b0c57810889b0a4fb6fee8f10748677bfe492fd30486" +dependencies = [ + "deno_error_macro", + "libc", +] + +[[package]] +name = "deno_error_macro" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8380a4224d5d2c3f84da4d764c4326cac62e9a1e3d4960442d29136fc07be863" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "deno_media_type" -version = "0.1.2" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a798670c20308e5770cc0775de821424ff9e85665b602928509c8c70430b3ee0" +checksum = "3d9080fcfcea53bcd6eea1916217bd5611c896f3a0db4c001a859722a1258a47" dependencies = [ "data-url", "serde", @@ -527,39 +719,39 @@ dependencies = [ [[package]] name = "deno_ops" -version = "0.140.0" +version = "0.196.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d421b045e2220215b55676f8874246f6b08f9c5de9cdfdfefb6f9b10a3e0f4b3" +checksum = "d35c75ae05062f37ec2ae5fd1d99b2dcdfa0aef70844d3706759b8775056c5f6" dependencies = [ "proc-macro-rules", "proc-macro2", "quote", + "stringcase", "strum", "strum_macros", - "syn 2.0.96", - "thiserror", + "syn", + "thiserror 1.0.57", ] [[package]] -name = "deno_unsync" -version = "0.3.2" +name = "deno_terminal" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30dff7e03584dbae188dae96a0f1876740054809b2ad0cf7c9fc5d361f20e739" +checksum = "23f71c27009e0141dedd315f1dfa3ebb0a6ca4acce7c080fac576ea415a465f6" dependencies = [ - "tokio", + "once_cell", + "termcolor", ] [[package]] -name = "derive_more" -version = "0.99.17" +name = "deno_unsync" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +checksum = "6742a724e8becb372a74c650a1aefb8924a5b8107f7d75b3848763ea24b27a87" dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version 0.4.0", - "syn 1.0.109", + "futures-util", + "parking_lot", + "tokio", ] [[package]] @@ -572,15 +764,25 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dprint-swc-ext" -version = "0.13.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2f24ce6b89a06ae3eb08d5d4f88c05d0aef1fa58e2eba8dd92c97b84210c25" +checksum = "9a09827d6db1a3af25e105553d674ee9019be58fa3d6745c2a2803f8ce8e3eb8" dependencies = [ - "bumpalo", "num-bigint", - "rustc-hash", + "rustc-hash 2.1.1", "swc_atoms", "swc_common", "swc_ecma_ast", @@ -657,13 +859,13 @@ dependencies = [ [[package]] name = "from_variant" -version = "0.1.7" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0b11eeb173ce52f84ebd943d42e58813a2ebb78a6a3ff0a243b71c5199cd7b" +checksum = "8d7ccf961415e7aa17ef93dcb6c2441faaa8e768abe09e659b908089546f74c5" dependencies = [ "proc-macro2", - "swc_macros_common", - "syn 2.0.96", + "swc_macros_common 1.0.0", + "syn", ] [[package]] @@ -676,6 +878,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.30" @@ -732,7 +940,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -807,11 +1015,26 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", +] + [[package]] name = "h2" -version = "0.3.24" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ "bytes", "fnv", @@ -828,9 +1051,13 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "heck" @@ -861,15 +1088,16 @@ dependencies = [ [[package]] name = "hstr" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17fafeca18cf0927e23ea44d7a5189c10536279dfe9094e0dfa953053fbb5377" +checksum = "71399f53a92ef72ee336a4b30201c6e944827e14e0af23204c291aad9c24cc85" dependencies = [ + "hashbrown", "new_debug_unreachable", "once_cell", "phf", - "rustc-hash", - "smallvec", + "rustc-hash 2.1.1", + "triomphe", ] [[package]] @@ -943,6 +1171,92 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -951,12 +1265,23 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "0.5.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -990,7 +1315,7 @@ dependencies = [ "Inflector", "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -999,11 +1324,20 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.10" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" @@ -1028,9 +1362,19 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.173" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" + +[[package]] +name = "libloading" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +dependencies = [ + "cfg-if", + "windows-targets 0.52.3", +] [[package]] name = "linux-raw-sys" @@ -1038,11 +1382,17 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -1075,6 +1425,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.2" @@ -1086,9 +1442,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", @@ -1096,6 +1452,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -1116,9 +1483,9 @@ dependencies = [ [[package]] name = "new_debug_unreachable" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" @@ -1126,12 +1493,22 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.1", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-bigint" version = "0.4.4" @@ -1184,17 +1561,17 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" -version = "0.10.64" +version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -1211,7 +1588,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -1222,9 +1599,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.101" +version = "0.9.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" dependencies = [ "cc", "libc", @@ -1232,11 +1609,42 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "outref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "par-core" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757892557993c69e82f9de0f9051e87144278aa342f03bf53617bbf044554484" +dependencies = [ + "once_cell", +] + +[[package]] +name = "par-iter" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b20f31e9ba82bfcbbb54a67aa40be6cebec9f668ba5753be138f9523c531a" +dependencies = [ + "either", + "par-core", +] + [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -1244,17 +1652,23 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.48.5", + "windows-targets 0.52.3", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "path-absolutize" version = "3.1.1" @@ -1315,7 +1729,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -1344,7 +1758,7 @@ checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -1366,14 +1780,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] -name = "pmutil" -version = "0.6.1" +name = "potential_utf" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.96", + "zerovec", ] [[package]] @@ -1383,7 +1795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "483f8c21f64f3ea09fe0f30f5d48c3e8eefe5dac9129f0075f76593b4c1da705" dependencies = [ "proc-macro2", - "syn 2.0.96", + "syn", ] [[package]] @@ -1394,7 +1806,7 @@ checksum = "07c277e4e643ef00c1233393c673f655e3672cf7eb3ba08a00bdd0ea59139b5f" dependencies = [ "proc-macro-rules-macros", "proc-macro2", - "syn 2.0.96", + "syn", ] [[package]] @@ -1406,7 +1818,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -1427,6 +1839,26 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9e76f66d3f9606f44e45598d155cb13ecf09f4a28199e48daf8c8fc937ea90" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca414edb151b4c8d125c12566ab0d74dc9cdba36fb80eb7b848c15f495fd32d1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.35" @@ -1436,6 +1868,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -1477,7 +1915,7 @@ dependencies = [ "serde_json", "similar", "textwrap", - "thiserror", + "thiserror 1.0.57", "tokio", "toml", "tree-sitter", @@ -1487,11 +1925,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.9.1", ] [[package]] @@ -1525,11 +1963,11 @@ checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] name = "reqwest" -version = "0.11.24" +version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", @@ -1586,21 +2024,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] -name = "rustc_version" -version = "0.2.3" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" -version = "0.4.0" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "semver 1.0.22", + "semver", ] [[package]] @@ -1609,7 +2044,7 @@ version = "0.38.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", @@ -1622,7 +2057,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -1696,12 +2131,6 @@ dependencies = [ "semver-parser", ] -[[package]] -name = "semver" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" - [[package]] name = "semver-parser" version = "0.7.0" @@ -1710,32 +2139,33 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.197" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "indexmap", "itoa", + "memchr", "ryu", "serde", ] @@ -1763,30 +2193,34 @@ dependencies = [ [[package]] name = "serde_v8" -version = "0.173.0" +version = "0.229.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a4cbf3daa409a0affe0b6363364ff829fc3ef62c2a0f57c5e26f202f9845ef" +checksum = "4e1dbbda82d67a393ea96f75d8383bc41fcd0bba43164aeaab599e1c2c2d46d7" dependencies = [ - "bytes", - "derive_more", "num-bigint", "serde", "smallvec", - "thiserror", + "thiserror 1.0.57", "v8", ] [[package]] -name = "sha-1" -version = "0.10.0" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.17" @@ -1804,7 +2238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" dependencies = [ "libc", - "mio", + "mio 0.8.11", "signal-hook", ] @@ -1817,6 +2251,15 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-abstraction" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" +dependencies = [ + "outref 0.1.0", +] + [[package]] name = "similar" version = "2.6.0" @@ -1873,36 +2316,47 @@ dependencies = [ [[package]] name = "sourcemap" -version = "6.4.1" +version = "8.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4cbf65ca7dc576cf50e21f8d0712d96d4fcfd797389744b7b222a85cdf5bd90" +checksum = "208d40b9e8cad9f93613778ea295ed8f3c2b1824217c6cfc7219d3f6f45b96d4" dependencies = [ + "base64-simd 0.7.0", + "bitvec", "data-encoding", "debugid", "if_chain", - "rustc_version 0.2.3", + "rustc-hash 1.1.0", + "rustc_version", "serde", "serde_json", - "unicode-id", + "unicode-id-start", "url", ] [[package]] name = "sourcemap" -version = "7.0.1" +version = "9.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10da010a590ed2fa9ca8467b00ce7e9c5a8017742c0c09c45450efc172208c4b" +checksum = "e22afbcb92ce02d23815b9795523c005cb9d3c214f8b7a66318541c240ea7935" dependencies = [ + "base64-simd 0.8.0", + "bitvec", "data-encoding", "debugid", "if_chain", - "rustc_version 0.2.3", + "rustc-hash 2.1.1", "serde", "serde_json", - "unicode-id", + "unicode-id-start", "url", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "stacker" version = "0.1.15" @@ -1930,16 +2384,22 @@ checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" [[package]] name = "string_enum" -version = "0.4.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b650ea2087d32854a0f20b837fc56ec987a1cb4f758c9757e1171ee9812da63" +checksum = "c9fe66b8ee349846ce2f9557a26b8f1e74843c4a13fb381f9a3d73617a5f956a" dependencies = [ "proc-macro2", "quote", - "swc_macros_common", - "syn 2.0.96", + "swc_macros_common 1.0.0", + "syn", ] +[[package]] +name = "stringcase" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04028eeb851ed08af6aba5caa29f2d59a13ed168cee4d6bd753aeefcf1d636b0" + [[package]] name = "strsim" version = "0.11.1" @@ -1965,27 +2425,42 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.96", + "syn", +] + +[[package]] +name = "swc_allocator" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6b926f0d94bbb34031fe5449428cfa1268cdc0b31158d6ad9c97e0fc1e79dd" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown", + "ptr_meta", + "rustc-hash 2.1.1", + "triomphe", ] [[package]] name = "swc_atoms" -version = "0.6.5" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d538eaaa6f085161d088a04cf0a3a5a52c5a7f2b3bd9b83f73f058b0ed357c0" +checksum = "9d7077ba879f95406459bc0c81f3141c529b34580bc64d7ab7bd15e7118a0391" dependencies = [ "hstr", "once_cell", - "rustc-hash", + "rustc-hash 2.1.1", "serde", ] [[package]] name = "swc_common" -version = "0.33.12" +version = "9.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b3ae36feceded27f0178dc9dabb49399830847ffb7f866af01798844de8f973" +checksum = "a56b6f5a8e5affa271b56757a93badee6f44defcd28f3ba106bb2603afe40d3d" dependencies = [ + "anyhow", "ast_node", "better_scoped_tls", "cfg-if", @@ -1994,24 +2469,26 @@ dependencies = [ "new_debug_unreachable", "num-bigint", "once_cell", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "siphasher", - "sourcemap 6.4.1", + "sourcemap 9.2.2", + "swc_allocator", "swc_atoms", "swc_eq_ignore_macros", "swc_visit", "tracing", - "unicode-width", + "unicode-width 0.1.11", "url", ] [[package]] name = "swc_config" -version = "0.1.9" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112884e66b60e614c0f416138b91b8b82b7fea6ed0ecc5e26bad4726c57a6c99" +checksum = "a01bfcbbdea182bdda93713aeecd997749ae324686bf7944f54d128e56be4ea9" dependencies = [ + "anyhow", "indexmap", "serde", "serde_json", @@ -2020,46 +2497,53 @@ dependencies = [ [[package]] name = "swc_config_macro" -version = "0.1.3" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2574f75082322a27d990116cd2a24de52945fc94172b24ca0b3e9e2a6ceb6b" +checksum = "7f2ebd37ef52a8555c8c9be78b694d64adcb5e3bc16c928f030d82f1d65fac57" dependencies = [ "proc-macro2", "quote", - "swc_macros_common", - "syn 2.0.96", + "swc_macros_common 1.0.0", + "syn", ] [[package]] name = "swc_ecma_ast" -version = "0.110.17" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79401a45da704f4fb2552c5bf86ee2198e8636b121cb81f8036848a300edd53b" +checksum = "0613d84468a6bb6d45d13c5a3368b37bd21f3067a089f69adac630dcb462a018" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.9.1", "is-macro", "num-bigint", + "once_cell", "phf", + "rustc-hash 2.1.1", "scoped-tls", "serde", "string_enum", "swc_atoms", "swc_common", - "unicode-id", + "swc_visit", + "unicode-id-start", ] [[package]] name = "swc_ecma_codegen" -version = "0.146.54" +version = "11.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99b61ca275e3663238b71c4b5da8e6fb745bde9989ef37d94984dfc81fc6d009" +checksum = "b01b3de365a86b8f982cc162f257c82f84bda31d61084174a3be37e8ab15c0f4" dependencies = [ + "ascii", + "compact_str", "memchr", "num-bigint", "once_cell", - "rustc-hash", + "regex", + "rustc-hash 2.1.1", "serde", - "sourcemap 6.4.1", + "sourcemap 9.2.2", + "swc_allocator", "swc_atoms", "swc_common", "swc_ecma_ast", @@ -2069,40 +2553,70 @@ dependencies = [ [[package]] name = "swc_ecma_codegen_macros" -version = "0.7.4" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "394b8239424b339a12012ceb18726ed0244fce6bf6345053cb9320b2791dcaa5" +checksum = "e99e1931669a67c83e2c2b4375674f6901d1480994a76aa75b23f1389e6c5076" dependencies = [ "proc-macro2", "quote", - "swc_macros_common", - "syn 2.0.96", + "swc_macros_common 1.0.0", + "syn", +] + +[[package]] +name = "swc_ecma_lexer" +version = "12.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d11c8e71901401b9aae2ece4946eeb7674b14b8301a53768afbbeeb0e48b599" +dependencies = [ + "arrayvec", + "bitflags 2.9.1", + "either", + "new_debug_unreachable", + "num-bigint", + "num-traits", + "phf", + "rustc-hash 2.1.1", + "serde", + "smallvec", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", + "typed-arena", ] [[package]] name = "swc_ecma_loader" -version = "0.45.13" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5713ab3429530c10bdf167170ebbde75b046c8003558459e4de5aaec62ce0f1" +checksum = "8eb574d660c05f3483c984107452b386e45b95531bdb1253794077edc986f413" dependencies = [ "anyhow", "pathdiff", + "rustc-hash 2.1.1", "serde", + "swc_atoms", "swc_common", "tracing", ] [[package]] name = "swc_ecma_parser" -version = "0.141.37" +version = "12.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4d17401dd95048a6a62b777d533c0999dabdd531ef9d667e22f8ae2a2a0d294" +checksum = "250786944fbc05f6484eda9213df129ccfe17226ae9ad51b62fce2f72135dbee" dependencies = [ + "arrayvec", + "bitflags 2.9.1", "either", "new_debug_unreachable", "num-bigint", "num-traits", "phf", + "rustc-hash 2.1.1", "serde", "smallvec", "smartstring", @@ -2110,22 +2624,24 @@ dependencies = [ "swc_atoms", "swc_common", "swc_ecma_ast", + "swc_ecma_lexer", "tracing", "typed-arena", ] [[package]] name = "swc_ecma_transforms_base" -version = "0.135.11" +version = "13.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d4ab26ec124b03e47f54d4daade8e9a9dcd66d3a4ca3cd47045f138d267a60e" +checksum = "6856da3da598f4da001b7e4ce225ee8970bc9d5cbaafcaf580190cf0a6031ec5" dependencies = [ "better_scoped_tls", - "bitflags 2.4.2", + "bitflags 2.9.1", "indexmap", "once_cell", + "par-core", "phf", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "smallvec", "swc_atoms", @@ -2139,9 +2655,9 @@ dependencies = [ [[package]] name = "swc_ecma_transforms_classes" -version = "0.124.11" +version = "13.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fe4376c024fa04394cafb8faecafb4623722b92dbbe46532258cc0a6b569d9c" +checksum = "0f84248f82bad599d250bbcd52cb4db6ff6409f48267fd6f001302a2e9716f80" dependencies = [ "swc_atoms", "swc_common", @@ -2153,24 +2669,24 @@ dependencies = [ [[package]] name = "swc_ecma_transforms_macros" -version = "0.5.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e309b88f337da54ef7fe4c5b99c2c522927071f797ee6c9fb8b6bf2d100481" +checksum = "6845dfb88569f3e8cd05901505916a8ebe98be3922f94769ca49f84e8ccec8f7" dependencies = [ "proc-macro2", "quote", - "swc_macros_common", - "syn 2.0.96", + "swc_macros_common 1.0.0", + "syn", ] [[package]] name = "swc_ecma_transforms_proposal" -version = "0.169.14" +version = "13.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86de99757fc31d8977f47c02a26e5c9a243cb63b03fe8aa8b36d79924b8fa29c" +checksum = "193237e318421ef621c2b3958b4db174770c5280ef999f1878f2df93a2837ca6" dependencies = [ "either", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "smallvec", "swc_atoms", @@ -2185,17 +2701,19 @@ dependencies = [ [[package]] name = "swc_ecma_transforms_react" -version = "0.181.15" +version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9918e22caf1ea4a71085f5d818d6c0bf5c19d669cfb9d38f9fdc3da0496abdc7" +checksum = "baae39c70229103a72090119887922fc5e32f934f5ca45c0423a5e65dac7e549" dependencies = [ - "base64", + "base64 0.22.1", "dashmap", "indexmap", "once_cell", + "rustc-hash 2.1.1", "serde", - "sha-1", + "sha1", "string_enum", + "swc_allocator", "swc_atoms", "swc_common", "swc_config", @@ -2209,10 +2727,12 @@ dependencies = [ [[package]] name = "swc_ecma_transforms_typescript" -version = "0.186.14" +version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d1495c969ffdc224384f1fb73646b9c1b170779f20fdb984518deb054aa522" +checksum = "a3c65e0b49f7e2a2bd92f1d89c9a404de27232ce00f6a4053f04bda446d50e5c" dependencies = [ + "once_cell", + "rustc-hash 2.1.1", "ryu-js", "serde", "swc_atoms", @@ -2226,14 +2746,17 @@ dependencies = [ [[package]] name = "swc_ecma_utils" -version = "0.125.4" +version = "13.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cead1083e46b0f072a82938f16d366014468f7510350957765bb4d013496890" +checksum = "7ed837406d5dbbfbf5792b1dc90964245a0cf659753d4745fe177ffebe8598b9" dependencies = [ "indexmap", "num_cpus", "once_cell", - "rustc-hash", + "par-core", + "par-iter", + "rustc-hash 2.1.1", + "ryu-js", "swc_atoms", "swc_common", "swc_ecma_ast", @@ -2244,10 +2767,11 @@ dependencies = [ [[package]] name = "swc_ecma_visit" -version = "0.96.17" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d0100c383fb08b6f34911ab6f925950416a5d14404c1cd520d59fb8dfbb3bf" +checksum = "249dc9eede1a4ad59a038f9cfd61ce67845bd2c1392ade3586d714e7181f3c1a" dependencies = [ + "new_debug_unreachable", "num-bigint", "swc_atoms", "swc_common", @@ -2258,59 +2782,58 @@ dependencies = [ [[package]] name = "swc_eq_ignore_macros" -version = "0.1.3" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695a1d8b461033d32429b5befbf0ad4d7a2c4d6ba9cd5ba4e0645c615839e8e4" +checksum = "e96e15288bf385ab85eb83cff7f9e2d834348da58d0a31b33bdb572e66ee413e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] name = "swc_macros_common" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50176cfc1cbc8bb22f41c6fe9d1ec53fbe057001219b5954961b8ad0f336fce9" +checksum = "27e18fbfe83811ffae2bb23727e45829a0d19c6870bced7c0f545cc99ad248dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] -name = "swc_visit" -version = "0.5.8" +name = "swc_macros_common" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b27078d8571abe23aa52ef608dd1df89096a37d867cf691cbb4f4c392322b7c9" +checksum = "a509f56fca05b39ba6c15f3e58636c3924c78347d63853632ed2ffcb6f5a0ac7" dependencies = [ - "either", - "swc_visit_macros", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "swc_visit_macros" -version = "0.5.9" +name = "swc_visit" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8bb05975506741555ea4d10c3a3bdb0e2357cd58e1a4a4332b8ebb4b44c34d" +checksum = "9138b6a36bbe76dd6753c4c0794f7e26480ea757bee499738bedbbb3ae3ec5f3" dependencies = [ - "Inflector", - "pmutil", - "proc-macro2", - "quote", - "swc_macros_common", - "syn 2.0.96", + "either", + "new_debug_unreachable", ] [[package]] -name = "syn" -version = "1.0.109" +name = "swc_visit_macros" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "92807d840959f39c60ce8a774a3f83e8193c658068e6d270dbe0a05e40e90b41" dependencies = [ + "Inflector", "proc-macro2", "quote", - "unicode-ident", + "swc_macros_common 0.3.14", + "syn", ] [[package]] @@ -2330,6 +2853,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -2351,6 +2885,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.10.0" @@ -2363,6 +2903,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "text_lines" version = "0.6.0" @@ -2380,7 +2929,7 @@ checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width", + "unicode-width 0.1.11", ] [[package]] @@ -2389,7 +2938,16 @@ version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.57", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", ] [[package]] @@ -2400,7 +2958,18 @@ checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2414,48 +2983,42 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.6.0" +name = "tinystr" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" -version = "1.36.0" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" dependencies = [ "backtrace", "bytes", "libc", - "mio", - "num_cpus", + "mio 1.0.3", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" -version = "2.2.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -2541,7 +3104,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", ] [[package]] @@ -2573,6 +3136,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "triomphe" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2591,18 +3164,18 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" -[[package]] -name = "unicode-bidi" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" - [[package]] name = "unicode-id" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1b6def86329695390197b82c1e244a54a131ceb66c996f2088a3876e2ae083f" +[[package]] +name = "unicode-id-start" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f322b60f6b9736017344fa0635d64be2f458fbc04eef65f6be22976dd1ffd5b" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -2616,25 +3189,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] -name = "unicode-normalization" -version = "0.1.23" +name = "unicode-width" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" -dependencies = [ - "tinyvec", -] +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "unicode-width" -version = "0.1.11" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "url" -version = "2.5.0" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -2642,6 +3212,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2659,13 +3235,18 @@ dependencies = [ [[package]] name = "v8" -version = "0.83.2" +version = "130.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6c8a960dd2eb74b22eda64f7e9f3d1688f82b80202828dc0425ebdeda826ef" +checksum = "a511192602f7b435b0a241c1947aa743eb7717f20a9195f4b5e8ed1952e01db1" dependencies = [ - "bitflags 2.4.2", + "bindgen", + "bitflags 2.9.1", "fslock", + "gzip-header", + "home", + "miniz_oxide", "once_cell", + "paste", "which", ] @@ -2681,6 +3262,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "want" version = "0.3.1" @@ -2717,7 +3304,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.96", + "syn", "wasm-bindgen-shared", ] @@ -2751,7 +3338,7 @@ checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2774,15 +3361,14 @@ dependencies = [ [[package]] name = "which" -version = "5.0.0" +version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bf3ea8596f3a0dd5980b46430f2058dfe2c36a27ccfbb1845d6fbfcd9ba6e14" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" dependencies = [ "either", "home", - "once_cell", "rustix", - "windows-sys 0.48.0", + "winsafe", ] [[package]] @@ -2801,6 +3387,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2957,3 +3552,122 @@ dependencies = [ "cfg-if", "windows-sys 0.48.0", ] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 1a92bad..231a3ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,8 @@ async-trait = "0.1.77" bon = "3.3.2" clap = { version = "4.5.26", features = ["derive"] } crossterm = { version = "0.27.0", features = ["event-stream"] } -deno_ast = { version = "1.0.1", features = ["transpiling"] } -deno_core = "0.264.0" +deno_ast = { version = "0.48.0", features = ["transpiling"] } +deno_core = "0.320.0" futures = "0.3.30" futures-timer = "3.0.2" fuzzy-matcher = "0.3.7" @@ -21,14 +21,14 @@ lazy_static = "1.5.0" nix = { version = "0.28.0", features = ["signal"] } once_cell = "1.19.0" path-absolutize = "3.1.1" -reqwest = "0.11.24" +reqwest = "0.11.27" ropey = "1.6.1" serde = { version = "1.0.196", features = ["derive"] } serde_json = "1.0.113" similar = "2.6.0" textwrap = "0.16" thiserror = "1.0" -tokio = { version = "1.36.0", features = ["full"] } +tokio = { version = "1.41.0", features = ["full"] } toml = "0.8.10" tree-sitter = "0.20.10" tree-sitter-rust = "0.20.4" diff --git a/default_config.toml b/default_config.toml index 4873293..c1de4fe 100644 --- a/default_config.toml +++ b/default_config.toml @@ -101,7 +101,8 @@ Esc = { EnterMode = "Normal" } "i" = "DumpDiagnostics" "c" = "DumpCapabilities" "h" = "DumpHistory" -"p" = "DoPing" +"l" = "ViewLogs" +"p" = "ListPlugins" [keys.search] Esc = { EnterMode = "Normal" } diff --git a/docs/HOT_RELOAD_PLAN.md b/docs/HOT_RELOAD_PLAN.md new file mode 100644 index 0000000..371aa79 --- /dev/null +++ b/docs/HOT_RELOAD_PLAN.md @@ -0,0 +1,480 @@ +# Hot Reload Implementation Plan for Red Editor Plugin System + +## Overview + +This document outlines the implementation plan for adding hot reload capabilities to the Red editor plugin system. Hot reloading will allow plugins to be automatically reloaded when their source files change, without requiring an editor restart. + +## Current Architecture Analysis + +### Current State +- Plugins are loaded once during editor startup in the `run()` method +- Each plugin runs in a shared JavaScript runtime environment +- Plugin registry has a `reload()` method that deactivates and reactivates all plugins +- No file watching mechanism exists currently +- Plugins are loaded from `~/.config/red/plugins/` directory + +### Key Components +- **PluginRegistry** (`src/plugin/registry.rs`): Manages plugin lifecycle +- **Runtime** (`src/plugin/runtime.rs`): Deno-based JavaScript runtime +- **Editor** (`src/editor.rs`): Main editor loop and plugin initialization + +## Implementation Plan + +### 1. File Watcher System (`src/plugin/watcher.rs`) + +Create a new module for watching plugin files: + +```rust +use notify::{Watcher, RecursiveMode, watcher, DebouncedEvent}; +use std::sync::mpsc::{channel, Receiver}; +use std::time::Duration; +use std::path::PathBuf; + +pub struct PluginWatcher { + watcher: Box, + rx: Receiver, + watched_plugins: HashMap, // path -> plugin_name +} + +impl PluginWatcher { + pub fn new(debounce_ms: u64) -> Result { + let (tx, rx) = channel(); + let watcher = watcher(tx, Duration::from_millis(debounce_ms))?; + + Ok(Self { + watcher: Box::new(watcher), + rx, + watched_plugins: HashMap::new(), + }) + } + + pub fn watch_plugin(&mut self, name: &str, path: &Path) -> Result<()> { + self.watcher.watch(path, RecursiveMode::NonRecursive)?; + self.watched_plugins.insert(path.to_path_buf(), name.to_string()); + Ok(()) + } + + pub fn check_changes(&mut self) -> Vec<(String, PathBuf)> { + let mut changes = Vec::new(); + while let Ok(event) = self.rx.try_recv() { + match event { + DebouncedEvent::Write(path) | DebouncedEvent::Create(path) => { + if let Some(name) = self.watched_plugins.get(&path) { + changes.push((name.clone(), path)); + } + } + _ => {} + } + } + changes + } +} +``` + +### 2. Update Plugin Registry (`src/plugin/registry.rs`) + +#### 2.1 Add File Tracking + +```rust +pub struct PluginRegistry { + plugins: Vec<(String, String)>, + metadata: HashMap, + file_paths: HashMap, // New: track actual file paths + last_modified: HashMap, // New: track modification times + initialized: bool, +} +``` + +#### 2.2 Implement Single Plugin Reload + +```rust +impl PluginRegistry { + /// Reload a single plugin + pub async fn reload_plugin(&mut self, name: &str, runtime: &mut Runtime) -> anyhow::Result<()> { + // 1. Deactivate the plugin + self.deactivate_plugin(name, runtime).await?; + + // 2. Clear from module cache + let clear_cache_code = format!(r#" + // Clear module cache for the plugin + delete globalThis.plugins['{}']; + delete globalThis.pluginInstances['{}']; + + // Notify plugin it's being reloaded + globalThis.context.notify('plugin:reloading', {{ name: '{}' }}); + "#, name, name, name); + + runtime.run(&clear_cache_code).await?; + + // 3. Re-read metadata if package.json exists + if let Some(path) = self.file_paths.get(name) { + if let Some(dir) = path.parent() { + let package_json = dir.join("package.json"); + if package_json.exists() { + if let Ok(metadata) = PluginMetadata::from_file(&package_json) { + self.metadata.insert(name.to_string(), metadata); + } + } + } + } + + // 4. Re-load the plugin + if let Some((idx, (plugin_name, plugin_path))) = self.plugins.iter().enumerate().find(|(_, (n, _))| n == name) { + let code = format!(r#" + import * as plugin_{idx}_new from '{}?t={}'; + const activate_{idx}_new = plugin_{idx}_new.activate; + const deactivate_{idx}_new = plugin_{idx}_new.deactivate || null; + + globalThis.plugins['{}'] = activate_{idx}_new; + globalThis.pluginInstances['{}'] = {{ + activate: activate_{idx}_new, + deactivate: deactivate_{idx}_new, + context: null + }}; + + // Activate the reloaded plugin + globalThis.pluginInstances['{}'].context = activate_{idx}_new(globalThis.context); + + // Notify plugin it's been reloaded + globalThis.context.notify('plugin:reloaded', {{ name: '{}' }}); + "#, plugin_path, SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis(), + plugin_name, plugin_name, plugin_name, plugin_name); + + runtime.run(&code).await?; + } + + Ok(()) + } + + async fn deactivate_plugin(&mut self, name: &str, runtime: &mut Runtime) -> anyhow::Result<()> { + let code = format!(r#" + (async () => {{ + const plugin = globalThis.pluginInstances['{}']; + if (plugin && plugin.deactivate) {{ + try {{ + await plugin.deactivate(); + globalThis.log(`Plugin {} deactivated for reload`); + }} catch (error) {{ + globalThis.log(`Error deactivating plugin {} for reload:`, error); + }} + }} + + // Clear this plugin's commands and event listeners + for (const [cmd, fn] of Object.entries(globalThis.context.commands)) {{ + // We need a way to track which commands belong to which plugin + } + }})(); + "#, name, name, name); + + runtime.run(&code).await?; + Ok(()) + } +} +``` + +### 3. Update Editor (`src/editor.rs`) + +#### 3.1 Add New Actions + +```rust +pub enum Action { + // ... existing actions ... + ReloadPlugin(String), // Reload a specific plugin by name + ReloadAllPlugins, // Reload all plugins + ToggleHotReload, // Enable/disable hot reload + ShowReloadStatus, // Show hot reload status +} +``` + +#### 3.2 Add Watcher Management + +```rust +use crate::plugin::watcher::PluginWatcher; + +pub struct Editor { + // ... existing fields ... + plugin_watcher: Option, + hot_reload_enabled: bool, +} +``` + +#### 3.3 Update Main Loop + +In the `run()` method, after initializing plugins: + +```rust +// Initialize hot reload if enabled +if self.config.dev.unwrap_or_default().hot_reload { + let mut watcher = PluginWatcher::new( + self.config.dev.unwrap_or_default().hot_reload_delay.unwrap_or(100) + )?; + + // Watch all loaded plugin files + for (name, path) in &self.config.plugins { + let full_path = Config::path("plugins").join(path); + watcher.watch_plugin(name, &full_path)?; + } + + self.plugin_watcher = Some(watcher); + self.hot_reload_enabled = true; +} +``` + +In the main event loop: + +```rust +// Check for plugin file changes +if let Some(watcher) = &mut self.plugin_watcher { + if self.hot_reload_enabled { + let changes = watcher.check_changes(); + for (plugin_name, path) in changes { + log!("Plugin file changed: {} ({})", plugin_name, path.display()); + + // Reload the plugin + match self.plugin_registry.reload_plugin(&plugin_name, &mut runtime).await { + Ok(_) => { + self.last_error = Some(format!("Plugin '{}' reloaded", plugin_name)); + } + Err(e) => { + self.last_error = Some(format!("Failed to reload plugin '{}': {}", plugin_name, e)); + } + } + } + } +} +``` + +### 4. Update Runtime Communication + +#### 4.1 Add New PluginRequest Variant + +```rust +pub enum PluginRequest { + // ... existing variants ... + PluginFileChanged { plugin_name: String, file_path: String }, + GetPluginState { plugin_name: String }, + SetPluginState { plugin_name: String, state: Value }, +} +``` + +### 5. JavaScript Runtime Updates (`src/plugin/runtime.js`) + +#### 5.1 Improve Module Management + +```javascript +// Track which commands belong to which plugin +let pluginCommands = {}; +let pluginEventHandlers = {}; + +class RedContext { + constructor() { + this.commands = {}; + this.eventSubscriptions = {}; + this.pluginStates = {}; // Store state between reloads + } + + addCommand(name, command, pluginName) { + this.commands[name] = command; + + // Track which plugin owns this command + if (pluginName) { + if (!pluginCommands[pluginName]) { + pluginCommands[pluginName] = []; + } + pluginCommands[pluginName].push(name); + } + } + + clearPluginCommands(pluginName) { + const commands = pluginCommands[pluginName] || []; + for (const cmd of commands) { + delete this.commands[cmd]; + } + delete pluginCommands[pluginName]; + } + + // State preservation for hot reload + savePluginState(pluginName, state) { + this.pluginStates[pluginName] = state; + } + + getPluginState(pluginName) { + return this.pluginStates[pluginName]; + } +} +``` + +#### 5.2 Add Reload Events + +```javascript +// Allow plugins to handle reload events +export function onBeforeReload(red) { + // Plugin can return state to preserve + return { /* state to preserve */ }; +} + +export function onAfterReload(red, previousState) { + // Plugin can restore state after reload +} +``` + +### 6. Configuration Updates + +Add to `config.toml`: + +```toml +[dev] +# Enable hot reload in development +hot_reload = true + +# Delay before reloading after file change (milliseconds) +hot_reload_delay = 100 + +# File patterns to watch (glob patterns) +hot_reload_watch = ["*.js", "*.ts", "package.json"] + +# Show reload notifications +hot_reload_notifications = true +``` + +### 7. Error Handling Strategy + +1. **Graceful Degradation**: If reload fails, keep the old version running +2. **Error Reporting**: Show clear error messages in the editor status line +3. **Rollback**: Ability to rollback to previous version if new version crashes +4. **Logging**: Detailed logs for debugging reload issues + +```rust +impl PluginRegistry { + pub async fn reload_plugin_safe(&mut self, name: &str, runtime: &mut Runtime) -> Result<()> { + // Save current state + let backup_state = self.backup_plugin_state(name, runtime).await?; + + // Try to reload + match self.reload_plugin(name, runtime).await { + Ok(_) => Ok(()), + Err(e) => { + // Restore previous state + self.restore_plugin_state(name, backup_state, runtime).await?; + Err(e) + } + } + } +} +``` + +### 8. Development Mode Features + +Create a special development mode that provides: + +1. **Reload Statistics**: Show reload count, time taken, success rate +2. **Debug Information**: Detailed logs of what's being reloaded +3. **Performance Monitoring**: Track reload performance +4. **State Inspector**: View plugin state between reloads + +### 9. Testing Strategy + +1. **Unit Tests**: Test individual components (watcher, reload logic) +2. **Integration Tests**: Test full reload cycle +3. **Error Scenarios**: Test various failure modes +4. **Performance Tests**: Ensure reload is fast enough + +## Usage Examples + +### Manual Reload Commands + +```vim +:reload-plugin buffer-picker " Reload specific plugin +:reload-all-plugins " Reload all plugins +:toggle-hot-reload " Enable/disable hot reload +:show-reload-status " Show current hot reload status +``` + +### Keybindings + +```toml +[keys.normal] +"pr" = { ReloadPlugin = "current" } # Reload current plugin +"pR" = "ReloadAllPlugins" # Reload all plugins +"ph" = "ToggleHotReload" # Toggle hot reload +``` + +### Plugin Development Workflow + +```javascript +// example-plugin.js +let state = { + counter: 0, + lastAction: null +}; + +export async function activate(red) { + // Restore state after reload + const previousState = red.getPluginState('example-plugin'); + if (previousState) { + state = previousState; + red.log('Plugin reloaded, state restored'); + } + + red.addCommand('IncrementCounter', () => { + state.counter++; + state.lastAction = new Date(); + red.log(`Counter: ${state.counter}`); + }); +} + +export async function onBeforeReload(red) { + // Save state before reload + red.savePluginState('example-plugin', state); + return state; +} + +export async function deactivate(red) { + red.log('Plugin deactivating...'); +} +``` + +## Benefits + +1. **Faster Development Cycle**: No need to restart editor for plugin changes +2. **State Preservation**: Maintain plugin state across reloads +3. **Better Error Recovery**: Graceful handling of reload failures +4. **Improved Developer Experience**: Immediate feedback on code changes + +## Challenges and Solutions + +### Challenge 1: Module Cache +**Problem**: JavaScript modules are cached and won't reload +**Solution**: Add timestamp query parameter to force re-import + +### Challenge 2: Event Listener Accumulation +**Problem**: Event listeners may accumulate on reload +**Solution**: Track listeners per plugin and clean up on deactivate + +### Challenge 3: Timer/Interval Cleanup +**Problem**: Timers may continue running after reload +**Solution**: Force cleanup in deactivate, track all timers per plugin + +### Challenge 4: Circular Dependencies +**Problem**: Plugins may have circular dependencies +**Solution**: Detect and warn about circular dependencies + +## Implementation Timeline + +1. **Phase 1** (2-3 days): Basic file watcher and reload infrastructure +2. **Phase 2** (2-3 days): State preservation and error handling +3. **Phase 3** (1-2 days): UI integration and commands +4. **Phase 4** (1-2 days): Testing and refinement +5. **Phase 5** (1 day): Documentation and examples + +## Future Enhancements + +1. **Dependency Tracking**: Reload dependent plugins automatically +2. **Partial Reload**: Reload only changed functions/components +3. **Hot Module Replacement**: True HMR without losing state +4. **Plugin Profiling**: Performance analysis during reload +5. **Remote Reload**: Reload plugins over network for remote development + +## Conclusion + +This hot reload implementation will significantly improve the plugin development experience for Red editor. By providing automatic reloading with state preservation and proper error handling, developers can iterate quickly on their plugins without the friction of constant editor restarts. \ No newline at end of file diff --git a/docs/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md new file mode 100644 index 0000000..5ae1ec9 --- /dev/null +++ b/docs/PLUGIN_SYSTEM.md @@ -0,0 +1,492 @@ +# Red Editor Plugin System Documentation + +## Overview + +The Red editor features a powerful plugin system built on Deno Core runtime, allowing developers to extend the editor's functionality using JavaScript or TypeScript. Plugins run in a sandboxed environment with controlled access to editor APIs, ensuring security while providing flexibility. + +## Architecture + +### Core Components + +The plugin system consists of three main modules located in `src/plugin/`: + +1. **`runtime.rs`** - Manages the Deno JavaScript runtime in a separate thread +2. **`loader.rs`** - Handles module loading and TypeScript transpilation +3. **`registry.rs`** - Manages plugin lifecycle and communication between plugins and the editor + +### Communication Model + +The plugin system uses a bidirectional communication model: + +``` +Editor Thread <-> Plugin Registry <-> Plugin Runtime Thread <-> JavaScript Plugins +``` + +- **Editor to Plugin**: Through `PluginRegistry` methods and event dispatching +- **Plugin to Editor**: Via custom Deno ops and the global `ACTION_DISPATCHER` + +## Plugin Development Guide + +### Plugin Metadata + +Plugins can include a `package.json` file to provide metadata: + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "description": "A helpful plugin for Red editor", + "author": "Your Name", + "license": "MIT", + "keywords": ["productivity", "tools"], + "repository": { + "type": "git", + "url": "https://github.com/user/my-plugin" + }, + "engines": { + "red": ">=0.1.0" + }, + "capabilities": { + "commands": true, + "events": true, + "buffer_manipulation": false, + "ui_components": true + } +} +``` + +View loaded plugins with the `dp` keybinding or `ListPlugins` command. + +### Creating a Plugin + +1. Create a JavaScript or TypeScript file that exports an `activate` function: + +**Plugin Lifecycle:** +- `activate(red)` - Called when the plugin is loaded +- `deactivate(red)` - Optional, called when the plugin is unloaded + +```javascript +export async function activate(red) { + // Initialize your plugin +} + +export async function deactivate(red) { + // Clean up resources (intervals, event listeners, etc.) + await red.clearInterval(myInterval); +} +``` + +For TypeScript development with full type safety: +```typescript +/// + +export async function activate(red: Red.RedAPI) { + // Your plugin code with IntelliSense and type checking +} +``` + +For JavaScript: + +```javascript +export async function activate(red) { + // Plugin initialization code + red.addCommand("MyCommand", async () => { + // Command implementation + }); +} +``` + +2. Add the plugin to your `config.toml`: + +```toml +[plugins] +my_plugin = "my_plugin.js" +``` + +3. Place the plugin file in `~/.config/red/plugins/` + +### Plugin API Reference + +The `red` object passed to the `activate` function provides the following APIs: + +#### Command Registration +```javascript +red.addCommand(name: string, callback: async function) +``` +Registers a new command that can be bound to keys or executed programmatically. + +#### Event Subscription +```javascript +red.on(event: string, callback: function) +``` +Subscribes to editor events. Available events include: +- `lsp:progress` - LSP progress notifications +- `editor:resize` - Editor window resize events +- `buffer:changed` - Buffer content changes (includes cursor position and buffer info) +- `picker:selected:${id}` - Picker selection events +- `mode:changed` - Editor mode changes (Normal, Insert, Visual, etc.) +- `cursor:moved` - Cursor position changes (may fire frequently) +- `file:opened` - File opened in a buffer +- `file:saved` - File saved from a buffer + +#### Editor Information +```javascript +const info = await red.getEditorInfo() +``` +Returns an object containing: +- `buffers` - Array of buffer information (id, name, path, language_id) +- `current_buffer_index` - Index of the active buffer +- `size` - Editor dimensions (rows, cols) +- `theme` - Current theme information + +#### UI Interaction +```javascript +// Show a picker dialog +const selected = await red.pick(title: string, values: array) + +// Open a buffer by name +red.openBuffer(name: string) + +// Draw text at specific coordinates +red.drawText(x: number, y: number, text: string, style?: object) +``` + +#### Buffer Manipulation +```javascript +// Insert text at position +red.insertText(x: number, y: number, text: string) + +// Delete text at position +red.deleteText(x: number, y: number, length: number) + +// Replace text at position +red.replaceText(x: number, y: number, length: number, text: string) + +// Get/set cursor position +const pos = await red.getCursorPosition() // Returns {x, y} +red.setCursorPosition(x: number, y: number) + +// Get buffer text +const text = await red.getBufferText(startLine?: number, endLine?: number) +``` + +#### Action Execution +```javascript +red.execute(command: string, args?: any) +``` +Executes any editor action programmatically. + +#### Command Discovery +```javascript +// Get list of available plugin commands +const commands = red.getCommands() // Returns array of command names +``` + +#### Configuration Access +```javascript +// Get configuration values +const theme = await red.getConfig("theme") // Get specific config value +const allConfig = await red.getConfig() // Get entire config object +``` + +Available configuration keys: +- `theme` - Current theme name +- `plugins` - Map of plugin names to paths +- `log_file` - Log file path +- `mouse_scroll_lines` - Lines to scroll with mouse wheel +- `show_diagnostics` - Whether to show diagnostics +- `keys` - Key binding configuration + +#### Logging +```javascript +// Log with different levels +red.logDebug(...messages) // Debug information +red.logInfo(...messages) // General information +red.logWarn(...messages) // Warnings +red.logError(...messages) // Errors +red.log(...messages) // Default (info level) + +// Open log viewer in editor +red.viewLogs() +``` + +Log messages are written to the file specified in `config.toml` with timestamps and level indicators. + +#### Timers +```javascript +// One-time timers +const timeoutId = await red.setTimeout(callback: function, delay: number) +await red.clearTimeout(timeoutId: string) + +// Repeating intervals +const intervalId = await red.setInterval(callback: function, delay: number) +await red.clearInterval(intervalId: string) +``` + +Example: +```javascript +// Update status every second +const interval = await red.setInterval(() => { + red.logDebug("Periodic update"); +}, 1000); + +// Clean up on deactivation +await red.clearInterval(interval); +``` + +### Example: Buffer Picker Plugin + +Here's a complete example of a buffer picker plugin: + +```javascript +export async function activate(red) { + red.addCommand("BufferPicker", async () => { + const info = await red.getEditorInfo(); + const buffers = info.buffers.map((buf) => ({ + id: buf.id, + name: buf.name, + path: buf.path, + language: buf.language_id + })); + + const bufferNames = buffers.map(b => b.name); + const selected = await red.pick("Open Buffer", bufferNames); + + if (selected) { + red.openBuffer(selected); + } + }); +} +``` + +### Keybinding Configuration + +To bind a plugin command to a key, add it to your `config.toml`: + +```toml +[keys.normal." "] # Space as leader key +"b" = { PluginCommand = "BufferPicker" } +``` + +## Implementation Details + +### Runtime Environment + +- **JavaScript Engine**: Deno Core v0.229.0 +- **TypeScript Support**: Automatic transpilation via swc +- **Module Loading**: Supports local files, HTTP/HTTPS imports, and various file types (JS, TS, JSX, TSX, JSON) +- **Thread Isolation**: Plugins run in a separate thread for safety and performance + +### Available Editor Actions + +Plugins can trigger any editor action through `red.execute()`, including: + +- Movement: `MoveUp`, `MoveDown`, `MoveLeft`, `MoveRight` +- Editing: `InsertString`, `DeleteLine`, `Undo`, `Redo` +- UI: `FilePicker`, `OpenPicker`, `CommandPalette` +- Buffer: `NextBuffer`, `PreviousBuffer`, `CloseBuffer` +- Mode changes: `NormalMode`, `InsertMode`, `VisualMode` + +### TypeScript Development + +Red provides full TypeScript support for plugin development: + +1. **Type Definitions**: Install `@red-editor/types` for complete type safety +2. **IntelliSense**: Get autocomplete and documentation in your IDE +3. **Type Checking**: Catch errors at development time +4. **Automatic Transpilation**: TypeScript files are automatically compiled + +Example with types: +```typescript +import type { RedAPI, BufferChangeEvent } from '@red-editor/types'; + +export async function activate(red: RedAPI) { + red.on("buffer:changed", (data: BufferChangeEvent) => { + // TypeScript knows data.cursor.x and data.cursor.y are numbers + red.log(`Change at ${data.cursor.x}, ${data.cursor.y}`); + }); +} +``` + +### Module System + +The plugin loader (`TsModuleLoader`) supports: + +```javascript +// Local imports +import { helper } from "./utils.js"; + +// HTTP imports (Deno-style) +import { serve } from "https://deno.land/std/http/server.ts"; + +// JSON imports +import config from "./config.json"; +``` + +### Error Handling + +- Plugin errors are captured and converted to Rust `Result` types +- Errors are displayed in the editor's status line with JavaScript stack traces +- Use log levels for appropriate error reporting: + - `red.logError()` for errors + - `red.logWarn()` for warnings + - `red.logInfo()` for general information + - `red.logDebug()` for detailed debugging + +Example error handling: +```javascript +try { + await riskyOperation(); +} catch (error) { + red.logError("Operation failed:", error.message); + red.logDebug("Stack trace:", error.stack); +} +``` + +## Advanced Examples + +### LSP Progress Monitor (fidget.js) + +This plugin displays LSP progress notifications: + +```javascript +export function activate(red) { + const messageStack = []; + const timers = {}; + + red.on("lsp:progress", (data) => { + const { token, kind, message, title, percentage } = data; + + if (kind === "begin") { + const fullMessage = percentage !== undefined + ? `${title}: ${message} (${percentage}%)` + : `${title}: ${message}`; + messageStack.push({ token, message: fullMessage }); + } else if (kind === "end") { + const index = messageStack.findIndex(m => m.token === token); + if (index !== -1) { + messageStack.splice(index, 1); + } + } + + renderMessages(); + }); + + function renderMessages() { + const info = red.getEditorInfo(); + const baseY = info.size.rows - messageStack.length - 2; + + messageStack.forEach((msg, index) => { + red.drawText(2, baseY + index, msg.message, { + fg: "yellow", + modifiers: ["bold"] + }); + }); + } +} +``` + +### Event-Driven Plugin + +```javascript +export function activate(red) { + // React to buffer changes + red.on("buffer:changed", (data) => { + red.log("Buffer changed:", data.buffer_id); + }); + + // React to editor resize + red.on("editor:resize", (data) => { + red.log(`New size: ${data.cols}x${data.rows}`); + }); + + // Custom picker with event handling + red.addCommand("CustomPicker", async () => { + const id = Date.now(); + const options = ["Option 1", "Option 2", "Option 3"]; + + red.on(`picker:selected:${id}`, (selection) => { + red.log("User selected:", selection); + }); + + red.execute("OpenPicker", { + id, + title: "Choose an option", + values: options + }); + }); +} +``` + +## Limitations and Considerations + +### Testing Plugins + +Red includes a comprehensive testing framework for plugin development: + +```javascript +// my-plugin.test.js +describe('My Plugin', () => { + test('should register command', async (red) => { + expect(red.hasCommand('MyCommand')).toBe(true); + }); +}); +``` + +Run tests with: +```bash +node test-harness/test-runner.js my-plugin.js my-plugin.test.js +``` + +See [test-harness/README.md](../test-harness/README.md) for complete documentation. + +### Current Limitations + +1. **Shared Runtime**: All plugins share the same JavaScript runtime context +2. **Plugin Management**: No built-in plugin installation/removal commands +3. **Inter-plugin Communication**: Limited ability for plugins to communicate with each other +4. **File System Access**: No direct filesystem APIs (must use editor buffer operations) +5. **Hot Reload**: Requires editor restart for plugin changes + +### Security Considerations + +- Plugins run in a sandboxed Deno environment +- No direct filesystem access (must use editor APIs) +- Limited to provided operation APIs +- Network access through Deno's permission system + +### Performance Considerations + +- Plugins run in a separate thread to avoid blocking the editor +- Heavy computations should be done asynchronously +- Use `setTimeout` for deferred operations to avoid blocking + +## Future Enhancements + +Areas identified for potential improvement: + +1. **Plugin Management** + - Plugin installation/removal commands + - Version management + - Dependency resolution + +2. **Developer Experience** + - Better error messages with stack traces + - Plugin development mode with hot reload + - Built-in plugin testing framework + +3. **API Enhancements** + - More granular buffer manipulation APIs + - File system access with permissions + - Plugin-to-plugin communication + +4. **Documentation** + - Interactive plugin command documentation + - API reference generation + - Plugin marketplace/registry + +## Conclusion + +The Red editor's plugin system provides a robust foundation for extending editor functionality while maintaining security and performance. By leveraging Deno's runtime and a well-designed API, developers can create powerful plugins that integrate seamlessly with the editor's core functionality. + +For questions or contributions to the plugin system, please refer to the main Red editor repository and its contribution guidelines. \ No newline at end of file diff --git a/docs/PLUGIN_SYSTEM_IMPROVEMENTS.md b/docs/PLUGIN_SYSTEM_IMPROVEMENTS.md new file mode 100644 index 0000000..eff9b77 --- /dev/null +++ b/docs/PLUGIN_SYSTEM_IMPROVEMENTS.md @@ -0,0 +1,287 @@ +# Red Editor Plugin System Improvement Plan + +## Executive Summary + +This document outlines a prioritized improvement plan for the Red editor's plugin system. The improvements are categorized by priority (High/Medium/Low) and implementation difficulty (Easy/Medium/Hard), focusing on enhancing stability, developer experience, and functionality. + +## Priority Matrix + +### High Priority + Easy Implementation +These should be tackled first as they provide immediate value with minimal effort. + +#### 1. Implement Missing Buffer Change Events +**Priority:** High | **Difficulty:** Easy | **Impact:** Critical for many plugins + +Currently, the `buffer:changed` event is documented but not implemented. This is essential for plugins that need to react to content changes. + +**Implementation:** +- Add notification call in `Editor::notify_change()` +- Emit events with buffer ID and change details +- Include line/column information for the change + +#### 2. Fix Memory Leak in Timer System +**Priority:** High | **Difficulty:** Easy | **Impact:** Prevents memory issues + +The timeout system never cleans up completed timers from the global HashMap. + +**Implementation:** +- Add cleanup after timer completion +- Consider using a different data structure (e.g., BTreeMap with expiration) +- Add timer limit per plugin + +#### 3. Add Plugin Error Context +**Priority:** High | **Difficulty:** Easy | **Impact:** Major DX improvement + +Plugin errors currently lack debugging information. + +**Implementation:** +- Capture and format JavaScript stack traces +- Add plugin name to error messages +- Log detailed errors to the debug log with line numbers + +### High Priority + Medium Implementation + +#### 4. Plugin Lifecycle Management +**Priority:** High | **Difficulty:** Medium | **Impact:** Critical for stability + +Plugins currently cannot be deactivated or cleaned up properly. + +**Implementation:** +- Add `deactivate()` export support in plugins +- Track event listeners per plugin +- Implement cleanup on plugin reload/disable +- Add enable/disable commands + +#### 5. Buffer Manipulation APIs +**Priority:** High | **Difficulty:** Medium | **Impact:** Enables rich editing plugins + +Current API only allows opening buffers, not editing them. + +**Implementation:** +- Add insert/delete/replace operations with position parameters +- Expose cursor position and selection APIs +- Add transaction support for multiple edits +- Include undo/redo integration + +#### 6. Expand Event System +**Priority:** High | **Difficulty:** Medium | **Impact:** Enables reactive plugins + +Many useful events are missing from the current implementation. + +**Implementation:** +- Add cursor movement events (throttled) +- Mode change notifications +- File save/open events +- Selection change events +- Window focus/blur events + +### High Priority + Hard Implementation + +#### 7. Plugin Isolation +**Priority:** High | **Difficulty:** Hard | **Impact:** Security and stability + +All plugins share the same runtime, allowing interference. + +**Implementation:** +- Migrate to separate V8 isolates per plugin +- Implement secure communication between isolates +- Add resource limits per plugin +- Consider using Deno's permissions system + +### Medium Priority + Easy Implementation + +#### 8. Command Discovery API +**Priority:** Medium | **Difficulty:** Easy | **Impact:** Better UX + +No way to list available plugin commands programmatically. + +**Implementation:** +- Add `red.getCommands()` API +- Include command descriptions/metadata +- Expose in command palette automatically + +#### 9. Plugin Configuration Support +**Priority:** Medium | **Difficulty:** Easy | **Impact:** Better customization + +Plugins cannot access configuration values. + +**Implementation:** +- Add `red.getConfig(key)` API +- Support plugin-specific config sections +- Add config change notifications + +#### 10. Improve Logging API +**Priority:** Medium | **Difficulty:** Easy | **Impact:** Better debugging + +Current logging is file-only and hard to access. + +**Implementation:** +- Add log levels (debug, info, warn, error) +- Create in-editor log viewer command +- Add structured logging with metadata + +### Medium Priority + Medium Implementation + +#### 11. TypeScript Definitions +**Priority:** Medium | **Difficulty:** Medium | **Impact:** Major DX improvement + +No type safety for plugin development. + +**Implementation:** +- Generate .d.ts files for the plugin API +- Publish as npm package for IDE support +- Include inline documentation +- Add type checking in development mode + +#### 12. File System APIs +**Priority:** Medium | **Difficulty:** Medium | **Impact:** Enables utility plugins + +Plugins need controlled file access for many use cases. + +**Implementation:** +- Add permission-based file APIs +- Support read/write with user confirmation +- Include directory operations +- Add file watching capabilities + +#### 13. Plugin Testing Framework +**Priority:** Medium | **Difficulty:** Medium | **Impact:** Quality improvement + +No way to test plugins currently. + +**Implementation:** +- Create mock implementations of editor APIs +- Add test runner integration +- Support async testing +- Include coverage reporting + +### Medium Priority + Hard Implementation + +#### 14. Hot Reload System +**Priority:** Medium | **Difficulty:** Hard | **Impact:** Major DX improvement + +Requires editor restart for plugin changes. + +**Implementation:** +- Watch plugin files for changes +- Implement safe reload with state preservation +- Handle cleanup of old version +- Add development mode flag + +#### 15. Plugin Package Management +**Priority:** Medium | **Difficulty:** Hard | **Impact:** Ecosystem growth + +No standard way to distribute plugins. + +**Implementation:** +- Define plugin manifest format +- Create installation/update commands +- Add dependency resolution +- Consider plugin registry/marketplace + +### Low Priority + Easy Implementation + +#### 16. More UI Components +**Priority:** Low | **Difficulty:** Easy | **Impact:** Richer plugins + +Limited to text drawing and pickers currently. + +**Implementation:** +- Add status bar API +- Support floating windows/tooltips +- Add progress indicators +- Include notification system + +#### 17. Plugin Metadata +**Priority:** Low | **Difficulty:** Easy | **Impact:** Better management + +Plugins lack descriptive information. + +**Implementation:** +- Support package.json for plugins +- Add version, author, description fields +- Show in plugin list command +- Add compatibility information + +### Low Priority + Medium Implementation + +#### 18. Inter-Plugin Communication +**Priority:** Low | **Difficulty:** Medium | **Impact:** Advanced scenarios + +Plugins cannot communicate with each other. + +**Implementation:** +- Add message passing system +- Support plugin dependencies +- Include shared state mechanism +- Add permission model + +#### 19. LSP Integration APIs +**Priority:** Low | **Difficulty:** Medium | **Impact:** IDE-like plugins + +Limited access to LSP functionality. + +**Implementation:** +- Expose completion, hover, definition APIs +- Add code action support +- Include diagnostics access +- Support custom LSP servers + +### Low Priority + Hard Implementation + +#### 20. Plugin Marketplace +**Priority:** Low | **Difficulty:** Hard | **Impact:** Ecosystem growth + +No central place to discover plugins. + +**Implementation:** +- Build web-based registry +- Add search/browse commands +- Include ratings/reviews +- Support automatic updates + +## Implementation Roadmap + +### Phase 1: Critical Fixes (1-2 weeks) +1. Implement buffer change events +2. Fix memory leaks +3. Add error context +4. Basic lifecycle management + +### Phase 2: Core Features (2-4 weeks) +5. Buffer manipulation APIs +6. Expand event system +7. Command discovery +8. Configuration support + +### Phase 3: Developer Experience (4-6 weeks) +9. TypeScript definitions +10. Testing framework +11. Improved logging +12. Hot reload system + +### Phase 4: Advanced Features (6-8 weeks) +13. Plugin isolation +14. File system APIs +15. Package management +16. More UI components + +### Phase 5: Ecosystem (8+ weeks) +17. Inter-plugin communication +18. LSP integration +19. Plugin marketplace +20. Advanced UI system + +## Success Metrics + +- **Stability**: Zero plugin-related crashes in normal usage +- **Performance**: Plugin operations complete in <50ms +- **Adoption**: 10+ quality plugins available +- **Developer Satisfaction**: <30min to create first plugin +- **Security**: No plugin can affect another or access unauthorized resources + +## Conclusion + +This improvement plan provides a structured approach to enhancing the Red editor's plugin system. By following the priority matrix and implementation roadmap, the project can deliver immediate value while building toward a comprehensive, production-ready plugin ecosystem. + +The focus on high-priority, easy-to-implement items first ensures quick wins and momentum, while the phased approach allows for continuous delivery of improvements without overwhelming the development team. \ No newline at end of file diff --git a/examples/async-plugin.test.js b/examples/async-plugin.test.js new file mode 100644 index 0000000..1bc3d9f --- /dev/null +++ b/examples/async-plugin.test.js @@ -0,0 +1,141 @@ +/** + * Test suite demonstrating async plugin testing + */ + +// Example async plugin for testing +const asyncPlugin = { + async activate(red) { + red.addCommand("DelayedGreeting", async () => { + red.log("Starting delayed greeting..."); + await new Promise(resolve => setTimeout(resolve, 100)); + red.log("Hello after delay!"); + }); + + red.addCommand("FetchData", async () => { + // Simulate async data fetching + const data = await new Promise(resolve => { + setTimeout(() => resolve({ status: "success", count: 42 }), 50); + }); + red.log(`Fetched data: ${JSON.stringify(data)}`); + return data; + }); + + // Event handler with async processing + red.on("buffer:changed", async (event) => { + await new Promise(resolve => setTimeout(resolve, 10)); + red.log(`Processed buffer change for ${event.buffer_name}`); + }); + } +}; + +describe('Async Plugin Tests', () => { + test('should handle async command execution', async (red) => { + await asyncPlugin.activate(red); + + // Execute async command + await red.executeCommand('DelayedGreeting'); + + // Check logs + const logs = red.getLogs(); + expect(logs).toContain('log: Starting delayed greeting...'); + expect(logs).toContain('log: Hello after delay!'); + }); + + test('should return data from async commands', async (red) => { + await asyncPlugin.activate(red); + + // Execute command that returns data + const result = await red.executeCommand('FetchData'); + + expect(result).toEqual({ status: "success", count: 42 }); + expect(red.getLogs()).toContain('log: Fetched data: {"status":"success","count":42}'); + }); + + test('should handle async event processing', async (red) => { + await asyncPlugin.activate(red); + + // Emit event + red.emit('buffer:changed', { + buffer_id: 0, + buffer_name: 'async-test.js', + file_path: '/tmp/async-test.js', + line_count: 5, + cursor: { x: 0, y: 0 } + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 20)); + + // Check that event was processed + expect(red.getLogs()).toContain('log: Processed buffer change for async-test.js'); + }); + + test('should handle setTimeout/clearTimeout', async (red) => { + await asyncPlugin.activate(red); + + let timerFired = false; + const timerId = await red.setTimeout(() => { + timerFired = true; + }, 50); + + // Timer should not have fired yet + expect(timerFired).toBe(false); + + // Wait for timer + await new Promise(resolve => setTimeout(resolve, 60)); + + // Timer should have fired + expect(timerFired).toBe(true); + }); + + test('should cancel timers with clearTimeout', async (red) => { + await asyncPlugin.activate(red); + + let timerFired = false; + const timerId = await red.setTimeout(() => { + timerFired = true; + }, 50); + + // Cancel timer + await red.clearTimeout(timerId); + + // Wait past timer duration + await new Promise(resolve => setTimeout(resolve, 60)); + + // Timer should not have fired + expect(timerFired).toBe(false); + }); +}); + +describe('Error Handling', () => { + test('should handle command errors gracefully', async (red) => { + const errorPlugin = { + async activate(red) { + red.addCommand("FailingCommand", async () => { + throw new Error("Command failed!"); + }); + } + }; + + await errorPlugin.activate(red); + + // Execute failing command + try { + await red.executeCommand('FailingCommand'); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe('Command failed!'); + } + }); + + test('should handle missing commands', async (red) => { + try { + await red.executeCommand('NonExistentCommand'); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + expect(error.message).toBe('Command not found: NonExistentCommand'); + } + }); +}); \ No newline at end of file diff --git a/examples/buffer-picker.js b/examples/buffer-picker.js new file mode 100644 index 0000000..9938fba --- /dev/null +++ b/examples/buffer-picker.js @@ -0,0 +1,17 @@ +/** + * Simple buffer picker plugin for testing + */ + +async function activate(red) { + red.addCommand("BufferPicker", async () => { + const info = await red.getEditorInfo(); + const bufferNames = info.buffers.map(b => b.name); + const selected = await red.pick("Open Buffer", bufferNames); + + if (selected) { + red.openBuffer(selected); + } + }); +} + +module.exports = { activate }; \ No newline at end of file diff --git a/examples/buffer-picker.test.js b/examples/buffer-picker.test.js new file mode 100644 index 0000000..4827c10 --- /dev/null +++ b/examples/buffer-picker.test.js @@ -0,0 +1,95 @@ +/** + * Test suite for the buffer picker plugin + */ + +describe('BufferPicker Plugin', () => { + let mockPick; + + beforeEach(() => { + // Reset mock functions + mockPick = jest.fn(); + }); + + test('should register BufferPicker command', async (red) => { + expect(red.hasCommand('BufferPicker')).toBe(true); + }); + + test('should show picker with buffer names', async (red) => { + // Override the pick method to capture calls + const originalPick = red.pick.bind(red); + red.pick = mockPick.mockImplementation(() => Promise.resolve(null)); + + // Execute the command + await red.executeCommand('BufferPicker'); + + // Verify picker was called + expect(mockPick).toHaveBeenCalled(); + expect(mockPick).toHaveBeenCalledWith('Open Buffer', ['test.js']); + + // Restore original method + red.pick = originalPick; + }); + + test('should open selected buffer', async (red) => { + // Mock picker to return a selection + red.pick = jest.fn().mockImplementation(() => Promise.resolve('selected.js')); + + // Execute the command + await red.executeCommand('BufferPicker'); + + // Verify buffer was opened + expect(red.getLogs()).toContain('openBuffer: selected.js'); + }); + + test('should handle cancelled picker', async (red) => { + // Mock picker to return null (cancelled) + red.pick = jest.fn().mockImplementation(() => Promise.resolve(null)); + + // Execute the command + await red.executeCommand('BufferPicker'); + + // Verify no buffer was opened + const logs = red.getLogs(); + const openBufferLogs = logs.filter(log => log.startsWith('openBuffer:')); + expect(openBufferLogs.length).toBe(0); + }); + + test('should handle multiple buffers', async (red) => { + // Add more buffers to the mock state + red.setMockState({ + buffers: [ + { id: 0, name: 'file1.js', path: '/tmp/file1.js', language_id: 'javascript' }, + { id: 1, name: 'file2.ts', path: '/tmp/file2.ts', language_id: 'typescript' }, + { id: 2, name: 'README.md', path: '/tmp/README.md', language_id: 'markdown' } + ] + }); + + // Mock picker + const originalPick = red.pick.bind(red); + red.pick = mockPick.mockImplementation(() => Promise.resolve(null)); + + // Execute the command + await red.executeCommand('BufferPicker'); + + // Verify all buffers were shown + expect(mockPick).toHaveBeenCalledWith('Open Buffer', ['file1.js', 'file2.ts', 'README.md']); + + red.pick = originalPick; + }); +}); + +describe('BufferPicker Event Handling', () => { + test('should react to buffer changes', async (red) => { + // Simulate buffer change + red.emit('buffer:changed', { + buffer_id: 0, + buffer_name: 'test.js', + file_path: '/tmp/test.js', + line_count: 10, + cursor: { x: 5, y: 3 } + }); + + // Plugin might log or update state + // This is where you'd test plugin's reaction to events + }); +}); \ No newline at end of file diff --git a/examples/example-plugin/index.js b/examples/example-plugin/index.js new file mode 100644 index 0000000..938bbbc --- /dev/null +++ b/examples/example-plugin/index.js @@ -0,0 +1,42 @@ +/** + * Example plugin demonstrating metadata usage + */ + +export function activate(red) { + red.logInfo("Example plugin activated!"); + + red.addCommand("ExampleCommand", async () => { + const config = await red.getConfig(); + const greeting = config.plugins?.example_plugin?.greeting || "Hello from Example Plugin!"; + + const info = await red.getEditorInfo(); + red.log(`${greeting} You have ${info.buffers.length} buffers open.`); + + // Show plugin list + const choices = [ + "Show Plugin List", + "View Logs", + "Cancel" + ]; + + const choice = await red.pick("Example Plugin", choices); + if (choice === "Show Plugin List") { + red.execute("ListPlugins"); + } else if (choice === "View Logs") { + red.viewLogs(); + } + }); + + // Example event handlers + red.on("buffer:changed", (event) => { + red.logDebug("Buffer changed in example plugin:", event.buffer_name); + }); + + red.on("file:saved", (event) => { + red.logInfo("File saved:", event.path); + }); +} + +export function deactivate(red) { + red.logInfo("Example plugin deactivated!"); +} \ No newline at end of file diff --git a/examples/example-plugin/package.json b/examples/example-plugin/package.json new file mode 100644 index 0000000..98bd995 --- /dev/null +++ b/examples/example-plugin/package.json @@ -0,0 +1,38 @@ +{ + "name": "example-plugin", + "version": "1.0.0", + "description": "An example Red editor plugin with metadata", + "author": "Red Editor Contributors", + "license": "MIT", + "main": "index.js", + "keywords": ["example", "demo", "metadata"], + "repository": { + "type": "git", + "url": "https://github.com/red-editor/red" + }, + "engines": { + "red": ">=0.1.0" + }, + "red_api_version": "1.0", + "capabilities": { + "commands": true, + "events": true, + "buffer_manipulation": false, + "ui_components": true, + "lsp_integration": false + }, + "activation_events": [ + "onCommand:ExampleCommand", + "onLanguage:javascript" + ], + "config_schema": { + "type": "object", + "properties": { + "greeting": { + "type": "string", + "default": "Hello from Example Plugin!", + "description": "The greeting message to display" + } + } + } +} \ No newline at end of file diff --git a/examples/interval-demo.js b/examples/interval-demo.js new file mode 100644 index 0000000..b571211 --- /dev/null +++ b/examples/interval-demo.js @@ -0,0 +1,107 @@ +/** + * Demo plugin showing setInterval/clearInterval usage + */ + +let statusUpdateInterval = null; +let clickCounter = 0; +let lastUpdate = new Date(); + +export async function activate(red) { + red.logInfo("Interval demo plugin activated!"); + + // Command that starts a status update interval + red.addCommand("StartStatusUpdates", async () => { + if (statusUpdateInterval) { + red.log("Status updates already running"); + return; + } + + red.log("Starting status updates every 2 seconds..."); + + statusUpdateInterval = await red.setInterval(() => { + clickCounter++; + const now = new Date(); + const elapsed = Math.floor((now - lastUpdate) / 1000); + + red.logDebug(`Status update #${clickCounter} - ${elapsed}s since activation`); + + // Stop after 10 updates + if (clickCounter >= 10) { + red.log("Reached 10 updates, stopping automatically"); + red.clearInterval(statusUpdateInterval); + statusUpdateInterval = null; + clickCounter = 0; + } + }, 2000); + }); + + // Command that stops the status updates + red.addCommand("StopStatusUpdates", async () => { + if (!statusUpdateInterval) { + red.log("No status updates running"); + return; + } + + await red.clearInterval(statusUpdateInterval); + statusUpdateInterval = null; + red.log(`Stopped status updates after ${clickCounter} updates`); + clickCounter = 0; + }); + + // Example: Multiple intervals with different frequencies + red.addCommand("MultipleIntervals", async () => { + const intervals = []; + + // Fast interval (500ms) + intervals.push(await red.setInterval(() => { + red.logDebug("Fast interval tick"); + }, 500)); + + // Medium interval (1s) + intervals.push(await red.setInterval(() => { + red.logInfo("Medium interval tick"); + }, 1000)); + + // Slow interval (3s) + intervals.push(await red.setInterval(() => { + red.logWarn("Slow interval tick"); + }, 3000)); + + red.log("Started 3 intervals with different frequencies"); + + // Stop all after 10 seconds + await red.setTimeout(async () => { + for (const id of intervals) { + await red.clearInterval(id); + } + red.log("Stopped all intervals"); + }, 10000); + }); + + // Example: Progress indicator using interval + red.addCommand("ShowProgress", async () => { + let progress = 0; + const total = 20; + + const progressInterval = await red.setInterval(() => { + progress++; + const bar = "=".repeat(progress) + "-".repeat(total - progress); + red.log(`Progress: [${bar}] ${Math.floor((progress / total) * 100)}%`); + + if (progress >= total) { + red.clearInterval(progressInterval); + red.log("Task completed!"); + } + }, 200); + }); +} + +export async function deactivate(red) { + // Clean up any running intervals + if (statusUpdateInterval) { + await red.clearInterval(statusUpdateInterval); + red.logInfo("Cleaned up status update interval"); + } + + red.logInfo("Interval demo plugin deactivated!"); +} \ No newline at end of file diff --git a/examples/interval.test.js b/examples/interval.test.js new file mode 100644 index 0000000..3220993 --- /dev/null +++ b/examples/interval.test.js @@ -0,0 +1,139 @@ +/** + * Tests for setInterval/clearInterval functionality + */ + +describe('Interval Support', () => { + test('should support basic interval', async (red) => { + let counter = 0; + const intervalId = await red.setInterval(() => { + counter++; + }, 50); + + // Wait for a few ticks + await new Promise(resolve => setTimeout(resolve, 175)); + + // Should have executed 3 times (at 50ms, 100ms, 150ms) + expect(counter).toBe(3); + + // Clear the interval + await red.clearInterval(intervalId); + + // Wait a bit more + await new Promise(resolve => setTimeout(resolve, 100)); + + // Counter should not have increased + expect(counter).toBe(3); + }); + + test('should support multiple intervals', async (red) => { + let fast = 0; + let slow = 0; + + const fastId = await red.setInterval(() => { + fast++; + }, 20); + + const slowId = await red.setInterval(() => { + slow++; + }, 50); + + // Wait for 110ms + await new Promise(resolve => setTimeout(resolve, 110)); + + // Fast should have run ~5 times (20, 40, 60, 80, 100) + // Slow should have run ~2 times (50, 100) + expect(fast >= 4).toBe(true); + expect(fast <= 6).toBe(true); + expect(slow >= 1).toBe(true); + expect(slow <= 3).toBe(true); + + // Clear both + await red.clearInterval(fastId); + await red.clearInterval(slowId); + }); + + test('should handle interval errors gracefully', async (red) => { + let errorCount = 0; + + const intervalId = await red.setInterval(() => { + errorCount++; + // Intervals should handle errors gracefully in real implementation + // In mock, we just count the executions + }, 50); + + // Wait for a couple ticks + await new Promise(resolve => setTimeout(resolve, 120)); + + // Should have executed at least once + expect(errorCount >= 1).toBe(true); + + // Clean up + await red.clearInterval(intervalId); + }); + + test('should clear interval on double clear', async (red) => { + const intervalId = await red.setInterval(() => {}, 50); + + // Clear once + await red.clearInterval(intervalId); + + // Clear again - should not throw + await red.clearInterval(intervalId); + + expect(true).toBe(true); // If we got here, no error was thrown + }); +}); + +describe('Interval Plugin Integration', () => { + const intervalPlugin = { + intervals: [], + + async activate(red) { + red.addCommand('StartInterval', async () => { + const id = await red.setInterval(() => { + red.log('Interval tick'); + }, 100); + this.intervals.push(id); + }); + + red.addCommand('StopAllIntervals', async () => { + for (const id of this.intervals) { + await red.clearInterval(id); + } + this.intervals = []; + red.log('All intervals stopped'); + }); + }, + + async deactivate(red) { + // Clean up all intervals + for (const id of this.intervals) { + await red.clearInterval(id); + } + } + }; + + test('plugin should manage intervals', async (red) => { + await intervalPlugin.activate(red); + + // Start an interval + await red.executeCommand('StartInterval'); + + // Wait for some ticks + await new Promise(resolve => setTimeout(resolve, 250)); + + // Check logs + const logs = red.getLogs(); + const tickLogs = logs.filter(log => log.includes('Interval tick')); + expect(tickLogs.length >= 2).toBe(true); + + // Stop all intervals + await red.executeCommand('StopAllIntervals'); + + // Verify stopped + expect(red.getLogs()).toContain('log: All intervals stopped'); + + // Deactivate plugin + await intervalPlugin.deactivate(red); + }); +}); \ No newline at end of file diff --git a/examples/logging-demo.js b/examples/logging-demo.js new file mode 100644 index 0000000..0adb128 --- /dev/null +++ b/examples/logging-demo.js @@ -0,0 +1,62 @@ +/** + * Demo plugin showing improved logging capabilities + */ + +export function activate(red) { + red.addCommand("LogDemo", async () => { + red.logDebug("This is a debug message - useful for detailed tracing"); + red.logInfo("This is an info message - general information"); + red.logWarn("This is a warning - something might be wrong"); + red.logError("This is an error - something definitely went wrong"); + + // Regular log still works (defaults to info level) + red.log("This is a regular log message"); + + // Log with multiple arguments + const data = { count: 42, status: "active" }; + red.logInfo("Processing data:", data); + + // Offer to open the log viewer + const result = await red.pick("Logging Demo Complete", [ + "View Logs", + "Close" + ]); + + if (result === "View Logs") { + red.viewLogs(); + } + }); + + // Example: Log different levels based on events + red.on("buffer:changed", (event) => { + red.logDebug("Buffer changed:", event.buffer_name, "at line", event.cursor.y); + }); + + red.on("mode:changed", (event) => { + red.logInfo(`Mode changed from ${event.from} to ${event.to}`); + }); + + red.on("file:saved", (event) => { + red.logInfo("File saved:", event.path); + }); + + // Example: Error handling with proper logging + red.addCommand("ErrorExample", async () => { + try { + // Simulate some operation that might fail + const result = await someRiskyOperation(); + red.logInfo("Operation succeeded:", result); + } catch (error) { + red.logError("Operation failed:", error.message); + red.logDebug("Full error details:", error.stack); + } + }); +} + +async function someRiskyOperation() { + // Simulate a 50% chance of failure + if (Math.random() > 0.5) { + throw new Error("Random failure occurred"); + } + return { success: true, value: Math.floor(Math.random() * 100) }; +} \ No newline at end of file diff --git a/examples/typescript-plugin.ts b/examples/typescript-plugin.ts new file mode 100644 index 0000000..a6bb3fa --- /dev/null +++ b/examples/typescript-plugin.ts @@ -0,0 +1,112 @@ +/// + +/** + * Example TypeScript plugin for Red editor + * Demonstrates type-safe plugin development + */ + +interface PluginState { + lastCursorPosition?: Red.CursorPosition; + bufferChangeCount: number; +} + +const state: PluginState = { + bufferChangeCount: 0 +}; + +export async function activate(red: Red.RedAPI): Promise { + red.log("TypeScript plugin activated!"); + + // Command with type-safe implementation + red.addCommand("ShowEditorStats", async () => { + const info = await red.getEditorInfo(); + const config = await red.getConfig(); + + const stats = [ + `Open buffers: ${info.buffers.length}`, + `Current buffer: ${info.buffers[info.current_buffer_index].name}`, + `Editor size: ${info.size.cols}x${info.size.rows}`, + `Theme: ${config.theme}`, + `Buffer changes: ${state.bufferChangeCount}` + ]; + + const selected = await red.pick("Editor Statistics", stats); + if (selected) { + red.log("User selected:", selected); + } + }); + + // Type-safe event handlers + red.on("buffer:changed", (data: Red.BufferChangeEvent) => { + state.bufferChangeCount++; + red.log(`Buffer ${data.buffer_name} changed at line ${data.cursor.y}`); + }); + + red.on("cursor:moved", (data: Red.CursorMoveEvent) => { + // Demonstrate type safety - TypeScript knows the structure + if (state.lastCursorPosition) { + const distance = Math.abs(data.to.x - data.from.x) + Math.abs(data.to.y - data.from.y); + if (distance > 10) { + red.log(`Large cursor jump: ${distance} positions`); + } + } + state.lastCursorPosition = data.to; + }); + + red.on("mode:changed", (data: Red.ModeChangeEvent) => { + red.log(`Mode changed from ${data.from} to ${data.to}`); + }); + + // Advanced example: Smart text manipulation + red.addCommand("SmartQuotes", async () => { + const pos = await red.getCursorPosition(); + const line = await red.getBufferText(pos.y, pos.y + 1); + + // Find quotes to replace + const singleQuoteRegex = /'/g; + const doubleQuoteRegex = /"/g; + + let match; + let replacements: Array<{x: number, length: number, text: string}> = []; + + // Process single quotes + while ((match = singleQuoteRegex.exec(line)) !== null) { + replacements.push({ + x: match.index, + length: 1, + text: match.index === 0 || line[match.index - 1] === ' ' ? ''' : ''' + }); + } + + // Process double quotes + let quoteCount = 0; + while ((match = doubleQuoteRegex.exec(line)) !== null) { + replacements.push({ + x: match.index, + length: 1, + text: quoteCount % 2 === 0 ? '"' : '"' + }); + quoteCount++; + } + + // Apply replacements in reverse order to maintain positions + replacements.sort((a, b) => b.x - a.x); + for (const replacement of replacements) { + red.replaceText(replacement.x, pos.y, replacement.length, replacement.text); + } + }); + + // Configuration example + red.addCommand("ShowTheme", async () => { + const theme = await red.getConfig("theme"); + const allCommands = red.getCommands(); + + red.log(`Current theme: ${theme}`); + red.log(`Available plugin commands: ${allCommands.join(", ")}`); + }); +} + +export function deactivate(red: Red.RedAPI): void { + red.log("TypeScript plugin deactivated!"); + // Cleanup would go here +} \ No newline at end of file diff --git a/src/buffer.rs b/src/buffer.rs index d5d9d0e..259bb97 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -89,7 +89,7 @@ impl Buffer { // TODO: use PathBuf? self.file.as_ref().and_then(|file| { file.split('.') - .last() + .next_back() .map(|ext| ext.to_string().to_lowercase()) }) } @@ -760,10 +760,10 @@ mod test { assert_eq!(word_start.unwrap(), (4, 0)); // space after "use", next word is "std" let word_start = buffer.find_word_start((4, 0)); - assert_eq!(word_start.unwrap(), (7, 0)); // From 's' in "std", next is ':' + assert_eq!(word_start.unwrap(), (7, 0)); // From 's' in "std", next is ':' let word_start = buffer.find_word_start((7, 0)); - assert_eq!(word_start.unwrap(), (4, 1)); // From ':', skips to 'c' in "collections" on next line + assert_eq!(word_start.unwrap(), (4, 1)); // From ':', skips to 'c' in "collections" on next line let word_start = buffer.find_word_start((5, 1)); assert_eq!(word_start.unwrap(), (15, 1)); // From 'o' in "collections", next is ':' (punctuation) diff --git a/src/editor.rs b/src/editor.rs index a5e9ff0..2b20f95 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -6,6 +6,7 @@ use std::{ collections::{HashMap, VecDeque}, io::stdout, mem, + path::PathBuf, time::{Duration, Instant}, }; @@ -27,7 +28,9 @@ use crossterm::{ terminal, ExecutableCommand, }; use futures::{future::FutureExt, select, StreamExt}; +#[cfg(unix)] use nix::sys::signal::{self, Signal}; +#[cfg(unix)] use nix::unistd::Pid; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; @@ -63,6 +66,37 @@ pub enum PluginRequest { Action(Action), EditorInfo(Option), OpenPicker(Option, Option, Vec), + BufferInsert { + x: usize, + y: usize, + text: String, + }, + BufferDelete { + x: usize, + y: usize, + length: usize, + }, + BufferReplace { + x: usize, + y: usize, + length: usize, + text: String, + }, + GetCursorPosition, + SetCursorPosition { + x: usize, + y: usize, + }, + GetBufferText { + start_line: Option, + end_line: Option, + }, + GetConfig { + key: Option, + }, + IntervalCallback { + interval_id: String, + }, } #[derive(Debug)] @@ -183,6 +217,8 @@ pub enum Action { RequestCompletion, ShowProgress(ProgressParams), NotifyPlugins(String, Value), + ViewLogs, + ListPlugins, } #[allow(unused)] @@ -936,7 +972,144 @@ impl Editor { val => val.to_string(), }).collect(); self.execute(&Action::OpenPicker(title, items, id), &mut buffer, &mut runtime).await?; - // self.render(&mut buffer)?; + // self.render(buffer)?; + } + PluginRequest::BufferInsert { x, y, text } => { + // Track undo action + self.undo_actions.push(Action::DeleteRange(x, y, x + text.len(), y)); + + self.current_buffer_mut().insert_str(x, y, &text); + self.notify_change(&mut runtime).await?; + self.render(&mut buffer)?; + } + PluginRequest::BufferDelete { x, y, length } => { + // Save deleted text for undo + let current_buf = self.current_buffer(); + let mut deleted_text = String::new(); + for i in 0..length { + if let Some(line) = current_buf.get(y) { + if x + i < line.len() { + deleted_text.push(line.chars().nth(x + i).unwrap_or(' ')); + } + } + } + self.undo_actions.push(Action::InsertText { + x, + y, + content: Content { + kind: ContentKind::Charwise, + text: deleted_text + } + }); + + for _ in 0..length { + self.current_buffer_mut().remove(x, y); + } + self.notify_change(&mut runtime).await?; + self.render(&mut buffer)?; + } + PluginRequest::BufferReplace { x, y, length, text } => { + // Save replaced text for undo + let current_buf = self.current_buffer(); + let mut replaced_text = String::new(); + for i in 0..length { + if let Some(line) = current_buf.get(y) { + if x + i < line.len() { + replaced_text.push(line.chars().nth(x + i).unwrap_or(' ')); + } + } + } + // For undo, we need to delete the new text and insert the old + self.undo_actions.push(Action::UndoMultiple(vec![ + Action::DeleteRange(x, y, x + text.len(), y), + Action::InsertText { + x, + y, + content: Content { + kind: ContentKind::Charwise, + text: replaced_text + } + } + ])); + + // Delete old text + for _ in 0..length { + self.current_buffer_mut().remove(x, y); + } + // Insert new text + self.current_buffer_mut().insert_str(x, y, &text); + self.notify_change(&mut runtime).await?; + self.render(&mut buffer)?; + } + PluginRequest::GetCursorPosition => { + let pos = serde_json::json!({ + "x": self.cx, + "y": self.cy + self.vtop + }); + self.plugin_registry + .notify(&mut runtime, "cursor:position", pos) + .await?; + } + PluginRequest::SetCursorPosition { x, y } => { + self.cx = x; + // Adjust viewport if needed + if y < self.vtop { + self.vtop = y; + self.cy = 0; + } else if y >= self.vtop + self.vheight() { + self.vtop = y.saturating_sub(self.vheight() - 1); + self.cy = self.vheight() - 1; + } else { + self.cy = y - self.vtop; + } + self.draw_cursor()?; + } + PluginRequest::GetBufferText { start_line, end_line } => { + let current_buf = self.current_buffer(); + let start = start_line.unwrap_or(0); + let end = end_line.unwrap_or(current_buf.len()); + let mut lines = Vec::new(); + for i in start..end.min(current_buf.len()) { + if let Some(line) = current_buf.get(i) { + lines.push(line); + } + } + let text = lines.join("\n"); + self.plugin_registry + .notify(&mut runtime, "buffer:text", serde_json::json!({ "text": text })) + .await?; + } + PluginRequest::GetConfig { key } => { + let config_value = if let Some(key) = key { + // Return specific config value + match key.as_str() { + "theme" => json!(self.config.theme), + "plugins" => json!(self.config.plugins), + "log_file" => json!(self.config.log_file), + "mouse_scroll_lines" => json!(self.config.mouse_scroll_lines), + "show_diagnostics" => json!(self.config.show_diagnostics), + "keys" => json!(self.config.keys), + _ => json!(null), + } + } else { + // Return entire config + json!({ + "theme": self.config.theme, + "plugins": self.config.plugins, + "log_file": self.config.log_file, + "mouse_scroll_lines": self.config.mouse_scroll_lines, + "show_diagnostics": self.config.show_diagnostics, + "keys": self.config.keys, + }) + }; + self.plugin_registry + .notify(&mut runtime, "config:value", json!({ "value": config_value })) + .await?; + } + PluginRequest::IntervalCallback { interval_id } => { + self.plugin_registry + .notify(&mut runtime, "interval:callback", json!({ "intervalId": interval_id })) + .await?; } } } @@ -1514,10 +1687,12 @@ impl Editor { if self.vtop > 0 { self.vtop -= 1; self.render(buffer)?; + self.notify_cursor_move(runtime).await?; } } else { self.cy = self.cy.saturating_sub(1); self.draw_cursor()?; + self.notify_cursor_move(runtime).await?; } } Action::MoveDown => { @@ -1529,6 +1704,7 @@ impl Editor { self.cy -= 1; self.render(buffer)?; } + self.notify_cursor_move(runtime).await?; } else { self.draw_cursor()?; } @@ -1538,12 +1714,14 @@ impl Editor { if self.cx < self.vleft { self.cx = self.vleft; } + self.notify_cursor_move(runtime).await?; } Action::MoveRight => { self.cx += 1; if self.cx > self.line_length() { self.cx = self.line_length(); } + self.notify_cursor_move(runtime).await?; } Action::MoveToLineStart => { self.cx = 0; @@ -1623,7 +1801,18 @@ impl Editor { } } + let old_mode = self.mode; self.mode = *new_mode; + + // Notify plugins about mode change + let mode_info = serde_json::json!({ + "old_mode": format!("{:?}", old_mode), + "new_mode": format!("{:?}", new_mode) + }); + self.plugin_registry + .notify(runtime, "mode:changed", mode_info) + .await?; + self.draw_statusline(buffer); } Action::InsertCharAtCursorPos(c) => { @@ -1633,18 +1822,18 @@ impl Editor { let cx = self.cx; self.current_buffer_mut().insert(cx, line, *c); - self.notify_change().await?; + self.notify_change(runtime).await?; self.cx += 1; self.draw_line(buffer); } Action::DeleteCharAt(x, y) => { self.current_buffer_mut().remove(*x, *y); - self.notify_change().await?; + self.notify_change(runtime).await?; self.draw_line(buffer); } Action::DeleteRange(x0, y0, x1, y1) => { self.current_buffer_mut().remove_range(*x0, *y0, *x1, *y1); - self.notify_change().await?; + self.notify_change(runtime).await?; self.render(buffer)?; } Action::DeleteCharAtCursorPos => { @@ -1652,13 +1841,13 @@ impl Editor { let line = self.buffer_line(); self.current_buffer_mut().remove(cx, line); - self.notify_change().await?; + self.notify_change(runtime).await?; self.draw_line(buffer); } Action::ReplaceLineAt(y, contents) => { self.current_buffer_mut() .replace_line(*y, contents.to_string()); - self.notify_change().await?; + self.notify_change(runtime).await?; self.draw_line(buffer); } Action::InsertNewLine => { @@ -1682,7 +1871,7 @@ impl Editor { let line = self.buffer_line(); self.current_buffer_mut().replace_line(line, before_cursor); - self.notify_change().await?; + self.notify_change(runtime).await?; self.cx = spaces; self.cy += 1; @@ -1706,7 +1895,7 @@ impl Editor { let contents = self.current_line_contents(); self.current_buffer_mut().remove_line(line); - self.notify_change().await?; + self.notify_change(runtime).await?; self.undo_actions.push(Action::InsertLineAt(line, contents)); self.render(buffer)?; } @@ -1724,7 +1913,7 @@ impl Editor { if let Some(contents) = contents { self.current_buffer_mut() .insert_line(*y, contents.to_string()); - self.notify_change().await?; + self.notify_change(runtime).await?; self.render(buffer)?; } } @@ -1765,7 +1954,7 @@ impl Editor { let line = self.buffer_line(); self.current_buffer_mut() .insert_line(line + 1, " ".repeat(leading_spaces)); - self.notify_change().await?; + self.notify_change(runtime).await?; self.cy += 1; self.cx = leading_spaces; @@ -1794,7 +1983,7 @@ impl Editor { let line = self.buffer_line(); self.current_buffer_mut() .insert_line(line, " ".repeat(leading_spaces)); - self.notify_change().await?; + self.notify_change(runtime).await?; self.cx = leading_spaces; self.render(buffer)?; } @@ -1814,7 +2003,7 @@ impl Editor { } Action::DeleteLineAt(y) => { self.current_buffer_mut().remove_line(*y); - self.notify_change().await?; + self.notify_change(runtime).await?; self.render(buffer)?; } Action::DeletePreviousChar => { @@ -1823,7 +2012,7 @@ impl Editor { let cx = self.cx; let line = self.buffer_line(); self.current_buffer_mut().remove(cx, line); - self.notify_change().await?; + self.notify_change(runtime).await?; self.draw_line(buffer); } } @@ -1895,6 +2084,111 @@ impl Editor { ) .await?; } + Action::ViewLogs => { + add_to_history = false; + if let Some(log_file) = &self.config.log_file { + let path = PathBuf::from(log_file); + if path.exists() { + // Check if the log file is already open + if let Some(index) = self.buffers.iter().position(|b| b.name() == *log_file) + { + self.set_current_buffer(buffer, index).await?; + } else { + let new_buffer = match Buffer::load_or_create( + &mut self.lsp, + Some(log_file.to_string()), + ) + .await + { + Ok(buffer) => buffer, + Err(e) => { + self.last_error = + Some(format!("Failed to open log file: {}", e)); + return Ok(false); + } + }; + self.buffers.push(new_buffer); + self.set_current_buffer(buffer, self.buffers.len() - 1) + .await?; + } + } else { + self.last_error = Some(format!("Log file not found: {}", log_file)); + } + } else { + self.last_error = Some("No log file configured".to_string()); + } + } + Action::ListPlugins => { + add_to_history = false; + + // Create a buffer with plugin information + let mut content = String::from("# Loaded Plugins\n\n"); + + let metadata = self.plugin_registry.all_metadata(); + if metadata.is_empty() { + content.push_str("No plugins loaded.\n"); + } else { + for meta in metadata.values() { + content.push_str(&format!("## {}\n", meta.name)); + content.push_str(&format!("Version: {}\n", meta.version)); + + if let Some(desc) = &meta.description { + content.push_str(&format!("Description: {}\n", desc)); + } + + if let Some(author) = &meta.author { + content.push_str(&format!("Author: {}\n", author)); + } + + if let Some(license) = &meta.license { + content.push_str(&format!("License: {}\n", license)); + } + + if !meta.keywords.is_empty() { + content.push_str(&format!("Keywords: {}\n", meta.keywords.join(", "))); + } + + content.push_str(&format!("Main: {}\n", meta.main)); + + // Show capabilities + if meta.capabilities.commands + || meta.capabilities.events + || meta.capabilities.buffer_manipulation + || meta.capabilities.ui_components + { + content.push_str("Capabilities: "); + let mut caps = vec![]; + if meta.capabilities.commands { + caps.push("commands"); + } + if meta.capabilities.events { + caps.push("events"); + } + if meta.capabilities.buffer_manipulation { + caps.push("buffer manipulation"); + } + if meta.capabilities.ui_components { + caps.push("UI components"); + } + if meta.capabilities.lsp_integration { + caps.push("LSP integration"); + } + content.push_str(&caps.join(", ")); + content.push('\n'); + } + + content.push('\n'); + } + } + + // Create a new buffer with the plugin list + let plugin_list_buffer = Buffer::new(Some("[Plugin List]".to_string()), content); + self.buffers.push(plugin_list_buffer); + self.current_buffer_index = self.buffers.len() - 1; + self.cx = 0; + self.cy = 0; + self.vtop = 0; + } Action::Command(cmd) => { log!("Handling command: {cmd}"); @@ -2007,7 +2301,7 @@ impl Editor { let line = self.buffer_line(); self.current_buffer_mut() .insert_str(cx, line, &" ".repeat(tabsize)); - self.notify_change().await?; + self.notify_change(runtime).await?; self.cx += tabsize; self.draw_line(buffer); } @@ -2015,6 +2309,17 @@ impl Editor { Ok(msg) => { // TODO: use last_message instead of last_error self.last_error = Some(msg); + + // Notify plugins about file save + if let Some(file) = &self.current_buffer().file { + let save_info = serde_json::json!({ + "file": file, + "buffer_index": self.current_buffer_index + }); + self.plugin_registry + .notify(runtime, "file:saved", save_info) + .await?; + } } Err(e) => { self.last_error = Some(e.to_string()); @@ -2025,6 +2330,15 @@ impl Editor { Ok(msg) => { // TODO: use last_message instead of last_error self.last_error = Some(msg); + + // Notify plugins about file save + let save_info = serde_json::json!({ + "file": new_file_name, + "buffer_index": self.current_buffer_index + }); + self.plugin_registry + .notify(runtime, "file:saved", save_info) + .await?; } Err(e) => { self.last_error = Some(e.to_string()); @@ -2068,7 +2382,7 @@ impl Editor { }); } - self.notify_change().await?; + self.notify_change(runtime).await?; self.draw_line(buffer); } Action::NextBuffer => { @@ -2109,6 +2423,15 @@ impl Editor { self.set_current_buffer(buffer, self.buffers.len() - 1) .await?; buffer.clear(); + + // Notify plugins about file open + let open_info = serde_json::json!({ + "file": path, + "buffer_index": self.buffers.len() - 1 + }); + self.plugin_registry + .notify(runtime, "file:opened", open_info) + .await?; } self.render(buffer)?; } @@ -2157,11 +2480,19 @@ impl Editor { } } Action::Suspend => { - self.stdout.execute(terminal::LeaveAlternateScreen)?; - let pid = Pid::from_raw(0); - let _ = signal::kill(pid, Signal::SIGSTOP); - self.stdout.execute(terminal::EnterAlternateScreen)?; - self.render(buffer)?; + #[cfg(unix)] + { + self.stdout.execute(terminal::LeaveAlternateScreen)?; + let pid = Pid::from_raw(0); + let _ = signal::kill(pid, Signal::SIGSTOP); + self.stdout.execute(terminal::EnterAlternateScreen)?; + self.render(buffer)?; + } + #[cfg(not(unix))] + { + // Suspend is not supported on Windows + // Just ignore the action + } } Action::Yank => { if self.selection.is_some() && self.yank(DEFAULT_REGISTER) { @@ -2176,7 +2507,7 @@ impl Editor { self.cy = y0 - self.vtop; } self.selection = None; - self.notify_change().await?; + self.notify_change(runtime).await?; self.render(buffer)?; } } @@ -2188,7 +2519,7 @@ impl Editor { } Action::InsertText { x, y, content } => { self.insert_content(*x, *y, content, true); - self.notify_change().await?; + self.notify_change(runtime).await?; self.render(buffer)?; } Action::BufferText(value) => { @@ -2214,7 +2545,7 @@ impl Editor { let line = self.buffer_line(); let cx = self.cx; self.current_buffer_mut().insert_str(cx, line, text); - self.notify_change().await?; + self.notify_change(runtime).await?; self.cx += text.len(); self.draw_line(buffer); } @@ -2640,14 +2971,48 @@ impl Editor { } } - async fn notify_change(&mut self) -> anyhow::Result<()> { + async fn notify_cursor_move(&mut self, runtime: &mut Runtime) -> anyhow::Result<()> { + let cursor_info = serde_json::json!({ + "x": self.cx, + "y": self.cy + self.vtop, + "viewport_top": self.vtop, + "buffer_index": self.current_buffer_index + }); + + self.plugin_registry + .notify(runtime, "cursor:moved", cursor_info) + .await?; + + Ok(()) + } + + async fn notify_change(&mut self, runtime: &mut Runtime) -> anyhow::Result<()> { let file = self.current_buffer().file.clone(); + + // Notify LSP if file exists if let Some(file) = &file { // self.sync_state.notify_change(file); self.lsp .did_change(file, &self.current_buffer().contents()) .await?; } + + // Notify plugins about buffer change + let buffer_info = serde_json::json!({ + "buffer_id": self.current_buffer_index, + "buffer_name": self.current_buffer().name(), + "file_path": file, + "line_count": self.current_buffer().len(), + "cursor": { + "line": self.cy + self.vtop, + "column": self.cx + } + }); + + self.plugin_registry + .notify(runtime, "buffer:changed", buffer_info) + .await?; + Ok(()) } @@ -3091,288 +3456,41 @@ fn adjust_color_brightness(color: Option, percentage: i32) -> Option anyhow::Result<(bool, bool, bool)> { + let mut needs_render = false; + let mut needs_lsp_notify = false; + let should_quit; - let style1 = Style { - fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), - bg: Some(Color::Rgb { - r: 255, - g: 255, - b: 255, - }), - bold: false, - italic: false, - }; - let style2 = Style { - fg: Some(Color::Rgb { - r: 255, - g: 255, - b: 255, - }), - bg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), - bold: false, - italic: false, - }; - let buffer1 = RenderBuffer::new_with_contents(5, 1, style1, contents.clone()); - let buffer2 = RenderBuffer::new_with_contents(5, 1, style2, contents.clone()); + match action { + Action::EnterMode(mode) => { + self.mode = *mode; + // Set selection start when entering visual mode + if matches!(mode, Mode::Visual | Mode::VisualLine | Mode::VisualBlock) { + self.selection_start = Some(Point::new(self.cx, self.buffer_line())); + } + needs_render = true; + should_quit = false; + } + Action::InsertCharAtCursorPos(c) => { + let line = self.buffer_line(); + let cx = self.cx; - let diffs = buffer2.diff(&buffer1); - assert_eq!(diffs.len(), 5); - } + #[cfg(test)] + { + println!( + "InsertCharAtCursorPos: char='{}', cx={}, line={}", + c, cx, line + ); + if let Some(line_content) = self.current_buffer().get(line) { + println!(" Line content before: {:?}", line_content); + } + } - // #[test] - // fn test_set_char() { - // let mut buffer = RenderBuffer::new(10, 10, Style::default()); - // buffer.set_char( - // 0, - // 0, - // 'a', - // &Style { - // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), - // bg: Some(Color::Rgb { - // r: 255, - // g: 255, - // b: 255, - // }), - // bold: false, - // italic: false, - // }, - // ); - // - // assert_eq!(buffer.cells[0].c, 'a'); - // } - // - // #[test] - // #[should_panic(expected = "out of bounds")] - // fn test_set_char_outside_buffer() { - // let mut buffer = RenderBuffer::new(2, 2, Style::default()); - // buffer.set_char( - // 2, - // 2, - // 'a', - // &Style { - // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), - // bg: Some(Color::Rgb { - // r: 255, - // g: 255, - // b: 255, - // }), - // bold: false, - // italic: false, - // }, - // ); - // } - // - // #[test] - // fn test_set_text() { - // let mut buffer = RenderBuffer::new(3, 15, Style::default()); - // buffer.set_text( - // 2, - // 2, - // "Hello, world!", - // &Style { - // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), - // bg: Some(Color::Rgb { - // r: 255, - // g: 255, - // b: 255, - // }), - // bold: false, - // italic: true, - // }, - // ); - // - // let start = 2 * 3 + 2; - // assert_eq!(buffer.cells[start].c, 'H'); - // assert_eq!( - // buffer.cells[start].style.fg, - // Some(Color::Rgb { r: 0, g: 0, b: 0 }) - // ); - // assert_eq!( - // buffer.cells[start].style.bg, - // Some(Color::Rgb { - // r: 255, - // g: 255, - // b: 255 - // }) - // ); - // assert_eq!(buffer.cells[start].style.italic, true); - // assert_eq!(buffer.cells[start + 1].c, 'e'); - // assert_eq!(buffer.cells[start + 2].c, 'l'); - // assert_eq!(buffer.cells[start + 3].c, 'l'); - // assert_eq!(buffer.cells[start + 4].c, 'o'); - // assert_eq!(buffer.cells[start + 5].c, ','); - // assert_eq!(buffer.cells[start + 6].c, ' '); - // assert_eq!(buffer.cells[start + 7].c, 'w'); - // assert_eq!(buffer.cells[start + 8].c, 'o'); - // assert_eq!(buffer.cells[start + 9].c, 'r'); - // assert_eq!(buffer.cells[start + 10].c, 'l'); - // assert_eq!(buffer.cells[start + 11].c, 'd'); - // assert_eq!(buffer.cells[start + 12].c, '!'); - // } - // - // #[test] - // fn test_diff() { - // let buffer1 = RenderBuffer::new(3, 3, Style::default()); - // let mut buffer2 = RenderBuffer::new(3, 3, Style::default()); - // - // buffer2.set_char( - // 0, - // 0, - // 'a', - // &Style { - // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), - // bg: Some(Color::Rgb { - // r: 255, - // g: 255, - // b: 255, - // }), - // bold: false, - // italic: false, - // }, - // ); - // - // let diff = buffer2.diff(&buffer1); - // assert_eq!(diff.len(), 1); - // assert_eq!(diff[0].x, 0); - // assert_eq!(diff[0].y, 0); - // assert_eq!(diff[0].cell.c, 'a'); - // } - // - // #[test] - // #[ignore] - // fn test_draw_viewport() { - // todo!("pass lsp to with_size"); - // // let contents = "hello\nworld!"; - // - // // let config = Config::default(); - // // let theme = Theme::default(); - // // let buffer = Buffer::new(None, contents.to_string()); - // // log!("buffer: {buffer:?}"); - // // let mut render_buffer = RenderBuffer::new(10, 10, Style::default()); - // // - // // let mut editor = Editor::with_size(10, 10, config, theme, buffer).unwrap(); - // // editor.draw_viewport(&mut render_buffer).unwrap(); - // // - // // log!("{}", render_buffer.dump()); - // // - // // assert_eq!(render_buffer.cells[0].c, ' '); - // // assert_eq!(render_buffer.cells[1].c, '1'); - // // assert_eq!(render_buffer.cells[2].c, ' '); - // // assert_eq!(render_buffer.cells[3].c, 'h'); - // // assert_eq!(render_buffer.cells[4].c, 'e'); - // // assert_eq!(render_buffer.cells[5].c, 'l'); - // // assert_eq!(render_buffer.cells[6].c, 'l'); - // // assert_eq!(render_buffer.cells[7].c, 'o'); - // // assert_eq!(render_buffer.cells[8].c, ' '); - // // assert_eq!(render_buffer.cells[9].c, ' '); - // } - // - // #[test] - // fn test_buffer_diff() { - // let contents1 = vec![" 1:2 ".to_string()]; - // let contents2 = vec![" 1:3 ".to_string()]; - // - // let buffer1 = RenderBuffer::new_with_contents(5, 1, Style::default(), contents1); - // let buffer2 = RenderBuffer::new_with_contents(5, 1, Style::default(), contents2); - // let diff = buffer2.diff(&buffer1); - // - // assert_eq!(diff.len(), 1); - // assert_eq!(diff[0].x, 3); - // assert_eq!(diff[0].y, 0); - // assert_eq!(diff[0].cell.c, '3'); - // // - // // let contents1 = vec![ - // // "fn main() {".to_string(), - // // " log!(\"Hello, world!\");".to_string(), - // // "".to_string(), - // // "}".to_string(), - // // ]; - // // let contents2 = vec![ - // // " log!(\"Hello, world!\");".to_string(), - // // "".to_string(), - // // "}".to_string(), - // // "".to_string(), - // // ]; - // // let buffer1 = RenderBuffer::new_with_contents(50, 4, Style::default(), contents1); - // // let buffer2 = RenderBuffer::new_with_contents(50, 4, Style::default(), contents2); - // // - // // let diff = buffer2.diff(&buffer1); - // // log!("{}", buffer1.dump()); - // } -} - -// Public methods for test utilities (hidden from docs) -impl Editor { - /// Core action logic without side effects - /// Returns (should_quit, needs_render, needs_lsp_notify) - #[doc(hidden)] - pub fn apply_action_core(&mut self, action: &Action) -> anyhow::Result<(bool, bool, bool)> { - let mut needs_render = false; - let mut needs_lsp_notify = false; - let should_quit; - - match action { - Action::EnterMode(mode) => { - self.mode = *mode; - // Set selection start when entering visual mode - if matches!(mode, Mode::Visual | Mode::VisualLine | Mode::VisualBlock) { - self.selection_start = Some(Point::new(self.cx, self.buffer_line())); - } - needs_render = true; - should_quit = false; - } - Action::InsertCharAtCursorPos(c) => { - let line = self.buffer_line(); - let cx = self.cx; - - #[cfg(test)] - { - println!("InsertCharAtCursorPos: char='{}', cx={}, line={}", c, cx, line); - if let Some(line_content) = self.current_buffer().get(line) { - println!(" Line content before: {:?}", line_content); - } - } - self.current_buffer_mut().insert(cx, line, *c); if self.mode == Mode::Insert { self.cx += 1; @@ -3484,7 +3602,8 @@ impl Editor { } Action::InsertLineBelowCursor => { let line = self.buffer_line(); - self.current_buffer_mut().insert_line(line + 1, "".to_string()); + self.current_buffer_mut() + .insert_line(line + 1, "".to_string()); self.cy += 1; self.cx = 0; self.mode = Mode::Insert; @@ -3502,7 +3621,10 @@ impl Editor { should_quit = false; } Action::MoveToNextWord => { - if let Some((x, y)) = self.current_buffer().find_next_word((self.cx, self.buffer_line())) { + if let Some((x, y)) = self + .current_buffer() + .find_next_word((self.cx, self.buffer_line())) + { self.cx = x; if y != self.buffer_line() { // TODO: Handle moving to next line @@ -3511,7 +3633,10 @@ impl Editor { should_quit = false; } Action::MoveToPreviousWord => { - if let Some((x, y)) = self.current_buffer().find_prev_word((self.cx, self.buffer_line())) { + if let Some((x, y)) = self + .current_buffer() + .find_prev_word((self.cx, self.buffer_line())) + { self.cx = x; if y != self.buffer_line() { // TODO: Handle moving to previous line @@ -3558,43 +3683,42 @@ impl Editor { } should_quit = false; } - + // ===== Line Operations ===== Action::InsertNewLine => { let spaces = self.current_line_indentation(); let current_line = self.current_line_contents().unwrap_or_default(); let current_line = current_line.trim_end(); - + let cx = if self.cx > current_line.len() { current_line.len() } else { self.cx }; - + let before_cursor = current_line[..cx].to_string(); let after_cursor = current_line[cx..].to_string(); - + let line = self.buffer_line(); self.current_buffer_mut().replace_line(line, before_cursor); - + self.cx = spaces; self.cy += 1; - + if self.cy >= self.vheight() { self.vtop += 1; self.cy -= 1; - needs_render = true; } - + let new_line = format!("{}{}", " ".repeat(spaces), &after_cursor); let line = self.buffer_line(); self.current_buffer_mut().insert_line(line, new_line); - + needs_lsp_notify = true; needs_render = true; should_quit = false; } - + // ===== Page Movement ===== Action::PageUp => { if self.vtop > 0 { @@ -3610,11 +3734,14 @@ impl Editor { } should_quit = false; } - + // ===== Search Actions ===== Action::FindNext => { if !self.search_term.is_empty() { - if let Some((x, y)) = self.current_buffer().find_next(&self.search_term, (self.cx, self.buffer_line())) { + if let Some((x, y)) = self + .current_buffer() + .find_next(&self.search_term, (self.cx, self.buffer_line())) + { self.cx = x; let new_line = y; if new_line != self.buffer_line() { @@ -3627,7 +3754,10 @@ impl Editor { } Action::FindPrevious => { if !self.search_term.is_empty() { - if let Some((x, y)) = self.current_buffer().find_prev(&self.search_term, (self.cx, self.buffer_line())) { + if let Some((x, y)) = self + .current_buffer() + .find_prev(&self.search_term, (self.cx, self.buffer_line())) + { self.cx = x; let new_line = y; if new_line != self.buffer_line() { @@ -3638,11 +3768,12 @@ impl Editor { } should_quit = false; } - + // ===== Buffer Management ===== Action::NextBuffer => { if self.buffers.len() > 1 { - self.current_buffer_index = (self.current_buffer_index + 1) % self.buffers.len(); + self.current_buffer_index = + (self.current_buffer_index + 1) % self.buffers.len(); needs_render = true; } should_quit = false; @@ -3658,13 +3789,13 @@ impl Editor { } should_quit = false; } - + // ===== Clipboard Operations ===== Action::Yank => { // Store current line in default register if let Some(line) = self.current_line_contents() { let content = Content { - kind: ContentKind::Linewise, // Yank line is linewise + kind: ContentKind::Linewise, // Yank line is linewise text: line.to_string(), }; self.registers.insert('"', content); @@ -3693,7 +3824,7 @@ impl Editor { } should_quit = false; } - + // ===== Other Movement Actions ===== Action::MoveToTop => { self.set_cursor_line(0); @@ -3716,7 +3847,7 @@ impl Editor { needs_render = true; should_quit = false; } - + // ===== Editing Operations ===== Action::DeletePreviousChar => { if self.cx > 0 { @@ -3733,11 +3864,12 @@ impl Editor { if let Some(prev_content) = self.current_buffer().get(prev_line) { let prev_len = prev_content.trim_end_matches('\n').len(); let current_content = self.current_line_contents().unwrap_or_default(); - let joined = format!("{}{}", prev_content.trim_end(), current_content.trim_end()); - + let joined = + format!("{}{}", prev_content.trim_end(), current_content.trim_end()); + self.current_buffer_mut().replace_line(prev_line, joined); self.current_buffer_mut().remove_line(current_line); - + self.set_cursor_line(prev_line); self.cx = prev_len; needs_lsp_notify = true; @@ -3756,11 +3888,11 @@ impl Editor { needs_render = true; should_quit = false; } - + // ===== Visual Mode Operations ===== // Visual mode is entered via Action::EnterMode(Mode::Visual) // which is already handled above - + // ===== Other Operations ===== Action::Refresh => { needs_render = true; @@ -3772,20 +3904,20 @@ impl Editor { needs_render = true; should_quit = false; } - + _ => { // Other actions not yet migrated should_quit = false; } } - + Ok((should_quit, needs_render, needs_lsp_notify)) } - + /// Helper to set cursor line and handle viewport scrolling fn set_cursor_line(&mut self, new_line: usize) { let viewport_height = self.vheight(); - + if new_line < self.vtop { // Scroll up self.vtop = new_line; @@ -3799,56 +3931,306 @@ impl Editor { self.cy = new_line - self.vtop; } } - + // These methods are made public for test utilities but hidden from docs - + #[doc(hidden)] pub fn test_cx(&self) -> usize { self.cx } - + #[doc(hidden)] pub fn test_buffer_line(&self) -> usize { self.buffer_line() } - + #[doc(hidden)] pub fn test_mode(&self) -> Mode { self.mode } - + #[doc(hidden)] pub fn test_current_buffer(&self) -> &Buffer { self.current_buffer() } - + #[doc(hidden)] pub fn test_is_insert(&self) -> bool { self.is_insert() } - + #[doc(hidden)] pub fn test_is_normal(&self) -> bool { self.is_normal() } - + #[doc(hidden)] pub fn test_vtop(&self) -> usize { self.vtop } - + #[doc(hidden)] pub fn test_current_line_contents(&self) -> Option { self.current_line_contents() } - + #[doc(hidden)] pub fn test_cursor_x(&self) -> usize { self.cx } - + #[doc(hidden)] pub fn test_set_size(&mut self, width: u16, height: u16) { self.size = (width, height); } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_buffer_diff() { + let contents1 = vec![" 1:2 ".to_string()]; + let contents2 = vec![" 1:3 ".to_string()]; + + let buffer1 = RenderBuffer::new_with_contents(5, 1, Style::default(), contents1); + let buffer2 = RenderBuffer::new_with_contents(5, 1, Style::default(), contents2); + let diff = buffer2.diff(&buffer1); + + assert_eq!(diff.len(), 1); + assert_eq!(diff[0].x, 3); + assert_eq!(diff[0].y, 0); + assert_eq!(diff[0].cell.c, '3'); + // + // let contents1 = vec![ + // "fn main() {".to_string(), + // " log!(\"Hello, world!\");".to_string(), + // "".to_string(), + // "}".to_string(), + // ]; + // let contents2 = vec![ + // " log!(\"Hello, world!\");".to_string(), + // "".to_string(), + // "}".to_string(), + // "".to_string(), + // ]; + // let buffer1 = RenderBuffer::new_with_contents(50, 4, Style::default(), contents1); + // let buffer2 = RenderBuffer::new_with_contents(50, 4, Style::default(), contents2); + // + // let diff = buffer2.diff(&buffer1); + // log!("{}", buffer1.dump()); + } + + #[test] + fn test_buffer_color_diff() { + let contents = vec![" 1:2 ".to_string()]; + + let style1 = Style { + fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), + bg: Some(Color::Rgb { + r: 255, + g: 255, + b: 255, + }), + bold: false, + italic: false, + }; + let style2 = Style { + fg: Some(Color::Rgb { + r: 255, + g: 255, + b: 255, + }), + bg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), + bold: false, + italic: false, + }; + let buffer1 = RenderBuffer::new_with_contents(5, 1, style1, contents.clone()); + let buffer2 = RenderBuffer::new_with_contents(5, 1, style2, contents.clone()); + + let diffs = buffer2.diff(&buffer1); + assert_eq!(diffs.len(), 5); + } + + // #[test] + // fn test_set_char() { + // let mut buffer = RenderBuffer::new(10, 10, Style::default()); + // buffer.set_char( + // 0, + // 0, + // 'a', + // &Style { + // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), + // bg: Some(Color::Rgb { + // r: 255, + // g: 255, + // b: 255, + // }), + // bold: false, + // italic: false, + // }, + // ); + // + // assert_eq!(buffer.cells[0].c, 'a'); + // } + // + // #[test] + // #[should_panic(expected = "out of bounds")] + // fn test_set_char_outside_buffer() { + // let mut buffer = RenderBuffer::new(2, 2, Style::default()); + // buffer.set_char( + // 2, + // 2, + // 'a', + // &Style { + // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), + // bg: Some(Color::Rgb { + // r: 255, + // g: 255, + // b: 255, + // }), + // bold: false, + // italic: false, + // }, + // ); + // } + // + // #[test] + // fn test_set_text() { + // let mut buffer = RenderBuffer::new(3, 15, Style::default()); + // buffer.set_text( + // 2, + // 2, + // "Hello, world!", + // &Style { + // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), + // bg: Some(Color::Rgb { + // r: 255, + // g: 255, + // b: 255, + // }), + // bold: false, + // italic: true, + // }, + // ); + // + // let start = 2 * 3 + 2; + // assert_eq!(buffer.cells[start].c, 'H'); + // assert_eq!( + // buffer.cells[start].style.fg, + // Some(Color::Rgb { r: 0, g: 0, b: 0 }) + // ); + // assert_eq!( + // buffer.cells[start].style.bg, + // Some(Color::Rgb { + // r: 255, + // g: 255, + // b: 255 + // }) + // ); + // assert_eq!(buffer.cells[start].style.italic, true); + // assert_eq!(buffer.cells[start + 1].c, 'e'); + // assert_eq!(buffer.cells[start + 2].c, 'l'); + // assert_eq!(buffer.cells[start + 3].c, 'l'); + // assert_eq!(buffer.cells[start + 4].c, 'o'); + // assert_eq!(buffer.cells[start + 5].c, ','); + // assert_eq!(buffer.cells[start + 6].c, ' '); + // assert_eq!(buffer.cells[start + 7].c, 'w'); + // assert_eq!(buffer.cells[start + 8].c, 'o'); + // assert_eq!(buffer.cells[start + 9].c, 'r'); + // assert_eq!(buffer.cells[start + 10].c, 'l'); + // assert_eq!(buffer.cells[start + 11].c, 'd'); + // assert_eq!(buffer.cells[start + 12].c, '!'); + // } + // + // #[test] + // fn test_diff() { + // let buffer1 = RenderBuffer::new(3, 3, Style::default()); + // let mut buffer2 = RenderBuffer::new(3, 3, Style::default()); + // + // buffer2.set_char( + // 0, + // 0, + // 'a', + // &Style { + // fg: Some(Color::Rgb { r: 0, g: 0, b: 0 }), + // bg: Some(Color::Rgb { + // r: 255, + // g: 255, + // b: 255, + // }), + // bold: false, + // italic: false, + // }, + // ); + // + // let diff = buffer2.diff(&buffer1); + // assert_eq!(diff.len(), 1); + // assert_eq!(diff[0].x, 0); + // assert_eq!(diff[0].y, 0); + // assert_eq!(diff[0].cell.c, 'a'); + // } + // + // #[test] + // #[ignore] + // fn test_draw_viewport() { + // todo!("pass lsp to with_size"); + // // let contents = "hello\nworld!"; + // + // // let config = Config::default(); + // // let theme = Theme::default(); + // // let buffer = Buffer::new(None, contents.to_string()); + // // log!("buffer: {buffer:?}"); + // // let mut render_buffer = RenderBuffer::new(10, 10, Style::default()); + // // + // // let mut editor = Editor::with_size(10, 10, config, theme, buffer).unwrap(); + // // editor.draw_viewport(&mut render_buffer).unwrap(); + // // + // // log!("{}", render_buffer.dump()); + // // + // // assert_eq!(render_buffer.cells[0].c, ' '); + // // assert_eq!(render_buffer.cells[1].c, '1'); + // // assert_eq!(render_buffer.cells[2].c, ' '); + // // assert_eq!(render_buffer.cells[3].c, 'h'); + // // assert_eq!(render_buffer.cells[4].c, 'e'); + // // assert_eq!(render_buffer.cells[5].c, 'l'); + // // assert_eq!(render_buffer.cells[6].c, 'l'); + // // assert_eq!(render_buffer.cells[7].c, 'o'); + // // assert_eq!(render_buffer.cells[8].c, ' '); + // // assert_eq!(render_buffer.cells[9].c, ' '); + // } + // + // #[test] + // fn test_buffer_diff() { + // let contents1 = vec![" 1:2 ".to_string()]; + // let contents2 = vec![" 1:3 ".to_string()]; + // + // let buffer1 = RenderBuffer::new_with_contents(5, 1, Style::default(), contents1); + // let buffer2 = RenderBuffer::new_with_contents(5, 1, Style::default(), contents2); + // let diff = buffer2.diff(&buffer1); + // + // assert_eq!(diff.len(), 1); + // assert_eq!(diff[0].x, 3); + // assert_eq!(diff[0].y, 0); + // assert_eq!(diff[0].cell.c, '3'); + // // + // // let contents1 = vec![ + // // "fn main() {".to_string(), + // // " log!(\"Hello, world!\");".to_string(), + // // "".to_string(), + // // "}".to_string(), + // // ]; + // // let contents2 = vec![ + // // " log!(\"Hello, world!\");".to_string(), + // // "".to_string(), + // // "}".to_string(), + // // "".to_string(), + // // ]; + // // let buffer1 = RenderBuffer::new_with_contents(50, 4, Style::default(), contents1); + // // let buffer2 = RenderBuffer::new_with_contents(50, 4, Style::default(), contents2); + // // + // // let diff = buffer2.diff(&buffer1); + // // log!("{}", buffer1.dump()); + // } +} diff --git a/src/logger.rs b/src/logger.rs index 90e49ad..9978acd 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1,12 +1,46 @@ #![allow(unused)] use std::{ + fmt, fs::{File, OpenOptions}, io::Write, sync::Mutex, + time::SystemTime, }; +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum LogLevel { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, +} + +impl fmt::Display for LogLevel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LogLevel::Debug => write!(f, "DEBUG"), + LogLevel::Info => write!(f, "INFO"), + LogLevel::Warn => write!(f, "WARN"), + LogLevel::Error => write!(f, "ERROR"), + } + } +} + +impl LogLevel { + pub fn parse(s: &str) -> Option { + match s.to_uppercase().as_str() { + "DEBUG" => Some(LogLevel::Debug), + "INFO" => Some(LogLevel::Info), + "WARN" => Some(LogLevel::Warn), + "ERROR" => Some(LogLevel::Error), + _ => None, + } + } +} + pub struct Logger { file: Mutex, + min_level: LogLevel, } impl Logger { @@ -19,11 +53,46 @@ impl Logger { Logger { file: Mutex::new(file), + min_level: LogLevel::Debug, // Default to showing all logs } } + pub fn set_level(&mut self, level: LogLevel) { + self.min_level = level; + } + pub fn log(&self, message: &str) { + self.log_with_level(LogLevel::Info, message); + } + + pub fn log_with_level(&self, level: LogLevel, message: &str) { + if level < self.min_level { + return; + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let formatted = format!("[{}] [{}] {}", timestamp, level, message); + let mut file = self.file.lock().unwrap(); - writeln!(file, "{}", message).expect("write to file works"); + writeln!(file, "{}", formatted).expect("write to file works"); + } + + pub fn debug(&self, message: &str) { + self.log_with_level(LogLevel::Debug, message); + } + + pub fn info(&self, message: &str) { + self.log_with_level(LogLevel::Info, message); + } + + pub fn warn(&self, message: &str) { + self.log_with_level(LogLevel::Warn, message); + } + + pub fn error(&self, message: &str) { + self.log_with_level(LogLevel::Error, message); } } diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs index 2337ffe..9348181 100644 --- a/src/plugin/loader.rs +++ b/src/plugin/loader.rs @@ -1,6 +1,6 @@ -use deno_ast::{MediaType, ParseParams, SourceTextInfo}; +use deno_ast::{MediaType, ParseParams}; use deno_core::{ - error::AnyError, futures::FutureExt, url::Url, ModuleLoadResponse, ModuleLoader, ModuleSource, + error::AnyError, futures::FutureExt, ModuleLoadResponse, ModuleLoader, ModuleSource, ModuleSourceCode, ModuleSpecifier, RequestedModuleType, ResolutionKind, }; @@ -82,14 +82,20 @@ impl ModuleLoader for TsModuleLoader { let code = if should_transpile { let parsed = deno_ast::parse_module(ParseParams { - specifier: module_specifier.to_string(), - text_info: SourceTextInfo::from_string(code), + specifier: module_specifier.clone(), + text: code.clone().into(), media_type, capture_tokens: false, scope_analysis: false, maybe_syntax: None, })?; - parsed.transpile(&Default::default())?.text + let transpile_options = Default::default(); + let transpile_result = parsed.transpile( + &transpile_options, + &Default::default(), + &Default::default(), + )?; + transpile_result.into_source().text } else { code }; @@ -98,7 +104,8 @@ impl ModuleLoader for TsModuleLoader { let module = ModuleSource::new( module_type, ModuleSourceCode::String(code.into()), - &Url::parse(module_specifier.as_ref())?, + &module_specifier, + None, ); Ok(module) diff --git a/src/plugin/metadata.rs b/src/plugin/metadata.rs new file mode 100644 index 0000000..011b6c1 --- /dev/null +++ b/src/plugin/metadata.rs @@ -0,0 +1,181 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Plugin metadata structure based on package.json format +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginMetadata { + /// Plugin name (required) + pub name: String, + + /// Plugin version following semver + #[serde(default = "default_version")] + pub version: String, + + /// Plugin description + pub description: Option, + + /// Plugin author (name or name ) + pub author: Option, + + /// Plugin license + pub license: Option, + + /// Main entry point (defaults to index.js) + #[serde(default = "default_main")] + pub main: String, + + /// Plugin homepage URL + pub homepage: Option, + + /// Repository information + pub repository: Option, + + /// Keywords for plugin discovery + #[serde(default)] + pub keywords: Vec, + + /// Red editor compatibility + pub engines: Option, + + /// Plugin dependencies (other plugins) + #[serde(default)] + pub dependencies: HashMap, + + /// Red API version compatibility + pub red_api_version: Option, + + /// Plugin configuration schema + pub config_schema: Option, + + /// Activation events (when to load the plugin) + #[serde(default)] + pub activation_events: Vec, + + /// Plugin capabilities + #[serde(default)] + pub capabilities: PluginCapabilities, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Repository { + #[serde(rename = "type")] + pub repo_type: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Engines { + pub red: Option, + pub node: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginCapabilities { + /// Whether the plugin provides commands + #[serde(default)] + pub commands: bool, + + /// Whether the plugin uses event handlers + #[serde(default)] + pub events: bool, + + /// Whether the plugin modifies buffers + #[serde(default)] + pub buffer_manipulation: bool, + + /// Whether the plugin provides UI components + #[serde(default)] + pub ui_components: bool, + + /// Whether the plugin integrates with LSP + #[serde(default)] + pub lsp_integration: bool, +} + +fn default_version() -> String { + "0.1.0".to_string() +} + +fn default_main() -> String { + "index.js".to_string() +} + +impl PluginMetadata { + /// Load metadata from a package.json file + pub fn from_file(path: &std::path::Path) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + let metadata: PluginMetadata = serde_json::from_str(&content)?; + Ok(metadata) + } + + /// Create minimal metadata with just a name + pub fn minimal(name: String) -> Self { + Self { + name, + version: default_version(), + description: None, + author: None, + license: None, + main: default_main(), + homepage: None, + repository: None, + keywords: vec![], + engines: None, + dependencies: HashMap::new(), + red_api_version: None, + config_schema: None, + activation_events: vec![], + capabilities: PluginCapabilities::default(), + } + } + + /// Check if the plugin is compatible with the current Red version + pub fn is_compatible(&self, red_version: &str) -> bool { + if let Some(engines) = &self.engines { + if let Some(required_red) = &engines.red { + // Simple version check - could be enhanced with semver + return required_red == "*" || red_version.starts_with(required_red); + } + } + true // If no version specified, assume compatible + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimal_metadata() { + let metadata = PluginMetadata::minimal("test-plugin".to_string()); + assert_eq!(metadata.name, "test-plugin"); + assert_eq!(metadata.version, "0.1.0"); + assert_eq!(metadata.main, "index.js"); + } + + #[test] + fn test_deserialize_metadata() { + let json = r#"{ + "name": "awesome-plugin", + "version": "1.0.0", + "description": "An awesome plugin for Red editor", + "author": "John Doe ", + "keywords": ["productivity", "tools"], + "capabilities": { + "commands": true, + "events": true + } + }"#; + + let metadata: PluginMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(metadata.name, "awesome-plugin"); + assert_eq!(metadata.version, "1.0.0"); + assert_eq!( + metadata.description, + Some("An awesome plugin for Red editor".to_string()) + ); + assert_eq!(metadata.keywords.len(), 2); + assert!(metadata.capabilities.commands); + assert!(metadata.capabilities.events); + } +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 9cdfc3e..6cdf2a6 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -1,6 +1,8 @@ mod loader; +mod metadata; mod registry; mod runtime; +pub use metadata::PluginMetadata; pub use registry::PluginRegistry; pub use runtime::Runtime; diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 637f83d..975adaf 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -1,9 +1,13 @@ use serde_json::json; +use std::collections::HashMap; +use std::path::Path; -use super::Runtime; +use super::{PluginMetadata, Runtime}; pub struct PluginRegistry { plugins: Vec<(String, String)>, + metadata: HashMap, + initialized: bool, } impl Default for PluginRegistry { @@ -16,29 +20,79 @@ impl PluginRegistry { pub fn new() -> Self { Self { plugins: Vec::new(), + metadata: HashMap::new(), + initialized: false, } } pub fn add(&mut self, name: &str, path: &str) { self.plugins.push((name.to_string(), path.to_string())); + + // Try to load metadata from package.json in the plugin directory + let plugin_path = Path::new(path); + if let Some(dir) = plugin_path.parent() { + let package_json = dir.join("package.json"); + if package_json.exists() { + match PluginMetadata::from_file(&package_json) { + Ok(metadata) => { + self.metadata.insert(name.to_string(), metadata); + } + Err(e) => { + // If no package.json or invalid, create minimal metadata + crate::log!("Failed to load metadata for plugin {}: {}", name, e); + self.metadata + .insert(name.to_string(), PluginMetadata::minimal(name.to_string())); + } + } + } else { + // No package.json, use minimal metadata + self.metadata + .insert(name.to_string(), PluginMetadata::minimal(name.to_string())); + } + } + } + + /// Get metadata for a specific plugin + pub fn get_metadata(&self, name: &str) -> Option<&PluginMetadata> { + self.metadata.get(name) + } + + /// Get all plugin metadata + pub fn all_metadata(&self) -> &HashMap { + &self.metadata } pub async fn initialize(&mut self, runtime: &mut Runtime) -> anyhow::Result<()> { let mut code = r#" - globalThis.plugins = []; + globalThis.plugins = {}; + globalThis.pluginInstances = {}; "# .to_string(); for (i, (name, plugin)) in self.plugins.iter().enumerate() { code += &format!( r#" - import {{ activate as activate_{i} }} from '{plugin}'; - globalThis.plugins['{name}'] = activate_{i}(globalThis.context); + import * as plugin_{i} from '{plugin}'; + const activate_{i} = plugin_{i}.activate; + const deactivate_{i} = plugin_{i}.deactivate || null; + + globalThis.plugins['{name}'] = activate_{i}; + + // Store plugin instance for lifecycle management + globalThis.pluginInstances['{name}'] = {{ + activate: activate_{i}, + deactivate: deactivate_{i}, + context: null + }}; + + // Activate the plugin + globalThis.pluginInstances['{name}'].context = activate_{i}(globalThis.context); "#, ); } runtime.add_module(&code).await?; + self.initialized = true; Ok(()) } @@ -75,4 +129,48 @@ impl PluginRegistry { Ok(()) } + + /// Deactivate all plugins (call their deactivate functions if available) + pub async fn deactivate_all(&mut self, runtime: &mut Runtime) -> anyhow::Result<()> { + if !self.initialized { + return Ok(()); + } + + let code = r#" + (async () => { + for (const [name, plugin] of Object.entries(globalThis.pluginInstances)) { + if (plugin.deactivate) { + try { + await plugin.deactivate(); + globalThis.log(`Plugin ${name} deactivated`); + } catch (error) { + globalThis.log(`Error deactivating plugin ${name}:`, error); + } + } + } + + // Clear event subscriptions + globalThis.context.eventSubscriptions = {}; + + // Clear commands + globalThis.context.commands = {}; + + // Clear plugin instances + globalThis.pluginInstances = {}; + globalThis.plugins = {}; + })(); + "#; + + runtime.run(code).await?; + self.initialized = false; + + Ok(()) + } + + /// Reload all plugins (deactivate then reactivate) + pub async fn reload(&mut self, runtime: &mut Runtime) -> anyhow::Result<()> { + self.deactivate_all(runtime).await?; + self.initialize(runtime).await?; + Ok(()) + } } diff --git a/src/plugin/runtime.js b/src/plugin/runtime.js index cc20b91..c3668ce 100644 --- a/src/plugin/runtime.js +++ b/src/plugin/runtime.js @@ -6,7 +6,24 @@ const print = (message) => { }; const log = (...message) => { - ops.op_log(message); + ops.op_log(null, message); +}; + +// Log level functions +const logDebug = (...message) => { + ops.op_log("debug", message); +}; + +const logInfo = (...message) => { + ops.op_log("info", message); +}; + +const logWarn = (...message) => { + ops.op_log("warn", message); +}; + +const logError = (...message) => { + ops.op_log("error", message); }; let nextReqId = 0; @@ -21,7 +38,12 @@ class RedContext { this.commands[name] = command; } - getCommands() { + getCommandList() { + // Return command names as an array + return Object.keys(this.commands); + } + + getCommandsWithCallbacks() { return this.commands; } @@ -81,12 +103,130 @@ class RedContext { drawText(x, y, text, style) { this.execute("BufferText", { x, y, text, style }); } + + // Buffer manipulation APIs + insertText(x, y, text) { + ops.op_buffer_insert(x, y, text); + } + + deleteText(x, y, length) { + ops.op_buffer_delete(x, y, length); + } + + replaceText(x, y, length, text) { + ops.op_buffer_replace(x, y, length, text); + } + + getCursorPosition() { + return new Promise((resolve, _reject) => { + const handler = (pos) => { + resolve(pos); + }; + this.once("cursor:position", handler); + ops.op_get_cursor_position(); + }); + } + + setCursorPosition(x, y) { + ops.op_set_cursor_position(x, y); + } + + getBufferText(startLine, endLine) { + return new Promise((resolve, _reject) => { + const handler = (data) => { + resolve(data.text); + }; + this.once("buffer:text", handler); + ops.op_get_buffer_text(startLine, endLine); + }); + } + + // Helper method for one-time event listeners + once(event, callback) { + const wrapper = (data) => { + this.off(event, wrapper); + callback(data); + }; + this.on(event, wrapper); + } + + // Method to remove event listeners + off(event, callback) { + const subs = this.eventSubscriptions[event] || []; + this.eventSubscriptions[event] = subs.filter(sub => sub !== callback); + } + + // Get list of available commands + getCommands() { + // Return plugin commands synchronously + // In the future, we could make this async to fetch built-in commands too + return this.getCommandList(); + } + + // Get configuration values + getConfig(key) { + return new Promise((resolve, _reject) => { + const handler = (data) => { + resolve(data.value); + }; + this.once("config:value", handler); + ops.op_get_config(key); + }); + } + + // Logging with levels + log(...messages) { + log(...messages); + } + + logDebug(...messages) { + logDebug(...messages); + } + + logInfo(...messages) { + logInfo(...messages); + } + + logWarn(...messages) { + logWarn(...messages); + } + + logError(...messages) { + logError(...messages); + } + + // View logs in editor + viewLogs() { + ops.op_trigger_action("ViewLogs"); + } + + // Timer functions + async setInterval(callback, delay) { + return await globalThis.setInterval(callback, delay); + } + + async clearInterval(id) { + return await globalThis.clearInterval(id); + } + + async setTimeout(callback, delay) { + return await globalThis.setTimeout(callback, delay); + } + + async clearTimeout(id) { + return await globalThis.clearTimeout(id); + } } async function execute(command, args) { const cmd = context.commands[command]; if (cmd) { - return cmd(args); + try { + return await cmd(args); + } catch (error) { + log(`Error executing command ${command}:`, error); + throw error; + } } return `Command not found: ${command}`; @@ -96,9 +236,67 @@ globalThis.log = log; globalThis.print = print; globalThis.context = new RedContext(); globalThis.execute = execute; + +// Timer functions +let intervalCallbacks = {}; +let intervalIdToCallbackId = {}; +let callbackIdCounter = 0; + globalThis.setTimeout = async (callback, delay) => { core.ops.op_set_timeout(delay).then(() => callback()); }; + globalThis.clearTimeout = async (id) => { core.ops.op_clear_timeout(id); }; + +globalThis.setInterval = async (callback, delay) => { + // Generate a unique callback ID + const callbackId = `interval_cb_${callbackIdCounter++}`; + + // Store the callback + intervalCallbacks[callbackId] = callback; + + // Register for interval callbacks and get the interval ID + const intervalId = await ops.op_set_interval(delay, callbackId); + + // Map interval ID to callback ID + intervalIdToCallbackId[intervalId] = callbackId; + + return intervalId; +}; + +globalThis.clearInterval = async (id) => { + // Clear the interval + await ops.op_clear_interval(id); + + // Clean up our mappings + const callbackId = intervalIdToCallbackId[id]; + if (callbackId) { + delete intervalCallbacks[callbackId]; + delete intervalIdToCallbackId[id]; + } +}; + +// Listen for interval callbacks +globalThis.context.on("interval:callback", async (data) => { + const intervalId = data.intervalId; + + try { + // Get the callback ID from the interval ID + const callbackId = await ops.op_get_interval_callback_id(intervalId); + + // Look up and execute the callback + const callback = intervalCallbacks[callbackId]; + if (callback) { + try { + callback(); + } catch (error) { + log("Error in interval callback:", error); + } + } + } catch (error) { + // Interval might have been cleared + log("Failed to get interval callback:", error); + } +}); diff --git a/src/plugin/runtime.rs b/src/plugin/runtime.rs index 1e9304e..0730ac7 100644 --- a/src/plugin/runtime.rs +++ b/src/plugin/runtime.rs @@ -7,8 +7,7 @@ use std::{ }; use deno_core::{ - error::AnyError, extension, op2, url::Url, FastString, JsRuntime, PollEventLoopOptions, - RuntimeOptions, + error::AnyError, extension, op2, FastString, JsRuntime, PollEventLoopOptions, RuntimeOptions, }; use serde_json::{json, Value}; use tokio::sync::oneshot; @@ -21,6 +20,57 @@ use crate::{ use super::loader::TsModuleLoader; +/// Format JavaScript errors with stack traces for better debugging +fn format_js_error(error: &anyhow::Error) -> String { + let error_str = error.to_string(); + + // Check if it's a JavaScript error with a stack trace + if let Some(js_error) = error.downcast_ref::() { + let mut formatted = String::new(); + + // Add the main error message + if let Some(message) = &js_error.message { + formatted.push_str(&format!("{}\n", message)); + } + + // Add stack frames if available + if !js_error.frames.is_empty() { + formatted.push_str("\nStack trace:\n"); + for frame in &js_error.frames { + let location = + if let (Some(line), Some(column)) = (frame.line_number, frame.column_number) { + format!( + "{}:{}:{}", + frame.file_name.as_deref().unwrap_or(""), + line, + column + ) + } else { + frame + .file_name + .as_deref() + .unwrap_or("") + .to_string() + }; + + if let Some(func_name) = &frame.function_name { + formatted.push_str(&format!(" at {} ({})\n", func_name, location)); + } else { + formatted.push_str(&format!(" at {}\n", location)); + } + } + } + + // Log the full error details for debugging + log!("Plugin error details: {}", formatted); + + formatted + } else { + // For non-JS errors, just return the error string + error_str + } +} + #[derive(Debug)] enum Task { LoadModule { @@ -75,7 +125,13 @@ impl Runtime { responder.send(Ok(())).unwrap(); } Err(e) => { - responder.send(Err(e)).unwrap(); + let formatted_error = format_js_error(&e); + responder + .send(Err(anyhow::anyhow!( + "Plugin error: {}", + formatted_error + ))) + .unwrap(); } } } @@ -85,7 +141,13 @@ impl Runtime { responder.send(Ok(())).unwrap(); } Err(e) => { - responder.send(Err(e)).unwrap(); + let formatted_error = format_js_error(&e); + responder + .send(Err(anyhow::anyhow!( + "Plugin error: {}", + formatted_error + ))) + .unwrap(); } } } @@ -121,17 +183,27 @@ async fn load_main_module( name: &str, code: String, ) -> anyhow::Result<()> { - let specifier = Url::parse(name)?; - let mod_id = js_runtime - .load_main_module(&specifier, Some(code.into())) + // Use Box::leak to create a 'static lifetime for the module name + let module_name: &'static str = Box::leak(name.to_string().into_boxed_str()); + + // Load the code as an ES module using the module loader + let module_specifier = deno_core::resolve_url(module_name)?; + + // First, we need to register the module with the runtime + let module_id = js_runtime + .load_side_es_module_from_code(&module_specifier, FastString::from(code)) .await?; - let result = js_runtime.mod_evaluate(mod_id); + // Instantiate and evaluate the module + let evaluate = js_runtime.mod_evaluate(module_id); + + // Run the event loop to execute the module js_runtime .run_event_loop(PollEventLoopOptions::default()) .await?; - result.await?; + // Wait for the module evaluation to complete + evaluate.await?; Ok(()) } @@ -201,35 +273,62 @@ fn op_trigger_action( } #[op2] -fn op_log(#[serde] msg: serde_json::Value) { - match msg { - serde_json::Value::String(s) => log!("{}", s), - serde_json::Value::Array(arr) => { - let arr = arr - .iter() - .map(|m| match m { - serde_json::Value::String(s) => s.to_string(), - _ => format!("{:?}", m), - }) - .collect::>(); - log!("{}", arr.join(" ")); - } - _ => log!("{:?}", msg), +fn op_log(#[string] level: Option, #[serde] msg: serde_json::Value) { + let message = match msg { + serde_json::Value::String(s) => s, + serde_json::Value::Array(arr) => arr + .iter() + .map(|m| match m { + serde_json::Value::String(s) => s.to_string(), + _ => format!("{:?}", m), + }) + .collect::>() + .join(" "), + _ => format!("{:?}", msg), + }; + + // Map plugin log levels to our LogLevel enum + match level.as_deref() { + Some("debug") => log!("[PLUGIN:DEBUG] {}", message), + Some("warn") => log!("[PLUGIN:WARN] {}", message), + Some("error") => log!("[PLUGIN:ERROR] {}", message), + _ => log!("[PLUGIN:INFO] {}", message), } } lazy_static::lazy_static! { static ref TIMEOUTS: Mutex>> = Mutex::new(HashMap::new()); + static ref INTERVALS: Mutex> = Mutex::new(HashMap::new()); + static ref INTERVAL_CALLBACKS: Mutex> = Mutex::new(HashMap::new()); +} + +struct IntervalHandle { + handle: tokio::task::JoinHandle<()>, + cancel_sender: Option>, } #[op2(async)] #[string] async fn op_set_timeout(delay: f64) -> Result { + // Limit the number of concurrent timers per plugin runtime + const MAX_TIMERS: usize = 1000; + + let mut timeouts = TIMEOUTS.lock().unwrap(); + if timeouts.len() >= MAX_TIMERS { + return Err(anyhow::anyhow!( + "Too many timers, maximum {} allowed", + MAX_TIMERS + )); + } + let id = Uuid::new_v4().to_string(); + let id_clone = id.clone(); let handle = tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(delay as u64)).await; + // Clean up the handle from the map after completion + TIMEOUTS.lock().unwrap().remove(&id_clone); }); - TIMEOUTS.lock().unwrap().insert(id.clone(), handle); + timeouts.insert(id.clone(), handle); Ok(id) } @@ -241,6 +340,156 @@ fn op_clear_timeout(#[string] id: String) -> Result<(), AnyError> { Ok(()) } +#[op2(async)] +#[string] +async fn op_set_interval(delay: f64, #[string] callback_id: String) -> Result { + // Limit the number of concurrent timers per plugin runtime + const MAX_TIMERS: usize = 1000; + + // Check combined limit of timeouts and intervals + let timeout_count = TIMEOUTS.lock().unwrap().len(); + let interval_count = INTERVALS.lock().unwrap().len(); + if timeout_count + interval_count >= MAX_TIMERS { + return Err(anyhow::anyhow!( + "Too many timers, maximum {} allowed", + MAX_TIMERS + )); + } + + let id = Uuid::new_v4().to_string(); + let id_clone = id.clone(); + let (cancel_sender, mut cancel_receiver) = tokio::sync::oneshot::channel::<()>(); + + // Store the callback ID for this interval + INTERVAL_CALLBACKS + .lock() + .unwrap() + .insert(id.clone(), callback_id); + + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_millis(delay as u64)); + interval.tick().await; // First tick is immediate, skip it + + loop { + tokio::select! { + _ = interval.tick() => { + // Send callback request to the editor + ACTION_DISPATCHER.send_request(PluginRequest::IntervalCallback { + interval_id: id_clone.clone() + }); + } + _ = &mut cancel_receiver => { + // Interval was cancelled + break; + } + } + } + + // Clean up + INTERVAL_CALLBACKS.lock().unwrap().remove(&id_clone); + INTERVALS.lock().unwrap().remove(&id_clone); + }); + + let mut intervals = INTERVALS.lock().unwrap(); + intervals.insert( + id.clone(), + IntervalHandle { + handle, + cancel_sender: Some(cancel_sender), + }, + ); + + Ok(id) +} + +#[op2(fast)] +fn op_clear_interval(#[string] id: String) -> Result<(), AnyError> { + // Remove from callbacks map + INTERVAL_CALLBACKS.lock().unwrap().remove(&id); + + // Remove from intervals map and cancel + if let Some(mut handle) = INTERVALS.lock().unwrap().remove(&id) { + // Send cancellation signal + if let Some(sender) = handle.cancel_sender.take() { + let _ = sender.send(()); // Ignore error if receiver already dropped + } + // Abort the task + handle.handle.abort(); + } + Ok(()) +} + +#[op2] +#[string] +fn op_get_interval_callback_id(#[string] interval_id: String) -> Result { + let callbacks = INTERVAL_CALLBACKS.lock().unwrap(); + callbacks + .get(&interval_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Interval ID not found")) +} + +#[op2(fast)] +fn op_buffer_insert(x: u32, y: u32, #[string] text: String) -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::BufferInsert { + x: x as usize, + y: y as usize, + text, + }); + Ok(()) +} + +#[op2(fast)] +fn op_buffer_delete(x: u32, y: u32, length: u32) -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::BufferDelete { + x: x as usize, + y: y as usize, + length: length as usize, + }); + Ok(()) +} + +#[op2(fast)] +fn op_buffer_replace(x: u32, y: u32, length: u32, #[string] text: String) -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::BufferReplace { + x: x as usize, + y: y as usize, + length: length as usize, + text, + }); + Ok(()) +} + +#[op2(fast)] +fn op_get_cursor_position() -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::GetCursorPosition); + Ok(()) +} + +#[op2(fast)] +fn op_set_cursor_position(x: u32, y: u32) -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::SetCursorPosition { + x: x as usize, + y: y as usize, + }); + Ok(()) +} + +#[op2] +fn op_get_buffer_text(start_line: Option, end_line: Option) -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::GetBufferText { + start_line: start_line.map(|l| l as usize), + end_line: end_line.map(|l| l as usize), + }); + Ok(()) +} + +#[op2] +fn op_get_config(#[string] key: Option) -> Result<(), AnyError> { + ACTION_DISPATCHER.send_request(PluginRequest::GetConfig { key }); + Ok(()) +} + extension!( js_runtime, ops = [ @@ -250,6 +499,16 @@ extension!( op_log, op_set_timeout, op_clear_timeout, + op_set_interval, + op_clear_interval, + op_get_interval_callback_id, + op_buffer_insert, + op_buffer_delete, + op_buffer_replace, + op_get_cursor_position, + op_set_cursor_position, + op_get_buffer_text, + op_get_config, ], js = ["src/plugin/runtime.js"], ); @@ -273,6 +532,24 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn test_runtime_plugin_with_import() { + let mut runtime = Runtime::new(); + runtime + .add_module( + r#" + // Test that ES module syntax works + export function testFunction() { + return "ES modules work!"; + } + + console.log("ES module test:", testFunction()); + "#, + ) + .await + .unwrap(); + } + #[tokio::test] async fn test_runtime_error() { let mut runtime = Runtime::new(); diff --git a/src/test_utils.rs b/src/test_utils.rs index 992dd8a..4448b01 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -1,43 +1,43 @@ /// Test utilities for the Red editor /// This module provides test helpers without requiring feature flags - use crate::editor::{Action, Editor, Mode}; /// Extension trait for Editor that provides test-specific functionality +#[allow(async_fn_in_trait)] pub trait EditorTestExt { /// Get current cursor position for testing fn test_cursor_position(&self) -> (usize, usize); - + /// Get current mode for testing fn test_mode(&self) -> Mode; - + /// Execute an action for testing - uses core logic only async fn test_execute_action(&mut self, action: Action) -> anyhow::Result<()>; - + /// Get buffer contents for testing fn test_buffer_contents(&self) -> String; - + /// Get specific line contents for testing fn test_line_contents(&self, line: usize) -> Option; - + /// Get the number of lines in the current buffer fn test_line_count(&self) -> usize; - + /// Check if editor is in insert mode fn test_is_insert(&self) -> bool; - + /// Check if editor is in normal mode fn test_is_normal(&self) -> bool; - + /// Check if editor is in visual mode fn test_is_visual(&self) -> bool; - + /// Get viewport top line fn test_viewport_top(&self) -> usize; - + /// Simulate typing text in insert mode async fn test_type_text(&mut self, text: &str) -> anyhow::Result<()>; - + /// Get the current line under cursor fn test_current_line(&self) -> Option; } @@ -46,55 +46,60 @@ impl EditorTestExt for Editor { fn test_cursor_position(&self) -> (usize, usize) { (self.test_cursor_x(), self.test_buffer_line()) } - + fn test_mode(&self) -> Mode { self.test_mode() } - + async fn test_execute_action(&mut self, action: Action) -> anyhow::Result<()> { self.apply_action_core(&action)?; Ok(()) } - + fn test_buffer_contents(&self) -> String { self.test_current_buffer().contents() } - + fn test_line_contents(&self, line: usize) -> Option { self.test_current_buffer().get(line) } - + fn test_line_count(&self) -> usize { self.test_current_buffer().len() } - + fn test_is_insert(&self) -> bool { self.test_is_insert() } - + fn test_is_normal(&self) -> bool { self.test_is_normal() } - + fn test_is_visual(&self) -> bool { - matches!(self.test_mode(), Mode::Visual | Mode::VisualLine | Mode::VisualBlock) + matches!( + self.test_mode(), + Mode::Visual | Mode::VisualLine | Mode::VisualBlock + ) } - + fn test_viewport_top(&self) -> usize { self.test_vtop() } - + async fn test_type_text(&mut self, text: &str) -> anyhow::Result<()> { if !self.test_is_insert() { - self.test_execute_action(Action::EnterMode(Mode::Insert)).await?; + self.test_execute_action(Action::EnterMode(Mode::Insert)) + .await?; } for ch in text.chars() { - self.test_execute_action(Action::InsertCharAtCursorPos(ch)).await?; + self.test_execute_action(Action::InsertCharAtCursorPos(ch)) + .await?; } Ok(()) } - + fn test_current_line(&self) -> Option { self.test_current_line_contents() } -} \ No newline at end of file +} diff --git a/test-harness/README.md b/test-harness/README.md new file mode 100644 index 0000000..037964f --- /dev/null +++ b/test-harness/README.md @@ -0,0 +1,221 @@ +# Red Editor Plugin Testing Framework + +A comprehensive testing framework for Red editor plugins that provides a mock implementation of the Red API and a Jest-like test runner. + +## Features + +- **Mock Red API**: Complete mock implementation of all plugin APIs +- **Jest-like syntax**: Familiar testing patterns with `describe`, `test`, `expect` +- **Async support**: Full support for testing async operations +- **Event simulation**: Test event handlers and subscriptions +- **State management**: Control and inspect mock editor state + +## Installation + +The test harness is included with the Red editor. No additional installation required. + +## Usage + +### Writing Tests + +Create a test file for your plugin: + +```javascript +// my-plugin.test.js +describe('My Plugin', () => { + test('should register command', async (red) => { + expect(red.hasCommand('MyCommand')).toBe(true); + }); + + test('should handle buffer changes', async (red) => { + // Simulate event + red.emit('buffer:changed', { + buffer_id: 0, + buffer_name: 'test.js', + line_count: 10, + cursor: { x: 0, y: 0 } + }); + + // Check plugin reaction + expect(red.getLogs()).toContain('log: Buffer changed'); + }); +}); +``` + +### Running Tests + +```bash +node test-harness/test-runner.js + +# Example +node test-harness/test-runner.js my-plugin.js my-plugin.test.js +``` + +## Test API + +### Test Structure + +- `describe(name, fn)` - Group related tests +- `test(name, fn)` or `it(name, fn)` - Define a test +- `beforeEach(fn)` - Run before each test +- `afterEach(fn)` - Run after each test +- `beforeAll(fn)` - Run once before all tests +- `afterAll(fn)` - Run once after all tests + +### Assertions + +- `expect(value).toBe(expected)` - Strict equality check +- `expect(value).toEqual(expected)` - Deep equality check +- `expect(array).toContain(item)` - Array/string contains +- `expect(fn).toHaveBeenCalled()` - Mock function was called +- `expect(fn).toHaveBeenCalledWith(...args)` - Mock called with args + +### Mock Red API + +The mock API provides all standard plugin methods plus testing utilities: + +```javascript +// Test helpers +red.getLogs() // Get all logged messages +red.clearLogs() // Clear log history +red.hasCommand(name) // Check if command exists +red.executeCommand(name, ...args) // Execute a command +red.setMockState(state) // Override mock state +red.getMockState() // Get current mock state +red.emit(event, data) // Emit an event +``` + +### Mock Functions + +Create mock functions with Jest-like API: + +```javascript +const mockFn = jest.fn(); +const mockWithImpl = jest.fn(() => 'return value'); + +// Use in tests +mockFn('arg1', 'arg2'); +expect(mockFn).toHaveBeenCalled(); +expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); +``` + +## Examples + +### Testing Commands + +```javascript +test('should execute command successfully', async (red) => { + await red.executeCommand('MyCommand', 'arg1'); + + const logs = red.getLogs(); + expect(logs).toContain('execute: MyAction {"param":"arg1"}'); +}); +``` + +### Testing Events + +```javascript +test('should handle cursor movement', async (red) => { + // Set initial position + red.setMockState({ cursor: { x: 0, y: 0 } }); + + // Move cursor (triggers event) + red.setCursorPosition(10, 5); + + // Wait for async handlers + await new Promise(resolve => setTimeout(resolve, 10)); + + // Check results + const pos = await red.getCursorPosition(); + expect(pos).toEqual({ x: 10, y: 5 }); +}); +``` + +### Testing Async Operations + +```javascript +test('should handle async operations', async (red) => { + // Test setTimeout + let called = false; + await red.setTimeout(() => { called = true; }, 50); + + await new Promise(resolve => setTimeout(resolve, 60)); + expect(called).toBe(true); + + // Test async command + const result = await red.executeCommand('AsyncCommand'); + expect(result).toEqual({ status: 'success' }); +}); +``` + +### Testing Buffer Manipulation + +```javascript +test('should modify buffer content', async (red) => { + // Insert text + red.insertText(0, 0, 'Hello '); + + // Get buffer text + const text = await red.getBufferText(); + expect(text).toContain('Hello '); + + // Check event was emitted + expect(red.getLogs()).toContain('insertText: 0,0 "Hello "'); +}); +``` + +## Mock State Structure + +The mock maintains the following state: + +```javascript +{ + buffers: [{ + id: 0, + name: "test.js", + path: "/tmp/test.js", + language_id: "javascript" + }], + current_buffer_index: 0, + size: { rows: 24, cols: 80 }, + theme: { + name: "test-theme", + style: { fg: "#ffffff", bg: "#000000" } + }, + cursor: { x: 0, y: 0 }, + bufferContent: ["// Test file", "console.log('hello');", ""], + config: { + theme: "test-theme", + plugins: { "test-plugin": "test-plugin.js" }, + log_file: "/tmp/red.log", + mouse_scroll_lines: 3, + show_diagnostics: true, + keys: {} + } +} +``` + +## Best Practices + +1. **Test in isolation**: Each test should be independent +2. **Use descriptive names**: Test names should explain what they verify +3. **Test edge cases**: Include tests for error conditions +4. **Mock external dependencies**: Use mock functions for external calls +5. **Clean up after tests**: Use afterEach to reset state +6. **Test async code properly**: Always await async operations + +## Debugging Tests + +- Use `red.log()` in your plugin to debug execution flow +- Check `red.getLogs()` to see all operations performed +- Use `console.log()` in tests for additional debugging +- The test runner shows execution time for performance issues + +## Contributing + +To improve the testing framework: + +1. Add new mock methods to `mock-red.js` +2. Add new assertions to `test-runner.js` +3. Update this documentation +4. Add example tests demonstrating new features \ No newline at end of file diff --git a/test-harness/mock-red.js b/test-harness/mock-red.js new file mode 100644 index 0000000..b741323 --- /dev/null +++ b/test-harness/mock-red.js @@ -0,0 +1,268 @@ +/** + * Mock implementation of the Red editor API for plugin testing + */ + +class MockRedAPI { + constructor() { + this.commands = new Map(); + this.eventListeners = new Map(); + this.logs = []; + this.timeouts = new Map(); + this.nextTimeoutId = 1; + + // Mock state + this.mockState = { + buffers: [ + { + id: 0, + name: "test.js", + path: "/tmp/test.js", + language_id: "javascript" + } + ], + current_buffer_index: 0, + size: { rows: 24, cols: 80 }, + theme: { + name: "test-theme", + style: { fg: "#ffffff", bg: "#000000" } + }, + cursor: { x: 0, y: 0 }, + bufferContent: ["// Test file", "console.log('hello');", ""], + config: { + theme: "test-theme", + plugins: { "test-plugin": "test-plugin.js" }, + log_file: "/tmp/red.log", + mouse_scroll_lines: 3, + show_diagnostics: true, + keys: {} + } + }; + } + + // Command registration + addCommand(name, callback) { + this.commands.set(name, callback); + } + + // Event handling + on(event, callback) { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, []); + } + this.eventListeners.get(event).push(callback); + } + + once(event, callback) { + const wrapper = (data) => { + this.off(event, wrapper); + callback(data); + }; + this.on(event, wrapper); + } + + off(event, callback) { + const listeners = this.eventListeners.get(event) || []; + const index = listeners.indexOf(callback); + if (index !== -1) { + listeners.splice(index, 1); + } + } + + emit(event, data) { + const listeners = this.eventListeners.get(event) || []; + listeners.forEach(callback => callback(data)); + } + + // API methods + async getEditorInfo() { + return { + buffers: this.mockState.buffers, + current_buffer_index: this.mockState.current_buffer_index, + size: this.mockState.size, + theme: this.mockState.theme + }; + } + + async pick(title, values) { + // In tests, return the first value by default + // Can be overridden by test setup + return values.length > 0 ? values[0] : null; + } + + openBuffer(name) { + this.logs.push(`openBuffer: ${name}`); + const existingIndex = this.mockState.buffers.findIndex(b => b.name === name); + if (existingIndex !== -1) { + this.mockState.current_buffer_index = existingIndex; + } else { + this.mockState.buffers.push({ + id: this.mockState.buffers.length, + name: name, + path: `/tmp/${name}`, + language_id: "text" + }); + this.mockState.current_buffer_index = this.mockState.buffers.length - 1; + } + } + + drawText(x, y, text, style) { + this.logs.push(`drawText: ${x},${y} "${text}" ${JSON.stringify(style || {})}`); + } + + insertText(x, y, text) { + this.logs.push(`insertText: ${x},${y} "${text}"`); + // Update mock buffer content + const line = this.mockState.bufferContent[y] || ""; + this.mockState.bufferContent[y] = + line.slice(0, x) + text + line.slice(x); + + // Emit buffer changed event + this.emit("buffer:changed", { + buffer_id: this.mockState.current_buffer_index, + buffer_name: this.mockState.buffers[this.mockState.current_buffer_index].name, + file_path: this.mockState.buffers[this.mockState.current_buffer_index].path, + line_count: this.mockState.bufferContent.length, + cursor: { x, y } + }); + } + + deleteText(x, y, length) { + this.logs.push(`deleteText: ${x},${y} length=${length}`); + const line = this.mockState.bufferContent[y] || ""; + this.mockState.bufferContent[y] = + line.slice(0, x) + line.slice(x + length); + } + + replaceText(x, y, length, text) { + this.logs.push(`replaceText: ${x},${y} length=${length} "${text}"`); + const line = this.mockState.bufferContent[y] || ""; + this.mockState.bufferContent[y] = + line.slice(0, x) + text + line.slice(x + length); + } + + async getCursorPosition() { + return this.mockState.cursor; + } + + setCursorPosition(x, y) { + this.logs.push(`setCursorPosition: ${x},${y}`); + const oldPos = { ...this.mockState.cursor }; + this.mockState.cursor = { x, y }; + + // Emit cursor moved event + this.emit("cursor:moved", { + from: oldPos, + to: { x, y } + }); + } + + async getBufferText(startLine, endLine) { + const start = startLine || 0; + const end = endLine || this.mockState.bufferContent.length; + return this.mockState.bufferContent.slice(start, end).join("\n"); + } + + execute(command, args) { + this.logs.push(`execute: ${command} ${JSON.stringify(args || {})}`); + } + + getCommands() { + return Array.from(this.commands.keys()); + } + + async getConfig(key) { + if (key) { + return this.mockState.config[key]; + } + return this.mockState.config; + } + + log(...messages) { + this.logs.push(`log: ${messages.join(" ")}`); + } + + logDebug(...messages) { + this.logs.push(`log:debug: ${messages.join(" ")}`); + } + + logInfo(...messages) { + this.logs.push(`log:info: ${messages.join(" ")}`); + } + + logWarn(...messages) { + this.logs.push(`log:warn: ${messages.join(" ")}`); + } + + logError(...messages) { + this.logs.push(`log:error: ${messages.join(" ")}`); + } + + async setTimeout(callback, delay) { + const id = `timeout-${this.nextTimeoutId++}`; + const handle = globalThis.setTimeout(() => { + this.timeouts.delete(id); + callback(); + }, delay); + this.timeouts.set(id, handle); + return id; + } + + async clearTimeout(id) { + const handle = this.timeouts.get(id); + if (handle) { + globalThis.clearTimeout(handle); + this.timeouts.delete(id); + } + } + + async setInterval(callback, delay) { + const id = `interval-${this.nextTimeoutId++}`; + const handle = globalThis.setInterval(() => { + callback(); + }, delay); + this.timeouts.set(id, handle); + return id; + } + + async clearInterval(id) { + const handle = this.timeouts.get(id); + if (handle) { + globalThis.clearInterval(handle); + this.timeouts.delete(id); + } + } + + // Test helper methods + getLogs() { + return this.logs; + } + + clearLogs() { + this.logs = []; + } + + hasCommand(name) { + return this.commands.has(name); + } + + async executeCommand(name, ...args) { + const command = this.commands.get(name); + if (command) { + return await command(...args); + } + throw new Error(`Command not found: ${name}`); + } + + setMockState(state) { + this.mockState = { ...this.mockState, ...state }; + } + + getMockState() { + return this.mockState; + } +} + +// Export for use in tests +if (typeof module !== 'undefined' && module.exports) { + module.exports = { MockRedAPI }; +} \ No newline at end of file diff --git a/test-harness/test-runner.js b/test-harness/test-runner.js new file mode 100755 index 0000000..e2a07bf --- /dev/null +++ b/test-harness/test-runner.js @@ -0,0 +1,276 @@ +#!/usr/bin/env node + +/** + * Test runner for Red editor plugins + * + * Usage: node test-runner.js + */ + +const { MockRedAPI } = require('./mock-red.js'); +const fs = require('fs'); +const path = require('path'); +const { performance } = require('perf_hooks'); + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + gray: '\x1b[90m' +}; + +// Test context +class TestContext { + constructor(name) { + this.name = name; + this.tests = []; + this.beforeEach = null; + this.afterEach = null; + this.beforeAll = null; + this.afterAll = null; + } + + test(name, fn) { + this.tests.push({ name, fn, status: 'pending' }); + } + + it(name, fn) { + this.test(name, fn); + } +} + +// Global test registry +const testSuites = []; +let currentSuite = null; + +// Test DSL +global.describe = function(name, fn) { + const suite = new TestContext(name); + const previousSuite = currentSuite; + currentSuite = suite; + testSuites.push(suite); + fn(); + currentSuite = previousSuite; +}; + +global.test = function(name, fn) { + if (!currentSuite) { + // Create a default suite + currentSuite = new TestContext('Default'); + testSuites.push(currentSuite); + } + currentSuite.test(name, fn); +}; + +global.it = global.test; + +global.beforeEach = function(fn) { + if (currentSuite) currentSuite.beforeEach = fn; +}; + +global.afterEach = function(fn) { + if (currentSuite) currentSuite.afterEach = fn; +}; + +global.beforeAll = function(fn) { + if (currentSuite) currentSuite.beforeAll = fn; +}; + +global.afterAll = function(fn) { + if (currentSuite) currentSuite.afterAll = fn; +}; + +// Assertion library +global.expect = function(actual) { + return { + toBe(expected) { + if (actual !== expected) { + throw new Error(`Expected ${JSON.stringify(actual)} to be ${JSON.stringify(expected)}`); + } + }, + toEqual(expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Expected ${JSON.stringify(actual)} to equal ${JSON.stringify(expected)}`); + } + }, + toContain(item) { + if (Array.isArray(actual)) { + if (!actual.includes(item)) { + throw new Error(`Expected array to contain ${JSON.stringify(item)}`); + } + } else if (typeof actual === 'string') { + if (!actual.includes(item)) { + throw new Error(`Expected string to contain "${item}"`); + } + } else { + throw new Error(`toContain can only be used with arrays or strings`); + } + }, + toHaveBeenCalled() { + if (!actual || !actual._isMock) { + throw new Error(`Expected a mock function`); + } + if (actual._calls.length === 0) { + throw new Error(`Expected function to have been called`); + } + }, + toHaveBeenCalledWith(...args) { + if (!actual || !actual._isMock) { + throw new Error(`Expected a mock function`); + } + const found = actual._calls.some(call => + JSON.stringify(call) === JSON.stringify(args) + ); + if (!found) { + throw new Error(`Expected function to have been called with ${JSON.stringify(args)}`); + } + }, + toThrow(message) { + let threw = false; + let error = null; + try { + if (typeof actual === 'function') { + actual(); + } + } catch (e) { + threw = true; + error = e; + } + if (!threw) { + throw new Error(`Expected function to throw`); + } + if (message && !error.message.includes(message)) { + throw new Error(`Expected error message to contain "${message}" but got "${error.message}"`); + } + } + }; +}; + +// Mock function creator +global.jest = { + fn(implementation) { + const mockFn = (...args) => { + mockFn._calls.push(args); + if (mockFn._implementation) { + return mockFn._implementation(...args); + } + }; + mockFn._isMock = true; + mockFn._calls = []; + mockFn._implementation = implementation; + mockFn.mockImplementation = (fn) => { + mockFn._implementation = fn; + return mockFn; + }; + mockFn.mockClear = () => { + mockFn._calls = []; + }; + return mockFn; + } +}; + +// Run tests +async function runTests(pluginPath, testPath) { + console.log(`${colors.blue}Red Editor Plugin Test Runner${colors.reset}\n`); + + // Load plugin + const plugin = require(path.resolve(pluginPath)); + + // Load test file + require(path.resolve(testPath)); + + let totalTests = 0; + let passedTests = 0; + let failedTests = 0; + + // Run all test suites + for (const suite of testSuites) { + console.log(`\n${colors.blue}${suite.name}${colors.reset}`); + + // Setup mock Red API for this suite + const red = new MockRedAPI(); + + // Activate plugin + if (plugin.activate) { + await plugin.activate(red); + } + + // Run beforeAll + if (suite.beforeAll) { + await suite.beforeAll(); + } + + // Run tests + for (const test of suite.tests) { + totalTests++; + + // Reset mock state + red.clearLogs(); + + // Run beforeEach + if (suite.beforeEach) { + await suite.beforeEach(); + } + + // Run test + const start = performance.now(); + try { + await test.fn(red); + const duration = performance.now() - start; + console.log(` ${colors.green}✓${colors.reset} ${test.name} ${colors.gray}(${duration.toFixed(0)}ms)${colors.reset}`); + passedTests++; + } catch (error) { + const duration = performance.now() - start; + console.log(` ${colors.red}✗${colors.reset} ${test.name} ${colors.gray}(${duration.toFixed(0)}ms)${colors.reset}`); + console.log(` ${colors.red}${error.message}${colors.reset}`); + if (error.stack) { + const stackLines = error.stack.split('\n').slice(1, 3); + stackLines.forEach(line => console.log(` ${colors.gray}${line.trim()}${colors.reset}`)); + } + failedTests++; + } + + // Run afterEach + if (suite.afterEach) { + await suite.afterEach(); + } + } + + // Run afterAll + if (suite.afterAll) { + await suite.afterAll(); + } + + // Deactivate plugin + if (plugin.deactivate) { + await plugin.deactivate(red); + } + } + + // Summary + console.log(`\n${colors.blue}Summary:${colors.reset}`); + console.log(` Total: ${totalTests}`); + console.log(` ${colors.green}Passed: ${passedTests}${colors.reset}`); + if (failedTests > 0) { + console.log(` ${colors.red}Failed: ${failedTests}${colors.reset}`); + } + + // Exit code + process.exit(failedTests > 0 ? 1 : 0); +} + +// Main +if (require.main === module) { + const args = process.argv.slice(2); + if (args.length < 2) { + console.error('Usage: node test-runner.js '); + process.exit(1); + } + + runTests(args[0], args[1]).catch(error => { + console.error(`${colors.red}Test runner error:${colors.reset}`, error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/tests/common/editor_harness.rs b/tests/common/editor_harness.rs index c90ff12..548e566 100644 --- a/tests/common/editor_harness.rs +++ b/tests/common/editor_harness.rs @@ -5,14 +5,14 @@ use red::{ config::Config, editor::{Action, Editor, Mode}, lsp::LspClient, - theme::Theme, test_utils::EditorTestExt, + theme::Theme, }; use super::mock_lsp::MockLsp; /// Test harness for editor integration tests -/// +/// /// This provides a wrapper around the Editor that exposes test-friendly methods /// for inspecting state and simulating user actions. pub struct EditorHarness { @@ -36,11 +36,8 @@ impl EditorHarness { let lsp = Box::new(MockLsp) as Box; let config = Config::default(); let theme = Theme::default(); - let mut editor = Editor::new(lsp, config, theme, vec![buffer]).unwrap(); - - // Set a default terminal size for tests - editor.test_set_size(80, 24); - + let editor = Editor::with_size(lsp, 80, 24, config, theme, vec![buffer]).unwrap(); + Self { editor } } @@ -48,11 +45,8 @@ impl EditorHarness { pub fn with_config(buffer: Buffer, config: Config) -> Self { let lsp = Box::new(MockLsp) as Box; let theme = Theme::default(); - let mut editor = Editor::new(lsp, config, theme, vec![buffer]).unwrap(); - - // Set a default terminal size for tests - editor.test_set_size(80, 24); - + let editor = Editor::with_size(lsp, 80, 24, config, theme, vec![buffer]).unwrap(); + Self { editor } } @@ -116,18 +110,36 @@ impl EditorHarness { /// Assert cursor is at expected position pub fn assert_cursor_at(&self, x: usize, y: usize) { let (cx, cy) = self.cursor_position(); - assert_eq!((cx, cy), (x, y), "Expected cursor at ({}, {}), but was at ({}, {})", x, y, cx, cy); + assert_eq!( + (cx, cy), + (x, y), + "Expected cursor at ({}, {}), but was at ({}, {})", + x, + y, + cx, + cy + ); } /// Assert editor is in expected mode pub fn assert_mode(&self, mode: Mode) { - assert_eq!(self.mode(), mode, "Expected mode {:?}, but was {:?}", mode, self.mode()); + assert_eq!( + self.mode(), + mode, + "Expected mode {:?}, but was {:?}", + mode, + self.mode() + ); } /// Assert buffer has expected contents pub fn assert_buffer_contents(&self, expected: &str) { let actual = self.buffer_contents(); - assert_eq!(actual, expected, "Buffer contents mismatch\nExpected:\n{}\nActual:\n{}", expected, actual); + assert_eq!( + actual, expected, + "Buffer contents mismatch\nExpected:\n{}\nActual:\n{}", + expected, actual + ); } /// Assert line has expected contents @@ -178,7 +190,7 @@ impl EditorTestBuilder { pub fn build(self) -> EditorHarness { let file_path = self.file_path.map(|p| p.to_string_lossy().into_owned()); let buffer = Buffer::new(file_path, self.content); - + if let Some(config) = self.config { EditorHarness::with_config(buffer, config) } else { @@ -223,11 +235,17 @@ mod tests { async fn test_mode_transition() { let mut harness = EditorHarness::new(); harness.assert_mode(Mode::Normal); - - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); harness.assert_mode(Mode::Normal); } -} \ No newline at end of file +} diff --git a/tests/common/mock_lsp.rs b/tests/common/mock_lsp.rs index 131ae97..7f3925b 100644 --- a/tests/common/mock_lsp.rs +++ b/tests/common/mock_lsp.rs @@ -149,4 +149,4 @@ impl LspClient for MockLsp { async fn shutdown(&mut self) -> Result<(), LspError> { Ok(()) } -} \ No newline at end of file +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index cf3215d..a1d67fe 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,5 +1,7 @@ +#![allow(dead_code, unused_imports)] + pub mod editor_harness; pub mod mock_lsp; pub use editor_harness::EditorHarness; -pub use mock_lsp::MockLsp; \ No newline at end of file +pub use mock_lsp::MockLsp; diff --git a/tests/editing.rs b/tests/editing.rs index 4c8563c..704c57a 100644 --- a/tests/editing.rs +++ b/tests/editing.rs @@ -6,135 +6,180 @@ use red::editor::{Action, Mode}; #[tokio::test] async fn test_insert_mode() { let mut harness = EditorHarness::with_content("Hello World"); - + // Debug: Check initial cursor position and buffer state println!("Initial cursor position: {:?}", harness.cursor_position()); println!("Number of lines: {}", harness.line_count()); if let Some(line) = harness.line_contents(0) { println!("Line 0 content: {:?}", line); } - + // Enter insert mode with 'i' - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - + // Debug: Check cursor position after entering insert mode - println!("Cursor position after entering insert mode: {:?}", harness.cursor_position()); - + println!( + "Cursor position after entering insert mode: {:?}", + harness.cursor_position() + ); + // Type some text harness.type_text("Hi ").await.unwrap(); - + // Debug: Check actual buffer contents let contents = harness.buffer_contents(); println!("Actual buffer contents: {:?}", contents); println!("Buffer length: {}", contents.len()); println!("Ends with newline: {}", contents.ends_with('\n')); - + harness.assert_buffer_contents("Hi Hello World"); - + // Exit insert mode (ESC) - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); harness.assert_mode(Mode::Normal); } #[tokio::test] async fn test_append_mode() { let mut harness = EditorHarness::with_content("Hello World"); - + // Move cursor to 'o' in 'Hello' (position 4) for _ in 0..4 { harness.execute_action(Action::MoveRight).await.unwrap(); } - + // Enter append mode with 'a' - should insert after current character harness.execute_action(Action::MoveRight).await.unwrap(); - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - + // Type text harness.type_text(" there").await.unwrap(); harness.assert_buffer_contents("Hello there World"); - + // Exit insert mode - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); } #[tokio::test] async fn test_open_line_below() { let mut harness = EditorHarness::with_content("Line 1\nLine 2"); - + // Open line below with 'o' - InsertLineBelowCursor - harness.execute_action(Action::InsertLineBelowCursor).await.unwrap(); + harness + .execute_action(Action::InsertLineBelowCursor) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - + // Should have created a new line and moved cursor there harness.assert_cursor_at(0, 1); - + // Type on the new line harness.type_text("New line").await.unwrap(); harness.assert_buffer_contents("Line 1\nNew line\nLine 2"); - + // Exit insert mode - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); } #[tokio::test] async fn test_open_line_above() { let mut harness = EditorHarness::with_content("Line 1\nLine 2"); - + // Move to second line harness.execute_action(Action::MoveDown).await.unwrap(); - println!("After MoveDown - cursor at: {:?}", harness.cursor_position()); - + println!( + "After MoveDown - cursor at: {:?}", + harness.cursor_position() + ); + // Open line above with 'O' - InsertLineAtCursor - harness.execute_action(Action::InsertLineAtCursor).await.unwrap(); - println!("After InsertLineAtCursor - cursor at: {:?}", harness.cursor_position()); + harness + .execute_action(Action::InsertLineAtCursor) + .await + .unwrap(); + println!( + "After InsertLineAtCursor - cursor at: {:?}", + harness.cursor_position() + ); println!("Buffer contents: {:?}", harness.buffer_contents()); harness.assert_mode(Mode::Insert); - + // Should have created a new line above and moved cursor there harness.assert_cursor_at(0, 1); - + // Type on the new line harness.type_text("Middle line").await.unwrap(); harness.assert_buffer_contents("Line 1\nMiddle line\nLine 2"); - + // Exit insert mode - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); } #[tokio::test] async fn test_delete_char() { let mut harness = EditorHarness::with_content("Hello World"); - + // Delete character under cursor with 'x' - harness.execute_action(Action::DeleteCharAtCursorPos).await.unwrap(); + harness + .execute_action(Action::DeleteCharAtCursorPos) + .await + .unwrap(); harness.assert_buffer_contents("ello World"); - + // Move to space and delete - harness.execute_action(Action::MoveToNextWord).await.unwrap(); + harness + .execute_action(Action::MoveToNextWord) + .await + .unwrap(); harness.execute_action(Action::MoveLeft).await.unwrap(); - harness.execute_action(Action::DeleteCharAtCursorPos).await.unwrap(); + harness + .execute_action(Action::DeleteCharAtCursorPos) + .await + .unwrap(); harness.assert_buffer_contents("elloWorld"); } #[tokio::test] async fn test_delete_line() { let mut harness = EditorHarness::with_content("Line 1\nLine 2\nLine 3"); - + // Move to second line harness.execute_action(Action::MoveDown).await.unwrap(); - + // Delete line with 'dd' println!("Before delete: {:?}", harness.buffer_contents()); println!("Cursor at: {:?}", harness.cursor_position()); println!("Line under cursor: {:?}", harness.current_line()); - harness.execute_action(Action::DeleteCurrentLine).await.unwrap(); + harness + .execute_action(Action::DeleteCurrentLine) + .await + .unwrap(); println!("After delete: {:?}", harness.buffer_contents()); println!("Cursor at after: {:?}", harness.cursor_position()); println!("Line under cursor after: {:?}", harness.current_line()); harness.assert_buffer_contents("Line 1\nLine 3"); - + // Cursor should be on what was line 3 harness.assert_cursor_at(0, 1); } @@ -142,19 +187,25 @@ async fn test_delete_line() { #[tokio::test] async fn test_delete_to_end_of_line() { let mut harness = EditorHarness::with_content("Hello World Test"); - + // Move to middle of line - harness.execute_action(Action::MoveToNextWord).await.unwrap(); - + harness + .execute_action(Action::MoveToNextWord) + .await + .unwrap(); + // Delete to end of line with 'D' - not a direct action, so delete from cursor to end // This would typically be a composed action in vim let (x, _) = harness.cursor_position(); let line_content = harness.current_line().unwrap(); let line_len = line_content.trim_end().len(); // Don't include newline - + // Delete all characters from cursor to end of line for _ in x..line_len { - harness.execute_action(Action::DeleteCharAtCursorPos).await.unwrap(); + harness + .execute_action(Action::DeleteCharAtCursorPos) + .await + .unwrap(); } harness.assert_buffer_contents("Hello "); } @@ -162,51 +213,75 @@ async fn test_delete_to_end_of_line() { #[tokio::test] async fn test_change_word() { let mut harness = EditorHarness::with_content("Hello World Test"); - + // Change word with 'cw' - delete word then enter insert mode harness.execute_action(Action::DeleteWord).await.unwrap(); - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - + // Type replacement harness.type_text("Hi ").await.unwrap(); harness.assert_buffer_contents("Hi World Test"); - + // Exit insert mode - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); } #[tokio::test] async fn test_change_line() { let mut harness = EditorHarness::with_content("Line 1\nLine 2\nLine 3"); - + // Move to second line harness.execute_action(Action::MoveDown).await.unwrap(); - + // Change line with 'cc' - delete line content and enter insert mode - harness.execute_action(Action::MoveToLineStart).await.unwrap(); + harness + .execute_action(Action::MoveToLineStart) + .await + .unwrap(); let line_len = harness.current_line().unwrap().trim_end().len(); for _ in 0..line_len { - harness.execute_action(Action::DeleteCharAtCursorPos).await.unwrap(); + harness + .execute_action(Action::DeleteCharAtCursorPos) + .await + .unwrap(); } - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - + // Type replacement harness.type_text("Changed line").await.unwrap(); harness.assert_buffer_contents("Line 1\nChanged line\nLine 3"); - + // Exit insert mode - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); } #[tokio::test] async fn test_replace_char() { let mut harness = EditorHarness::with_content("Hello World"); - + // Replace character with 'r' - delete char and insert new one - harness.execute_action(Action::DeleteCharAtCursorPos).await.unwrap(); - harness.execute_action(Action::InsertCharAtCursorPos('J')).await.unwrap(); + harness + .execute_action(Action::DeleteCharAtCursorPos) + .await + .unwrap(); + harness + .execute_action(Action::InsertCharAtCursorPos('J')) + .await + .unwrap(); harness.assert_buffer_contents("Jello World"); harness.assert_mode(Mode::Normal); // Should stay in normal mode } @@ -214,49 +289,67 @@ async fn test_replace_char() { #[tokio::test] async fn test_insert_at_line_start() { let mut harness = EditorHarness::with_content(" Hello World"); - + // Move cursor to middle - harness.execute_action(Action::MoveToNextWord).await.unwrap(); - + harness + .execute_action(Action::MoveToNextWord) + .await + .unwrap(); + // Insert at start of line with 'I' - move to start and enter insert - harness.execute_action(Action::MoveToLineStart).await.unwrap(); - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::MoveToLineStart) + .await + .unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); harness.assert_cursor_at(0, 0); - + // Type text harness.type_text("Start: ").await.unwrap(); harness.assert_buffer_contents("Start: Hello World"); - + // Exit insert mode - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); } #[tokio::test] async fn test_append_at_line_end() { let mut harness = EditorHarness::with_content("Hello World"); - + // Append at end of line with 'A' - move to end and enter insert harness.execute_action(Action::MoveToLineEnd).await.unwrap(); - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - + // Type text harness.type_text(" Test").await.unwrap(); harness.assert_buffer_contents("Hello World Test"); - + // Exit insert mode - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); } #[tokio::test] async fn test_delete_word() { let mut harness = EditorHarness::with_content("Hello World Test"); - + // Delete word with 'dw' harness.execute_action(Action::DeleteWord).await.unwrap(); harness.assert_buffer_contents("World Test"); - + // Delete another word (including space) harness.execute_action(Action::DeleteWord).await.unwrap(); harness.assert_buffer_contents("Test"); @@ -265,7 +358,7 @@ async fn test_delete_word() { #[tokio::test] async fn test_join_lines() { let _harness = EditorHarness::with_content("Line 1\nLine 2\nLine 3"); - + // Join lines is typically a complex operation - skip for now // Would need to delete newline and add space } @@ -273,15 +366,18 @@ async fn test_join_lines() { #[tokio::test] async fn test_undo_redo() { let mut harness = EditorHarness::with_content("Hello World"); - + // Make a change - harness.execute_action(Action::DeleteCharAtCursorPos).await.unwrap(); + harness + .execute_action(Action::DeleteCharAtCursorPos) + .await + .unwrap(); harness.assert_buffer_contents("ello World"); - + // Undo with 'u' harness.execute_action(Action::Undo).await.unwrap(); harness.assert_buffer_contents("Hello World"); - + // Redo is not implemented as a separate action // Skip redo test } @@ -289,11 +385,11 @@ async fn test_undo_redo() { #[tokio::test] async fn test_paste() { let mut harness = EditorHarness::with_content("Hello World"); - + // Delete a word (should be yanked to clipboard) harness.execute_action(Action::DeleteWord).await.unwrap(); harness.assert_buffer_contents("World"); - + // Move to end and paste with 'p' harness.execute_action(Action::MoveToLineEnd).await.unwrap(); harness.execute_action(Action::Paste).await.unwrap(); @@ -304,10 +400,10 @@ async fn test_paste() { #[tokio::test] async fn test_yank_and_paste() { let mut harness = EditorHarness::with_content("Line 1\nLine 2\nLine 3"); - + // Yank action exists harness.execute_action(Action::Yank).await.unwrap(); - + // Move down and paste harness.execute_action(Action::MoveDown).await.unwrap(); harness.execute_action(Action::Paste).await.unwrap(); @@ -317,15 +413,24 @@ async fn test_yank_and_paste() { #[tokio::test] async fn test_editing_empty_buffer() { let mut harness = EditorHarness::new(); - + // Enter insert mode in empty buffer - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.type_text("First line").await.unwrap(); harness.assert_buffer_contents("First line\n"); - + // Exit and create new line below - harness.execute_action(Action::EnterMode(Mode::Normal)).await.unwrap(); - harness.execute_action(Action::InsertLineBelowCursor).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Normal)) + .await + .unwrap(); + harness + .execute_action(Action::InsertLineBelowCursor) + .await + .unwrap(); harness.type_text("Second line").await.unwrap(); harness.assert_buffer_contents("First line\nSecond line\n"); } @@ -333,14 +438,20 @@ async fn test_editing_empty_buffer() { #[tokio::test] async fn test_delete_at_end_of_file() { let mut harness = EditorHarness::with_content("Line 1\nLine 2"); - + // Move to last line harness.execute_action(Action::MoveToBottom).await.unwrap(); - println!("After MoveToBottom: cursor at {:?}", harness.cursor_position()); + println!( + "After MoveToBottom: cursor at {:?}", + harness.cursor_position() + ); println!("Current line: {:?}", harness.current_line()); - + // Try to delete line at end of file - harness.execute_action(Action::DeleteCurrentLine).await.unwrap(); + harness + .execute_action(Action::DeleteCurrentLine) + .await + .unwrap(); println!("After delete: {:?}", harness.buffer_contents()); harness.assert_buffer_contents("Line 1\n"); } @@ -348,20 +459,29 @@ async fn test_delete_at_end_of_file() { #[tokio::test] async fn test_change_to_end_of_line() { let mut harness = EditorHarness::with_content("Hello World Test"); - + // Move to middle - harness.execute_action(Action::MoveToNextWord).await.unwrap(); - + harness + .execute_action(Action::MoveToNextWord) + .await + .unwrap(); + // Change to end of line with 'C' - delete to end and enter insert let (x, _) = harness.cursor_position(); let line_len = harness.current_line().unwrap().trim_end().len(); for _ in x..line_len { - harness.execute_action(Action::DeleteCharAtCursorPos).await.unwrap(); + harness + .execute_action(Action::DeleteCharAtCursorPos) + .await + .unwrap(); } - harness.execute_action(Action::EnterMode(Mode::Insert)).await.unwrap(); + harness + .execute_action(Action::EnterMode(Mode::Insert)) + .await + .unwrap(); harness.assert_mode(Mode::Insert); - + // Type replacement harness.type_text("Universe").await.unwrap(); harness.assert_buffer_contents("Hello Universe"); -} \ No newline at end of file +} diff --git a/tests/movement.rs b/tests/movement.rs index 024348f..b94c71f 100644 --- a/tests/movement.rs +++ b/tests/movement.rs @@ -6,22 +6,22 @@ use red::editor::Action; #[tokio::test] async fn test_basic_cursor_movement() { let mut harness = EditorHarness::with_content("Hello, World!\nThis is a test\nThird line"); - + // Initial position harness.assert_cursor_at(0, 0); - + // Move right (l) harness.execute_action(Action::MoveRight).await.unwrap(); harness.assert_cursor_at(1, 0); - + // Move down (j) harness.execute_action(Action::MoveDown).await.unwrap(); harness.assert_cursor_at(1, 1); - + // Move left (h) harness.execute_action(Action::MoveLeft).await.unwrap(); harness.assert_cursor_at(0, 1); - + // Move up (k) harness.execute_action(Action::MoveUp).await.unwrap(); harness.assert_cursor_at(0, 0); @@ -30,43 +30,55 @@ async fn test_basic_cursor_movement() { #[tokio::test] async fn test_line_movement() { let mut harness = EditorHarness::with_content("Hello, World!"); - + // Move to end of line ($) harness.execute_action(Action::MoveToLineEnd).await.unwrap(); harness.assert_cursor_at(13, 0); // "Hello, World!" is 13 chars, cursor after last char - + // Move to start of line (0) - harness.execute_action(Action::MoveToLineStart).await.unwrap(); + harness + .execute_action(Action::MoveToLineStart) + .await + .unwrap(); harness.assert_cursor_at(0, 0); } #[tokio::test] async fn test_word_movement() { let mut harness = EditorHarness::with_content("Hello world this is test"); - + // Move to next word (w) - harness.execute_action(Action::MoveToNextWord).await.unwrap(); + harness + .execute_action(Action::MoveToNextWord) + .await + .unwrap(); harness.assert_cursor_at(6, 0); // Should be at 'w' of 'world' - + // Move to next word again - harness.execute_action(Action::MoveToNextWord).await.unwrap(); + harness + .execute_action(Action::MoveToNextWord) + .await + .unwrap(); harness.assert_cursor_at(12, 0); // Should be at 't' of 'this' - + // Move to previous word (b) - harness.execute_action(Action::MoveToPreviousWord).await.unwrap(); + harness + .execute_action(Action::MoveToPreviousWord) + .await + .unwrap(); harness.assert_cursor_at(6, 0); // Back at 'w' of 'world' } #[tokio::test] async fn test_file_movement() { let mut harness = EditorHarness::with_content("Line 1\nLine 2\nLine 3\nLine 4\nLine 5"); - + // Move to bottom of file (G) // buffer.len() returns len_lines() - 1, which is 4 for 5 lines // Last line index = buffer.len() = 4 harness.execute_action(Action::MoveToBottom).await.unwrap(); harness.assert_cursor_at(0, 4); // Last line is at index 4 - + // Move to top of file (gg) harness.execute_action(Action::MoveToTop).await.unwrap(); harness.assert_cursor_at(0, 0); // First line @@ -75,27 +87,27 @@ async fn test_file_movement() { #[tokio::test] async fn test_movement_boundaries() { let mut harness = EditorHarness::with_content("abc\ndef"); - + // Try to move left at start of buffer harness.assert_cursor_at(0, 0); harness.execute_action(Action::MoveLeft).await.unwrap(); harness.assert_cursor_at(0, 0); // Should stay at (0, 0) - + // Try to move up at start of buffer harness.execute_action(Action::MoveUp).await.unwrap(); harness.assert_cursor_at(0, 0); // Should stay at (0, 0) - + // Move to end of file harness.execute_action(Action::MoveToBottom).await.unwrap(); harness.execute_action(Action::MoveToLineEnd).await.unwrap(); // MoveToBottom goes to line 1 (last line) for "abc\ndef" // MoveToLineEnd on "def" puts us at position 3 harness.assert_cursor_at(3, 1); // After 'f' in "def" - + // Try to move right at end of line harness.execute_action(Action::MoveRight).await.unwrap(); harness.assert_cursor_at(3, 1); // Should stay at position 3 - + // Try to move down at end of buffer (already at last line) harness.execute_action(Action::MoveDown).await.unwrap(); harness.assert_cursor_at(3, 1); // Should stay at line 1 @@ -104,14 +116,20 @@ async fn test_movement_boundaries() { #[tokio::test] async fn test_first_last_line_char_movement() { let mut harness = EditorHarness::with_content(" Hello, World! "); - + // Move to first non-whitespace character (^) - harness.execute_action(Action::MoveToFirstLineChar).await.unwrap(); + harness + .execute_action(Action::MoveToFirstLineChar) + .await + .unwrap(); harness.assert_cursor_at(4, 0); // Should be at 'H' - + // Move to end, then to last non-whitespace character (g_) harness.execute_action(Action::MoveToLineEnd).await.unwrap(); - harness.execute_action(Action::MoveToLastLineChar).await.unwrap(); + harness + .execute_action(Action::MoveToLastLineChar) + .await + .unwrap(); // " Hello, World! " - last non-whitespace is at position 16 (!) harness.assert_cursor_at(16, 0); // Should be at '!' (excluding trailing spaces) } @@ -119,19 +137,22 @@ async fn test_first_last_line_char_movement() { #[tokio::test] async fn test_page_movement() { // Create content with many lines - let content = (0..50).map(|i| format!("Line {}", i)).collect::>().join("\n"); + let content = (0..50) + .map(|i| format!("Line {}", i)) + .collect::>() + .join("\n"); let mut harness = EditorHarness::with_content(&content); - + // Page down harness.execute_action(Action::PageDown).await.unwrap(); // Exact position depends on viewport size, but cursor should have moved down let (_, y1) = harness.cursor_position(); - + // Page down again harness.execute_action(Action::PageDown).await.unwrap(); let (_, y2) = harness.cursor_position(); assert!(y2 > y1, "Cursor should move down on PageDown"); - + // Page up harness.execute_action(Action::PageUp).await.unwrap(); let (_, y3) = harness.cursor_position(); @@ -141,16 +162,16 @@ async fn test_page_movement() { #[tokio::test] async fn test_goto_line() { let mut harness = EditorHarness::with_content("Line 1\nLine 2\nLine 3\nLine 4\nLine 5"); - + // GoToLine appears to be 1-based like vim // Go to line 3 harness.execute_action(Action::GoToLine(3)).await.unwrap(); harness.assert_cursor_at(0, 2); - + // Go to line 5 harness.execute_action(Action::GoToLine(5)).await.unwrap(); harness.assert_cursor_at(0, 4); - + // Go to line 1 harness.execute_action(Action::GoToLine(1)).await.unwrap(); harness.assert_cursor_at(0, 0); @@ -159,27 +180,30 @@ async fn test_goto_line() { #[tokio::test] async fn test_movement_preserves_mode() { let mut harness = EditorHarness::with_content("Hello\nWorld"); - + // Verify we start in normal mode harness.assert_mode(red::editor::Mode::Normal); - + // Move around harness.execute_action(Action::MoveRight).await.unwrap(); harness.execute_action(Action::MoveDown).await.unwrap(); - + // Should still be in normal mode harness.assert_mode(red::editor::Mode::Normal); } #[tokio::test] async fn test_scroll_movement() { - let content = (0..30).map(|i| format!("Line {}", i)).collect::>().join("\n"); + let content = (0..30) + .map(|i| format!("Line {}", i)) + .collect::>() + .join("\n"); let mut harness = EditorHarness::with_content(&content); - + // Scroll down harness.execute_action(Action::ScrollDown).await.unwrap(); // Viewport should have scrolled, but exact behavior depends on implementation - + // Scroll up harness.execute_action(Action::ScrollUp).await.unwrap(); // Viewport should have scrolled back @@ -188,13 +212,13 @@ async fn test_scroll_movement() { #[tokio::test] async fn test_move_to_specific_position() { let mut harness = EditorHarness::with_content("Hello\nWorld\nTest"); - + // MoveTo(x, y) where y is 1-based line number (like vim) // Move to position (3, 1) - line 1 (0-indexed = 0), column 3 harness.execute_action(Action::MoveTo(3, 1)).await.unwrap(); harness.assert_cursor_at(3, 0); // At 'l' in "Hello" (line 0) - - // Move to position (0, 3) - line 3 (0-indexed = 2), column 0 + + // Move to position (0, 3) - line 3 (0-indexed = 2), column 0 harness.execute_action(Action::MoveTo(0, 3)).await.unwrap(); harness.assert_cursor_at(0, 2); // At 'T' in "Test" (line 2) -} \ No newline at end of file +} diff --git a/types/README.md b/types/README.md new file mode 100644 index 0000000..4ecf207 --- /dev/null +++ b/types/README.md @@ -0,0 +1,59 @@ +# Red Editor TypeScript Types + +TypeScript type definitions for developing plugins for the Red editor. + +## Installation + +```bash +npm install --save-dev @red-editor/types +``` + +or + +```bash +yarn add -D @red-editor/types +``` + +## Usage + +In your plugin's TypeScript file: + +```typescript +/// + +export async function activate(red: Red.RedAPI) { + // Your plugin code with full type safety + red.addCommand("MyCommand", async () => { + const info = await red.getEditorInfo(); + red.log(`Current buffer: ${info.buffers[info.current_buffer_index].name}`); + }); +} +``` + +Or with ES modules: + +```typescript +import type { RedAPI } from '@red-editor/types'; + +export async function activate(red: RedAPI) { + // Your plugin code +} +``` + +## API Documentation + +See the [Plugin System Documentation](../docs/PLUGIN_SYSTEM.md) for detailed API usage. + +## Type Coverage + +The type definitions include: + +- All Red API methods +- Event types with proper typing for event data +- Configuration structure +- Buffer and editor information interfaces +- Style and UI component types + +## Contributing + +If you find any issues with the type definitions or want to add missing types, please submit a pull request to the main Red editor repository. \ No newline at end of file diff --git a/types/package.json b/types/package.json new file mode 100644 index 0000000..6787fb7 --- /dev/null +++ b/types/package.json @@ -0,0 +1,27 @@ +{ + "name": "@red-editor/types", + "version": "0.1.0", + "description": "TypeScript type definitions for Red editor plugin development", + "main": "red.d.ts", + "types": "red.d.ts", + "files": [ + "red.d.ts" + ], + "keywords": [ + "red", + "editor", + "plugin", + "types", + "typescript" + ], + "author": "Red Editor Contributors", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/red-editor/red.git", + "directory": "types" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/types/red.d.ts b/types/red.d.ts new file mode 100644 index 0000000..34fc8db --- /dev/null +++ b/types/red.d.ts @@ -0,0 +1,373 @@ +/** + * Red Editor Plugin API Type Definitions + * + * This file provides TypeScript type definitions for the Red editor plugin API. + * Plugins can reference this file to get full type safety and IntelliSense support. + */ + +declare namespace Red { + /** + * Style configuration for text rendering + */ + interface Style { + /** Foreground color */ + fg?: string; + /** Background color */ + bg?: string; + /** Text modifiers */ + modifiers?: Array<"bold" | "italic" | "underline">; + } + + /** + * Information about a buffer + */ + interface BufferInfo { + /** Buffer ID */ + id: number; + /** Buffer name (usually filename) */ + name: string; + /** Full file path */ + path?: string; + /** Language ID for syntax highlighting */ + language_id?: string; + } + + /** + * Editor information + */ + interface EditorInfo { + /** List of open buffers */ + buffers: BufferInfo[]; + /** Index of the currently active buffer */ + current_buffer_index: number; + /** Editor dimensions */ + size: { + /** Number of rows */ + rows: number; + /** Number of columns */ + cols: number; + }; + /** Current theme information */ + theme: { + name: string; + style: Style; + }; + } + + /** + * Cursor position + */ + interface CursorPosition { + /** Column position */ + x: number; + /** Line position */ + y: number; + } + + /** + * Buffer change event data + */ + interface BufferChangeEvent { + /** Buffer ID */ + buffer_id: number; + /** Buffer name */ + buffer_name: string; + /** File path */ + file_path?: string; + /** Total line count */ + line_count: number; + /** Current cursor position */ + cursor: CursorPosition; + } + + /** + * Mode change event data + */ + interface ModeChangeEvent { + /** Previous mode */ + from: string; + /** New mode */ + to: string; + } + + /** + * Cursor move event data + */ + interface CursorMoveEvent { + /** Previous position */ + from: CursorPosition; + /** New position */ + to: CursorPosition; + } + + /** + * File event data + */ + interface FileEvent { + /** Buffer ID */ + buffer_id: number; + /** File path */ + path: string; + } + + /** + * LSP progress event data + */ + interface LspProgressEvent { + /** Progress token */ + token: string | number; + /** Progress kind */ + kind: "begin" | "report" | "end"; + /** Progress title */ + title?: string; + /** Progress message */ + message?: string; + /** Progress percentage */ + percentage?: number; + } + + /** + * Editor resize event data + */ + interface ResizeEvent { + /** New number of rows */ + rows: number; + /** New number of columns */ + cols: number; + } + + /** + * Configuration object + */ + interface Config { + /** Current theme name */ + theme: string; + /** Map of plugin names to paths */ + plugins: Record; + /** Log file path */ + log_file?: string; + /** Lines to scroll with mouse wheel */ + mouse_scroll_lines?: number; + /** Whether to show diagnostics */ + show_diagnostics: boolean; + /** Key binding configuration */ + keys: any; // Complex nested structure + } + + /** + * The main Red editor API object passed to plugins + */ + interface RedAPI { + /** + * Register a new command + * @param name Command name + * @param callback Command implementation + */ + addCommand(name: string, callback: () => void | Promise): void; + + /** + * Subscribe to an editor event + * @param event Event name + * @param callback Event handler + */ + on(event: "buffer:changed", callback: (data: BufferChangeEvent) => void): void; + on(event: "mode:changed", callback: (data: ModeChangeEvent) => void): void; + on(event: "cursor:moved", callback: (data: CursorMoveEvent) => void): void; + on(event: "file:opened", callback: (data: FileEvent) => void): void; + on(event: "file:saved", callback: (data: FileEvent) => void): void; + on(event: "lsp:progress", callback: (data: LspProgressEvent) => void): void; + on(event: "editor:resize", callback: (data: ResizeEvent) => void): void; + on(event: string, callback: (data: any) => void): void; + + /** + * Subscribe to an event for one-time execution + * @param event Event name + * @param callback Event handler + */ + once(event: string, callback: (data: any) => void): void; + + /** + * Unsubscribe from an event + * @param event Event name + * @param callback Event handler to remove + */ + off(event: string, callback: (data: any) => void): void; + + /** + * Get editor information + * @returns Promise resolving to editor info + */ + getEditorInfo(): Promise; + + /** + * Show a picker dialog + * @param title Dialog title + * @param values List of options to choose from + * @returns Promise resolving to selected value or null + */ + pick(title: string, values: string[]): Promise; + + /** + * Open a buffer by name + * @param name Buffer name or file path + */ + openBuffer(name: string): void; + + /** + * Draw text at specific coordinates + * @param x Column position + * @param y Row position + * @param text Text to draw + * @param style Optional style configuration + */ + drawText(x: number, y: number, text: string, style?: Style): void; + + /** + * Insert text at position + * @param x Column position + * @param y Line position + * @param text Text to insert + */ + insertText(x: number, y: number, text: string): void; + + /** + * Delete text at position + * @param x Column position + * @param y Line position + * @param length Number of characters to delete + */ + deleteText(x: number, y: number, length: number): void; + + /** + * Replace text at position + * @param x Column position + * @param y Line position + * @param length Number of characters to replace + * @param text Replacement text + */ + replaceText(x: number, y: number, length: number, text: string): void; + + /** + * Get current cursor position + * @returns Promise resolving to cursor position + */ + getCursorPosition(): Promise; + + /** + * Set cursor position + * @param x Column position + * @param y Line position + */ + setCursorPosition(x: number, y: number): void; + + /** + * Get buffer text + * @param startLine Optional start line (0-indexed) + * @param endLine Optional end line (exclusive) + * @returns Promise resolving to buffer text + */ + getBufferText(startLine?: number, endLine?: number): Promise; + + /** + * Execute an editor action + * @param command Action name + * @param args Optional action arguments + */ + execute(command: string, args?: any): void; + + /** + * Get list of available plugin commands + * @returns Array of command names + */ + getCommands(): string[]; + + /** + * Get configuration value + * @param key Optional configuration key + * @returns Promise resolving to config value or entire config + */ + getConfig(): Promise; + getConfig(key: "theme"): Promise; + getConfig(key: "plugins"): Promise>; + getConfig(key: "log_file"): Promise; + getConfig(key: "mouse_scroll_lines"): Promise; + getConfig(key: "show_diagnostics"): Promise; + getConfig(key: "keys"): Promise; + getConfig(key: string): Promise; + + /** + * Log messages to the debug log (info level) + * @param messages Messages to log + */ + log(...messages: any[]): void; + + /** + * Log debug messages + * @param messages Messages to log + */ + logDebug(...messages: any[]): void; + + /** + * Log info messages + * @param messages Messages to log + */ + logInfo(...messages: any[]): void; + + /** + * Log warning messages + * @param messages Messages to log + */ + logWarn(...messages: any[]): void; + + /** + * Log error messages + * @param messages Messages to log + */ + logError(...messages: any[]): void; + + /** + * Open the log viewer in the editor + */ + viewLogs(): void; + + /** + * Set a timeout + * @param callback Function to execute + * @param delay Delay in milliseconds + * @returns Timer ID + */ + setTimeout(callback: () => void, delay: number): Promise; + + /** + * Clear a timeout + * @param id Timer ID + */ + clearTimeout(id: string): Promise; + + /** + * Set an interval + * @param callback Function to execute repeatedly + * @param delay Delay between executions in milliseconds + * @returns Interval ID + */ + setInterval(callback: () => void, delay: number): Promise; + + /** + * Clear an interval + * @param id Interval ID + */ + clearInterval(id: string): Promise; + } +} + +/** + * Plugin activation function + * @param red The Red editor API object + */ +export function activate(red: Red.RedAPI): void | Promise; + +/** + * Plugin deactivation function (optional) + * @param red The Red editor API object + */ +export function deactivate?(red: Red.RedAPI): void | Promise; \ No newline at end of file