diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb2c4a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + NODE_VERSION: 24.x + +jobs: + # ── Frontend ────────────────────────────────────────────────────────────── + frontend: + name: Frontend + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Setup pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + with: + version: 10.33.0 + + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint (Biome) + run: pnpm lint + + - name: Type-check + run: pnpm exec tsc --noEmit + + - name: Build + run: pnpm build + + # ── Backend (Rust) ──────────────────────────────────────────────────────── + backend: + name: Backend + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + # Tauri v2 system dependencies required on Linux + - name: Cache apt packages + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + with: + path: /var/cache/apt/archives + key: apt-tauri-${{ runner.os }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + curl \ + file \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libssl-dev \ + libwebkit2gtk-4.1-dev \ + patchelf \ + wget + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 + with: + workspaces: src-tauri -> target + cache-on-failure: true + shared-key: lineup-rust + + - name: Check + working-directory: src-tauri + run: cargo check + + - name: Clippy + working-directory: src-tauri + run: cargo clippy + + - name: Test + working-directory: src-tauri + run: cargo test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4d94738 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to Lineup are documented here. + +--- + +## [1.1.0] — 2026-04-30 + +### Added + +#### TypeScript support +- Lineup can now scan TypeScript projects (`.ts` and `.tsx` files) for V8 JIT memory layout inefficiencies. V8 stores `number` properties as unboxed 8-byte doubles while all other property types are stored as 4-byte tagged compressed pointers (Node.js 14+, 64-bit). Interleaving `number` and non-`number` properties creates the same hidden padding waste as Go struct misalignment. +- Analyzes three TypeScript constructs: `class` declarations (instance properties only; static properties are skipped), `interface` declarations, and object `type` aliases (`type Foo = { ... }`). +- Supports both exported and non-exported declarations, including `export default class`. +- New `ts_parser.rs` Rust module: walks `.ts`/`.tsx` files using `oxc_parser` 0.128 (pure Rust, no C build step). Auto-skips `node_modules/`, `dist/`, `build/`, `.next/`, `.nuxt/`, `coverage/`, and `.cache/` in addition to `.gitignore` exclusions. +- New TypeScript analysis pipeline in `analyzer.rs`: `ts_type_info()` V8 size model, `analyze_ts_files()`, and `build_ts_def()`. Shares the same `align_up`, `struct_layout`, and optimal-sort primitives as the Go pipeline. +- TypeScript declarations that `extend` or `implement` other types are flagged `~approximate` because parent properties affect V8 hidden-class layout but are not resolved across files. + +#### Language selector +- New **Language** toggle (`Go` / `TypeScript`) in the Scan Options modal. Arch selector is hidden when TypeScript is selected (V8 always uses 64-bit pointer compression). +- New **Default Language** setting in the Settings page (`go` by default). +- `ScanOptions` now carries a `language` field passed to the Tauri `scan_repo` command. + +#### Declaration kind +- `declaration_kind` field added to all result types: `"struct"` for Go, `"class"` / `"interface"` / `"type"` for TypeScript. +- Kind badge shown next to the type name in `StructCard` and `StructDetail` for non-Go results. +- Toolbar on the Scan Results screen shows `"declarations"` instead of `"structs"` for TypeScript scans. + +### Changed + +- **Database schema** — two additive `ALTER TABLE` migrations applied at startup: + - `scans.language TEXT NOT NULL DEFAULT 'go'` + - `struct_results.declaration_kind TEXT NOT NULL DEFAULT 'struct'` + - Both migrations are no-ops on existing databases (duplicate-column errors are silently ignored). +- `save_scan` and `save_struct_result` in `db.rs` now accept and store `language` / `declaration_kind` respectively. +- `get_history`, `get_scan_detail`, and `get_struct_detail` queries updated to select and map the new columns. +- `ScanSummary`, `StructSummary`, and `StructDetail` Rust and TypeScript types updated with new fields. +- `AppSettings` TypeScript interface gains `defaultLanguage: string`. +- Re-scan pre-fills the `language` of the original scan (same as it does for arch and ignore patterns). + +### Dependencies + +- Added `oxc_allocator`, `oxc_parser`, `oxc_ast`, `oxc_span` at version `0.128.0` (pure Rust TypeScript/JSX parser). + +### UI improvements + +- **History card language badge** — the language is now displayed as a bordered monospace pill (`GO` / `TS`) next to the repo path instead of inline parenthetical text. The path remains truncatable while the badge is always fully visible. +- **Language-aware ignore pattern placeholders** — the ignore patterns textarea in the Scan Options modal and in Settings now shows language-appropriate placeholder examples. Go shows `vendor/`, `generated/`, `_test\.go$`; TypeScript shows `\.test\.tsx?$`, `\.spec\.tsx?$`, `\.d\.ts$`. The modal placeholder updates reactively when the language toggle is switched. + +### Internal + +- **`PARSER_LANGUAGE` type narrowed to `"GO" | "TS"`** — language values are now uppercase string literals throughout the frontend (`types.ts`, `util.ts`, settings store, all components and pages) and stored as `"GO"` / `"TS"` in the SQLite database. The database `DEFAULT` and Rust fallback values updated to match. +- **GitHub Actions CI pipeline** (`.github/workflows/ci.yml`) — two jobs running on `ubuntu-24.04`: + - *Frontend*: pnpm install (frozen lockfile) → Biome lint → `tsc --noEmit` type-check → Vite build. Uses `actions/setup-node` pnpm store cache. + - *Backend*: apt system-dep cache → Tauri Linux deps → `cargo check` → `cargo clippy` → `cargo test`. Uses `Swatinem/rust-cache` with `cache-on-failure` and a shared cache key across branches. + - All action references pinned to full commit SHAs. Triggers on push to `main` and `develop`, and on PRs targeting `main`. + +--- + +## [1.0.0] — initial release + +- Go struct padding analysis for `amd64` and `arm64` targets. +- Three-pane results UI (file tree / declaration list / detail panel). +- Scan history persisted to local SQLite database. +- Re-scan with pre-filled options. +- Configurable ignore patterns (regex). +- Copy optimized definition to clipboard. +- Dark / light theme. diff --git a/DEVELOPER.md b/DEVELOPER.md index 6e04929..956a1ee 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -20,6 +20,7 @@ For user-facing documentation see [README.md](README.md). | Persistence | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) (bundled) | | Directory walking | [ignore](https://crates.io/crates/ignore) | | Regex | [regex](https://crates.io/crates/regex) | +| TypeScript parsing | [oxc_parser](https://crates.io/crates/oxc_parser) 0.128 (pure Rust) | --- @@ -127,8 +128,8 @@ lineup/ │ │ ├── FileTree.tsx # Left-pane file tree on results screen │ │ ├── HistoryCard.tsx # Scan history card on home screen │ │ ├── ProgressModal.tsx # Scan progress overlay with cancel -│ │ ├── ScanOptionsModal.tsx# Pre-scan config (arch, ignore patterns) -│ │ ├── StructCard.tsx # Per-struct summary card +│ │ ├── ScanOptionsModal.tsx# Pre-scan config (language, arch, ignore patterns) +│ │ ├── StructCard.tsx # Per-declaration summary card │ │ └── StructDetail.tsx # Right-pane detail view with copy button │ ├── pages/ │ │ ├── Home.tsx # Home / history screen @@ -150,7 +151,8 @@ lineup/ │ ├── lib.rs # Tauri commands, managed state, app setup │ ├── db.rs # SQLite schema, queries, data types │ ├── parser.rs # Go source walker and struct parser -│ └── analyzer.rs # Padding analyzer and optimal-order engine +│ ├── ts_parser.rs # TypeScript source walker and declaration parser +│ └── analyzer.rs # Padding analyzer and optimal-order engine (Go + TS) ├── public/ # Static assets ├── biome.json # Biome linter / formatter config ├── vite.config.ts @@ -166,6 +168,8 @@ lineup/ Defines `AppState` (the Tauri managed state), all `#[tauri::command]` handlers, and the `tauri::Builder` setup. `AppState` holds a `Mutex` and an `Arc` cancel flag. +`ScanOptions` carries a `language` field (`"GO"` or `"TS"`) that controls which walker and analyzer are invoked by `scan_repo`. + ### `db.rs` All database logic. Initializes the schema on first run and exposes typed functions for every persistence operation. The SQLite file is stored in the platform app-data directory resolved by `tauri::Manager::path().app_data_dir()`. @@ -178,10 +182,11 @@ All database logic. Initializes the schema on first run and exposes typed functi | `repo_path` | TEXT | Absolute path to the scanned directory | | `scanned_at` | INTEGER | Unix timestamp (seconds) | | `total_structs` | INTEGER | | -| `padded_structs` | INTEGER | Structs with bytes_saved > 0 | -| `bytes_saved` | INTEGER | Sum across all structs | +| `padded_structs` | INTEGER | Declarations with bytes_saved > 0 | +| `bytes_saved` | INTEGER | Sum across all declarations | | `ignore_patterns` | TEXT | JSON array of regex strings | | `target_arch` | TEXT | `"amd64"` or `"arm64"` | +| `language` | TEXT | `"GO"` or `"TS"` (added in 1.1.0; defaults to `"GO"` for existing rows) | **Schema — `struct_results` table** @@ -199,21 +204,50 @@ All database logic. Initializes the schema on first run and exposes typed functi | `optimized_def` | TEXT | Reordered definition with added comment header | | `has_generics` | INTEGER | Boolean (0/1); sizes are approximate | | `has_embedded` | INTEGER | Boolean (0/1) | +| `declaration_kind` | TEXT | `"struct"` for Go; `"class"`, `"interface"`, or `"type"` for TypeScript (added in 1.1.0; defaults to `"struct"` for existing rows) | ### `parser.rs` -Walks a repository using `ignore::Walk` — automatically respects `.gitignore` and skips `vendor/`. User-supplied ignore patterns (regex strings) are compiled into a `RegexSet` before the walk begins and tested against each file's repo-relative path. Uses brace-counting (not pure regex) to reliably extract `type Name[TypeParams] struct { ... }` blocks including generic structs. Captures doc comments (`//`-prefixed lines immediately preceding `type Name struct`) and preserves inline field comments and struct tags. +Walks a **Go** repository using `ignore::Walk` — automatically respects `.gitignore` and skips `vendor/` and `testdata/`. User-supplied ignore patterns (regex strings) are compiled into a `RegexSet` before the walk begins and tested against each file's repo-relative path. Uses brace-counting (not pure regex) to reliably extract `type Name[TypeParams] struct { ... }` blocks including generic structs. Captures doc comments (`//`-prefixed lines immediately preceding `type Name struct`) and preserves inline field comments and struct tags. + +### `ts_parser.rs` + +Walks a **TypeScript** repository using the same `ignore::Walk` infrastructure. Hard-skips `node_modules/`, `dist/`, `build/`, `.next/`, `.nuxt/`, `coverage/`, and `.cache/` in addition to `.gitignore` exclusions. Accepts `.ts` and `.tsx` files. + +Parsing is done with [`oxc_parser`](https://crates.io/crates/oxc_parser) (pure Rust, no C build step). The parser produces a typed AST from which the following top-level constructs are extracted: + +- `class` declarations — instance `PropertyDefinition` nodes (static properties are skipped) +- `interface` declarations — `TSPropertySignature` nodes +- object `type` aliases (`type Foo = { ... }`) — `TSPropertySignature` nodes inside `TSTypeLiteral` + +Exported and non-exported variants, including `export default class`, are all handled. For each property the raw source line is captured via `span` byte offsets for use in reconstructed definitions. ### `analyzer.rs` +Contains two independent analysis pipelines that share the same core layout primitives (`align_up`, `struct_layout`, optimal-sort by align desc / size desc / name asc). + +**Go pipeline (`analyze_files`)** + Two-pass analysis: -1. **Pass 1** — parse all files and build a `HashMap` registry mapping type names to their computed size and alignment. -2. **Pass 2** — for each struct, resolve embedded types recursively from the registry, compute current size (with padding), compute optimal size (fields sorted by alignment descending, ties broken by size then name), and generate the `optimized_def` string. +1. **Pass 1** — parse all files and build a `HashMap` registry mapping type names to their computed size and alignment. +2. **Pass 2** — for each struct, resolve embedded types recursively from the registry, compute current size (with padding), compute optimal size, and generate the `optimized_def` string with a `// Reordered for optimal memory alignment` header. + +The `Arch` enum (`Amd64` | `Arm64`) selects the type-size/alignment table. Generic type parameters and unresolved types default to size 8 / align 8 and set `approximate = true`. + +**TypeScript pipeline (`analyze_ts_files`)** + +Single-pass analysis using the V8 type-size model: -The `Arch` enum (`Amd64` | `Arm64`) selects the type-size/alignment table. Current tables are identical for both 64-bit targets. Generic type parameters and unresolved types default to size 8 / align 8 and are flagged `has_generics = true`. +| TypeScript type | V8 representation | Size | Align | +|---|---|---|---| +| `number` | Unboxed Double | 8 B | 8 | +| `any`, `unknown`, `never`, `void` | Tagged pointer (conservative) | 4 B | 4 | +| Everything else | Tagged/compressed pointer | 4 B | 4 | -**Type table (amd64 / arm64)** +Types with generics (`has_generics`) or that extend/implement other types (`has_embedded`) are flagged `approximate = true`. The `declaration_kind` field on `AnalyzedStruct` is set to `"class"`, `"interface"`, or `"type"`. The `optimized_def` header is `// Reordered for optimal V8 memory layout`. + +**Go type table (amd64 / arm64)** | Go type(s) | Size (bytes) | Align (bytes) | |---|---|---| @@ -230,9 +264,34 @@ The `Arch` enum (`Amd64` | `Arm64`) selects the type-size/alignment table. Curre --- -## Tauri Command API +## Data & Log Locations + +The bundle identifier is **`com.capabletechnology.lineup`**. + +### SQLite database + +The database file is created on first launch at `{app_data_dir}/lineup.db`. + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/com.capabletechnology.lineup/lineup.db` | +| Linux | `$XDG_DATA_HOME/com.capabletechnology.lineup/lineup.db` (typically `~/.local/share/com.capabletechnology.lineup/lineup.db`) | +| Windows | `%APPDATA%\com.capabletechnology.lineup\lineup.db` (e.g. `C:\Users\\AppData\Roaming\com.capabletechnology.lineup\lineup.db`) | -All commands are invoked from the frontend via `@tauri-apps/api/core` `invoke()`. Errors are returned as rejected promises (the Rust `Err(String)` maps to a JS string rejection). +Deleting the file resets all scan history. The app will re-create the schema automatically on next launch. + +### Logs + +Lineup does not use `tauri-plugin-log` — no log files are written to disk. + +- **Development** (`pnpm tauri:dev`): Rust `println!`/`eprintln!` output appears in the terminal that launched the dev command. Frontend `console.*` output appears in the WebView DevTools (right-click → Inspect, or `Ctrl+Shift+I` / `Cmd+Option+I`). +- **Production**: stdout/stderr is captured by the host OS. Use the platform system log viewer to inspect it: + +| Platform | How to view | +|---|---| +| macOS | **Console.app** → filter by `com.capabletechnology.lineup`, or `log stream --predicate 'process == "Lineup"'` in Terminal | +| Linux | `journalctl -f` (if launched via systemd) or redirect stdout/stderr when launching from the command line | +| Windows | **Event Viewer** → Windows Logs → Application, or launch from a terminal to capture stdout/stderr directly | via `@tauri-apps/api/core` `invoke()`. Errors are returned as rejected promises (the Rust `Err(String)` maps to a JS string rejection). --- @@ -264,7 +323,8 @@ invoke('scan_repo', { ```ts interface ScanOptions { ignore_patterns: string[]; // regex strings; matched against repo-relative file paths - target_arch: string; // "amd64" | "arm64" + target_arch: string; // "amd64" | "arm64" (used only when language is "go") + language: string; // "GO" | "TS" } ``` @@ -314,6 +374,7 @@ interface ScanSummary { bytes_saved: number; ignore_patterns: string[]; target_arch: string; // "amd64" | "arm64" + language: string; // "GO" | "TS" } ``` @@ -364,6 +425,7 @@ interface StructSummary { bytes_saved: number; has_generics: boolean; has_embedded: boolean; + declaration_kind: string; // "struct" | "class" | "interface" | "type" } ``` @@ -389,10 +451,11 @@ interface StructDetail { current_size: number; optimal_size: number; bytes_saved: number; - current_def: string; // original struct source - optimized_def: string; // reordered struct with "// Reordered for optimal memory alignment" header + current_def: string; // original source + optimized_def: string; // reordered source with comment header has_generics: boolean; has_embedded: boolean; + declaration_kind: string; // "struct" | "class" | "interface" | "type" } ``` @@ -451,4 +514,4 @@ Run `pnpm format` and `pnpm lint` before submitting a pull request. - Follow the output's suggestions to install the missing dependencies before re-running any build **Database errors on startup** -- The SQLite file lives in the platform app-data directory. Delete `lineup.db` in that directory to reset state (all scan history will be lost). +- The SQLite file lives at the path shown in the [Data & Log Locations](#data--log-locations) section above. Delete `lineup.db` in that directory to reset state (all scan history will be lost). diff --git a/README.md b/README.md index 18df59d..4a1b8d3 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,21 @@ # Lineup -Lineup is a desktop app that scans Go repositories and finds `struct` types that waste memory due to field padding. It shows you exactly which structs can be improved, how many bytes can be saved, and what the optimized field order looks like — without touching your source files. +Lineup is a desktop app that scans **Go** and **TypeScript** repositories and finds types that waste memory due to field padding. It shows you exactly which types can be improved, how many bytes can be saved, and what the optimized field order looks like — without touching your source files. --- ## What does it do? +### Go + Go aligns struct fields in memory according to their type's alignment requirements. When fields are ordered carelessly, the compiler inserts hidden padding bytes between them to satisfy alignment rules. Lineup detects this, computes the optimal field order, and tells you how much memory each struct is wasting. +### TypeScript + +V8's JIT compiler stores `number` properties as unboxed 8-byte doubles, while all other property types are stored as 4-byte tagged compressed pointers (Node.js 14+, 64-bit). Interleaving `number` and non-`number` properties in a class, interface, or object type creates the same hidden padding waste as Go struct misalignment. Lineup models this layout, computes the optimal property order, and reports how many bytes per instance can be recovered. + +Lineup analyzes `class` declarations, `interface` declarations, and object `type` aliases in `.ts` and `.tsx` files. + No changes are made to your code. Lineup only reads and reports. --- @@ -18,53 +26,59 @@ No changes are made to your code. Lineup only reads and reports. ![Home screen](screenshots/home.png) -**Scan results** — three-pane view: file tree on the left, struct list in the center, and field-level detail on the right. +**Scan results - GO** — three-pane view: file tree on the left, struct list in the center, and field-level detail on the right. + +![Scan results - TS](screenshots/scan.png "Go Scan Results") -![Scan results](screenshots/scan.png) +**Scan results - TS** +![Scan results - TS](screenshots/scan-ts.png "Typescript Scan Results") --- ## Using the App ### 1. Open a repository -Click **Open Repository** on the home screen and select the root folder of any Go project. Lineup will walk the directory tree, skipping `vendor/` and files excluded by `.gitignore`. +Click **Open Repository** on the home screen and select the root folder of any Go or TypeScript project. Lineup will walk the directory tree, skipping files excluded by `.gitignore` and language-specific noise directories. ### 2. Configure scan options -Before the scan starts you can set two options: +Before the scan starts you can set options: | Option | Description | |---|---| -| **Architecture** | `x86_64` (default) or `ARM64`. Selects the type-size table used to calculate padding. | +| **Language** | `Go` (default) or `TypeScript`. Selects which files are scanned and which size model is used. | +| **Architecture** | `x86_64` (default) or `ARM64`. Only shown for Go scans. Selects the type-size table. | | **Ignore patterns** | One regex per line. Any file whose path matches a pattern is skipped (e.g. `.*\.pb\.go$` to skip generated protobuf files). | ### 3. Read the results The results screen has three panes: -- **Left — File tree.** Every Go file that contained at least one struct. A badge shows the number of structs with fixable padding. Click a file to filter the center list. -- **Center — Struct list.** Each card shows the struct name, current size, optimal size, and bytes that can be saved. Structs with no padding waste show `0 B saveable`. -- **Right — Detail panel.** Select a struct to see its current field order and the suggested optimized order side by side. A "Copy Optimized" button puts the reordered struct definition on your clipboard. +- **Left — File tree.** Every file that contained at least one analyzed type. A badge shows the number of types with fixable padding. Click a file to filter the center list. +- **Center — Declaration list.** Each card shows the type name, its kind (`class`, `interface`, or `type` for TypeScript), current size, optimal size, and bytes that can be saved. Types with no padding waste show `0 B saveable`. +- **Right — Detail panel.** Select a type to see its current field order and the suggested optimized order side by side. A "Copy Optimized" button puts the reordered definition on your clipboard. ### 4. Understand the numbers | Label | Meaning | |---|---| -| **structs** | Total number of struct types found in the repository. | -| **padded** | How many of those structs currently waste at least 1 byte to padding. | +| **structs / declarations** | Total number of types found (`structs` for Go, `declarations` for TypeScript). | +| **padded** | How many of those types currently waste at least 1 byte to padding. | | **B saveable** | Bytes per instance that would be recovered by reordering the fields shown. | -| **~approximate** badge | The struct uses generics; sizes are estimated conservatively. | -| **embedded** badge | The struct embeds another type; sizes account for the embedded layout. | +| **class / interface / type** badge | TypeScript declaration kind, shown next to the name in results. | +| **~approximate** badge | The type uses generics, or extends/implements another type; sizes are estimated conservatively. | +| **embedded** badge | The struct embeds another Go type; sizes account for the embedded layout. | ### 5. Act on the suggestions Lineup never modifies your source files. To apply a suggestion: 1. Click **Copy Optimized** in the detail panel. -2. Open the source file and replace the struct definition with the copied version. -3. Verify that the new order does not break anything that depends on field position — for example, `encoding/binary` reads, `unsafe.Offsetof` calls, or cgo-shared types. -4. Compile and test as normal. +2. Open the source file and replace the type definition with the copied version. +3. For **Go**: verify the new order does not break anything that depends on field position — for example, `encoding/binary` reads, `unsafe.Offsetof` calls, or cgo-shared types. +4. For **TypeScript**: verify that no code depends on property declaration order (e.g. class field initializer execution order or serialization key order). Compiled TypeScript is unaffected — this optimization targets runtime V8 memory layout only. +5. Compile and test as normal. ### 6. Re-scan @@ -86,9 +100,10 @@ Scan data is stored in a SQLite database inside the app's local data directory. ## Practical notes -- Lineup targets the `amd64` (x86-64) memory model by default. Switch to `ARM64` in scan options if you are building for Apple Silicon or another 64-bit ARM target. The type tables are currently identical for both; the option is plumbed through so future 32-bit target support requires no API change. -- Not every suggested reordering is appropriate. Review the proposed order before applying it, especially for structs that are serialized, passed over a network boundary, or shared with C code. -- Structs that use type parameters (generics) are flagged as `~approximate` because their concrete field sizes depend on how the type is instantiated. +- **Go:** Lineup targets the `amd64` (x86-64) memory model by default. Switch to `ARM64` in scan options if you are building for Apple Silicon or another 64-bit ARM target. +- **TypeScript:** The V8 model assumes 64-bit pointer compression (the default in Node.js 14+ and all modern Chromium-based runtimes). `number` is always modelled as an unboxed 8-byte Double (worst-case). If a property is always an integer, V8 may use a Smi (4 bytes) at runtime — the reported savings are a conservative upper bound. +- Not every suggested reordering is appropriate. Review the proposed order before applying it, especially for types that are serialized, passed over a network boundary, or shared with C code. +- Go structs that use type parameters (generics) are flagged as `~approximate` because their concrete field sizes depend on how the type is instantiated. TypeScript types that `extend` or `implement` other types are also flagged `~approximate` because parent properties affect the V8 hidden-class layout but are not resolved across files. --- diff --git a/index.html b/index.html index 9c2276a..b201867 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - Tauri + Solid + Typescript App + Lineup diff --git a/package.json b/package.json index 44e9deb..01da1aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lineup", - "version": "1.0.0", + "version": "1.1.0", "description": "", "type": "module", "author": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2df8ca..f3fce2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 4.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)) '@tauri-apps/api': specifier: ^2 - version: 2.10.1 + version: 2.11.0 '@tauri-apps/plugin-dialog': specifier: ^2.7.0 version: 2.7.0 @@ -41,7 +41,7 @@ importers: version: 2.4.13 '@tauri-apps/cli': specifier: ^2 - version: 2.10.1 + version: 2.11.0 '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -61,8 +61,8 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -115,8 +115,8 @@ packages: resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true @@ -692,82 +692,82 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tauri-apps/api@2.10.1': - resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} - '@tauri-apps/cli-darwin-arm64@2.10.1': - resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} + '@tauri-apps/cli-darwin-arm64@2.11.0': + resolution: {integrity: sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.10.1': - resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==} + '@tauri-apps/cli-darwin-x64@2.11.0': + resolution: {integrity: sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': - resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0': + resolution: {integrity: sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.10.1': - resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.0': + resolution: {integrity: sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.10.1': - resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} + '@tauri-apps/cli-linux-arm64-musl@2.11.0': + resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': - resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': + resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.10.1': - resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} + '@tauri-apps/cli-linux-x64-gnu@2.11.0': + resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.10.1': - resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} + '@tauri-apps/cli-linux-x64-musl@2.11.0': + resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.10.1': - resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.0': + resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.10.1': - resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.0': + resolution: {integrity: sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.10.1': - resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==} + '@tauri-apps/cli-win32-x64-msvc@2.11.0': + resolution: {integrity: sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.10.1': - resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==} + '@tauri-apps/cli@2.11.0': + resolution: {integrity: sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==} engines: {node: '>= 10'} hasBin: true @@ -809,8 +809,8 @@ packages: solid-js: optional: true - baseline-browser-mapping@2.10.22: - resolution: {integrity: sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==} + baseline-browser-mapping@2.10.24: + resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==} engines: {node: '>=6.0.0'} hasBin: true @@ -819,8 +819,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - caniuse-lite@1.0.30001790: - resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -841,8 +841,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - electron-to-chromium@1.5.344: - resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + electron-to-chromium@1.5.346: + resolution: {integrity: sha512-3PGbvVwt9AppQsta0Kuq5DIcSj7aQfDfCVS7KnV3nhXEDtuJVRS7kK28Q+qy5KRkQ4bICV4xOaXNeUaXe78dDg==} enhanced-resolve@5.21.0: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} @@ -993,8 +993,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1011,8 +1011,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + postcss@8.5.13: + resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} rollup@4.60.2: @@ -1158,7 +1158,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -1167,7 +1167,7 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -1182,7 +1182,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -1190,7 +1190,7 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.2 lru-cache: 5.1.1 @@ -1231,7 +1231,7 @@ snapshots: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.29.2': + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -1243,7 +1243,7 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -1251,7 +1251,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -1652,66 +1652,66 @@ snapshots: tailwindcss: 4.2.4 vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0) - '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.0': {} - '@tauri-apps/cli-darwin-arm64@2.10.1': + '@tauri-apps/cli-darwin-arm64@2.11.0': optional: true - '@tauri-apps/cli-darwin-x64@2.10.1': + '@tauri-apps/cli-darwin-x64@2.11.0': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.10.1': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.0': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.10.1': + '@tauri-apps/cli-linux-arm64-gnu@2.11.0': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.10.1': + '@tauri-apps/cli-linux-arm64-musl@2.11.0': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.0': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.10.1': + '@tauri-apps/cli-linux-x64-gnu@2.11.0': optional: true - '@tauri-apps/cli-linux-x64-musl@2.10.1': + '@tauri-apps/cli-linux-x64-musl@2.11.0': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.10.1': + '@tauri-apps/cli-win32-arm64-msvc@2.11.0': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.10.1': + '@tauri-apps/cli-win32-ia32-msvc@2.11.0': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.10.1': + '@tauri-apps/cli-win32-x64-msvc@2.11.0': optional: true - '@tauri-apps/cli@2.10.1': + '@tauri-apps/cli@2.11.0': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.10.1 - '@tauri-apps/cli-darwin-x64': 2.10.1 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1 - '@tauri-apps/cli-linux-arm64-gnu': 2.10.1 - '@tauri-apps/cli-linux-arm64-musl': 2.10.1 - '@tauri-apps/cli-linux-riscv64-gnu': 2.10.1 - '@tauri-apps/cli-linux-x64-gnu': 2.10.1 - '@tauri-apps/cli-linux-x64-musl': 2.10.1 - '@tauri-apps/cli-win32-arm64-msvc': 2.10.1 - '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 - '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/cli-darwin-arm64': 2.11.0 + '@tauri-apps/cli-darwin-x64': 2.11.0 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.0 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.0 + '@tauri-apps/cli-linux-arm64-musl': 2.11.0 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.0 + '@tauri-apps/cli-linux-x64-gnu': 2.11.0 + '@tauri-apps/cli-linux-x64-musl': 2.11.0 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.0 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.0 + '@tauri-apps/cli-win32-x64-msvc': 2.11.0 '@tauri-apps/plugin-dialog@2.7.0': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.0 '@tauri-apps/plugin-opener@2.5.3': dependencies: - '@tauri-apps/api': 2.10.1 + '@tauri-apps/api': 2.11.0 '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 @@ -1723,7 +1723,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': @@ -1752,17 +1752,17 @@ snapshots: optionalDependencies: solid-js: 1.9.12 - baseline-browser-mapping@2.10.22: {} + baseline-browser-mapping@2.10.24: {} browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.22 - caniuse-lite: 1.0.30001790 - electron-to-chromium: 1.5.344 + baseline-browser-mapping: 2.10.24 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.346 node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) - caniuse-lite@1.0.30001790: {} + caniuse-lite@1.0.30001791: {} convert-source-map@2.0.0: {} @@ -1774,7 +1774,7 @@ snapshots: detect-libc@2.1.2: {} - electron-to-chromium@1.5.344: {} + electron-to-chromium@1.5.346: {} enhanced-resolve@5.21.0: dependencies: @@ -1900,7 +1900,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.11: {} + nanoid@3.3.12: {} node-releases@2.0.38: {} @@ -1912,9 +1912,9 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.10: + postcss@8.5.13: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2027,7 +2027,7 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.13 rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: diff --git a/screenshots/home.png b/screenshots/home.png index 18e8e82..41eae5f 100644 Binary files a/screenshots/home.png and b/screenshots/home.png differ diff --git a/screenshots/scan-ts.png b/screenshots/scan-ts.png new file mode 100644 index 0000000..0b34566 Binary files /dev/null and b/screenshots/scan-ts.png differ diff --git a/screenshots/scan.png b/screenshots/scan.png index fb642da..858c17d 100644 Binary files a/screenshots/scan.png and b/screenshots/scan.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e868c3f..bdc5f64 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,10 +4,14 @@ version = 4 [[package]] name = "Lineup" -version = "1.0.0" +version = "1.1.0" dependencies = [ "ignore", "once_cell", + "oxc_allocator", + "oxc_ast", + "oxc_parser", + "oxc_span", "regex", "rusqlite", "serde", @@ -48,6 +52,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -427,6 +437,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.61" @@ -492,6 +511,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -557,6 +590,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -848,6 +887,12 @@ dependencies = [ "serde", ] +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + [[package]] name = "dtoa" version = "1.0.11" @@ -1526,6 +1571,9 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", +] [[package]] name = "hashlink" @@ -2272,12 +2320,37 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2467,6 +2540,216 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "oxc-miette" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356a61f2ed4c9b3610245215fbf48970eb277126919f87db9d0efa93a74245c" +dependencies = [ + "cfg-if", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "oxc-miette-derive" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "oxc_allocator" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b554cc48bdde5684b8a2bf3355524694ee47d9de4246eaf6199b8aecfd952cb" +dependencies = [ + "allocator-api2", + "hashbrown 0.17.0", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d027d8f8b23257e1711e0db8b80c9dacb3ab567a3357b4560eaa1d0a04da2d30" +dependencies = [ + "bitflags 2.11.1", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340ac9cb05bc9963811e3dc1585b85618471cc339d0ab0072d097dd85d78d09e" +dependencies = [ + "phf 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "oxc_data_structures" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c425cdc1a05603d9b6d13786892d69364a0c18de06ffa511109a9e0a760b423c" + +[[package]] +name = "oxc_diagnostics" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa06c0bec3b31c76e6b30b935f80dd3b29c01bf0d0fbc13b5b8f3eca508ad9ee" +dependencies = [ + "cow-utils", + "oxc-miette", + "percent-encoding", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c675d7ad122e907016b6b7eb3e01228f313e6ff59f2a49d35d230ce214a8be9d" +dependencies = [ + "cow-utils", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_regular_expression", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aef225084b2735b871215ceba04582ecfe15be563c4c3a9e22f33e34fab74f4" + +[[package]] +name = "oxc_index" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e6120999627ec9703025eab7c9f410ebb7e95557632a8902ca48210416c2b" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad27270e0ef6b957eeda354a9a4c3ba2b42a055d4d3f2311bc72735cefaac5f" +dependencies = [ + "bitflags 2.11.1", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e92ddddf8645910675528f66b3159c018c553fa47e4644514513705f5d3c22b" +dependencies = [ + "bitflags 2.11.1", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf 0.13.1", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_span" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03b54ae4c2254ffdbba43f82e4ea097182b300d2f3ccd1f81f8ca145556e659" +dependencies = [ + "compact_str", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686c0fe58e5a4a3698921871fbe23043ac271cf324540591dfcc5e7d0f127a5a" +dependencies = [ + "compact_str", + "hashbrown 0.17.0", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.128.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0e13e50d92d4c518ed2484d4c5beea46c2f3311688aaff866420abf6a73eb" +dependencies = [ + "bitflags 2.11.1", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf 0.13.1", + "unicode-id-start", +] + [[package]] name = "pango" version = "0.18.3" @@ -3194,6 +3477,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3307,6 +3596,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -3531,6 +3826,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -3607,6 +3908,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -4112,6 +4419,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4508,18 +4826,36 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 829dd4a..ddb5607 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Lineup" -version = "1.0.0" +version = "1.1.0" description = "Lineup is a desktop app that scans Go repositories and finds struct types that waste memory due to field padding" authors = ["Jaylen Douglas"] edition = "2021" @@ -27,4 +27,8 @@ ignore = "0.4.25" regex = "1.12.3" once_cell = "1.21.4" tauri-plugin-dialog = "2.7.0" +oxc_allocator = "0.128.0" +oxc_parser = "0.128.0" +oxc_ast = "0.128.0" +oxc_span = "0.128.0" diff --git a/src-tauri/src/analyzer.rs b/src-tauri/src/analyzer.rs index 8b7a508..10b04bd 100644 --- a/src-tauri/src/analyzer.rs +++ b/src-tauri/src/analyzer.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; -use crate::parser::{GoField, GoFile, GoStruct}; +use crate::go_parser::{GoField, GoFile, GoStruct}; +use crate::ts_parser::{TsDeclaration, TsField, TsFile}; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -132,6 +133,8 @@ pub struct AnalyzedStruct { pub has_generics: bool, pub has_embedded: bool, pub approximate: bool, + /// "struct" for Go, "class" / "interface" / "type" for TypeScript. + pub declaration_kind: String, } pub fn analyze_files(files: &[GoFile], arch: Arch) -> Vec<(String, AnalyzedStruct)> { @@ -262,9 +265,134 @@ fn analyze_struct( has_generics, has_embedded, approximate, + declaration_kind: "struct".to_string(), }) } +// --------------------------------------------------------------------------- +// TypeScript / V8 analysis +// --------------------------------------------------------------------------- + +/// V8 JIT type-size model (64-bit, pointer compression enabled). +/// `number` fields are stored as unboxed 8-byte doubles. +/// Everything else is a 4-byte tagged/compressed pointer. +pub fn ts_type_info(type_str: &str) -> TypeInfo { + match type_str.trim() { + "number" => TypeInfo { size: 8, align: 8, approximate: false }, + "any" | "unknown" | "never" | "void" => TypeInfo { size: 4, align: 4, approximate: true }, + _ => TypeInfo { size: 4, align: 4, approximate: false }, + } +} + +pub fn analyze_ts_files(files: &[TsFile]) -> Vec<(String, AnalyzedStruct)> { + let mut results = Vec::new(); + for file in files { + for decl in &file.declarations { + if let Some(analyzed) = analyze_ts_decl(decl) { + results.push((file.path.clone(), analyzed)); + } + } + } + results +} + +fn analyze_ts_decl(decl: &TsDeclaration) -> Option { + if decl.fields.is_empty() { + return None; + } + + let has_generics = decl.type_params.is_some(); + let has_embedded = decl.has_extends; + let mut approximate = has_generics || has_embedded; + + let field_infos: Vec<(TypeInfo, &TsField)> = decl + .fields + .iter() + .map(|f| { + let info = ts_type_info(&f.type_str); + if info.approximate { + approximate = true; + } + (info, f) + }) + .collect(); + + let current_fields: Vec<(TypeInfo, String)> = field_infos + .iter() + .map(|(info, f)| (info.clone(), f.name.clone())) + .collect(); + let (current_size, _) = struct_layout(¤t_fields); + + // Optimal: numbers (8B) first, then everything else, stable by name within each group. + let mut sorted = field_infos.clone(); + sorted.sort_by(|(a_info, a_field), (b_info, b_field)| { + b_info + .align + .cmp(&a_info.align) + .then(b_info.size.cmp(&a_info.size)) + .then(a_field.name.cmp(&b_field.name)) + }); + let optimal_fields: Vec<(TypeInfo, String)> = sorted + .iter() + .map(|(info, f)| (info.clone(), f.name.clone())) + .collect(); + let (optimal_size, _) = struct_layout(&optimal_fields); + + let bytes_saved = current_size.saturating_sub(optimal_size); + + let current_refs: Vec<&TsField> = decl.fields.iter().collect(); + let current_def = build_ts_def(decl, ¤t_refs, false); + let sorted_fields: Vec<&TsField> = sorted.iter().map(|(_, f)| *f).collect(); + let optimized_def = build_ts_def(decl, &sorted_fields, true); + + Some(AnalyzedStruct { + name: decl.name.clone(), + type_params: decl.type_params.clone(), + line_number: decl.line_number, + current_size, + optimal_size, + bytes_saved, + current_def, + optimized_def, + has_generics, + has_embedded, + approximate, + declaration_kind: decl.kind.as_str().to_string(), + }) +} + +fn build_ts_def(decl: &TsDeclaration, fields: &[&TsField], optimized: bool) -> String { + use crate::ts_parser::TsDeclKind; + let mut lines: Vec = Vec::new(); + + for doc in &decl.doc_comments { + lines.push(doc.clone()); + } + if optimized { + lines.push("// Reordered for optimal V8 memory layout".to_string()); + } + + let tp = decl.type_params.as_deref().unwrap_or(""); + let header = match decl.kind { + TsDeclKind::Class => format!("class {}{} {{", decl.name, tp), + TsDeclKind::Interface => format!("interface {}{} {{", decl.name, tp), + TsDeclKind::TypeAlias => format!("type {}{} = {{", decl.name, tp), + }; + lines.push(header); + + for field in fields { + lines.push(format!("\t{}", field.raw_line.trim())); + } + + let footer = if decl.kind == TsDeclKind::TypeAlias { "};" } else { "}" }; + lines.push(footer.to_string()); + lines.join("\n") +} + +// --------------------------------------------------------------------------- +// Go analysis (unchanged) +// --------------------------------------------------------------------------- + fn build_def(s: &GoStruct, fields: &[&GoField], optimized: bool) -> String { let mut lines: Vec = Vec::new(); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 6e3afa1..72de836 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -11,6 +11,7 @@ pub struct ScanSummary { pub bytes_saved: i64, pub ignore_patterns: Vec, pub target_arch: String, + pub language: String, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -29,6 +30,7 @@ pub struct StructSummary { pub bytes_saved: i64, pub has_generics: bool, pub has_embedded: bool, + pub declaration_kind: String, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -45,6 +47,7 @@ pub struct StructDetail { pub optimized_def: String, pub has_generics: bool, pub has_embedded: bool, + pub declaration_kind: String, } pub fn init_db(conn: &Connection) -> Result<()> { @@ -77,7 +80,11 @@ pub fn init_db(conn: &Connection) -> Result<()> { has_generics INTEGER NOT NULL DEFAULT 0, has_embedded INTEGER NOT NULL DEFAULT 0 );", - ) + )?; + // Additive migrations — silently ignored on existing databases + let _ = conn.execute("ALTER TABLE scans ADD COLUMN language TEXT NOT NULL DEFAULT 'GO'", []); + let _ = conn.execute("ALTER TABLE struct_results ADD COLUMN declaration_kind TEXT NOT NULL DEFAULT 'struct'", []); + Ok(()) } pub fn save_scan( @@ -89,12 +96,13 @@ pub fn save_scan( bytes_saved: i64, ignore_patterns: &[String], target_arch: &str, + language: &str, ) -> Result { let patterns_json = serde_json::to_string(ignore_patterns).unwrap_or_else(|_| "[]".to_string()); conn.execute( - "INSERT INTO scans (repo_path, scanned_at, total_structs, padded_structs, bytes_saved, ignore_patterns, target_arch) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![repo_path, scanned_at, total_structs, padded_structs, bytes_saved, patterns_json, target_arch], + "INSERT INTO scans (repo_path, scanned_at, total_structs, padded_structs, bytes_saved, ignore_patterns, target_arch, language) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![repo_path, scanned_at, total_structs, padded_structs, bytes_saved, patterns_json, target_arch, language.to_uppercase()], )?; Ok(conn.last_insert_rowid()) } @@ -112,16 +120,18 @@ pub fn save_struct_result( optimized_def: &str, has_generics: bool, has_embedded: bool, + declaration_kind: &str, ) -> Result { conn.execute( "INSERT INTO struct_results - (scan_id, file_path, struct_name, line_number, current_size, optimal_size, bytes_saved, current_def, optimized_def, has_generics, has_embedded) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + (scan_id, file_path, struct_name, line_number, current_size, optimal_size, bytes_saved, current_def, optimized_def, has_generics, has_embedded, declaration_kind) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ scan_id, file_path, struct_name, line_number, current_size, optimal_size, bytes_saved, current_def, optimized_def, - has_generics as i64, has_embedded as i64 + has_generics as i64, has_embedded as i64, + declaration_kind ], )?; Ok(conn.last_insert_rowid()) @@ -129,7 +139,7 @@ pub fn save_struct_result( pub fn get_history(conn: &Connection) -> Result> { let mut stmt = conn.prepare( - "SELECT id, repo_path, scanned_at, total_structs, padded_structs, bytes_saved, ignore_patterns, target_arch + "SELECT id, repo_path, scanned_at, total_structs, padded_structs, bytes_saved, ignore_patterns, target_arch, language FROM scans ORDER BY scanned_at DESC", )?; let rows = stmt.query_map([], |row| { @@ -145,6 +155,7 @@ pub fn get_history(conn: &Connection) -> Result> { bytes_saved: row.get(5)?, ignore_patterns, target_arch: row.get(7)?, + language: row.get::<_, Option>(8)?.unwrap_or_else(|| "GO".to_string().to_uppercase()), }) })?; rows.collect() @@ -152,7 +163,7 @@ pub fn get_history(conn: &Connection) -> Result> { pub fn get_scan_detail(conn: &Connection, scan_id: i64) -> Result> { let mut stmt = conn.prepare( - "SELECT id, file_path, struct_name, line_number, current_size, optimal_size, bytes_saved, has_generics, has_embedded + "SELECT id, file_path, struct_name, line_number, current_size, optimal_size, bytes_saved, has_generics, has_embedded, declaration_kind FROM struct_results WHERE scan_id = ?1 ORDER BY file_path, line_number", )?; let mut file_map: std::collections::BTreeMap> = @@ -169,6 +180,7 @@ pub fn get_scan_detail(conn: &Connection, scan_id: i64) -> Result(7)? != 0, has_embedded: row.get::<_, i64>(8)? != 0, + declaration_kind: row.get::<_, Option>(9)?.unwrap_or_else(|| "struct".to_string()), }, )) })?; @@ -184,7 +196,7 @@ pub fn get_scan_detail(conn: &Connection, scan_id: i64) -> Result Result { conn.query_row( - "SELECT id, scan_id, file_path, struct_name, line_number, current_size, optimal_size, bytes_saved, current_def, optimized_def, has_generics, has_embedded + "SELECT id, scan_id, file_path, struct_name, line_number, current_size, optimal_size, bytes_saved, current_def, optimized_def, has_generics, has_embedded, declaration_kind FROM struct_results WHERE id = ?1", [struct_id], |row| { @@ -201,6 +213,7 @@ pub fn get_struct_detail(conn: &Connection, struct_id: i64) -> Result(10)? != 0, has_embedded: row.get::<_, i64>(11)? != 0, + declaration_kind: row.get::<_, Option>(12)?.unwrap_or_else(|| "struct".to_string()), }) }, ) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7562c3e..0e6d336 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,10 +9,11 @@ use tauri::{AppHandle, Emitter, Manager, State}; mod analyzer; mod db; -mod parser; +mod parsers; use analyzer::Arch; use db::{FileScanResult, ScanSummary, StructDetail}; +use parsers::{go_parser, ts_parser}; pub struct AppState { pub conn: Mutex, @@ -23,6 +24,8 @@ pub struct AppState { pub struct ScanOptions { pub ignore_patterns: Vec, pub target_arch: String, + /// "go" or "typescript" + pub language: String, } #[derive(Debug, Serialize, Clone)] @@ -54,6 +57,8 @@ async fn scan_repo( let arch: Arch = opts.target_arch.parse().unwrap_or(Arch::Amd64); let ignore_patterns = opts.ignore_patterns.clone(); + let language = opts.language.clone().to_uppercase(); + let is_typescript = language == "TS"; let _ = app.emit( "scan-progress", @@ -67,13 +72,19 @@ async fn scan_repo( let path = Path::new(&repo_path).to_path_buf(); let patterns_clone = ignore_patterns.clone(); - let files = parser::walk_repo(&path, &patterns_clone); - - if cancel.load(Ordering::SeqCst) { - return Err("cancelled".to_string()); - } - - let total_files = files.len().max(1); + let files_go; + let files_ts; + let total_files; + + let analyzed = if is_typescript { + files_ts = ts_parser::walk_ts_repo(&path, &patterns_clone); + total_files = files_ts.len().max(1); + analyzer::analyze_ts_files(&files_ts) + } else { + files_go = go_parser::walk_repo(&path, &patterns_clone); + total_files = files_go.len().max(1); + analyzer::analyze_files(&files_go, arch) + }; let mut total_structs: i64 = 0; let mut padded_structs: i64 = 0; let mut total_bytes_saved: i64 = 0; @@ -94,11 +105,10 @@ async fn scan_repo( 0, &ignore_patterns, arch.as_str(), + &language, ) .map_err(|e| e.to_string())?; - let analyzed = analyzer::analyze_files(&files, arch); - for (idx, (file_path, s)) in analyzed.iter().enumerate() { if cancel.load(Ordering::SeqCst) { let _ = db::delete_scan(&conn, scan_id); @@ -134,6 +144,7 @@ async fn scan_repo( &s.optimized_def, s.has_generics, s.has_embedded, + &s.declaration_kind, ) .map_err(|e| e.to_string())?; } @@ -162,6 +173,7 @@ async fn scan_repo( bytes_saved: total_bytes_saved, ignore_patterns, target_arch: arch.as_str().to_string(), + language, }) } diff --git a/src-tauri/src/parser.rs b/src-tauri/src/parsers/go_parser.rs similarity index 98% rename from src-tauri/src/parser.rs rename to src-tauri/src/parsers/go_parser.rs index 0b70863..e0558c4 100644 --- a/src-tauri/src/parser.rs +++ b/src-tauri/src/parsers/go_parser.rs @@ -2,6 +2,8 @@ use std::path::Path; use ignore::Walk; use regex::RegexSet; +use crate::parsers::SKIP_DIRS; + #[derive(Debug, Clone)] pub struct GoField { pub name: String, @@ -51,10 +53,7 @@ pub fn walk_repo(repo_path: &Path, ignore_patterns: &[String]) -> Vec { // Skip vendor and testdata directories let rel_str = rel.as_ref(); - if rel_str.starts_with("vendor/") - || rel_str.contains("/vendor/") - || rel_str.starts_with("testdata/") - || rel_str.contains("/testdata/") + if SKIP_DIRS.iter().any(|d| rel_str.starts_with(d) || rel_str.contains(&format!("/{}/", d))) { continue; } diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs new file mode 100644 index 0000000..f9cffe8 --- /dev/null +++ b/src-tauri/src/parsers/mod.rs @@ -0,0 +1,26 @@ +pub mod go_parser; +pub mod ts_parser; + +/// Directories to always skip +const SKIP_DIRS: &[&str] = &[ + "vendor", + "testdata", + "node_modules", + "dist", + "build", + ".next", + ".nuxt", + "coverage", + ".cache", + ".github", + ".git", + "docs", + "public", + ".vscode", + "assets", + "configs", + "scripts", + "testdata", + ".bin", + ".local", +]; \ No newline at end of file diff --git a/src-tauri/src/parsers/ts_parser.rs b/src-tauri/src/parsers/ts_parser.rs new file mode 100644 index 0000000..3282f59 --- /dev/null +++ b/src-tauri/src/parsers/ts_parser.rs @@ -0,0 +1,308 @@ +use std::path::Path; + +use ignore::Walk; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ClassElement, Declaration, PropertyKey, Statement, TSSignature, TSType}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use regex::RegexSet; + +use crate::parsers::SKIP_DIRS; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TsDeclKind { + Class, + Interface, + TypeAlias, +} + +impl TsDeclKind { + pub fn as_str(&self) -> &'static str { + match self { + TsDeclKind::Class => "class", + TsDeclKind::Interface => "interface", + TsDeclKind::TypeAlias => "type", + } + } +} + +#[derive(Debug, Clone)] +pub struct TsField { + pub name: String, + pub type_str: String, + pub optional: bool, + pub line_number: usize, + pub raw_line: String, +} + +#[derive(Debug, Clone)] +pub struct TsDeclaration { + pub name: String, + pub kind: TsDeclKind, + pub type_params: Option, + pub fields: Vec, + pub doc_comments: Vec, + pub line_number: usize, + pub has_extends: bool, +} + +#[derive(Debug, Clone)] +pub struct TsFile { + pub path: String, + pub declarations: Vec, +} + +fn should_skip(rel_str: &str) -> bool { + for dir in SKIP_DIRS { + if rel_str.starts_with(&format!("{dir}/")) || rel_str.contains(&format!("/{dir}/")) { + return true; + } + } + false +} + +pub fn walk_ts_repo(repo_path: &Path, ignore_patterns: &[String]) -> Vec { + let pattern_set = if ignore_patterns.is_empty() { + None + } else { + RegexSet::new(ignore_patterns).ok() + }; + + let mut files = Vec::new(); + for entry in Walk::new(repo_path).flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext != "ts" && ext != "tsx" { + continue; + } + let rel = path.strip_prefix(repo_path).unwrap_or(path).to_string_lossy(); + let rel_str = rel.as_ref(); + + if should_skip(rel_str) { + continue; + } + + if let Some(ref set) = pattern_set { + if set.is_match(rel_str) { + continue; + } + } + + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + + let is_tsx = ext == "tsx"; + let declarations = parse_ts_declarations(&content, is_tsx); + if !declarations.is_empty() { + files.push(TsFile { + path: path.to_string_lossy().into_owned(), + declarations, + }); + } + } + files +} + +/// Convert a byte offset into a 1-based line number. +fn byte_to_line(src: &str, offset: usize) -> usize { + let safe = offset.min(src.len()); + src[..safe].bytes().filter(|&b| b == b'\n').count() + 1 +} + +/// Extract the full source line that contains `offset`. +fn line_at(src: &str, offset: usize) -> String { + let safe = offset.min(src.len()); + let line_start = src[..safe].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_end = src[safe..].find('\n').map(|i| i + safe).unwrap_or(src.len()); + src[line_start..line_end].to_string() +} + +/// Walk backwards through lines immediately before `decl_start` collecting `//` comments. +fn doc_comments_before(src: &str, decl_start: usize) -> Vec { + let before = &src[..decl_start.min(src.len())]; + let lines: Vec<&str> = before.lines().collect(); + let mut comments = Vec::new(); + let mut idx = lines.len(); + while idx > 0 { + idx -= 1; + let trimmed = lines[idx].trim(); + if trimmed.starts_with("//") { + comments.insert(0, lines[idx].to_string()); + } else { + break; + } + } + comments +} + +/// Strip a leading `: ` from a type-annotation span slice. +fn strip_colon(s: &str) -> &str { + s.trim_start_matches(':').trim_start_matches('?').trim_start_matches(':').trim() +} + +// --------------------------------------------------------------------------- +// Field extraction helpers +// --------------------------------------------------------------------------- + +fn field_name_from_key(key: &PropertyKey) -> Option { + match key { + PropertyKey::StaticIdentifier(ident) => Some(ident.name.as_str().to_string()), + PropertyKey::StringLiteral(lit) => Some(lit.value.as_str().to_string()), + // Skip private (#field) and computed ([expr]) keys + _ => None, + } +} + +fn type_str_from_annotation(ann_span_start: u32, ann_span_end: u32, src: &str) -> String { + let text = &src[ann_span_start as usize..ann_span_end as usize]; + strip_colon(text).to_string() +} + +// --------------------------------------------------------------------------- +// Per-declaration handlers +// --------------------------------------------------------------------------- + +fn handle_class(cls: &oxc_ast::ast::Class, src: &str, results: &mut Vec) { + let Some(ref id) = cls.id else { return }; + let name = id.name.as_str().to_string(); + let line_number = byte_to_line(src, cls.span.start as usize); + let has_extends = cls.super_class.is_some(); + let type_params = cls.type_parameters.as_ref().map(|tp| { + src[tp.span.start as usize..tp.span.end as usize].to_string() + }); + let doc_comments = doc_comments_before(src, cls.span.start as usize); + + let fields = cls + .body + .body + .iter() + .filter_map(|element| { + let ClassElement::PropertyDefinition(prop) = element else { return None }; + // Skip static properties — they don't live in the instance shape + if prop.r#static { + return None; + } + let field_name = field_name_from_key(&prop.key)?; + let type_str = if let Some(ann) = &prop.type_annotation { + type_str_from_annotation(ann.span.start, ann.span.end, src) + } else { + "any".to_string() + }; + let lnum = byte_to_line(src, prop.span.start as usize); + let raw = line_at(src, prop.span.start as usize); + Some(TsField { name: field_name, type_str, optional: prop.optional, line_number: lnum, raw_line: raw }) + }) + .collect(); + + results.push(TsDeclaration { name, kind: TsDeclKind::Class, type_params, fields, doc_comments, line_number, has_extends }); +} + +fn handle_interface(iface: &oxc_ast::ast::TSInterfaceDeclaration, src: &str, results: &mut Vec) { + let name = iface.id.name.as_str().to_string(); + let line_number = byte_to_line(src, iface.span.start as usize); + let has_extends = !iface.extends.is_empty(); + let type_params = iface.type_parameters.as_ref().map(|tp| { + src[tp.span.start as usize..tp.span.end as usize].to_string() + }); + let doc_comments = doc_comments_before(src, iface.span.start as usize); + + let fields = iface + .body + .body + .iter() + .filter_map(|sig| { + let TSSignature::TSPropertySignature(prop) = sig else { return None }; + let field_name = field_name_from_key(&prop.key)?; + let type_str = if let Some(ann) = &prop.type_annotation { + type_str_from_annotation(ann.span.start, ann.span.end, src) + } else { + "any".to_string() + }; + let lnum = byte_to_line(src, prop.span.start as usize); + let raw = line_at(src, prop.span.start as usize); + Some(TsField { name: field_name, type_str, optional: prop.optional, line_number: lnum, raw_line: raw }) + }) + .collect(); + + results.push(TsDeclaration { name, kind: TsDeclKind::Interface, type_params, fields, doc_comments, line_number, has_extends }); +} + +fn handle_type_alias(alias: &oxc_ast::ast::TSTypeAliasDeclaration, src: &str, results: &mut Vec) { + // Only handle object type literals — `type Foo = { ... }` + let TSType::TSTypeLiteral(lit) = &alias.type_annotation else { return }; + + let name = alias.id.name.as_str().to_string(); + let line_number = byte_to_line(src, alias.span.start as usize); + let type_params = alias.type_parameters.as_ref().map(|tp| { + src[tp.span.start as usize..tp.span.end as usize].to_string() + }); + let doc_comments = doc_comments_before(src, alias.span.start as usize); + + let fields = lit + .members + .iter() + .filter_map(|sig| { + let TSSignature::TSPropertySignature(prop) = sig else { return None }; + let field_name = field_name_from_key(&prop.key)?; + let type_str = if let Some(ann) = &prop.type_annotation { + type_str_from_annotation(ann.span.start, ann.span.end, src) + } else { + "any".to_string() + }; + let lnum = byte_to_line(src, prop.span.start as usize); + let raw = line_at(src, prop.span.start as usize); + Some(TsField { name: field_name, type_str, optional: prop.optional, line_number: lnum, raw_line: raw }) + }) + .collect(); + + results.push(TsDeclaration { name, kind: TsDeclKind::TypeAlias, type_params, fields, doc_comments, line_number, has_extends: false }); +} + +fn handle_declaration(decl: &Declaration, src: &str, results: &mut Vec) { + match decl { + Declaration::ClassDeclaration(cls) => handle_class(cls, src, results), + Declaration::TSInterfaceDeclaration(iface) => handle_interface(iface, src, results), + Declaration::TSTypeAliasDeclaration(alias) => handle_type_alias(alias, src, results), + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +pub fn parse_ts_declarations(src: &str, is_tsx: bool) -> Vec { + let allocator = Allocator::default(); + let source_type = if is_tsx { SourceType::tsx() } else { SourceType::ts() }; + let ret = Parser::new(&allocator, src, source_type).parse(); + + let mut results = Vec::new(); + for stmt in &ret.program.body { + match stmt { + // Non-exported declarations (direct variants) + Statement::ClassDeclaration(cls) => handle_class(cls, src, &mut results), + Statement::TSInterfaceDeclaration(iface) => handle_interface(iface, src, &mut results), + Statement::TSTypeAliasDeclaration(alias) => handle_type_alias(alias, src, &mut results), + // `export class/interface/type ...` + Statement::ExportNamedDeclaration(exp) => { + if let Some(decl) = &exp.declaration { + handle_declaration(decl, src, &mut results); + } + } + // `export default class Foo { ... }` + Statement::ExportDefaultDeclaration(exp) => { + use oxc_ast::ast::ExportDefaultDeclarationKind; + if let ExportDefaultDeclarationKind::ClassDeclaration(cls) = &exp.declaration { + handle_class(cls, src, &mut results); + } + } + _ => {} + } + } + results +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ab96aaa..7f37d4b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lineup", - "version": "1.0.0", + "version": "1.1.0", "identifier": "com.capabletechnology.lineup", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/components/HistoryCard.tsx b/src/components/HistoryCard.tsx index 9bc8bd7..063d073 100644 --- a/src/components/HistoryCard.tsx +++ b/src/components/HistoryCard.tsx @@ -17,9 +17,14 @@ const HistoryCard: Component = (props) => { return (
-

- {props.scan.repo_path} -

+
+

+ {props.scan.repo_path} +

+ + {props.scan.language.toLocaleUpperCase()} + +
{fmt(props.scan.scanned_at)} {props.scan.total_structs} structs diff --git a/src/components/ScanOptionsModal.tsx b/src/components/ScanOptionsModal.tsx index e74078e..eb212fb 100644 --- a/src/components/ScanOptionsModal.tsx +++ b/src/components/ScanOptionsModal.tsx @@ -1,7 +1,8 @@ import { Dialog } from "@kobalte/core/dialog"; import type { Component } from "solid-js"; -import { createSignal, For } from "solid-js"; +import { createSignal, For, Show } from "solid-js"; import type { ScanOptions } from "@/types"; +import { getPlaceholderText } from "@/util"; interface Props { open: boolean; @@ -12,6 +13,9 @@ interface Props { } const ScanOptionsModal: Component = (props) => { + const [language, setLanguage] = createSignal( + props.initialOptions?.language ?? "GO", + ); const [arch, setArch] = createSignal( props.initialOptions?.target_arch ?? "amd64", ); @@ -46,7 +50,11 @@ const ScanOptionsModal: Component = (props) => { .split("\n") .map((p) => p.trim()) .filter(Boolean); - props.onStart({ ignore_patterns: patterns, target_arch: arch() }); + props.onStart({ + ignore_patterns: patterns, + target_arch: arch(), + language: language(), + }); }; return ( @@ -66,37 +74,75 @@ const ScanOptionsModal: Component = (props) => { {props.repoPath}

- {/* Architecture */} + {/* Language */}

- Target Architecture + Language

+

+ {language() === "TS" + ? "Scans .ts / .tsx files for V8 JIT layout inefficiencies." + : "Scans .go files for struct field padding waste."} +

+ {/* Architecture — only relevant for Go */} + +
+

+ Target Architecture +

+
+ + +
+
+
+ {/* Ignore patterns */}