├── .devcontainer └── devcontainer.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ ├── setup-script │ │ └── action.yml │ └── setup-ui-deps │ │ └── action.yml ├── codecov.yml ├── dependabot.yml └── workflows │ ├── build-webui.yml │ ├── built-tests.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── release.yml │ └── scorecard.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── README_zh_CN.md ├── SECURITY.md ├── assets └── example.svg ├── cmd ├── gsa │ ├── command.go │ ├── entry.go │ ├── init.go │ ├── main.go │ ├── pgo_main.go │ └── profiler_main.go └── wasm │ └── main_js_wasm.go ├── go.mod ├── go.sum ├── internal ├── analyze.go ├── analyze_test.go ├── constant │ └── const.go ├── diff │ ├── diff.go │ ├── diff_test.go │ ├── load.go │ ├── load_test.go │ ├── printer.go │ ├── printer_test.go │ ├── result.go │ ├── result_test.go │ ├── utils.go │ └── utils_test.go ├── disasm │ ├── doc.go │ ├── extract.go │ ├── interface.go │ ├── interface_test.go │ └── x86_pattern.go ├── dwarf │ ├── dwarf.go │ ├── dwarf_info.go │ ├── dwarf_info_test.go │ ├── read.go │ ├── utils.go │ └── utils_test.go ├── entity │ ├── addr.go │ ├── addr_test.go │ ├── analyzer.go │ ├── coverage.go │ ├── coverage_test.go │ ├── file.go │ ├── file_marshaler.go │ ├── file_test.go │ ├── function.go │ ├── knownaddr.go │ ├── marshaler │ │ └── file_normal.go │ ├── package.go │ ├── package_marshaler.go │ ├── pcln_symbol.go │ ├── section.go │ ├── section_marshaler.go │ ├── sectionstore.go │ ├── sectionstore_test.go │ ├── space.go │ ├── space_test.go │ ├── symbol.go │ ├── symbol_marshaler.go │ ├── symbol_test.go │ └── type.go ├── knowninfo │ ├── collect.go │ ├── dependencies.go │ ├── disasm.go │ ├── disasm_js_wasm.go │ ├── dwarf.go │ ├── dwarf_test.go │ ├── imports.go │ ├── imports_wasm.go │ ├── knowninfo.go │ ├── section.go │ └── symbol.go ├── printer │ ├── console.go │ ├── html.go │ ├── json.go │ ├── svg.go │ └── wasm │ │ └── obj_js_wasm.go ├── result │ ├── result.go │ ├── result_js_wasm_test.go │ ├── result_marshaler.go │ ├── result_marshaler_test.go │ └── result_test.go ├── test │ ├── test.go │ └── testutils │ │ └── utils.go ├── tui │ ├── const.go │ ├── detail_model.go │ ├── key.go │ ├── main_model.go │ ├── table.go │ ├── tui.go │ ├── tui_test.go │ ├── update.go │ ├── view.go │ ├── wrapper.go │ └── wrapper_test.go ├── utils │ ├── addr.go │ ├── addr_test.go │ ├── debug.go │ ├── debug_debug.go │ ├── gc.go │ ├── gc_wasm.go │ ├── log.go │ ├── log_test.go │ ├── map.go │ ├── map_test.go │ ├── reader.go │ ├── reader_test.go │ ├── set.go │ ├── set_test.go │ ├── signal.go │ ├── table.go │ ├── utils.go │ └── utils_test.go ├── webui │ ├── .gitignore │ ├── embed.go │ ├── embed_test.go │ ├── flag.go │ ├── net.go │ ├── net_test.go │ ├── server.go │ └── server_test.go └── wrapper │ ├── elf.go │ ├── elf_test.go │ ├── macho.go │ ├── macho_test.go │ ├── pe.go │ ├── pe_test.go │ ├── static.go │ ├── wasm.go │ ├── wasm_future.go │ ├── wrapper.go │ └── wrapper_test.go ├── scripts ├── .gitignore ├── .python-version ├── binaries.csv ├── ensure.py ├── generate.py ├── pgo.py ├── pyproject.toml ├── report.py ├── research │ ├── .gitignore │ └── watch.py ├── skip.csv ├── tests.py ├── tool │ ├── __init__.py │ ├── example.py │ ├── gsa.py │ ├── html.py │ ├── junit.py │ ├── matplotlib.py │ ├── merge.py │ ├── process.py │ ├── remote.py │ └── utils.py ├── uv.lock └── wasm.py ├── testdata ├── result.gob.gz ├── result.json └── wasm │ ├── main.go │ └── test.wasm ├── typos.toml ├── ui ├── .gitignore ├── README.md ├── eslint.config.mjs ├── index.html ├── package.json ├── pnpm-lock.yaml ├── src │ ├── Node.tsx │ ├── Tooltip.test.tsx │ ├── Tooltip.tsx │ ├── TreeMap.tsx │ ├── Treemap.test.tsx │ ├── __snapshots__ │ │ └── Treemap.test.tsx.snap │ ├── explorer │ │ ├── Explorer.test.tsx │ │ ├── Explorer.tsx │ │ ├── Explorer.wasm.test.tsx │ │ ├── FileSelector.test.tsx │ │ └── FileSelector.tsx │ ├── explorer_main.tsx │ ├── main.tsx │ ├── runtime │ │ ├── fs.d.ts │ │ ├── fs.js │ │ ├── wasm_exec.d.ts │ │ └── wasm_exec.js │ ├── schema │ │ └── schema.ts │ ├── style.scss │ ├── test │ │ └── testhelper.ts │ ├── tool │ │ ├── __snapshots__ │ │ │ └── entry.test.ts.snap │ │ ├── aligner.test.ts │ │ ├── aligner.ts │ │ ├── color.ts │ │ ├── const.ts │ │ ├── copy.ts │ │ ├── entry.test.ts │ │ ├── entry.ts │ │ ├── id.ts │ │ ├── useHash.test.ts │ │ ├── useHash.ts │ │ ├── useMouse.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ └── worker │ │ ├── __mocks__ │ │ └── helper.ts │ │ ├── event.ts │ │ ├── helper.ts │ │ └── worker.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.common.ts ├── vite.config-explorer.ts ├── vite.config.ts ├── vitest.config.ts └── vitest.setup.ts ├── version.go └── version_test.go /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/devcontainers/features/node:1": {}, 5 | "ghcr.io/devcontainers/features/go:1": {}, 6 | "ghcr.io/devcontainers/features/python:1": { 7 | "version": "3.12", 8 | "toolsToInstall": "flake8,autopep8" 9 | }, 10 | "ghcr.io/devcontainers/features/git:1": {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Zxilly -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/setup-script/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup test script 2 | description: 'Setup test script, should be called after checkout' 3 | inputs: 4 | cache: 5 | default: "" 6 | description: 'Cache key for test bins' 7 | report: 8 | default: "false" 9 | required: false 10 | description: 'Report results' 11 | script: 12 | required: true 13 | description: 'Script to run' 14 | coverage: 15 | default: "false" 16 | required: false 17 | description: "Collect coverage" 18 | cache-python: 19 | default: "true" 20 | required: false 21 | description: "Cache python dependencies" 22 | runs: 23 | using: 'composite' 24 | steps: 25 | - name: Setup testbin cache 26 | shell: bash 27 | if: ${{ runner.environment != 'github-hosted' }} 28 | run: | 29 | echo "TESTDATA_PATH=/opt/cache/testdata" >> $GITHUB_ENV 30 | 31 | - name: Install uv 32 | uses: astral-sh/setup-uv@c7f87aa956e4c323abf06d5dec078e358f6b4d04 # v6.0.0 33 | with: 34 | working-directory: './scripts' 35 | 36 | - name: Add python dependencies 37 | shell: bash 38 | working-directory: ./scripts 39 | run: | 40 | uv sync 41 | 42 | - name: Cache test binaries 43 | if: ${{ inputs.cache != '' && runner.environment == 'github-hosted' }} 44 | uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 45 | with: 46 | path: ./scripts/bins 47 | key: testbins-${{ hashFiles('scripts/binaries.csv') }}-${{ inputs.cache }}-v2 48 | enableCrossOsArchive: true 49 | 50 | - name: Run script 51 | shell: bash 52 | working-directory: ./scripts 53 | run: uv run ${{ inputs.script }} 54 | 55 | - name: Report results 56 | if: ${{ inputs.report == 'true' }} 57 | shell: bash 58 | working-directory: ./scripts 59 | run: uv run python report.py 60 | -------------------------------------------------------------------------------- /.github/actions/setup-ui-deps/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup UI dependencies 2 | description: 'Setup UI dependencies for linting and testing' 3 | runs: 4 | using: 'composite' 5 | steps: 6 | - name: Set up Node.js 7 | uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 8 | with: 9 | node-version: '22' 10 | 11 | - name: Set up pnpm 12 | uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 13 | with: 14 | package_json_file: 'ui/package.json' 15 | 16 | - name: Print pnpm version 17 | shell: bash 18 | run: pnpm --version 19 | 20 | - name: Get pnpm store directory 21 | shell: bash 22 | run: | 23 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 24 | 25 | - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 26 | name: Setup pnpm cache 27 | with: 28 | path: ${{ env.STORE_PATH }} 29 | key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 30 | restore-keys: | 31 | ${{ runner.os }}-${{ runner.arch }}-pnpm-store- 32 | 33 | - name: Install node dependencies 34 | shell: bash 35 | working-directory: ./ui 36 | run: | 37 | pnpm install --frozen-lockfile 38 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | allow_pseudo_compare: true 3 | allow_coverage_offsets: true 4 | 5 | coverage: 6 | status: 7 | patch: 8 | default: 9 | target: 10% 10 | threshold: 10% 11 | project: 12 | default: 13 | threshold: 5% 14 | target: 70% 15 | 16 | comment: 17 | layout: "condensed_header, condensed_files, diff, flags, condensed_footer" 18 | behavior: default 19 | require_changes: false 20 | hide_project_coverage: true 21 | 22 | flags: 23 | unit: 24 | carryforward: true 25 | integration: 26 | carryforward: true 27 | 28 | component_management: 29 | individual_components: 30 | - component_id: "analyzer" 31 | name: "Analyzer" 32 | paths: 33 | - "/**.go" 34 | - component_id: "ui" 35 | name: "Web UI" 36 | paths: 37 | - "ui/**" -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | gomod-normal-deps: 9 | update-types: 10 | - patch 11 | - minor 12 | gomod-breaking-deps: 13 | update-types: 14 | - major 15 | 16 | - package-ecosystem: "npm" 17 | directory: "/ui" 18 | schedule: 19 | interval: "weekly" 20 | groups: 21 | ui-prod-deps: 22 | dependency-type: production 23 | ui-dev-deps: 24 | dependency-type: development 25 | 26 | - package-ecosystem: "pip" 27 | directory: "/scripts" 28 | schedule: 29 | interval: "weekly" 30 | groups: 31 | pip-deps: 32 | patterns: 33 | - "*" 34 | 35 | - package-ecosystem: "github-actions" 36 | directory: "/" 37 | schedule: 38 | interval: "weekly" 39 | groups: 40 | actions-deps: 41 | patterns: 42 | - "*" -------------------------------------------------------------------------------- /.github/workflows/build-webui.yml: -------------------------------------------------------------------------------- 1 | name: Build UI 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | release: 7 | description: 'Make a release for the UI' 8 | type: boolean 9 | required: true 10 | secrets: 11 | CODECOV_TOKEN: 12 | required: false 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build-webui: 19 | name: Build Treemap WebUI 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: write 23 | steps: 24 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Setup UI dependencies 29 | uses: ./.github/actions/setup-ui-deps 30 | 31 | - name: Build 32 | working-directory: ./ui 33 | run: pnpm run build:ui 34 | env: 35 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 36 | PULL_REQUEST_COMMIT_SHA: ${{ github.event.pull_request.head.sha }} 37 | 38 | - name: Release 39 | if: ${{ inputs.release }} 40 | uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0 41 | with: 42 | artifactErrorsFailBuild: true 43 | allowUpdates: true 44 | artifactContentType: 'text/html' 45 | artifacts: 'ui/dist/webui/index.html' 46 | tag: ui-v1 47 | commit: master 48 | prerelease: true 49 | 50 | - name: Upload artifact 51 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 52 | with: 53 | name: ui 54 | path: 'ui/dist/webui/index.html' 55 | 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: '31 23 * * 3' 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | GOTOOLCHAIN: "local" 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze (${{ matrix.language }}) 20 | runs-on: 'ubuntu-latest' 21 | timeout-minutes: 360 22 | permissions: 23 | security-events: write 24 | packages: read 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | include: 30 | - language: go 31 | build-mode: autobuild 32 | - language: javascript-typescript 33 | build-mode: none 34 | - language: python 35 | build-mode: none 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 39 | 40 | - name: Setup Go 41 | if: matrix.language == 'go' 42 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 43 | with: 44 | go-version: '1.24.2' 45 | cache: true 46 | check-latest: true 47 | 48 | - name: Setup Python 49 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 50 | with: 51 | python-version: '3.12' 52 | check-latest: true 53 | 54 | - name: Initialize CodeQL 55 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 56 | with: 57 | languages: ${{ matrix.language }} 58 | build-mode: ${{ matrix.build-mode }} 59 | 60 | - name: Perform CodeQL Analysis 61 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 62 | with: 63 | category: "/language:${{matrix.language}}" 64 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: 'Checkout Repository' 13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | 15 | - name: 'Dependency Review' 16 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: '36 15 * * 5' 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | GOTOOLCHAIN: "local" 16 | 17 | jobs: 18 | eslint: 19 | permissions: 20 | security-events: write 21 | checks: write 22 | name: ESLint 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | 28 | - name: Setup UI dependencies 29 | uses: ./.github/actions/setup-ui-deps 30 | 31 | - name: Run ESLint 32 | working-directory: ./ui 33 | run: >- 34 | pnpm eslint . 35 | --format @microsoft/eslint-formatter-sarif 36 | --output-file eslint-results.sarif 37 | continue-on-error: true 38 | 39 | - name: Upload analysis results to GitHub 40 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 41 | with: 42 | sarif_file: ./ui/eslint-results.sarif 43 | wait-for-processing: true 44 | 45 | golangci-lint: 46 | name: GolangCI Lint 47 | runs-on: ubuntu-latest 48 | permissions: 49 | security-events: write 50 | checks: write 51 | steps: 52 | - name: Checkout Actions Repository 53 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 54 | 55 | - name: Setup Go 56 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 57 | with: 58 | go-version: '1.24.2' 59 | cache: false 60 | check-latest: true 61 | 62 | - name: Run golangci-lint 63 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 64 | with: 65 | version: 'latest' 66 | install-mode: 'binary' 67 | args: '--output.text.path stdout --output.sarif.path golangci-lint-results.sarif' 68 | continue-on-error: true 69 | 70 | - name: Upload analysis results to GitHub 71 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 72 | with: 73 | sarif_file: ./golangci-lint-results.sarif 74 | wait-for-processing: true 75 | 76 | typos: 77 | name: Spell Check with Typos 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Checkout 81 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 82 | 83 | - name: typos-action 84 | uses: crate-ci/typos@0f0ccba9ed1df83948f0c15026e4f5ccfce46109 # v1.32.0 85 | with: 86 | config: typos.toml 87 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: read-all 9 | 10 | env: 11 | GOTOOLCHAIN: "local" 12 | 13 | jobs: 14 | build-ui: 15 | name: Build Treemap WebUI 16 | permissions: 17 | contents: write 18 | uses: ./.github/workflows/build-webui.yml 19 | with: 20 | release: false 21 | 22 | goreleaser: 23 | name: Release 24 | permissions: 25 | contents: write 26 | id-token: write 27 | attestations: write 28 | needs: build-ui 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Get tags 36 | run: git fetch --tags 37 | 38 | - name: Setup Go 39 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 40 | with: 41 | go-version: '1.24.2' 42 | cache: true 43 | check-latest: true 44 | 45 | - name: Download UI file 46 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 47 | with: 48 | name: ui 49 | path: internal/webui 50 | 51 | - name: Collect profiles 52 | uses: ./.github/actions/setup-script 53 | with: 54 | cache: 'integration-real' 55 | script: 'pgo.py' 56 | coverage: 'true' 57 | 58 | - name: Download deps 59 | run: go mod download 60 | 61 | - name: Build and release 62 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 63 | with: 64 | distribution: goreleaser 65 | version: latest 66 | args: release --clean 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | 70 | - name: Attest build provenance 71 | id: attest 72 | uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 73 | with: 74 | subject-path: | 75 | dist/go-size-analyzer*.apk 76 | dist/go-size-analyzer*.deb 77 | dist/go-size-analyzer*.pkg.tar.zst 78 | dist/go-size-analyzer*.rpm 79 | dist/go-size-analyzer*.tar.gz 80 | dist/go-size-analyzer*.zip 81 | 82 | - name: Upload build provenance 83 | uses: svenstaro/upload-release-action@04733e069f2d7f7f0b4aebc4fbdbce8613b03ccd # v2.9.0 84 | with: 85 | asset_name: attestations.intoto.jsonl 86 | file: ${{ steps.attest.outputs.bundle-path }} 87 | overwrite: false -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | name: Scorecard supply-chain security 2 | on: 3 | branch_protection_rule: 4 | schedule: 5 | - cron: '44 14 * * 4' 6 | push: 7 | branches: [ "master" ] 8 | 9 | permissions: read-all 10 | 11 | jobs: 12 | analysis: 13 | name: Scorecard analysis 14 | runs-on: ubuntu-latest 15 | permissions: 16 | security-events: write 17 | id-token: write 18 | 19 | steps: 20 | - name: "Checkout code" 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | with: 23 | persist-credentials: false 24 | 25 | - name: "Run analysis" 26 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 27 | with: 28 | results_file: results.sarif 29 | results_format: sarif 30 | publish_results: true 31 | 32 | - name: "Upload artifact" 33 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 34 | with: 35 | name: SARIF file 36 | path: results.sarif 37 | retention-days: 5 38 | 39 | - name: "Upload to code-scanning" 40 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 41 | with: 42 | sarif_file: results.sarif 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | *.test 7 | *.out 8 | go.work 9 | 10 | dist/ 11 | 12 | vendor/ 13 | venv/ 14 | 15 | .idea/ 16 | .vscode/ 17 | 18 | results/ 19 | covdata/ 20 | temp/ 21 | 22 | *.profile 23 | default.pgo 24 | 25 | data.json 26 | 27 | *.sarif 28 | 29 | unit*.xml -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | version: 2 4 | 5 | before: 6 | hooks: 7 | - go mod tidy 8 | 9 | builds: 10 | - binary: gsa 11 | main: ./cmd/gsa 12 | ldflags: 13 | - "-s -w" 14 | - -X github.com/Zxilly/go-size-analyzer.version={{.Version}} 15 | - -X github.com/Zxilly/go-size-analyzer.buildDate={{.Date}} 16 | env: 17 | - CGO_ENABLED=0 18 | goos: 19 | - linux 20 | - darwin 21 | - windows 22 | goarch: 23 | - amd64 24 | - arm64 25 | goamd64: 26 | - v3 27 | flags: 28 | - "-trimpath" 29 | - "-pgo=default.pgo" 30 | tags: 31 | - embed 32 | - nethttpomithttp2 33 | 34 | archives: 35 | - formats: tar.gz 36 | name_template: >- 37 | {{- .ProjectName }}_ 38 | {{- .Version }}_ 39 | {{- .Os }}_ 40 | {{- .Arch }} 41 | format_overrides: 42 | - goos: windows 43 | formats: zip 44 | 45 | nfpms: 46 | - id: go-size-analyzer 47 | package_name: go-size-analyzer 48 | homepage: https://github.com/Zxilly/go-size-analyzer 49 | description: A tool for analyzing the dependencies in compiled Golang binaries, providing insight into their impact on the final build. 50 | maintainer: Zxilly 51 | license: AGPL-3.0-only 52 | formats: 53 | - apk 54 | - deb 55 | - rpm 56 | - archlinux 57 | 58 | checksum: 59 | name_template: 'checksums.txt' 60 | 61 | changelog: 62 | sort: asc 63 | use: github 64 | filters: 65 | exclude: 66 | - "^docs:" 67 | - "^test:" 68 | - "^chore:" 69 | - "^ci:" -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | If you discover a security vulnerability, please send an email to **zxilly@outlook.com**. Please do not create a public issue for the vulnerability. 12 | 13 | Your email should include the following information: 14 | 15 | - A description of the vulnerability 16 | - Steps to reproduce the vulnerability 17 | - Possible impact of the vulnerability 18 | - Any suggested mitigation or remediation steps 19 | 20 | We will respond to your email as soon as possible and work with you to address any security issues. 21 | 22 | ## Bug Bounty Program 23 | 24 | At this time, we do not offer a bug bounty program. 25 | 26 | ## Maintainer Responsibilities 27 | 28 | Maintainers are responsible for the security of the project. This includes: 29 | 30 | - Responding to security reports in a timely manner 31 | - Investigating reported vulnerabilities 32 | - Developing and releasing patches for confirmed vulnerabilities 33 | - Communicating with the reporting party as the issue is addressed 34 | 35 | ## Disclosures 36 | 37 | - We will acknowledge receipt of your report within one of our business days as soon as possible. 38 | - We will confirm the vulnerability and determine its impact. 39 | - We will release a fix as soon as possible, depending on the complexity of the issue. 40 | - We will communicate the vulnerability and any patches or workarounds to our users. 41 | 42 | Thank you for your help in making Community Standards project/repository a more secure place. 43 | -------------------------------------------------------------------------------- /cmd/gsa/init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/Zxilly/go-size-analyzer/internal/utils" 4 | 5 | func init() { 6 | utils.ApplyMemoryLimit() 7 | } 8 | -------------------------------------------------------------------------------- /cmd/gsa/main.go: -------------------------------------------------------------------------------- 1 | //go:build !profiler && !pgo && !wasm 2 | 3 | package main 4 | 5 | import "github.com/Zxilly/go-size-analyzer/internal/utils" 6 | 7 | func main() { 8 | if err := entry(); err != nil { 9 | utils.FatalError(err) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /cmd/gsa/pgo_main.go: -------------------------------------------------------------------------------- 1 | //go:build pgo && !wasm 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | 8 | "github.com/knadh/profiler" 9 | 10 | "github.com/Zxilly/go-size-analyzer/internal/utils" 11 | ) 12 | 13 | func main() { 14 | utils.UsePanicForExit() 15 | 16 | outputDir := os.Getenv("OUTPUT_DIR") 17 | if outputDir == "" { 18 | panic("OUTPUT_DIR environment variable is not set") 19 | } 20 | 21 | p := profiler.New( 22 | profiler.Conf{ 23 | DirPath: outputDir, 24 | NoShutdownHook: true, 25 | }, 26 | profiler.Cpu, 27 | ) 28 | 29 | p.Start() 30 | defer p.Stop() 31 | 32 | if err := entry(); err != nil { 33 | utils.FatalError(err) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cmd/gsa/profiler_main.go: -------------------------------------------------------------------------------- 1 | //go:build profiler && !wasm 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "runtime/pprof" 11 | "time" 12 | 13 | "github.com/knadh/profiler" 14 | 15 | "github.com/Zxilly/go-size-analyzer/internal/utils" 16 | ) 17 | 18 | func main() { 19 | utils.UsePanicForExit() 20 | 21 | outputDir := os.Getenv("OUTPUT_DIR") 22 | if outputDir == "" { 23 | panic("OUTPUT_DIR environment variable is not set") 24 | } 25 | 26 | targets := []int{profiler.Cpu, profiler.Mutex, profiler.Goroutine, profiler.Block, profiler.ThreadCreate, profiler.Trace} 27 | 28 | p := profiler.New( 29 | profiler.Conf{ 30 | DirPath: outputDir, 31 | NoShutdownHook: true, 32 | }, 33 | targets..., 34 | ) 35 | 36 | p.Start() 37 | defer p.Stop() 38 | 39 | startWriteHeapProfile(outputDir) 40 | defer stopWriteHeapProfile() 41 | 42 | pprof.Lookup("heap") 43 | 44 | if err := entry(); err != nil { 45 | utils.FatalError(err) 46 | } 47 | } 48 | 49 | var heapProfileStop context.CancelFunc 50 | 51 | func startWriteHeapProfile(outputDir string) { 52 | var ctx context.Context 53 | ctx, heapProfileStop = context.WithCancel(context.Background()) 54 | 55 | go func() { 56 | ticker := time.NewTicker(1 * time.Second) 57 | defer ticker.Stop() 58 | 59 | id := 0 60 | 61 | write := func() { 62 | id++ 63 | path := filepath.Join(outputDir, fmt.Sprintf("mem-%d.pprof", id)) 64 | f, err := os.Create(path) 65 | defer func(f *os.File) { 66 | err = f.Close() 67 | if err != nil { 68 | panic(err) 69 | } 70 | }(f) 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | err = pprof.WriteHeapProfile(f) 76 | if err != nil { 77 | panic(err) 78 | } 79 | } 80 | 81 | for { 82 | select { 83 | case <-ctx.Done(): 84 | write() 85 | return 86 | case <-ticker.C: 87 | write() 88 | } 89 | } 90 | }() 91 | } 92 | 93 | func stopWriteHeapProfile() { 94 | heapProfileStop() 95 | } 96 | -------------------------------------------------------------------------------- /cmd/wasm/main_js_wasm.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "log/slog" 9 | "syscall/js" 10 | 11 | "github.com/Zxilly/go-size-analyzer/internal" 12 | "github.com/Zxilly/go-size-analyzer/internal/printer/wasm" 13 | "github.com/Zxilly/go-size-analyzer/internal/utils" 14 | ) 15 | 16 | func analyze(_ js.Value, args []js.Value) any { 17 | utils.InitLogger(slog.LevelDebug) 18 | 19 | name := args[0].String() 20 | length := args[1].Length() 21 | data := make([]byte, length) 22 | js.CopyBytesToGo(data, args[1]) 23 | 24 | reader := bytes.NewReader(data) 25 | 26 | result, err := internal.Analyze(name, reader, uint64(length), internal.Options{ 27 | SkipDisasm: true, 28 | }) 29 | if err != nil { 30 | slog.Error(fmt.Sprintf("Error: %v\n", err)) 31 | return js.ValueOf(nil) 32 | } 33 | 34 | return wasm.JavaScript(result) 35 | } 36 | 37 | func main() { 38 | utils.ApplyMemoryLimit() 39 | 40 | js.Global().Set("gsa_analyze", js.FuncOf(analyze)) 41 | js.Global().Get("console").Call("log", "Go size analyzer initialized") 42 | 43 | select {} 44 | } 45 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Zxilly/go-size-analyzer 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/ZxillyFork/gore v0.0.0-20250515160049-66c9fdb68efe 7 | github.com/ZxillyFork/gosym v0.0.0-20240510024817-deed2b882525 8 | github.com/ZxillyFork/trie v0.0.0-20240512061834-f75150731646 9 | github.com/ZxillyFork/wazero v0.0.0-20250514163401-2d87ad97b37c 10 | github.com/alecthomas/kong v1.11.0 11 | github.com/blacktop/go-macho v1.1.246 12 | github.com/charmbracelet/bubbles v0.21.0 13 | github.com/charmbracelet/bubbletea v1.3.5 14 | github.com/charmbracelet/lipgloss v1.1.0 15 | github.com/charmbracelet/x/exp/teatest v0.0.0-20250514204301-7f4ee4d0d5fe 16 | github.com/charmbracelet/x/term v0.2.1 17 | github.com/dustin/go-humanize v1.0.1 18 | github.com/go-delve/delve v1.24.2 19 | github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 20 | github.com/jedib0t/go-pretty/v6 v6.6.7 21 | github.com/knadh/profiler v0.2.0 22 | github.com/muesli/reflow v0.3.0 23 | github.com/muesli/termenv v0.16.0 24 | github.com/nikolaydubina/treemap v1.2.5 25 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 26 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c 27 | github.com/puzpuzpuz/xsync/v4 v4.1.0 28 | github.com/samber/lo v1.50.0 29 | github.com/stretchr/testify v1.10.0 30 | golang.org/x/arch v0.17.0 31 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 32 | golang.org/x/net v0.40.0 33 | golang.org/x/sync v0.14.0 34 | ) 35 | 36 | require ( 37 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 38 | github.com/aymanbagabas/go-udiff v0.2.0 // indirect 39 | github.com/blacktop/go-dwarf v1.0.14 // indirect 40 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 41 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 42 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 43 | github.com/charmbracelet/x/exp/golden v0.0.0-20250514204301-7f4ee4d0d5fe // indirect 44 | github.com/davecgh/go-spew v1.1.1 // indirect 45 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 46 | github.com/kr/pretty v0.3.1 // indirect 47 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 48 | github.com/mattn/go-isatty v0.0.20 // indirect 49 | github.com/mattn/go-localereader v0.0.1 // indirect 50 | github.com/mattn/go-runewidth v0.0.16 // indirect 51 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 52 | github.com/muesli/cancelreader v0.2.2 // indirect 53 | github.com/pmezard/go-difflib v1.0.0 // indirect 54 | github.com/rivo/uniseg v0.4.7 // indirect 55 | github.com/stretchr/objx v0.5.2 // indirect 56 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 57 | golang.org/x/sys v0.33.0 // indirect 58 | golang.org/x/text v0.25.0 // indirect 59 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 60 | gopkg.in/yaml.v3 v3.0.1 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /internal/analyze_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | "golang.org/x/exp/mmap" 11 | 12 | "github.com/Zxilly/go-size-analyzer/internal/test/testutils" 13 | ) 14 | 15 | func FuzzAnalyze(f *testing.F) { 16 | f.Fuzz(func(t *testing.T, name string, data []byte) { 17 | require.NotPanics(t, func() { 18 | reader := bytes.NewReader(data) 19 | _, err := Analyze(name, reader, uint64(len(data)), Options{}) 20 | if err != nil { 21 | t.Logf("Error: %v", err) 22 | } 23 | }) 24 | }) 25 | } 26 | 27 | func GetCurrentRunningBinary(t *testing.T) string { 28 | t.Helper() 29 | 30 | path, err := os.Executable() 31 | require.NoError(t, err) 32 | 33 | return path 34 | } 35 | 36 | func TestAnalyzeImports(t *testing.T) { 37 | bin := GetCurrentRunningBinary(t) 38 | 39 | f, err := mmap.Open(bin) 40 | require.NoError(t, err) 41 | defer func() { 42 | err = f.Close() 43 | require.NoError(t, err) 44 | }() 45 | 46 | result, err := Analyze(bin, f, uint64(f.Len()), Options{ 47 | SkipDisasm: true, 48 | SkipDwarf: true, 49 | SkipSymbol: true, 50 | Imports: true, 51 | }) 52 | require.NoError(t, err) 53 | 54 | require.NotNil(t, result) 55 | 56 | testingPkg := result.Packages["testing"] 57 | require.NotNil(t, testingPkg) 58 | 59 | require.Contains(t, testingPkg.ImportedBy, "github.com/Zxilly/go-size-analyzer/internal") 60 | } 61 | 62 | func TestAnalyzeWASM(t *testing.T) { 63 | loc := filepath.Join(testutils.GetProjectRoot(t), "testdata", "wasm", "test.wasm") 64 | data, err := os.ReadFile(loc) 65 | require.NoError(t, err) 66 | 67 | b := bytes.NewReader(data) 68 | 69 | result, err := Analyze("test.wasm", b, uint64(len(data)), Options{}) 70 | require.NoError(t, err) 71 | require.NotNil(t, result) 72 | require.NotNil(t, result.Packages["main"]) 73 | } 74 | -------------------------------------------------------------------------------- /internal/constant/const.go: -------------------------------------------------------------------------------- 1 | package constant 2 | 3 | const ReplacedStr = `"GSA_PACKAGE_DATA"` 4 | -------------------------------------------------------------------------------- /internal/diff/load.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "strings" 8 | 9 | "github.com/go-json-experiment/json" 10 | "golang.org/x/exp/mmap" 11 | 12 | "github.com/Zxilly/go-size-analyzer/internal" 13 | "github.com/Zxilly/go-size-analyzer/internal/printer" 14 | "github.com/Zxilly/go-size-analyzer/internal/utils" 15 | ) 16 | 17 | type Options struct { 18 | internal.Options 19 | 20 | OldTarget string 21 | NewTarget string 22 | 23 | Format string 24 | 25 | Indent *int 26 | } 27 | 28 | func formatAnalyzer(analyzers []string) string { 29 | if len(analyzers) == 0 { 30 | return "none" 31 | } 32 | 33 | return strings.Join(analyzers, ", ") 34 | } 35 | 36 | func Diff(writer io.Writer, options Options) error { 37 | oldResult, err := autoLoadFile(options.OldTarget, options.Options) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | newResult, err := autoLoadFile(options.NewTarget, options.Options) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | if !requireAnalyzeModeSame(oldResult, newResult) { 48 | slog.Warn("The analyze mode of the two files is different") 49 | slog.Warn(fmt.Sprintf("%s: %s", options.NewTarget, formatAnalyzer(newResult.Analyzers))) 50 | slog.Warn(fmt.Sprintf("%s: %s", options.OldTarget, formatAnalyzer(oldResult.Analyzers))) 51 | } 52 | 53 | diff := newDiffResult(newResult, oldResult) 54 | 55 | switch options.Format { 56 | case "json": 57 | return printer.JSON(&diff, writer, &printer.JSONOption{ 58 | Indent: nil, 59 | }) 60 | case "text": 61 | return text(&diff, writer) 62 | default: 63 | return fmt.Errorf("format %s is not supported in diff mode", options.Format) 64 | } 65 | } 66 | 67 | func autoLoadFile(name string, options internal.Options) (*commonResult, error) { 68 | reader, err := mmap.Open(name) 69 | if err != nil { 70 | return nil, err 71 | } 72 | defer func(reader *mmap.ReaderAt) { 73 | err = reader.Close() 74 | if err != nil { 75 | slog.Warn("failed to close file", "error", err) 76 | } 77 | }(reader) 78 | 79 | r := new(commonResult) 80 | if utils.DetectJSON(reader) { 81 | err = json.UnmarshalRead(utils.NewReaderAtAdapter(reader), r) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return r, nil 87 | } 88 | 89 | fullResult, err := internal.Analyze( 90 | name, 91 | reader, 92 | uint64(reader.Len()), 93 | options) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return fromResult(fullResult), nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/diff/load_test.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/Zxilly/go-size-analyzer/internal/entity" 12 | "github.com/Zxilly/go-size-analyzer/internal/printer" 13 | "github.com/Zxilly/go-size-analyzer/internal/test" 14 | ) 15 | 16 | func TestDiffJSONAndBinary(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | old string 20 | new string 21 | format string 22 | }{ 23 | { 24 | name: "json to binary", 25 | old: test.GetTestJSONPath(t), 26 | new: test.GetTestDiffBinPath(t), 27 | format: "json", 28 | }, 29 | { 30 | name: "binary to binary", 31 | old: test.GetTestBinPath(t), 32 | new: test.GetTestDiffBinPath(t), 33 | format: "text", 34 | }, 35 | } 36 | 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | require.NoError(t, Diff(io.Discard, Options{ 40 | OldTarget: tt.old, 41 | NewTarget: tt.new, 42 | Format: tt.format, 43 | })) 44 | }) 45 | } 46 | } 47 | 48 | func TestDifferentAnalyzer(t *testing.T) { 49 | dir := t.TempDir() 50 | first := filepath.Join(dir, "first") 51 | second := filepath.Join(dir, "second") 52 | 53 | createFile := func(name string, analyzers []entity.Analyzer) { 54 | f, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) 55 | require.NoError(t, err) 56 | defer func() { 57 | require.NoError(t, f.Close()) 58 | }() 59 | 60 | r := commonResult{ 61 | Analyzers: analyzers, 62 | } 63 | 64 | require.NoError(t, printer.JSON(r, f, &printer.JSONOption{})) 65 | } 66 | 67 | createFile(first, []entity.Analyzer{entity.AnalyzerDwarf, entity.AddrSourceSymbol}) 68 | createFile(second, []entity.Analyzer{entity.AnalyzerDisasm}) 69 | 70 | require.Error(t, Diff(io.Discard, Options{ 71 | OldTarget: first, 72 | NewTarget: second, 73 | })) 74 | } 75 | 76 | func TestFormatAnalyzer(t *testing.T) { 77 | cases := []struct { 78 | name string 79 | analyzers []string 80 | expected string 81 | }{ 82 | { 83 | name: "Empty slice returns 'none'", 84 | analyzers: []string{}, 85 | expected: "none", 86 | }, 87 | { 88 | name: "Single element returns the element itself", 89 | analyzers: []string{"Analyzer1"}, 90 | expected: "Analyzer1", 91 | }, 92 | { 93 | name: "Multiple elements returns comma-separated string", 94 | analyzers: []string{"Analyzer1", "Analyzer2", "Analyzer3"}, 95 | expected: "Analyzer1, Analyzer2, Analyzer3", 96 | }, 97 | } 98 | 99 | for _, tc := range cases { 100 | t.Run(tc.name, func(t *testing.T) { 101 | result := formatAnalyzer(tc.analyzers) 102 | require.Equal(t, tc.expected, result) 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /internal/diff/printer.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | 8 | "github.com/dustin/go-humanize" 9 | "github.com/jedib0t/go-pretty/v6/table" 10 | 11 | "github.com/Zxilly/go-size-analyzer/internal/utils" 12 | ) 13 | 14 | func diffString(b Base) string { 15 | switch b.ChangeType { 16 | case changeTypeChange: 17 | sign := "+" 18 | if b.To < b.From { 19 | sign = "" 20 | } 21 | 22 | return sign + utils.PercentString(float64(b.To-b.From)/float64(b.From)) 23 | case changeTypeAdd: 24 | return "+100%" 25 | case changeTypeRemove: 26 | return "-100%" 27 | default: 28 | panic("unreachable") 29 | } 30 | } 31 | 32 | func bytesWithIgnore(b int64) string { 33 | if b == 0 { 34 | return "" 35 | } 36 | return humanize.Bytes(uint64(b)) 37 | } 38 | 39 | func signedBytesString(b int64) string { 40 | sign := "+" 41 | if b < 0 { 42 | sign = "-" 43 | b = -b 44 | } 45 | return sign + humanize.Bytes(uint64(b)) 46 | } 47 | 48 | func text(r *diffResult, writer io.Writer) error { 49 | slog.Info("Printing text diff report") 50 | 51 | t := table.NewWriter() 52 | t.SetStyle(utils.GetTableStyle()) 53 | 54 | t.SetTitle("Diff between %s and %s", r.OldName, r.NewName) 55 | t.AppendHeader(table.Row{"Percent", "Name", "Old Size", "New Size", "Diff"}) 56 | 57 | for _, pkg := range r.Packages { 58 | name := pkg.Name 59 | if name == "" { 60 | name = "" 61 | } 62 | 63 | t.AppendRow(table.Row{ 64 | diffString(pkg.Base), 65 | name, 66 | bytesWithIgnore(pkg.From), 67 | bytesWithIgnore(pkg.To), 68 | signedBytesString(pkg.To - pkg.From), 69 | }) 70 | } 71 | 72 | t.AppendSeparator() 73 | 74 | for _, section := range r.Sections { 75 | t.AppendRow(table.Row{ 76 | diffString(section.Base), 77 | section.Name, 78 | bytesWithIgnore(section.From), 79 | bytesWithIgnore(section.To), 80 | signedBytesString(section.To - section.From), 81 | }) 82 | } 83 | 84 | t.AppendFooter(table.Row{ 85 | diffString(Base{ 86 | From: r.OldSize, 87 | To: r.NewSize, 88 | ChangeType: changeTypeChange, 89 | }), 90 | fmt.Sprintf("%s\n%s", r.OldName, r.NewName), 91 | humanize.Bytes(uint64(r.OldSize)), 92 | humanize.Bytes(uint64(r.NewSize)), 93 | signedBytesString(r.NewSize - r.OldSize), 94 | }) 95 | 96 | data := []byte(t.Render() + "\n") 97 | 98 | slog.Info("Diff report rendered") 99 | 100 | _, err := writer.Write(data) 101 | 102 | slog.Info("Diff report written") 103 | 104 | return err 105 | } 106 | -------------------------------------------------------------------------------- /internal/diff/printer_test.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestDiffStringChangeTypeChangeReturnsPercentage(t *testing.T) { 12 | b := Base{From: 100, To: 150, ChangeType: changeTypeChange} 13 | assert.Equal(t, "+50.00%", diffString(b)) 14 | b = Base{From: 150, To: 100, ChangeType: changeTypeChange} 15 | assert.Equal(t, "-33.33%", diffString(b)) 16 | } 17 | 18 | func TestDiffStringChangeTypeAddReturnsAdd(t *testing.T) { 19 | b := Base{ChangeType: changeTypeAdd} 20 | assert.Equal(t, "+100%", diffString(b)) 21 | } 22 | 23 | func TestDiffStringChangeTypeRemoveReturnsRemove(t *testing.T) { 24 | b := Base{ChangeType: changeTypeRemove} 25 | assert.Equal(t, "-100%", diffString(b)) 26 | } 27 | 28 | func TestSignedBytesStringPositiveValueReturnsPlusSign(t *testing.T) { 29 | assert.Equal(t, "+1 B", signedBytesString(1)) 30 | } 31 | 32 | func TestSignedBytesStringNegativeValueReturnsMinusSign(t *testing.T) { 33 | assert.Equal(t, "-1 B", signedBytesString(-1)) 34 | } 35 | 36 | func TestTextRendersTableCorrectly(t *testing.T) { 37 | var buf bytes.Buffer 38 | r := &diffResult{ 39 | OldName: "old", 40 | NewName: "new", 41 | OldSize: 100, 42 | NewSize: 150, 43 | Sections: []diffSection{ 44 | {Base: Base{Name: "sec1", From: 100, To: 150, ChangeType: changeTypeChange}}, 45 | }, 46 | Packages: []diffPackage{ 47 | {Base: Base{Name: "pkg1", From: 100, To: 150, ChangeType: changeTypeChange}}, 48 | }, 49 | } 50 | err := text(r, &buf) 51 | require.NoError(t, err) 52 | assert.Contains(t, buf.String(), "Diff between old and new") 53 | assert.Contains(t, buf.String(), "sec1") 54 | assert.Contains(t, buf.String(), "pkg1") 55 | } 56 | 57 | func TestTextHandlesEmptyResultWithoutError(t *testing.T) { 58 | var buf bytes.Buffer 59 | r := &diffResult{} 60 | err := text(r, &buf) 61 | require.NoError(t, err) 62 | assert.Contains(t, buf.String(), "Diff between and ") 63 | } 64 | -------------------------------------------------------------------------------- /internal/diff/result.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "github.com/Zxilly/go-size-analyzer/internal/entity" 5 | "github.com/Zxilly/go-size-analyzer/internal/result" 6 | ) 7 | 8 | // commonResult keeps these json fields are strict subset of result.Result 9 | type commonResult struct { 10 | Name string `json:"name"` 11 | Size int64 `json:"size"` 12 | 13 | Analyzers []entity.Analyzer `json:"analyzers"` 14 | Packages map[string]commonPackage `json:"packages"` 15 | Sections []commonSection `json:"sections"` 16 | } 17 | 18 | type commonPackage struct { 19 | Name string `json:"name"` 20 | Size int64 `json:"size"` 21 | } 22 | 23 | type commonSection struct { 24 | Name string `json:"name"` 25 | 26 | FileSize int64 `json:"file_size"` 27 | KnownSize int64 `json:"known_size"` 28 | } 29 | 30 | func (c commonSection) UnknownSize() int64 { 31 | return c.FileSize - c.KnownSize 32 | } 33 | 34 | func fromResult(r *result.Result) *commonResult { 35 | c := commonResult{ 36 | Name: r.Name, 37 | Size: int64(r.Size), 38 | Analyzers: r.Analyzers, 39 | Packages: make(map[string]commonPackage), 40 | Sections: make([]commonSection, len(r.Sections)), 41 | } 42 | 43 | for k, v := range r.Packages { 44 | c.Packages[k] = commonPackage{ 45 | Name: v.Name, 46 | Size: int64(v.Size), 47 | } 48 | } 49 | 50 | for i, v := range r.Sections { 51 | c.Sections[i] = commonSection{ 52 | Name: v.Name, 53 | FileSize: int64(v.FileSize), 54 | KnownSize: int64(v.KnownSize), 55 | } 56 | } 57 | 58 | return &c 59 | } 60 | -------------------------------------------------------------------------------- /internal/diff/result_test.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/go-json-experiment/json" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/Zxilly/go-size-analyzer/internal/printer" 12 | "github.com/Zxilly/go-size-analyzer/internal/test" 13 | ) 14 | 15 | func TestCommonResultFromFullResult(t *testing.T) { 16 | fullResult := test.GetTestResult(t) 17 | 18 | cr := fromResult(fullResult) 19 | 20 | jsonData := new(bytes.Buffer) 21 | 22 | err := printer.JSON(fullResult, jsonData, &printer.JSONOption{ 23 | Indent: nil, 24 | HideDetail: false, 25 | }) 26 | require.NoError(t, err) 27 | 28 | crFromJSON := new(commonResult) 29 | err = json.UnmarshalRead(jsonData, crFromJSON) 30 | require.NoError(t, err) 31 | 32 | assert.Equal(t, cr, crFromJSON) //nolint:testifylint // workaround github.com/Antonboom/testifylint/issues/198 33 | } 34 | 35 | func TestCommonResultFromFullAndCompactJSON(t *testing.T) { 36 | fullResult := test.GetTestResult(t) 37 | 38 | compactJSONData := new(bytes.Buffer) 39 | err := printer.JSON(fullResult, compactJSONData, &printer.JSONOption{ 40 | Indent: nil, 41 | HideDetail: true, 42 | }) 43 | require.NoError(t, err) 44 | 45 | fullJSONData := new(bytes.Buffer) 46 | err = printer.JSON(fullResult, fullJSONData, &printer.JSONOption{ 47 | Indent: nil, 48 | HideDetail: false, 49 | }) 50 | require.NoError(t, err) 51 | 52 | crFromCompactJSON := new(commonResult) 53 | crFromFullJSON := new(commonResult) 54 | 55 | err = json.UnmarshalRead(compactJSONData, crFromCompactJSON) 56 | require.NoError(t, err) 57 | 58 | err = json.UnmarshalRead(fullJSONData, crFromFullJSON) 59 | require.NoError(t, err) 60 | 61 | assert.Equal(t, crFromCompactJSON, crFromFullJSON) //nolint:testifylint // workaround github.com/Antonboom/testifylint/issues/198 62 | } 63 | -------------------------------------------------------------------------------- /internal/diff/utils.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "github.com/Zxilly/go-size-analyzer/internal/entity" 5 | "github.com/Zxilly/go-size-analyzer/internal/utils" 6 | ) 7 | 8 | func requireAnalyzeModeSame(oldResult, newResult *commonResult) bool { 9 | oldModes := utils.NewSet[entity.Analyzer]() 10 | for _, v := range oldResult.Analyzers { 11 | oldModes.Add(v) 12 | } 13 | 14 | newModes := utils.NewSet[entity.Analyzer]() 15 | for _, v := range newResult.Analyzers { 16 | newModes.Add(v) 17 | } 18 | 19 | return oldModes.Equals(newModes) 20 | } 21 | -------------------------------------------------------------------------------- /internal/diff/utils_test.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/entity" 9 | ) 10 | 11 | func Test_requireAnalyzeModeSame(t *testing.T) { 12 | type args struct { 13 | oldResult *commonResult 14 | newResult *commonResult 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want bool 20 | }{ 21 | { 22 | name: "same order", 23 | args: args{ 24 | oldResult: &commonResult{ 25 | Analyzers: []entity.Analyzer{"a", "b"}, 26 | }, 27 | newResult: &commonResult{ 28 | Analyzers: []entity.Analyzer{"a", "b"}, 29 | }, 30 | }, 31 | want: true, 32 | }, 33 | { 34 | name: "different order", 35 | args: args{ 36 | oldResult: &commonResult{ 37 | Analyzers: []entity.Analyzer{"a", "b"}, 38 | }, 39 | newResult: &commonResult{ 40 | Analyzers: []entity.Analyzer{"b", "a"}, 41 | }, 42 | }, 43 | want: true, 44 | }, 45 | { 46 | name: "different", 47 | args: args{ 48 | oldResult: &commonResult{ 49 | Analyzers: []entity.Analyzer{"a", "b"}, 50 | }, 51 | newResult: &commonResult{ 52 | Analyzers: []entity.Analyzer{"a", "c"}, 53 | }, 54 | }, 55 | want: false, 56 | }, 57 | } 58 | for _, tt := range tests { 59 | t.Run(tt.name, func(t *testing.T) { 60 | assert.Equalf(t, tt.want, requireAnalyzeModeSame(tt.args.oldResult, tt.args.newResult), "requireAnalyzeModeSame(%v, %v)", tt.args.oldResult, tt.args.newResult) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/disasm/doc.go: -------------------------------------------------------------------------------- 1 | // Package disasm This package was written based on the go/cmd/internal/objfile package. 2 | package disasm 3 | -------------------------------------------------------------------------------- /internal/disasm/extract.go: -------------------------------------------------------------------------------- 1 | package disasm 2 | 3 | import ( 4 | "golang.org/x/arch/x86/x86asm" 5 | 6 | "github.com/Zxilly/go-size-analyzer/internal/utils" 7 | ) 8 | 9 | var extractFuncs = map[string]extractorFunc{ 10 | "amd64": extractAmd64, 11 | } 12 | 13 | func extractAmd64(code []byte, pc uint64) []PossibleStr { 14 | resultSet := utils.NewSet[PossibleStr]() 15 | 16 | insts := make([]x86PosInst, 0) 17 | 18 | for len(code) > 0 { 19 | inst, err := x86asm.Decode(code, 64) 20 | size := 0 21 | if err != nil || inst.Len == 0 || inst.Op == 0 { 22 | size = 1 23 | } else { 24 | size = inst.Len 25 | if inst.Op != x86asm.NOP { 26 | insts = append(insts, x86PosInst{pc: pc, inst: inst}) 27 | } 28 | } 29 | code = code[size:] 30 | pc += uint64(size) 31 | } 32 | 33 | for i := range len(insts) { 34 | for _, p := range x86Patterns { 35 | if len(insts) < i+p.windowSize { 36 | continue 37 | } 38 | matchRet := p.matchFunc(insts[i : i+p.windowSize]) 39 | if matchRet != nil { 40 | resultSet.Add(*matchRet) 41 | } 42 | } 43 | } 44 | 45 | return resultSet.ToSlice() 46 | } 47 | -------------------------------------------------------------------------------- /internal/disasm/interface_test.go: -------------------------------------------------------------------------------- 1 | package disasm 2 | 3 | import ( 4 | "debug/dwarf" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/Zxilly/go-size-analyzer/internal/entity" 11 | "github.com/Zxilly/go-size-analyzer/internal/wrapper" 12 | ) 13 | 14 | type TestFileWrapper struct { 15 | arch string 16 | 17 | textStart uint64 18 | text []byte 19 | textErr error 20 | } 21 | 22 | func (TestFileWrapper) LoadSymbols(func(name string, addr uint64, size uint64, typ entity.AddrType), func(addr uint64, size uint64)) error { 23 | panic("not reachable") 24 | } 25 | 26 | func (TestFileWrapper) DWARF() (*dwarf.Data, error) { 27 | panic("not reachable") 28 | } 29 | 30 | func (t TestFileWrapper) Text() (textStart uint64, text []byte, err error) { 31 | return t.textStart, t.text, t.textErr 32 | } 33 | 34 | func (t TestFileWrapper) GoArch() string { 35 | return t.arch 36 | } 37 | 38 | func (TestFileWrapper) ReadAddr(_, _ uint64) ([]byte, error) { 39 | panic("not reachable") 40 | } 41 | 42 | func (TestFileWrapper) LoadSections() *entity.Store { 43 | panic("not reachable") 44 | } 45 | 46 | var _ wrapper.RawFileWrapper = TestFileWrapper{} 47 | 48 | func TestNewExtractorNoText(t *testing.T) { 49 | w := TestFileWrapper{textErr: errors.New("text error")} 50 | _, err := NewExtractor(w, 0, nil, nil) 51 | assert.Error(t, err) 52 | } 53 | 54 | func TestNewExtractorNoGoArch(t *testing.T) { 55 | w := TestFileWrapper{} 56 | _, err := NewExtractor(w, 0, nil, nil) 57 | assert.ErrorIs(t, err, ErrArchNotSupported) 58 | } 59 | 60 | func TestNewExtractorNoExtractor(t *testing.T) { 61 | w := TestFileWrapper{arch: "unsupported"} 62 | _, err := NewExtractor(w, 0, nil, nil) 63 | assert.ErrorIs(t, err, ErrArchNotSupported) 64 | } 65 | 66 | func TestExtractor_Extract(t *testing.T) { 67 | t.Run("start before text", func(t *testing.T) { 68 | extractor := Extractor{textStart: 0x100, textEnd: 0x200} 69 | assert.Panics(t, func() { 70 | extractor.Extract(0x50, 0x100) 71 | }) 72 | }) 73 | 74 | t.Run("end after text", func(t *testing.T) { 75 | extractor := Extractor{textStart: 0x100, textEnd: 0x200} 76 | assert.Panics(t, func() { 77 | extractor.Extract(0x150, 0x250) 78 | }) 79 | }) 80 | } 81 | 82 | func TestExtractor_LoadAddrString(t *testing.T) { 83 | t.Run("size <= 0", func(t *testing.T) { 84 | extractor := Extractor{} 85 | ok := extractor.checkAddrString(0, 0) 86 | assert.False(t, ok) 87 | }) 88 | 89 | t.Run("size > file size", func(t *testing.T) { 90 | extractor := Extractor{size: 10} 91 | ok := extractor.checkAddrString(0, 20) 92 | assert.False(t, ok) 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /internal/dwarf/dwarf_info_test.go: -------------------------------------------------------------------------------- 1 | package dwarf 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | ) 7 | 8 | func TestLanguageString(t *testing.T) { 9 | tests := []struct { 10 | input Language 11 | expected string 12 | }{ 13 | {DwLangC89, "C89"}, 14 | {DwLangC, "C"}, 15 | {DwLangAda83, "Ada83"}, 16 | {DwLangCPP, "C++"}, 17 | {DwLangCobol74, "Cobol74"}, 18 | {DwLangCobol85, "Cobol85"}, 19 | {DwLangFortran77, "Fortran77"}, 20 | {DwLangFortran90, "Fortran90"}, 21 | {DwLangPascal83, "Pascal83"}, 22 | {DwLangModula2, "Modula2"}, 23 | {DwLangJava, "Java"}, 24 | {DwLangC99, "C99"}, 25 | {DwLangAda95, "Ada95"}, 26 | {DwLangFortran95, "Fortran95"}, 27 | {DwLangPLI, "PLI"}, 28 | {DwLangObjC, "ObjC"}, 29 | {DwLangObjCPP, "ObjC++"}, 30 | {DwLangUPC, "UPC"}, 31 | {DwLangD, "D"}, 32 | {DwLangPython, "Python"}, 33 | {DwLangOpenCL, "OpenCL"}, 34 | {DwLangGo, "Go"}, 35 | {DwLangModula3, "Modula3"}, 36 | {DwLangHaskell, "Haskell"}, 37 | {DwLangCPP03, "C++03"}, 38 | {DwLangCPP11, "C++11"}, 39 | {DwLangOCaml, "OCaml"}, 40 | {DwLangRust, "Rust"}, 41 | {DwLangC11, "C11"}, 42 | {DwLangSwift, "Swift"}, 43 | {DwLangJulia, "Julia"}, 44 | {DwLangDylan, "Dylan"}, 45 | {DwLangCPP14, "C++14"}, 46 | {DwLangFortran03, "Fortran03"}, 47 | {DwLangFortran08, "Fortran08"}, 48 | {DwLangRenderScript, "RenderScript"}, 49 | {DwLangBLISS, "BLISS"}, 50 | {DwLangKotlin, "Kotlin"}, 51 | {DwLangZig, "Zig"}, 52 | {DwLangCrystal, "Crystal"}, 53 | {DwLangCPP17, "C++17"}, 54 | {DwLangCPP20, "C++20"}, 55 | {DwLangC17, "C17"}, 56 | {DwLangFortran18, "Fortran18"}, 57 | {DwLangAda2005, "Ada2005"}, 58 | {DwLangAda2012, "Ada2012"}, 59 | {DwLangHIP, "HIP"}, 60 | {DwLangAssembly, "Assembly"}, 61 | {DwLangCSharp, "C#"}, 62 | {DwLangMojo, "Mojo"}, 63 | {DwLangGLSL, "GLSL"}, 64 | {DwLangGLSLES, "GLSLES"}, 65 | {DwLangHLSL, "HLSL"}, 66 | {DwLangOpenCLCPP, "OpenCL++"}, 67 | {DwLangCPPForOpenCL, "C++ForOpenCL"}, 68 | {DwLangSYCL, "SYCL"}, 69 | {DwLangRuby, "Ruby"}, 70 | {DwLangMove, "Move"}, 71 | {DwLangHylo, "Hylo"}, 72 | {Language(0x9999), "Language(39321)"}, // Test case for an unknown language constant 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(strconv.Itoa(int(tt.input)), func(t *testing.T) { 77 | result := LanguageString(tt.input) 78 | if result != tt.expected { 79 | t.Errorf("LanguageString(%v) = %v; want %v", tt.input, result, tt.expected) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/dwarf/utils.go: -------------------------------------------------------------------------------- 1 | package dwarf 2 | 3 | import ( 4 | "debug/dwarf" 5 | "fmt" 6 | ) 7 | 8 | type fieldPattern struct { 9 | name string 10 | typ string 11 | } 12 | 13 | func checkField(typ *dwarf.StructType, fields ...fieldPattern) error { 14 | if len(typ.Field) != len(fields) { 15 | return fmt.Errorf("%s struct has %d fields", typ.StructName, len(typ.Field)) 16 | } 17 | 18 | for i, field := range fields { 19 | if typ.Field[i].Name != field.name { 20 | return fmt.Errorf("field %d name is %s, expect %s", i, typ.Field[i].Name, field.name) 21 | } 22 | 23 | if typ.Field[i].Type.String() != field.typ { 24 | return fmt.Errorf("field %d type is %s, expect %s", i, typ.Field[i].Type.String(), field.typ) 25 | } 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/dwarf/utils_test.go: -------------------------------------------------------------------------------- 1 | package dwarf 2 | 3 | import ( 4 | "debug/dwarf" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCheckFieldValidatesStructFieldsCorrectly(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | typ *dwarf.StructType 14 | fields []fieldPattern 15 | expected string 16 | }{ 17 | { 18 | name: "ValidStructWithMatchingFields", 19 | typ: &dwarf.StructType{ 20 | StructName: "TestStruct", 21 | Field: []*dwarf.StructField{ 22 | {Name: "Field1", Type: &dwarf.BasicType{CommonType: dwarf.CommonType{Name: "int"}}}, 23 | {Name: "Field2", Type: &dwarf.BasicType{CommonType: dwarf.CommonType{Name: "string"}}}, 24 | }, 25 | }, 26 | fields: []fieldPattern{ 27 | {name: "Field1", typ: "int"}, 28 | {name: "Field2", typ: "string"}, 29 | }, 30 | expected: "", 31 | }, 32 | { 33 | name: "StructWithMismatchedFieldCount", 34 | typ: &dwarf.StructType{ 35 | StructName: "TestStruct", 36 | Field: []*dwarf.StructField{ 37 | {Name: "Field1", Type: &dwarf.BasicType{CommonType: dwarf.CommonType{Name: "int"}}}, 38 | }, 39 | }, 40 | fields: []fieldPattern{ 41 | {name: "Field1", typ: "int"}, 42 | {name: "Field2", typ: "string"}, 43 | }, 44 | expected: "TestStruct struct has 1 fields", 45 | }, 46 | { 47 | name: "StructWithMismatchedFieldName", 48 | typ: &dwarf.StructType{ 49 | StructName: "TestStruct", 50 | Field: []*dwarf.StructField{ 51 | {Name: "Field1", Type: &dwarf.BasicType{CommonType: dwarf.CommonType{Name: "int"}}}, 52 | {Name: "Field2", Type: &dwarf.BasicType{CommonType: dwarf.CommonType{Name: "string"}}}, 53 | }, 54 | }, 55 | fields: []fieldPattern{ 56 | {name: "Field1", typ: "int"}, 57 | {name: "Field3", typ: "string"}, 58 | }, 59 | expected: "field 1 name is Field2, expect Field3", 60 | }, 61 | { 62 | name: "StructWithMismatchedFieldType", 63 | typ: &dwarf.StructType{ 64 | StructName: "TestStruct", 65 | Field: []*dwarf.StructField{ 66 | {Name: "Field1", Type: &dwarf.BasicType{CommonType: dwarf.CommonType{Name: "int"}}}, 67 | {Name: "Field2", Type: &dwarf.BasicType{CommonType: dwarf.CommonType{Name: "string"}}}, 68 | }, 69 | }, 70 | fields: []fieldPattern{ 71 | {name: "Field1", typ: "int"}, 72 | {name: "Field2", typ: "bool"}, 73 | }, 74 | expected: "field 1 type is string, expect bool", 75 | }, 76 | } 77 | 78 | for _, test := range tests { 79 | t.Run(test.name, func(t *testing.T) { 80 | err := checkField(test.typ, test.fields...) 81 | if err != nil { 82 | require.Equal(t, test.expected, err.Error()) 83 | } 84 | if err == nil && test.expected != "" { 85 | require.Empty(t, test.expected) 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/entity/addr.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type AddrPos struct { 9 | Addr uint64 10 | Size uint64 11 | Type AddrType 12 | } 13 | 14 | func (a *AddrPos) String() string { 15 | return fmt.Sprintf("Addr: 0x%x CodeSize: %d Type: %s", a.Addr, a.Size, a.Type) 16 | } 17 | 18 | type Addr struct { 19 | *AddrPos 20 | 21 | Pkg *Package // package can be nil for cgo symbols 22 | 23 | Function *Function // for symbol source it will be a nil 24 | Symbol *Symbol // for function source it will be a nil 25 | 26 | SourceType AddrSourceType 27 | } 28 | 29 | func (a *Addr) String() string { 30 | ret := new(strings.Builder) 31 | _, _ = fmt.Fprintf(ret, "AddrPos: %s", a.AddrPos) 32 | if a.Pkg != nil { 33 | _, _ = fmt.Fprintf(ret, " Pkg: %s", a.Pkg.Name) 34 | } 35 | if a.Function != nil { 36 | _, _ = fmt.Fprintf(ret, " Function: %s", a.Function.Name) 37 | } 38 | if a.Symbol != nil { 39 | _, _ = fmt.Fprintf(ret, " Symbol: %s", a.Symbol.Name) 40 | } 41 | _, _ = fmt.Fprintf(ret, " SourceType: %s", a.SourceType) 42 | return ret.String() 43 | } 44 | -------------------------------------------------------------------------------- /internal/entity/analyzer.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type Analyzer = string 4 | 5 | const ( 6 | AnalyzerDwarf Analyzer = "dwarf" 7 | AnalyzerDisasm Analyzer = "disasm" 8 | AnalyzerSymbol Analyzer = "symbol" 9 | AnalyzerPclntab Analyzer = "pclntab" 10 | ) 11 | -------------------------------------------------------------------------------- /internal/entity/file.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type File struct { 4 | FilePath string `json:"file_path"` 5 | Functions []*Function `json:"functions"` 6 | 7 | PkgName string `json:"-"` 8 | } 9 | 10 | func (f *File) FullSize() uint64 { 11 | size := uint64(0) 12 | for _, fn := range f.Functions { 13 | size += fn.Size() 14 | } 15 | return size 16 | } 17 | 18 | func (f *File) PclnSize() uint64 { 19 | size := uint64(0) 20 | for _, fn := range f.Functions { 21 | size += fn.PclnSize.Size() 22 | } 23 | return size 24 | } 25 | -------------------------------------------------------------------------------- /internal/entity/file_marshaler.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package entity 4 | 5 | func (f *File) MarshalJavaScript() any { 6 | return map[string]any{ 7 | "file_path": f.FilePath, 8 | "size": f.FullSize(), 9 | "pcln_size": f.PclnSize(), 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /internal/entity/file_test.go: -------------------------------------------------------------------------------- 1 | package entity_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-json-experiment/json" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/Zxilly/go-size-analyzer/internal/entity" 11 | "github.com/Zxilly/go-size-analyzer/internal/entity/marshaler" 12 | ) 13 | 14 | func TestFile_MarshalJSON(t *testing.T) { 15 | // Create a sample File instance 16 | file := &entity.File{ 17 | FilePath: "/path/to/file", 18 | Functions: []*entity.Function{ 19 | {CodeSize: 10}, 20 | {CodeSize: 20}, 21 | {CodeSize: 30}, 22 | }, 23 | } 24 | 25 | t.Run("Compact mode", func(t *testing.T) { 26 | data, err := json.Marshal(file, json.WithMarshalers(marshaler.GetFileCompactMarshaler())) 27 | // Verify the result 28 | require.NoError(t, err) 29 | expected := `{"file_path":"/path/to/file","pcln_size":0,"size":60}` 30 | assert.JSONEq(t, expected, string(data)) 31 | }) 32 | 33 | t.Run("Full mode", func(t *testing.T) { 34 | data, err := json.Marshal(file) 35 | 36 | // Verify the result 37 | require.NoError(t, err) 38 | expected := ` 39 | { 40 | "file_path": "/path/to/file", 41 | "functions": [ 42 | { 43 | "name": "", 44 | "addr": 0, 45 | "code_size": 10, 46 | "type": "", 47 | "receiver": "", 48 | "pcln_size": { 49 | "name": 0, 50 | "pcfile": 0, 51 | "pcsp": 0, 52 | "pcln": 0, 53 | "header": 0, 54 | "funcdata": 0, 55 | "pcdata": {} 56 | } 57 | }, 58 | { 59 | "name": "", 60 | "addr": 0, 61 | "code_size": 20, 62 | "type": "", 63 | "receiver": "", 64 | "pcln_size": { 65 | "name": 0, 66 | "pcfile": 0, 67 | "pcsp": 0, 68 | "pcln": 0, 69 | "header": 0, 70 | "funcdata": 0, 71 | "pcdata": {} 72 | } 73 | }, 74 | { 75 | "name": "", 76 | "addr": 0, 77 | "code_size": 30, 78 | "type": "", 79 | "receiver": "", 80 | "pcln_size": { 81 | "name": 0, 82 | "pcfile": 0, 83 | "pcsp": 0, 84 | "pcln": 0, 85 | "header": 0, 86 | "funcdata": 0, 87 | "pcdata": {} 88 | } 89 | } 90 | ] 91 | }` 92 | assert.JSONEq(t, expected, string(data)) 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /internal/entity/function.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type FuncType = string 4 | 5 | const ( 6 | FuncTypeFunction FuncType = "function" 7 | FuncTypeMethod FuncType = "method" 8 | ) 9 | 10 | type Function struct { 11 | Name string `json:"name"` 12 | Addr uint64 `json:"addr"` 13 | CodeSize uint64 `json:"code_size"` 14 | Type FuncType `json:"type"` 15 | Receiver string `json:"receiver"` // only for methods 16 | 17 | PclnSize PclnSymbolSize `json:"pcln_size"` 18 | 19 | disasm AddrSpace 20 | pkg *Package 21 | } 22 | 23 | func (f *Function) Init() { 24 | f.disasm = make(AddrSpace) 25 | } 26 | 27 | func (f *Function) Size() uint64 { 28 | return f.CodeSize + f.PclnSize.Size() 29 | } 30 | -------------------------------------------------------------------------------- /internal/entity/marshaler/file_normal.go: -------------------------------------------------------------------------------- 1 | package marshaler 2 | 3 | import ( 4 | "github.com/go-json-experiment/json" 5 | "github.com/go-json-experiment/json/jsontext" 6 | 7 | "github.com/Zxilly/go-size-analyzer/internal/entity" 8 | "github.com/Zxilly/go-size-analyzer/internal/utils" 9 | ) 10 | 11 | func GetFileCompactMarshaler() *json.Marshalers { 12 | return json.MarshalToFunc[entity.File](func(encoder *jsontext.Encoder, file entity.File) error { 13 | options := encoder.Options() 14 | utils.Must(encoder.WriteToken(jsontext.BeginObject)) 15 | 16 | utils.Must(json.MarshalEncode(encoder, "file_path", options)) 17 | utils.Must(json.MarshalEncode(encoder, file.FilePath, options)) 18 | utils.Must(json.MarshalEncode(encoder, "size", options)) 19 | utils.Must(json.MarshalEncode(encoder, file.FullSize(), options)) 20 | utils.Must(json.MarshalEncode(encoder, "pcln_size", options)) 21 | utils.Must(json.MarshalEncode(encoder, file.PclnSize(), options)) 22 | 23 | utils.Must(encoder.WriteToken(jsontext.EndObject)) 24 | return nil 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /internal/entity/package_marshaler.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package entity 4 | 5 | import ( 6 | "github.com/samber/lo" 7 | ) 8 | 9 | func (m PackageMap) MarshalJavaScript() any { 10 | ret := map[string]any{} 11 | 12 | for k, v := range m { 13 | ret[k] = v.MarshalJavaScript() 14 | } 15 | 16 | return ret 17 | } 18 | 19 | func (p *Package) MarshalJavaScript() any { 20 | var symbols, files []any 21 | symbols = lo.Map(p.Symbols, func(s *Symbol, _ int) any { return s.MarshalJavaScript() }) 22 | files = lo.Map(p.Files, func(f *File, _ int) any { return f.MarshalJavaScript() }) 23 | subs := p.SubPackages.MarshalJavaScript() 24 | 25 | return map[string]any{ 26 | "name": p.Name, 27 | "type": p.Type, 28 | "size": p.Size, 29 | "symbols": symbols, 30 | "subPackages": subs, 31 | "files": files, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/entity/pcln_symbol.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "github.com/ZxillyFork/gosym" 4 | 5 | // PclnSymbolSize represents a pcln symbol sizes 6 | type PclnSymbolSize struct { 7 | Name uint64 `json:"name"` // the function name size 8 | PCFile uint64 `json:"pcfile"` // the file name tab size 9 | PCSP uint64 `json:"pcsp"` // the pc to stack pointer table size 10 | PCLN uint64 `json:"pcln"` // the pc to line number table size 11 | Header uint64 `json:"header"` // the header size 12 | FuncData uint64 `json:"funcdata"` // the funcdata size 13 | PCData map[string]int `json:"pcdata"` // the pcdata size 14 | } 15 | 16 | func (p *PclnSymbolSize) Size() uint64 { 17 | var size uint64 18 | size += p.Name 19 | size += p.PCFile 20 | size += p.PCSP 21 | size += p.PCLN 22 | size += p.Header 23 | size += p.FuncData 24 | for _, v := range p.PCData { 25 | size += uint64(v) 26 | } 27 | return size 28 | } 29 | 30 | func NewPclnSymbolSize(s *gosym.Func) PclnSymbolSize { 31 | return PclnSymbolSize{ 32 | Name: uint64(s.FuncNameSize()), 33 | PCFile: uint64(s.TablePCFileSize()), 34 | PCSP: uint64(s.TablePCSPSize()), 35 | PCLN: uint64(s.TablePCLnSize()), 36 | Header: uint64(s.FixedHeaderSize()), 37 | FuncData: uint64(s.FuncDataSize()), 38 | PCData: s.PCDataSize(), 39 | } 40 | } 41 | 42 | func NewEmptyPclnSymbolSize() PclnSymbolSize { 43 | return PclnSymbolSize{ 44 | PCData: make(map[string]int), 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/entity/section.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type SectionContentType int 4 | 5 | const ( 6 | SectionContentOther SectionContentType = iota 7 | SectionContentText 8 | SectionContentData 9 | ) 10 | 11 | type Section struct { 12 | Name string `json:"name"` 13 | 14 | Size uint64 `json:"size"` 15 | FileSize uint64 `json:"file_size"` 16 | 17 | KnownSize uint64 `json:"known_size"` 18 | 19 | Offset uint64 `json:"offset"` 20 | End uint64 `json:"end"` 21 | 22 | Addr uint64 `json:"addr"` 23 | AddrEnd uint64 `json:"addr_end"` 24 | 25 | OnlyInMemory bool `json:"only_in_memory"` 26 | Debug bool `json:"debug"` 27 | 28 | ContentType SectionContentType `json:"-"` 29 | } 30 | -------------------------------------------------------------------------------- /internal/entity/section_marshaler.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package entity 4 | 5 | func (s Section) MarshalJavaScript() any { 6 | return map[string]any{ 7 | "name": s.Name, 8 | "size": s.Size, 9 | "file_size": s.FileSize, 10 | "known_size": s.KnownSize, 11 | "offset": s.Offset, 12 | "end": s.End, 13 | "addr": s.Addr, 14 | "addr_end": s.AddrEnd, 15 | "only_in_memory": s.OnlyInMemory, 16 | "debug": s.Debug, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /internal/entity/sectionstore.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Store struct { 8 | Sections map[string]*Section 9 | DataSectionsCache []AddrPos 10 | TextSectionsCache []AddrPos 11 | } 12 | 13 | func NewStore() *Store { 14 | return &Store{ 15 | Sections: make(map[string]*Section), 16 | DataSectionsCache: make([]AddrPos, 0), 17 | TextSectionsCache: make([]AddrPos, 0), 18 | } 19 | } 20 | 21 | func (s *Store) FindSection(addr, size uint64) *Section { 22 | for _, section := range s.Sections { 23 | if section.Debug { 24 | // we can't find things in debug sections 25 | continue 26 | } 27 | 28 | if section.ContentType == SectionContentOther { 29 | continue 30 | } 31 | 32 | if section.Addr <= addr && addr+size <= section.AddrEnd { 33 | return section 34 | } 35 | } 36 | return nil 37 | } 38 | 39 | func (s *Store) AssertSize(size uint64) error { 40 | sectionsSize := uint64(0) 41 | for _, section := range s.Sections { 42 | if section.OnlyInMemory { 43 | continue 44 | } 45 | sectionsSize += section.FileSize 46 | } 47 | 48 | if sectionsSize > size { 49 | return fmt.Errorf("section size %d > file size %d", sectionsSize, size) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | func (s *Store) BuildCache() { 56 | for _, section := range s.Sections { 57 | if section.Debug { 58 | continue 59 | } 60 | 61 | switch section.ContentType { 62 | case SectionContentText: 63 | s.TextSectionsCache = append(s.TextSectionsCache, AddrPos{ 64 | Addr: section.Addr, 65 | Size: section.Size, 66 | Type: AddrTypeText, 67 | }) 68 | case SectionContentData: 69 | s.DataSectionsCache = append(s.DataSectionsCache, AddrPos{ 70 | Addr: section.Addr, 71 | Size: section.Size, 72 | Type: AddrTypeData, 73 | }) 74 | default: 75 | // ignore 76 | } 77 | } 78 | } 79 | 80 | func (s *Store) IsData(addr, size uint64) bool { 81 | for _, sect := range s.DataSectionsCache { 82 | if sect.Addr <= addr && addr+size <= sect.Addr+sect.Size { 83 | return true 84 | } 85 | } 86 | return false 87 | } 88 | 89 | func (s *Store) IsText(addr, size uint64) bool { 90 | for _, sect := range s.TextSectionsCache { 91 | if sect.Addr <= addr && addr+size <= sect.Addr+sect.Size { 92 | return true 93 | } 94 | } 95 | return false 96 | } 97 | 98 | func (s *Store) IsType(addr, size uint64, t AddrType) bool { 99 | switch t { 100 | case AddrTypeData: 101 | return s.IsData(addr, size) 102 | case AddrTypeText: 103 | return s.IsText(addr, size) 104 | default: 105 | return true 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/entity/sectionstore_test.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestSectionStore_AssertSize(t *testing.T) { 9 | type fields struct { 10 | Sections map[string]*Section 11 | } 12 | type args struct { 13 | size uint64 14 | } 15 | tests := []struct { 16 | name string 17 | fields fields 18 | args args 19 | wantErr bool 20 | }{ 21 | { 22 | name: "AssertSize works correctly", 23 | fields: fields{ 24 | Sections: map[string]*Section{ 25 | "section1": { 26 | FileSize: 10, 27 | }, 28 | }, 29 | }, 30 | args: args{ 31 | size: 20, 32 | }, 33 | wantErr: false, 34 | }, 35 | { 36 | name: "AssertSize throws error", 37 | fields: fields{ 38 | Sections: map[string]*Section{ 39 | "section1": { 40 | FileSize: 10, 41 | }, 42 | "section2": { 43 | FileSize: 15, 44 | }, 45 | }, 46 | }, 47 | args: args{ 48 | size: 20, 49 | }, 50 | wantErr: true, 51 | }, 52 | } 53 | for _, tt := range tests { 54 | t.Run(tt.name, func(t *testing.T) { 55 | s := &Store{ 56 | Sections: tt.fields.Sections, 57 | } 58 | if err := s.AssertSize(tt.args.size); (err != nil) != tt.wantErr { 59 | t.Errorf("AssertSize() error = %v, wantErr %v", err, tt.wantErr) 60 | } 61 | }) 62 | } 63 | } 64 | 65 | func TestSectionStore_FindSection(t *testing.T) { 66 | type fields struct { 67 | Sections map[string]*Section 68 | } 69 | type args struct { 70 | addr uint64 71 | size uint64 72 | } 73 | tests := []struct { 74 | name string 75 | fields fields 76 | args args 77 | want *Section 78 | }{ 79 | { 80 | name: "FindSection failed", 81 | fields: fields{ 82 | Sections: map[string]*Section{ 83 | "section1": { 84 | Debug: true, 85 | Addr: 100, 86 | AddrEnd: 200, 87 | }, 88 | }, 89 | }, 90 | args: args{ 91 | addr: 150, 92 | }, 93 | want: nil, 94 | }, 95 | } 96 | for _, tt := range tests { 97 | t.Run(tt.name, func(t *testing.T) { 98 | s := &Store{ 99 | Sections: tt.fields.Sections, 100 | } 101 | if got := s.FindSection(tt.args.addr, tt.args.size); !reflect.DeepEqual(got, tt.want) { 102 | t.Errorf("FindSection() = %v, want %v", got, tt.want) 103 | } 104 | }) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/entity/space.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | ) 6 | 7 | // AddrSpace is a map of address to Addr 8 | type AddrSpace map[uint64]*Addr 9 | 10 | func (a AddrSpace) Get(addr uint64) (ret *Addr, ok bool) { 11 | ret, ok = a[addr] 12 | return ret, ok 13 | } 14 | 15 | func (a AddrSpace) Insert(addr *Addr) { 16 | old, ok := a.Get(addr.Addr) 17 | if ok { 18 | // use the larger one 19 | if old.Size < addr.Size { 20 | a[addr.Addr] = addr 21 | } 22 | return 23 | } 24 | a[addr.Addr] = addr 25 | } 26 | 27 | func MergeAddrSpace(others ...AddrSpace) AddrSpace { 28 | ret := make(AddrSpace) 29 | for _, other := range others { 30 | for _, addr := range other { 31 | ret.Insert(addr) 32 | } 33 | } 34 | return ret 35 | } 36 | 37 | // ToDirtyCoverage get the coverage of the current address space 38 | func (a AddrSpace) ToDirtyCoverage() AddrCoverage { 39 | return lo.MapToSlice(a, func(_ uint64, v *Addr) *CoveragePart { 40 | return &CoveragePart{ 41 | Pos: v.AddrPos, 42 | Addrs: []*Addr{v}, 43 | } 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /internal/entity/space_test.go: -------------------------------------------------------------------------------- 1 | package entity_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/entity" 9 | ) 10 | 11 | func TestAddrSpaceGetReturnsExistingAddr(t *testing.T) { 12 | a := entity.AddrSpace{1: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1}}} 13 | addr, ok := a.Get(1) 14 | assert.True(t, ok) 15 | assert.Equal(t, uint64(1), addr.Addr) 16 | } 17 | 18 | func TestAddrSpaceGetReturnsNilForNonExistingAddr(t *testing.T) { 19 | a := entity.AddrSpace{1: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1}}} 20 | addr, ok := a.Get(2) 21 | assert.False(t, ok) 22 | assert.Nil(t, addr) 23 | } 24 | 25 | func TestAddrSpaceInsertAddsNewAddr(t *testing.T) { 26 | a := entity.AddrSpace{} 27 | addr := &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1}} 28 | a.Insert(addr) 29 | assert.Equal(t, addr, a[1]) 30 | } 31 | 32 | func TestAddrSpaceInsertUpdatesExistingAddrWithLargerSize(t *testing.T) { 33 | a := entity.AddrSpace{1: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 1}}} 34 | addr := &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 2}} 35 | a.Insert(addr) 36 | assert.Equal(t, addr, a[1]) 37 | } 38 | 39 | func TestAddrSpaceInsertIgnoresExistingAddrWithSmallerSize(t *testing.T) { 40 | existingAddr := &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 2}} 41 | a := entity.AddrSpace{1: existingAddr} 42 | addr := &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 1}} 43 | a.Insert(addr) 44 | assert.Equal(t, existingAddr, a[1]) 45 | } 46 | 47 | func TestMergeAddrSpaceMergesMultipleAddrSpaces(t *testing.T) { 48 | a1 := entity.AddrSpace{1: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 1}}} 49 | a2 := entity.AddrSpace{2: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 2, Size: 2}}} 50 | merged := entity.MergeAddrSpace(a1, a2) 51 | 52 | assert.Len(t, merged, 2) 53 | assert.Equal(t, a1[1], merged[1]) 54 | assert.Equal(t, a2[2], merged[2]) 55 | } 56 | 57 | func TestMergeAddrSpacePrefersLargerSize(t *testing.T) { 58 | a1 := entity.AddrSpace{1: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 1}}} 59 | a2 := entity.AddrSpace{1: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 2}}} 60 | merged := entity.MergeAddrSpace(a1, a2) 61 | 62 | assert.Len(t, merged, 1) 63 | assert.Equal(t, a2[1], merged[1]) 64 | } 65 | 66 | func TestToDirtyCoverageReturnsCoverageParts(t *testing.T) { 67 | a := entity.AddrSpace{1: &entity.Addr{AddrPos: &entity.AddrPos{Addr: 1, Size: 1}}} 68 | coverage := a.ToDirtyCoverage() 69 | 70 | assert.Len(t, coverage, 1) 71 | assert.Equal(t, a[1].AddrPos, coverage[0].Pos) 72 | assert.Equal(t, a[1], coverage[0].Addrs[0]) 73 | } 74 | -------------------------------------------------------------------------------- /internal/entity/symbol.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Zxilly/go-size-analyzer/internal/utils" 7 | ) 8 | 9 | type Symbol struct { 10 | Name string `json:"name"` 11 | Addr uint64 `json:"addr"` 12 | Size uint64 `json:"size"` 13 | Type AddrType `json:"type"` 14 | } 15 | 16 | func NewSymbol(name string, addr, size uint64, typ AddrType) *Symbol { 17 | return &Symbol{ 18 | Name: utils.Deduplicate(name), 19 | Addr: addr, 20 | Size: size, 21 | Type: typ, 22 | } 23 | } 24 | 25 | func (s *Symbol) String() string { 26 | return fmt.Sprintf("Symbol: %s Addr: %x Size: %x Type: %s", s.Name, s.Addr, s.Size, s.Type) 27 | } 28 | -------------------------------------------------------------------------------- /internal/entity/symbol_marshaler.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package entity 4 | 5 | func (s *Symbol) MarshalJavaScript() any { 6 | return map[string]any{ 7 | "name": s.Name, 8 | "addr": s.Addr, 9 | "size": s.Size, 10 | "type": s.Type, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /internal/entity/symbol_test.go: -------------------------------------------------------------------------------- 1 | package entity_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/entity" 9 | ) 10 | 11 | func TestSymbolStringRepresentation(t *testing.T) { 12 | symbol := &entity.Symbol{ 13 | Name: "testSymbol", 14 | Addr: 4096, 15 | Size: 256, 16 | Type: entity.AddrTypeData, 17 | } 18 | 19 | expected := "Symbol: testSymbol Addr: 1000 Size: 100 Type: data" 20 | result := symbol.String() 21 | 22 | assert.Equal(t, expected, result) 23 | } 24 | 25 | func TestSymbolStringRepresentationWithDifferentType(t *testing.T) { 26 | symbol := &entity.Symbol{ 27 | Name: "testSymbol", 28 | Addr: 4096, 29 | Size: 256, 30 | Type: entity.AddrTypeText, 31 | } 32 | 33 | expected := "Symbol: testSymbol Addr: 1000 Size: 100 Type: text" 34 | result := symbol.String() 35 | 36 | assert.Equal(t, expected, result) 37 | } 38 | 39 | func TestSymbolStringRepresentationWithZeroSize(t *testing.T) { 40 | symbol := &entity.Symbol{ 41 | Name: "testSymbol", 42 | Addr: 4096, 43 | Size: 0, 44 | Type: entity.AddrTypeData, 45 | } 46 | 47 | expected := "Symbol: testSymbol Addr: 1000 Size: 0 Type: data" 48 | result := symbol.String() 49 | 50 | assert.Equal(t, expected, result) 51 | } 52 | 53 | func TestSymbolStringRepresentationWithZeroAddr(t *testing.T) { 54 | symbol := &entity.Symbol{ 55 | Name: "testSymbol", 56 | Addr: 0, 57 | Size: 256, 58 | Type: entity.AddrTypeData, 59 | } 60 | 61 | expected := "Symbol: testSymbol Addr: 0 Size: 100 Type: data" 62 | result := symbol.String() 63 | 64 | assert.Equal(t, expected, result) 65 | } 66 | 67 | func TestNewSymbolCreation(t *testing.T) { 68 | name := "testSymbol" 69 | addr := uint64(4096) 70 | size := uint64(256) 71 | typ := entity.AddrTypeData 72 | 73 | symbol := entity.NewSymbol(name, addr, size, typ) 74 | 75 | assert.Equal(t, name, symbol.Name) 76 | assert.Equal(t, addr, symbol.Addr) 77 | assert.Equal(t, size, symbol.Size) 78 | assert.Equal(t, typ, symbol.Type) 79 | } 80 | -------------------------------------------------------------------------------- /internal/entity/type.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | type AddrType = string 4 | 5 | const ( 6 | AddrTypeUnknown AddrType = "unknown" // it exists, but should never be collected 7 | AddrTypeText AddrType = "text" // for text section 8 | AddrTypeData AddrType = "data" // data / rodata section 9 | ) 10 | 11 | type AddrSourceType = string 12 | 13 | const ( 14 | AddrSourceGoPclntab AddrSourceType = "pclntab" 15 | AddrSourceSymbol AddrSourceType = "symbol" 16 | AddrSourceDisasm AddrSourceType = "disasm" 17 | AddrSourceDwarf AddrSourceType = "dwarf" 18 | ) 19 | -------------------------------------------------------------------------------- /internal/knowninfo/collect.go: -------------------------------------------------------------------------------- 1 | package knowninfo 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "math" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/entity" 9 | ) 10 | 11 | func (k *KnownInfo) CollectCoverage() error { 12 | // load coverage for pclntab and symbol 13 | pclntabCov := k.KnownAddr.TextAddrSpace.ToDirtyCoverage() 14 | 15 | // merge all 16 | covs := make([]entity.AddrCoverage, 0) 17 | 18 | // collect packages coverage 19 | _ = k.Deps.Trie.Walk(func(_ string, p *entity.Package) error { 20 | covs = append(covs, p.GetPackageCoverage()) 21 | return nil 22 | }) 23 | 24 | covs = append(covs, pclntabCov) 25 | 26 | var err error 27 | k.Coverage, err = entity.MergeAndCleanCoverage(covs) 28 | return err 29 | } 30 | 31 | func (k *KnownInfo) CalculateSectionSize() error { 32 | sectCache := make(map[*entity.Section]uint64) 33 | // minus coverage part 34 | for _, cp := range k.Coverage { 35 | s := k.Sects.FindSection(cp.Pos.Addr, cp.Pos.Size) 36 | if s == nil { 37 | slog.Debug(fmt.Sprintf("possible bss addr %s", cp)) 38 | continue 39 | } 40 | sectCache[s] += cp.Pos.Size 41 | } 42 | 43 | pclntabSize := uint64(0) 44 | _ = k.Deps.Trie.Walk(func(_ string, p *entity.Package) error { 45 | for fn := range p.Functions { 46 | pclntabSize += fn.PclnSize.Size() 47 | } 48 | return nil 49 | }) 50 | 51 | // minus pclntab size 52 | var pclntabSection *entity.Section 53 | for _, s := range k.Sects.Sections { 54 | if s.Addr <= k.PClnTabAddr && k.PClnTabAddr < s.AddrEnd { 55 | pclntabSection = s 56 | sectCache[s] += pclntabSize 57 | break 58 | } 59 | } 60 | if pclntabSection == nil { 61 | slog.Warn(fmt.Sprintf("pclntab addr %d not in any section", k.PClnTabAddr)) 62 | } 63 | 64 | // linear map virtual size to file size 65 | for s, size := range sectCache { 66 | mapper := 1.0 67 | if s.Size != s.FileSize { 68 | // need to map to file size 69 | mapper = float64(s.FileSize) / float64(s.Size) 70 | } 71 | s.KnownSize = uint64(math.Floor(float64(size) * mapper)) 72 | 73 | if s.KnownSize > s.FileSize && s.FileSize != 0 { 74 | if s != pclntabSection { 75 | // pclntab contains lots of reuse 76 | // todo: add coverage support for pclntab size 77 | slog.Warn(fmt.Sprintf("section %s known size %d > file size %d", s.Name, s.KnownSize, s.FileSize)) 78 | } 79 | s.KnownSize = s.FileSize 80 | } 81 | 82 | if s.FileSize == 0 { 83 | s.KnownSize = 0 84 | } 85 | } 86 | return nil 87 | } 88 | 89 | // CalculatePackageSize calculate the size of each package 90 | // Happens after disassembly 91 | func (k *KnownInfo) CalculatePackageSize() { 92 | _ = k.Deps.Trie.Walk(func(_ string, p *entity.Package) error { 93 | p.AssignPackageSize() 94 | return nil 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /internal/knowninfo/disasm.go: -------------------------------------------------------------------------------- 1 | //go:build !wasm 2 | 3 | package knowninfo 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "runtime" 11 | "time" 12 | 13 | "github.com/samber/lo" 14 | "golang.org/x/sync/errgroup" 15 | 16 | "github.com/Zxilly/go-size-analyzer/internal/disasm" 17 | "github.com/Zxilly/go-size-analyzer/internal/entity" 18 | ) 19 | 20 | func (k *KnownInfo) Disasm() error { 21 | k.KnownAddr.BuildSymbolCoverage() 22 | 23 | startTime := time.Now() 24 | slog.Info("Disassemble functions...") 25 | 26 | e, err := disasm.NewExtractor(k.Wrapper, k.Size, k.Sects.IsData, k.GoStringSymbol) 27 | if err != nil { 28 | if errors.Is(err, disasm.ErrArchNotSupported) { 29 | slog.Warn("Disassembler not supported on this architecture") 30 | return nil 31 | } 32 | return err 33 | } 34 | 35 | type result struct { 36 | addr, size uint64 37 | fn *entity.Function 38 | } 39 | 40 | resultChan := make(chan result, 32) 41 | 42 | resultProcess, resultDone := context.WithCancel(context.Background()) 43 | 44 | added := 0 45 | throw := 0 46 | 47 | go func() { 48 | defer resultDone() 49 | for r := range resultChan { 50 | if !e.Validate(r.addr, r.size) { 51 | throw++ 52 | continue 53 | } 54 | added++ 55 | 56 | k.KnownAddr.InsertDisasm(r.addr, r.size, r.fn) 57 | } 58 | }() 59 | 60 | var ( 61 | maxWorkers = runtime.NumCPU() 62 | eg = errgroup.Group{} 63 | ) 64 | eg.SetLimit(maxWorkers) 65 | 66 | for fn := range k.Deps.Functions { 67 | eg.Go(func() error { 68 | candidates := e.Extract(fn.Addr, fn.Addr+fn.CodeSize) 69 | 70 | lo.ForEach(candidates, func(p disasm.PossibleStr, _ int) { 71 | resultChan <- result{ 72 | addr: p.Addr, 73 | size: p.Size, 74 | fn: fn, 75 | } 76 | }) 77 | 78 | return nil 79 | }) 80 | } 81 | 82 | if err = eg.Wait(); err != nil { 83 | slog.Error(fmt.Sprintf("Disassemble functions failed: %v", err)) 84 | return err 85 | } 86 | 87 | close(resultChan) 88 | <-resultProcess.Done() 89 | 90 | slog.Info(fmt.Sprintf("Disassemble functions done, took %s, added %d, throw %d", time.Since(startTime), added, throw)) 91 | 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/knowninfo/disasm_js_wasm.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package knowninfo 4 | 5 | import ( 6 | "log/slog" 7 | ) 8 | 9 | func (k *KnownInfo) Disasm() error { 10 | slog.Info("disassembler disabled for wasm") 11 | return nil 12 | } 13 | -------------------------------------------------------------------------------- /internal/knowninfo/dwarf_test.go: -------------------------------------------------------------------------------- 1 | package knowninfo 2 | 3 | import ( 4 | "debug/dwarf" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSafeGetEntryValReturnsValueOnSuccess(t *testing.T) { 11 | entry := &dwarf.Entry{} 12 | 13 | value, ok := safeGetEntryVal[int](entry, dwarf.Attr(1), "test attribute") 14 | assert.False(t, ok) 15 | assert.Zero(t, value) 16 | } 17 | -------------------------------------------------------------------------------- /internal/knowninfo/imports.go: -------------------------------------------------------------------------------- 1 | //go:build !wasm 2 | 3 | package knowninfo 4 | 5 | import ( 6 | "fmt" 7 | "go/parser" 8 | "go/token" 9 | "log/slog" 10 | "runtime" 11 | "slices" 12 | "strings" 13 | 14 | "github.com/puzpuzpuz/xsync/v4" 15 | "golang.org/x/sync/errgroup" 16 | 17 | "github.com/Zxilly/go-size-analyzer/internal/entity" 18 | "github.com/Zxilly/go-size-analyzer/internal/utils" 19 | ) 20 | 21 | func (m *Dependencies) UpdateImportBy() { 22 | slog.Info("Analyzing package imports...") 23 | 24 | var ( 25 | maxWorkers = runtime.NumCPU() 26 | eg = errgroup.Group{} 27 | ) 28 | eg.SetLimit(maxWorkers) 29 | 30 | importedBy := xsync.NewMap[string, utils.Set[string]]() 31 | 32 | _ = m.Trie.Walk(func(_ string, pkg *entity.Package) error { 33 | eg.Go(func() error { 34 | fset := token.NewFileSet() 35 | imports := utils.NewSet[string]() 36 | for _, f := range pkg.Files { 37 | if f.FilePath == "" || f.FilePath == "" { 38 | // not a real file 39 | continue 40 | } 41 | 42 | if !strings.HasSuffix(f.FilePath, ".go") { 43 | // for .s no need to check 44 | continue 45 | } 46 | 47 | // check for import statements 48 | pf, err := parser.ParseFile(fset, f.FilePath, nil, parser.ImportsOnly) 49 | if err != nil { 50 | slog.Warn(fmt.Sprintf("failed to parse package %s file %s: %v", pkg.Name, f.FilePath, err)) 51 | continue 52 | } 53 | for _, imp := range pf.Imports { 54 | pname := strings.Trim(imp.Path.Value, `"`) 55 | imports.Add(pname) 56 | } 57 | } 58 | 59 | for _, imp := range imports.ToSlice() { 60 | importedBy.Compute(imp, func(oldValue utils.Set[string], loaded bool) (newValue utils.Set[string], op xsync.ComputeOp) { 61 | if loaded { 62 | newValue = oldValue 63 | } else { 64 | newValue = utils.NewSet[string]() 65 | } 66 | newValue.Add(pkg.Name) 67 | op = xsync.UpdateOp 68 | return newValue, op 69 | }) 70 | } 71 | return nil 72 | }) 73 | 74 | return nil 75 | }) 76 | 77 | _ = eg.Wait() 78 | 79 | _ = m.Trie.Walk(func(_ string, pkg *entity.Package) error { 80 | fs, ok := importedBy.Load(pkg.Name) 81 | if !ok { 82 | return nil 83 | } 84 | ps := fs.ToSlice() 85 | slices.Sort(ps) 86 | pkg.ImportedBy = ps 87 | 88 | return nil 89 | }) 90 | 91 | slog.Info("Package imports analysis completed.") 92 | } 93 | -------------------------------------------------------------------------------- /internal/knowninfo/imports_wasm.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package knowninfo 4 | 5 | func (m *Dependencies) UpdateImportBy() { 6 | // No-op for wasm 7 | } 8 | -------------------------------------------------------------------------------- /internal/knowninfo/knowninfo.go: -------------------------------------------------------------------------------- 1 | package knowninfo 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/ZxillyFork/gore" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/entity" 9 | "github.com/Zxilly/go-size-analyzer/internal/wrapper" 10 | ) 11 | 12 | type VersionFlag struct { 13 | Leq118 bool 14 | Meq120 bool 15 | } 16 | 17 | type KnownInfo struct { 18 | Size uint64 19 | BuildInfo *gore.BuildInfo 20 | Sects *entity.Store 21 | Deps *Dependencies 22 | KnownAddr *entity.KnownAddr 23 | 24 | GoStringSymbol *entity.AddrPos 25 | 26 | Coverage entity.AddrCoverage 27 | 28 | Gore *gore.GoFile 29 | PClnTabAddr uint64 30 | Wrapper wrapper.RawFileWrapper 31 | 32 | VersionFlag VersionFlag 33 | 34 | HasDWARF bool 35 | } 36 | 37 | func (k *KnownInfo) LoadGoreInfo(f *gore.GoFile, isWasm bool) error { 38 | err := k.LoadPackages(f, isWasm) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | slog.Info("Loading version flag and meta info...") 44 | k.VersionFlag = UpdateVersionFlag(f) 45 | slog.Info("Loaded version flag") 46 | k.PClnTabAddr = f.GetPCLNTableAddr() 47 | slog.Info("Loaded meta info") 48 | 49 | return nil 50 | } 51 | 52 | func UpdateVersionFlag(f *gore.GoFile) VersionFlag { 53 | ver, err := f.GetCompilerVersion() 54 | if err != nil { 55 | // if we can't get build info, we assume it's go1.20 plus 56 | return VersionFlag{ 57 | Leq118: false, 58 | Meq120: true, 59 | } 60 | } 61 | 62 | return VersionFlag{ 63 | Leq118: gore.GoVersionCompare(ver.Name, "go1.18.10") <= 0, 64 | Meq120: gore.GoVersionCompare(ver.Name, "go1.20rc1") >= 0, 65 | } 66 | } 67 | 68 | func (k *KnownInfo) convertAddr(addr uint64) uint64 { 69 | if w, ok := k.Wrapper.(*wrapper.MachoWrapper); ok { 70 | return w.SlidePointer(addr) 71 | } 72 | return addr 73 | } 74 | -------------------------------------------------------------------------------- /internal/knowninfo/section.go: -------------------------------------------------------------------------------- 1 | package knowninfo 2 | 3 | import ( 4 | "errors" 5 | "log/slog" 6 | ) 7 | 8 | func (k *KnownInfo) LoadSectionMap() error { 9 | slog.Info("Loading sections...") 10 | 11 | store := k.Wrapper.LoadSections() 12 | if store == nil { 13 | return errors.New("failed to load sections") 14 | } 15 | 16 | store.BuildCache() 17 | 18 | slog.Info("Loaded sections") 19 | 20 | k.Sects = store 21 | return k.Sects.AssertSize(k.Size) 22 | } 23 | -------------------------------------------------------------------------------- /internal/knowninfo/symbol.go: -------------------------------------------------------------------------------- 1 | package knowninfo 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | 7 | "github.com/ZxillyFork/gosym" 8 | 9 | "github.com/Zxilly/go-size-analyzer/internal/entity" 10 | "github.com/Zxilly/go-size-analyzer/internal/utils" 11 | ) 12 | 13 | // ExtractPackageFromSymbol copied from debug/gosym/symtab.go 14 | func (k *KnownInfo) ExtractPackageFromSymbol(s string) string { 15 | var ver gosym.Version 16 | if k.VersionFlag.Meq120 { 17 | ver = gosym.Ver120 // ver120 18 | } else if k.VersionFlag.Leq118 { 19 | ver = gosym.Ver118 // ver118 20 | } 21 | 22 | sym := &gosym.Sym{ 23 | Name: s, 24 | GoVersion: ver, 25 | } 26 | 27 | packageName := sym.PackageName() 28 | 29 | return utils.UglyGuess(packageName) 30 | } 31 | 32 | func (k *KnownInfo) MarkSymbol(name string, addr, size uint64, typ entity.AddrType) { 33 | if typ != entity.AddrTypeData { 34 | // todo: support text symbols, cross check with pclntab 35 | // and further work on cgo symbols 36 | return 37 | } 38 | 39 | var pkg *entity.Package 40 | pkgName := k.ExtractPackageFromSymbol(name) 41 | 42 | switch { 43 | case pkgName == "" || strings.HasPrefix(name, "x_cgo"): 44 | // we assume it's a cgo symbol 45 | return // todo: implement cgo analysis in the future 46 | case pkgName == "$f64" || pkgName == "$f32": 47 | return 48 | default: 49 | var ok bool 50 | pkg, ok = k.Deps.GetPackage(pkgName) 51 | if !ok { 52 | slog.Debug("package not found", "name", pkgName, "symbol", name, "type", typ) 53 | return // no package found, skip 54 | } 55 | } 56 | 57 | symbol := entity.NewSymbol(name, addr, size, typ) 58 | 59 | ap := k.KnownAddr.InsertSymbol(symbol, pkg) 60 | if ap == nil { 61 | return 62 | } 63 | pkg.AddSymbol(symbol, ap) 64 | } 65 | 66 | func (k *KnownInfo) AnalyzeSymbol(store bool) error { 67 | slog.Info("Analyzing symbols...") 68 | 69 | marker := k.MarkSymbol 70 | if !store { 71 | marker = nil 72 | } 73 | 74 | err := k.Wrapper.LoadSymbols(marker, func(addr, size uint64) { 75 | k.GoStringSymbol = &entity.AddrPos{ 76 | Addr: addr, 77 | Size: size, 78 | Type: entity.AddrTypeData, 79 | } 80 | }) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | slog.Info("Analyzing symbols done") 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /internal/printer/console.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package printer 4 | 5 | import ( 6 | "cmp" 7 | "io" 8 | "log/slog" 9 | "maps" 10 | "slices" 11 | 12 | "github.com/dustin/go-humanize" 13 | "github.com/jedib0t/go-pretty/v6/table" 14 | "github.com/samber/lo" 15 | 16 | "github.com/Zxilly/go-size-analyzer/internal/entity" 17 | "github.com/Zxilly/go-size-analyzer/internal/result" 18 | "github.com/Zxilly/go-size-analyzer/internal/utils" 19 | ) 20 | 21 | type CommonOption struct { 22 | HideSections bool 23 | HideMain bool 24 | HideStd bool 25 | } 26 | 27 | func Text(r *result.Result, writer io.Writer, options *CommonOption) error { 28 | slog.Info("Printing text report") 29 | 30 | t := table.NewWriter() 31 | t.SetStyle(utils.GetTableStyle()) 32 | 33 | allKnownSize := uint64(0) 34 | 35 | t.SetTitle("%s", r.Name) 36 | t.AppendHeader(table.Row{"Percent", "Name", "Size", "Type"}) 37 | 38 | type sizeEntry struct { 39 | name string 40 | size uint64 41 | typ string 42 | percent string 43 | } 44 | 45 | entries := make([]sizeEntry, 0) 46 | 47 | pkgs := utils.Collect(maps.Values(r.Packages)) 48 | for _, p := range pkgs { 49 | if options.HideMain && p.Type == entity.PackageTypeMain { 50 | continue 51 | } 52 | if options.HideStd && p.Type == entity.PackageTypeStd { 53 | continue 54 | } 55 | 56 | allKnownSize += p.Size 57 | entries = append(entries, sizeEntry{ 58 | name: p.Name, 59 | size: p.Size, 60 | typ: p.Type, 61 | percent: utils.PercentString(float64(p.Size) / float64(r.Size)), 62 | }) 63 | } 64 | 65 | if !options.HideSections { 66 | sections := lo.Filter(r.Sections, func(s *entity.Section, _ int) bool { 67 | return s.Size > s.KnownSize && s.Size != s.KnownSize && !s.OnlyInMemory 68 | }) 69 | for _, s := range sections { 70 | unknownSize := s.FileSize - s.KnownSize 71 | allKnownSize += unknownSize 72 | entries = append(entries, sizeEntry{ 73 | name: s.Name, 74 | size: unknownSize, 75 | typ: "section", 76 | percent: utils.PercentString(float64(unknownSize) / float64(r.Size)), 77 | }) 78 | } 79 | } 80 | 81 | allKnownSize = min(allKnownSize, r.Size) // since we can have overlap 82 | 83 | slices.SortFunc(entries, func(a, b sizeEntry) int { 84 | return -cmp.Compare(a.size, b.size) 85 | }) 86 | 87 | for _, e := range entries { 88 | t.AppendRow(table.Row{e.percent, e.name, humanize.Bytes(e.size), e.typ}) 89 | } 90 | 91 | t.AppendFooter(table.Row{utils.PercentString(float64(allKnownSize) / float64(r.Size)), "Known", humanize.Bytes(allKnownSize)}) 92 | t.AppendFooter(table.Row{"100%", "Total", humanize.Bytes(r.Size)}) 93 | 94 | data := []byte(t.Render() + "\n") 95 | 96 | slog.Info("Report rendered") 97 | 98 | _, err := writer.Write(data) 99 | 100 | slog.Info("Report written") 101 | 102 | return err 103 | } 104 | -------------------------------------------------------------------------------- /internal/printer/html.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package printer 4 | 5 | import ( 6 | "errors" 7 | "io" 8 | "strings" 9 | 10 | "github.com/Zxilly/go-size-analyzer/internal/constant" 11 | "github.com/Zxilly/go-size-analyzer/internal/result" 12 | "github.com/Zxilly/go-size-analyzer/internal/webui" 13 | ) 14 | 15 | var ErrTemplateInvalid = errors.New("template invalid") 16 | 17 | func HTML(r *result.Result, writer io.Writer) error { 18 | parts := strings.Split(webui.GetTemplate(), constant.ReplacedStr) 19 | if len(parts) != 2 { 20 | return ErrTemplateInvalid 21 | } 22 | 23 | _, err := writer.Write([]byte(parts[0])) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | err = JSON(r, writer, &JSONOption{HideDetail: true}) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | _, err = writer.Write([]byte(parts[1])) 34 | return err 35 | } 36 | -------------------------------------------------------------------------------- /internal/printer/json.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package printer 4 | 5 | import ( 6 | "io" 7 | "log/slog" 8 | "strings" 9 | 10 | "github.com/go-json-experiment/json" 11 | "github.com/go-json-experiment/json/jsontext" 12 | 13 | "github.com/Zxilly/go-size-analyzer/internal/entity/marshaler" 14 | ) 15 | 16 | type JSONOption struct { 17 | Indent *int 18 | HideDetail bool 19 | } 20 | 21 | func JSON(r any, writer io.Writer, options *JSONOption) error { 22 | slog.Info("JSON encoding...") 23 | 24 | jsonOptions := []json.Options{ 25 | json.DefaultOptionsV2(), 26 | json.Deterministic(true), 27 | } 28 | if options.Indent != nil { 29 | jsonOptions = append(jsonOptions, jsontext.WithIndent(strings.Repeat(" ", *options.Indent))) 30 | } 31 | if options.HideDetail { 32 | jsonOptions = append(jsonOptions, json.WithMarshalers(marshaler.GetFileCompactMarshaler())) 33 | } 34 | 35 | err := json.MarshalWrite(writer, r, jsonOptions...) 36 | 37 | slog.Info("JSON encoded") 38 | 39 | return err 40 | } 41 | -------------------------------------------------------------------------------- /internal/printer/svg.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package printer 4 | 5 | import ( 6 | "image/color" 7 | "io" 8 | 9 | "github.com/nikolaydubina/treemap" 10 | "github.com/nikolaydubina/treemap/render" 11 | 12 | "github.com/Zxilly/go-size-analyzer/internal/entity" 13 | "github.com/Zxilly/go-size-analyzer/internal/result" 14 | ) 15 | 16 | type SvgOption struct { 17 | CommonOption 18 | Width int 19 | Height int 20 | MarginBox int 21 | PaddingBox int 22 | PaddingRoot int 23 | } 24 | 25 | func Svg(r *result.Result, writer io.Writer, options *SvgOption) error { 26 | baseName := r.Name 27 | 28 | tree := &treemap.Tree{ 29 | Nodes: make(map[string]treemap.Node), 30 | To: make(map[string][]string), 31 | Root: baseName, 32 | } 33 | 34 | insert := func(path string, size float64) { 35 | tree.Nodes[path] = treemap.Node{ 36 | Path: path, 37 | Size: size, 38 | } 39 | } 40 | 41 | relation := func(parent, child string) { 42 | tree.To[parent] = append(tree.To[parent], child) 43 | } 44 | 45 | merge := func(s string) string { 46 | return baseName + "/" + s 47 | } 48 | 49 | // write file 50 | insert(baseName, float64(r.Size)) 51 | 52 | // write sections 53 | if !options.HideSections { 54 | for _, sec := range r.Sections { 55 | insert(merge(sec.Name), float64(sec.FileSize-sec.KnownSize)) 56 | relation(baseName, merge(sec.Name)) 57 | } 58 | } 59 | 60 | // write packages 61 | var writePackage func(p *entity.Package) 62 | writePackage = func(p *entity.Package) { 63 | if (!options.HideMain || p.Type != entity.PackageTypeMain) && 64 | (!options.HideStd || p.Type != entity.PackageTypeStd) { 65 | insert(merge(p.Name), float64(p.Size)) 66 | } 67 | for _, sub := range p.SubPackages { 68 | relation(merge(p.Name), merge(sub.Name)) 69 | writePackage(sub) 70 | } 71 | } 72 | 73 | for _, p := range r.Packages { 74 | writePackage(p) 75 | relation(baseName, merge(p.Name)) 76 | } 77 | 78 | treemap.SetNamesFromPaths(tree) 79 | treemap.CollapseLongPaths(tree) 80 | 81 | sizeImputer := treemap.SumSizeImputer{EmptyLeafSize: 1} 82 | sizeImputer.ImputeSize(*tree) 83 | 84 | tree.NormalizeHeat() 85 | 86 | colorer := render.NoneColorer{} 87 | borderColor := color.RGBA{R: 128, G: 128, B: 128, A: 255} 88 | 89 | uiBuilder := render.UITreeMapBuilder{ 90 | Colorer: colorer, 91 | BorderColor: borderColor, 92 | } 93 | spec := uiBuilder.NewUITreeMap(*tree, 94 | float64(options.Width), 95 | float64(options.Height), 96 | float64(options.MarginBox), 97 | float64(options.PaddingBox), 98 | float64(options.PaddingRoot)) 99 | renderer := render.SVGRenderer{} 100 | 101 | data := renderer.Render(spec, float64(options.Width), float64(options.Height)) 102 | _, err := writer.Write(data) 103 | return err 104 | } 105 | -------------------------------------------------------------------------------- /internal/printer/wasm/obj_js_wasm.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package wasm 4 | 5 | import ( 6 | "syscall/js" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/result" 9 | ) 10 | 11 | func JavaScript(r *result.Result) js.Value { 12 | ret := r.MarshalJavaScript() 13 | 14 | return js.ValueOf(ret) 15 | } 16 | -------------------------------------------------------------------------------- /internal/result/result.go: -------------------------------------------------------------------------------- 1 | package result 2 | 3 | import ( 4 | "github.com/Zxilly/go-size-analyzer/internal/entity" 5 | ) 6 | 7 | type Result struct { 8 | Name string `json:"name"` 9 | Size uint64 `json:"size"` 10 | 11 | Analyzers []entity.Analyzer `json:"analyzers"` 12 | Packages entity.PackageMap `json:"packages"` 13 | Sections []*entity.Section `json:"sections"` 14 | } 15 | -------------------------------------------------------------------------------- /internal/result/result_js_wasm_test.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package result_test 4 | 5 | import ( 6 | "bytes" 7 | "compress/gzip" 8 | _ "embed" 9 | "encoding/gob" 10 | "os" 11 | "syscall/js" 12 | "testing" 13 | 14 | "github.com/go-json-experiment/json" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | 18 | "github.com/Zxilly/go-size-analyzer/internal/result" 19 | ) 20 | 21 | func TestResultMarshalJavaScript(t *testing.T) { 22 | testdataGob, err := os.ReadFile("../../testdata/result.gob.gz") 23 | require.NoError(t, err) 24 | 25 | testdataJSON, err := os.ReadFile("../../testdata/result.json") 26 | require.NoError(t, err) 27 | 28 | decompressedReader, err := gzip.NewReader(bytes.NewReader(testdataGob)) 29 | require.NoError(t, err) 30 | 31 | // use JSON.stringify to compare the result 32 | JSON := js.Global().Get("JSON") 33 | stringify := JSON.Get("stringify") 34 | 35 | var r result.Result 36 | err = gob.NewDecoder(decompressedReader).Decode(&r) 37 | require.NoError(t, err) 38 | 39 | t.Run("Result", func(t *testing.T) { 40 | jsVal := r.MarshalJavaScript() 41 | jsStr := stringify.Invoke(jsVal).String() 42 | assert.JSONEq(t, string(testdataJSON), jsStr) 43 | }) 44 | 45 | var testdataJSONVal map[string]any 46 | err = json.Unmarshal(testdataJSON, &testdataJSONVal) 47 | require.NoError(t, err) 48 | 49 | t.Run("Section", func(t *testing.T) { 50 | sectionsAny := testdataJSONVal["sections"].([]any) 51 | 52 | for i, sect := range r.Sections { 53 | jsVal := sect.MarshalJavaScript() 54 | jsStr := stringify.Invoke(jsVal).String() 55 | 56 | sectAny := sectionsAny[i] 57 | sectStr, err := json.Marshal(sectAny) 58 | require.NoError(t, err) 59 | 60 | assert.JSONEq(t, string(sectStr), jsStr) 61 | } 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /internal/result/result_marshaler.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package result 4 | 5 | func (r *Result) MarshalJavaScript() any { 6 | var sections []any 7 | for _, s := range r.Sections { 8 | sections = append(sections, s.MarshalJavaScript()) 9 | } 10 | var analyzers []any 11 | for _, a := range r.Analyzers { 12 | analyzers = append(analyzers, a) 13 | } 14 | 15 | packages := r.Packages.MarshalJavaScript() 16 | 17 | return map[string]any{ 18 | "name": r.Name, 19 | "size": r.Size, 20 | "packages": packages, 21 | "sections": sections, 22 | "analyzers": analyzers, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/result/result_marshaler_test.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package result_test 4 | 5 | import ( 6 | "bytes" 7 | "compress/gzip" 8 | _ "embed" 9 | "encoding/gob" 10 | "os" 11 | "testing" 12 | 13 | "github.com/go-json-experiment/json" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | 17 | "github.com/Zxilly/go-size-analyzer/internal/entity/marshaler" 18 | "github.com/Zxilly/go-size-analyzer/internal/result" 19 | ) 20 | 21 | func TestResultMarshalJavaScriptCross(t *testing.T) { 22 | testdataGob, err := os.ReadFile("../../testdata/result.gob.gz") 23 | require.NoError(t, err) 24 | 25 | decompressedReader, err := gzip.NewReader(bytes.NewBuffer(testdataGob)) 26 | require.NoError(t, err) 27 | 28 | r := new(result.Result) 29 | err = gob.NewDecoder(decompressedReader).Decode(r) 30 | require.NoError(t, err) 31 | 32 | jsonPrinterResult, err := json.Marshal(r, 33 | json.DefaultOptionsV2(), 34 | json.Deterministic(true), 35 | json.WithMarshalers(marshaler.GetFileCompactMarshaler())) 36 | require.NoError(t, err) 37 | 38 | resultJSAny := r.MarshalJavaScript() 39 | resultJSJson, err := json.Marshal(resultJSAny) 40 | require.NoError(t, err) 41 | 42 | assert.JSONEq(t, string(jsonPrinterResult), string(resultJSJson)) 43 | } 44 | -------------------------------------------------------------------------------- /internal/result/result_test.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package result_test 4 | 5 | import ( 6 | "compress/gzip" 7 | "encoding/gob" 8 | "flag" 9 | "os" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/Zxilly/go-size-analyzer/internal/printer" 15 | "github.com/Zxilly/go-size-analyzer/internal/test" 16 | ) 17 | 18 | var update = flag.Bool("update", false, "update testdata") 19 | 20 | func TestUpdateResultTestData(t *testing.T) { 21 | t.Helper() 22 | 23 | if !*update { 24 | t.Skip("not updating testdata") 25 | } 26 | 27 | r := test.GetTestResult(t) 28 | 29 | testdataJSON, err := os.OpenFile(test.GetTestJSONPath(t), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 30 | require.NoError(t, err) 31 | defer func() { 32 | require.NoError(t, testdataJSON.Close()) 33 | }() 34 | 35 | indent := 2 36 | err = printer.JSON(r, testdataJSON, &printer.JSONOption{ 37 | HideDetail: true, 38 | Indent: &indent, 39 | }) 40 | require.NoError(t, err) 41 | 42 | testdataGob, err := os.OpenFile(test.GetTestGobPath(t), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 43 | require.NoError(t, err) 44 | defer func() { 45 | require.NoError(t, testdataGob.Close()) 46 | }() 47 | 48 | compressedWriter, err := gzip.NewWriterLevel(testdataGob, gzip.BestCompression) 49 | require.NoError(t, err) 50 | defer func() { 51 | require.NoError(t, compressedWriter.Close()) 52 | }() 53 | 54 | err = gob.NewEncoder(compressedWriter).Encode(r) 55 | require.NoError(t, err) 56 | } 57 | -------------------------------------------------------------------------------- /internal/test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "golang.org/x/exp/mmap" 10 | 11 | "github.com/Zxilly/go-size-analyzer/internal" 12 | "github.com/Zxilly/go-size-analyzer/internal/result" 13 | "github.com/Zxilly/go-size-analyzer/internal/test/testutils" 14 | ) 15 | 16 | func getTestBinBasePath(t *testing.T) string { 17 | t.Helper() 18 | 19 | testdataPath := os.Getenv("TESTDATA_PATH") 20 | if testdataPath != "" { 21 | return testdataPath 22 | } 23 | 24 | return filepath.Join(testutils.GetProjectRoot(t), "scripts", "bins") 25 | } 26 | 27 | func GetTestBinPath(t *testing.T) string { 28 | t.Helper() 29 | 30 | p := filepath.Join(getTestBinBasePath(t), "bin-linux-1.21-amd64") 31 | p, err := filepath.Abs(p) 32 | require.NoError(t, err) 33 | 34 | _, err = os.Stat(p) 35 | require.NoError(t, err) 36 | 37 | return testutils.RewritePathOnDemand(t, p) 38 | } 39 | 40 | func GetTestDiffBinPath(t *testing.T) string { 41 | t.Helper() 42 | 43 | p := filepath.Join(getTestBinBasePath(t), "bin-linux-1.22-amd64") 44 | p, err := filepath.Abs(p) 45 | require.NoError(t, err) 46 | 47 | _, err = os.Stat(p) 48 | require.NoError(t, err) 49 | 50 | return testutils.RewritePathOnDemand(t, p) 51 | } 52 | 53 | func GetTestJSONPath(t *testing.T) string { 54 | t.Helper() 55 | 56 | p := filepath.Join(testutils.GetProjectRoot(t), "testdata", "result.json") 57 | p, err := filepath.Abs(p) 58 | require.NoError(t, err) 59 | 60 | return testutils.RewritePathOnDemand(t, p) 61 | } 62 | 63 | func GetTestGobPath(t *testing.T) string { 64 | t.Helper() 65 | 66 | p := filepath.Join(testutils.GetProjectRoot(t), "testdata", "result.gob.gz") 67 | p, err := filepath.Abs(p) 68 | require.NoError(t, err) 69 | 70 | return testutils.RewritePathOnDemand(t, p) 71 | } 72 | 73 | func GetTestResult(t *testing.T) *result.Result { 74 | t.Helper() 75 | 76 | path := GetTestBinPath(t) 77 | 78 | f, err := mmap.Open(path) 79 | require.NoError(t, err) 80 | defer func(f *mmap.ReaderAt) { 81 | require.NoError(t, f.Close()) 82 | }(f) 83 | 84 | r, err := internal.Analyze(path, f, uint64(f.Len()), internal.Options{ 85 | SkipDwarf: false, 86 | SkipDisasm: true, 87 | SkipSymbol: false, 88 | }) 89 | require.NoError(t, err) 90 | 91 | return r 92 | } 93 | -------------------------------------------------------------------------------- /internal/test/testutils/utils.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | "testing" 7 | ) 8 | 9 | func RewritePathOnDemand(t *testing.T, path string) string { 10 | t.Helper() 11 | 12 | first := path[0] 13 | // is upper? 14 | if first >= 'A' && first <= 'Z' { 15 | // we assume it's a Windows environment 16 | n := []byte(path) 17 | 18 | for i, c := range n { 19 | if c == '/' { 20 | n[i] = '\\' 21 | } 22 | } 23 | return string(n) 24 | } 25 | return path 26 | } 27 | 28 | func GetProjectRoot(t *testing.T) string { 29 | t.Helper() 30 | 31 | _, filename, _, _ := runtime.Caller(0) 32 | return RewritePathOnDemand(t, filepath.Join(filepath.Dir(filename), "..", "..", "..")) 33 | } 34 | -------------------------------------------------------------------------------- /internal/tui/const.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | type focusState int 6 | 7 | const ( 8 | focusedDetail focusState = iota 9 | focusedMain 10 | ) 11 | 12 | var baseStyle = lipgloss.NewStyle(). 13 | BorderStyle(lipgloss.ThickBorder()). 14 | BorderForeground(lipgloss.Color("69")). 15 | BorderBottom(true). 16 | BorderTop(true) 17 | 18 | const ( 19 | rowWidthSize = 13 20 | ) 21 | -------------------------------------------------------------------------------- /internal/tui/detail_model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/viewport" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | type detailModel struct { 10 | viewPort viewport.Model 11 | } 12 | 13 | func newDetailModel(width, height int) detailModel { 14 | return detailModel{ 15 | viewPort: viewport.New(width, height), 16 | } 17 | } 18 | 19 | func (d detailModel) Update(msg tea.Msg) (detailModel, tea.Cmd) { 20 | var cmd tea.Cmd 21 | d.viewPort, cmd = d.viewPort.Update(msg) 22 | return d, cmd 23 | } 24 | 25 | func (d detailModel) View() string { 26 | return d.viewPort.View() 27 | } 28 | 29 | func (d detailModel) KeyMap() []key.Binding { 30 | km := d.viewPort.KeyMap 31 | return []key.Binding{ 32 | km.Up, 33 | km.Down, 34 | km.PageUp, 35 | km.PageDown, 36 | km.HalfPageUp, 37 | km.HalfPageDown, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/tui/key.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/help" 5 | "github.com/charmbracelet/bubbles/key" 6 | "github.com/charmbracelet/bubbles/table" 7 | ) 8 | 9 | type KeyMapTyp struct { 10 | Switch key.Binding 11 | Backward key.Binding 12 | Enter key.Binding 13 | Exit key.Binding 14 | } 15 | 16 | var DefaultKeyMap = KeyMapTyp{ 17 | Switch: key.NewBinding( 18 | key.WithKeys("tab"), 19 | key.WithHelp("tab", "switch focus"), 20 | ), 21 | Backward: key.NewBinding( 22 | key.WithKeys("esc", "backspace"), 23 | key.WithHelp("esc/backspace", "go back"), 24 | ), 25 | Enter: key.NewBinding( 26 | key.WithKeys("enter"), 27 | key.WithHelp("enter", "explore"), 28 | ), 29 | Exit: key.NewBinding( 30 | key.WithKeys("q", "ctrl+c"), 31 | key.WithHelp("q/ctrl+c", "exit"), 32 | ), 33 | } 34 | 35 | var _ help.KeyMap = (*DynamicKeyMap)(nil) 36 | 37 | type DynamicKeyMap struct { 38 | Short []key.Binding 39 | Long [][]key.Binding 40 | } 41 | 42 | func (d DynamicKeyMap) ShortHelp() []key.Binding { 43 | return d.Short 44 | } 45 | 46 | func (d DynamicKeyMap) FullHelp() [][]key.Binding { 47 | return d.Long 48 | } 49 | 50 | func tableKeyMap() []key.Binding { 51 | all := table.DefaultKeyMap() 52 | return []key.Binding{ 53 | all.LineUp, 54 | all.LineDown, 55 | all.PageUp, 56 | all.PageDown, 57 | all.HalfPageUp, 58 | all.HalfPageDown, 59 | all.GotoTop, 60 | all.GotoBottom, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/tui/table.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import "github.com/charmbracelet/bubbles/table" 4 | 5 | func getTableColumns(width int) []table.Column { 6 | return []table.Column{ 7 | { 8 | Title: "Name", 9 | Width: width/2 - rowWidthSize - 6, // fixme: why 6 is ok here? 10 | }, 11 | { 12 | Title: "Size", 13 | Width: rowWidthSize, 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/result" 9 | ) 10 | 11 | func RunTUI(r *result.Result, width, height int) error { 12 | model := newMainModel(r, width, height) 13 | _, err := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run() 14 | if err != nil { 15 | return fmt.Errorf("TUI error: %w", err) 16 | } 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/tui/tui_test.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package tui 4 | 5 | import ( 6 | "bytes" 7 | "testing" 8 | "time" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/charmbracelet/x/exp/teatest" 13 | "github.com/muesli/termenv" 14 | 15 | "github.com/Zxilly/go-size-analyzer/internal/test" 16 | ) 17 | 18 | func init() { 19 | lipgloss.SetColorProfile(termenv.Ascii) 20 | } 21 | 22 | func TestFullOutput(t *testing.T) { 23 | m := newMainModel(test.GetTestResult(t), 300, 100) 24 | tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(300, 100)) 25 | 26 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 27 | return bytes.Contains(bts, []byte("runtime")) 28 | }, teatest.WithCheckInterval(time.Millisecond*200), teatest.WithDuration(time.Second*10)) 29 | 30 | // test scroll 31 | tm.Send(tea.MouseMsg{ 32 | Action: tea.MouseActionPress, 33 | Button: tea.MouseButtonWheelDown, 34 | }) 35 | 36 | tm.Send(tea.MouseMsg{ 37 | Action: tea.MouseActionPress, 38 | Button: tea.MouseButtonWheelUp, 39 | }) 40 | 41 | // test detail 42 | tm.Send(tea.KeyMsg{ 43 | Type: tea.KeyTab, 44 | }) 45 | 46 | tm.Send(tea.KeyMsg{ 47 | Type: tea.KeyDown, 48 | }) 49 | tm.Send(tea.MouseMsg{ 50 | Action: tea.MouseActionPress, 51 | Button: tea.MouseButtonWheelUp, 52 | }) 53 | 54 | // switch back 55 | tm.Send(tea.KeyMsg{ 56 | Type: tea.KeyTab, 57 | }) 58 | 59 | tm.Send(tea.KeyMsg{ 60 | Type: tea.KeyEnter, 61 | }) 62 | 63 | // resize window 64 | tm.Send(tea.WindowSizeMsg{ 65 | Width: 200, 66 | Height: 100, 67 | }) 68 | 69 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 70 | return bytes.Contains(bts, []byte("proc.go")) 71 | }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) 72 | 73 | // enter proc.go 74 | for range 4 { 75 | tm.Send(tea.KeyMsg{ 76 | Type: tea.KeyDown, 77 | }) 78 | } 79 | tm.Send(tea.KeyMsg{ 80 | Type: tea.KeyEnter, 81 | }) 82 | 83 | // list all 84 | for range 20 { 85 | tm.Send(tea.KeyMsg{ 86 | Type: tea.KeyDown, 87 | }) 88 | } 89 | 90 | // back to runtime 91 | tm.Send(tea.KeyMsg{ 92 | Type: tea.KeyBackspace, 93 | }) 94 | 95 | for range 20 { 96 | tm.Send(tea.KeyMsg{ 97 | Type: tea.KeyDown, 98 | }) 99 | } 100 | 101 | // back to root 102 | tm.Send(tea.KeyMsg{ 103 | Type: tea.KeyBackspace, 104 | }) 105 | 106 | teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 107 | return bytes.Contains(bts, []byte(".gopclntab")) 108 | }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3)) 109 | 110 | tm.Send(tea.KeyMsg{ 111 | Type: tea.KeyCtrlC, 112 | }) 113 | 114 | tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second*3)) 115 | } 116 | -------------------------------------------------------------------------------- /internal/tui/view.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/bubbles/table" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/muesli/reflow/wordwrap" 9 | ) 10 | 11 | func getTableStyle(hasChildren bool) table.Styles { 12 | s := table.DefaultStyles() 13 | 14 | if hasChildren { 15 | s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("36")) 16 | } 17 | 18 | return s 19 | } 20 | 21 | func (m mainModel) View() string { 22 | if m.width < 70 || m.height < 20 { 23 | return wordwrap.String( 24 | fmt.Sprintf("Your terminal window is too small. "+ 25 | "Please make it at least 70x20 and try again. Current size: %d x %d", m.width, m.height), 26 | m.width) 27 | } 28 | 29 | title := lipgloss.NewStyle(). 30 | Bold(true). 31 | Width(m.width). 32 | MaxWidth(m.width). 33 | Align(lipgloss.Center). 34 | Render(m.title()) 35 | 36 | m.leftTable.SetStyles(getTableStyle(m.currentSelection().hasChildren())) 37 | // Render the left table 38 | left := m.leftTable.View() 39 | 40 | // Render the right detail 41 | right := m.rightDetail.View() 42 | 43 | borderStyle := baseStyle.Width(m.width / 2) 44 | disabledBorderStyle := borderStyle.BorderForeground(lipgloss.Color("241")) 45 | 46 | switch m.focus { 47 | case focusedMain: 48 | left = borderStyle.Render(left) 49 | right = disabledBorderStyle.Render(right) 50 | case focusedDetail: 51 | left = disabledBorderStyle.Render(left) 52 | right = borderStyle.Render(right) 53 | } 54 | 55 | main := lipgloss.JoinHorizontal(lipgloss.Top, left, right) 56 | 57 | help := m.help.View(m.getKeyMap()) 58 | 59 | full := lipgloss.JoinVertical(lipgloss.Top, title, main, help) 60 | return full 61 | } 62 | -------------------------------------------------------------------------------- /internal/tui/wrapper_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/Zxilly/go-size-analyzer/internal/entity" 10 | ) 11 | 12 | func Test_newWrapper(t *testing.T) { 13 | assert.Panics(t, func() { 14 | newWrapper(nil) 15 | }) 16 | } 17 | 18 | func Test_wrapper_Description(t *testing.T) { 19 | w := wrapper{} 20 | assert.Panics(t, func() { 21 | w.Description() 22 | }) 23 | } 24 | 25 | func Test_wrapper_Title(t *testing.T) { 26 | w := wrapper{} 27 | assert.Panics(t, func() { 28 | w.Title() 29 | }) 30 | 31 | w = wrapper{function: &entity.Function{ 32 | Type: "invalid", 33 | }} 34 | 35 | assert.Panics(t, func() { 36 | w.Title() 37 | }) 38 | } 39 | 40 | func Test_wrapper_children(t *testing.T) { 41 | w := wrapper{cacheOnce: &sync.Once{}} 42 | assert.Panics(t, func() { 43 | w.children() 44 | }) 45 | } 46 | 47 | func Test_wrapper_size(t *testing.T) { 48 | w := wrapper{} 49 | assert.Panics(t, func() { 50 | w.size() 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /internal/utils/addr.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net" 7 | ) 8 | 9 | const defaultURL = "http://localhost:8080" 10 | 11 | func GetURLFromListen(listen string) string { 12 | addr, err := net.ResolveTCPAddr("tcp", listen) 13 | if err != nil { 14 | slog.Warn(fmt.Sprintf("Error resolving listen address: %v", err)) 15 | return defaultURL 16 | } 17 | 18 | if addr.Port == 0 { 19 | addr.Port = 8080 20 | } 21 | 22 | return fmt.Sprintf("http://localhost:%d", addr.Port) 23 | } 24 | -------------------------------------------------------------------------------- /internal/utils/addr_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetUrlFromListen(t *testing.T) { 10 | type args struct { 11 | listen string 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want string 17 | }{ 18 | { 19 | name: "Valid listen address with port", 20 | args: args{ 21 | listen: "127.0.0.1:8080", 22 | }, 23 | want: "http://localhost:8080", 24 | }, 25 | { 26 | name: "Valid listen address with different port", 27 | args: args{ 28 | listen: "127.0.0.1:3000", 29 | }, 30 | want: "http://localhost:3000", 31 | }, 32 | { 33 | name: "Listen address without port", 34 | args: args{ 35 | listen: "127.0.0.1", 36 | }, 37 | want: "http://localhost:8080", 38 | }, 39 | { 40 | name: "Empty listen address", 41 | args: args{ 42 | listen: "", 43 | }, 44 | want: "http://localhost:8080", 45 | }, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | assert.Equalf(t, tt.want, GetURLFromListen(tt.args.listen), "GetURLFromListen(%v)", tt.args.listen) 50 | }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/utils/debug.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package utils 4 | 5 | // WaitDebugger is a no-op on normal build 6 | func WaitDebugger(string) {} 7 | -------------------------------------------------------------------------------- /internal/utils/debug_debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package utils 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | func WaitDebugger(reason string) { 11 | fmt.Printf("%s: debug %d\n", reason, os.Getpid()) 12 | _, _ = fmt.Scanln() 13 | } 14 | -------------------------------------------------------------------------------- /internal/utils/gc.go: -------------------------------------------------------------------------------- 1 | //go:build !wasm 2 | 3 | package utils 4 | 5 | import ( 6 | "fmt" 7 | "log/slog" 8 | "runtime/debug" 9 | 10 | "github.com/dustin/go-humanize" 11 | "github.com/pbnjay/memory" 12 | ) 13 | 14 | const oneGB = 1 << 30 15 | 16 | func ApplyMemoryLimit() { 17 | // memory available 18 | avail := memory.FreeMemory() 19 | use := avail / 5 * 4 20 | 21 | // at least we need 1GB 22 | limit := max(use, oneGB) 23 | 24 | slog.Debug(fmt.Sprintf("memory limit: %s", humanize.Bytes(limit))) 25 | 26 | debug.SetMemoryLimit(int64(limit)) 27 | } 28 | -------------------------------------------------------------------------------- /internal/utils/gc_wasm.go: -------------------------------------------------------------------------------- 1 | //go:build js && wasm 2 | 3 | package utils 4 | 5 | import ( 6 | "runtime/debug" 7 | ) 8 | 9 | // WasmMemoryLimit use 2 GB memory limit 10 | const WasmMemoryLimit = 2 * 1024 * 1024 * 1024 11 | 12 | func ApplyMemoryLimit() { 13 | debug.SetMemoryLimit(WasmMemoryLimit) 14 | } 15 | -------------------------------------------------------------------------------- /internal/utils/log.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log/slog" 7 | "os" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | var startTime time.Time 13 | 14 | func InitLogger(level slog.Level) { 15 | startTime = time.Now() 16 | slog.SetDefault(slog.New(slog.NewTextHandler(SyncStderr, &slog.HandlerOptions{ 17 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { 18 | // replace time with duration 19 | if a.Key == "time" { 20 | return slog.Duration(slog.TimeKey, time.Since(startTime)) 21 | } 22 | return a 23 | }, 24 | Level: level, 25 | }))) 26 | } 27 | 28 | var exitFunc = os.Exit 29 | 30 | func UsePanicForExit() { 31 | exitFunc = func(code int) { 32 | panic(fmt.Errorf("exit: %d", code)) 33 | } 34 | } 35 | 36 | func FatalError(err error) { 37 | if err == nil { 38 | return 39 | } 40 | 41 | slog.Error(fmt.Sprintf("Fatal error: %v", err)) 42 | 43 | exitFunc(1) 44 | } 45 | 46 | type SyncOutput struct { 47 | sync.Mutex 48 | output io.Writer 49 | } 50 | 51 | func (s *SyncOutput) Write(p []byte) (n int, err error) { 52 | s.Lock() 53 | defer s.Unlock() 54 | return s.output.Write(p) 55 | } 56 | 57 | func (s *SyncOutput) SetOutput(output io.Writer) { 58 | s.Lock() 59 | defer s.Unlock() 60 | s.output = output 61 | } 62 | 63 | var SyncStdout = &SyncOutput{ 64 | Mutex: sync.Mutex{}, 65 | output: os.Stdout, 66 | } 67 | 68 | var SyncStderr = &SyncOutput{ 69 | Mutex: sync.Mutex{}, 70 | output: os.Stderr, 71 | } 72 | 73 | var ( 74 | _ io.Writer = SyncStdout 75 | _ io.Writer = SyncStderr 76 | ) 77 | -------------------------------------------------------------------------------- /internal/utils/log_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestFatalError(t *testing.T) { 13 | m := &mock.Mock{} 14 | m.On("os.Exit", 1).Return() 15 | exitFunc = func(code int) { 16 | m.MethodCalled("os.Exit", code) 17 | } 18 | 19 | FatalError(nil) 20 | assert.True(t, m.AssertNotCalled(t, "os.Exit", 1)) 21 | 22 | FatalError(assert.AnError) 23 | assert.True(t, m.AssertCalled(t, "os.Exit", 1)) 24 | } 25 | 26 | func TestUsePanicForExit(t *testing.T) { 27 | m := &mock.Mock{} 28 | m.On("os.Exit", 1).Return() 29 | exitFunc = func(code int) { 30 | m.MethodCalled("os.Exit", code) 31 | } 32 | 33 | FatalError(assert.AnError) 34 | assert.True(t, m.AssertCalled(t, "os.Exit", 1)) 35 | 36 | UsePanicForExit() 37 | 38 | assert.PanicsWithError(t, "exit: 1", func() { 39 | FatalError(assert.AnError) 40 | }) 41 | } 42 | 43 | func TestSyncOutputWriteLocksAndWrites(t *testing.T) { 44 | var buf bytes.Buffer 45 | syncOutput := &SyncOutput{output: &buf} 46 | _, err := syncOutput.Write([]byte("test")) 47 | 48 | require.NoError(t, err) 49 | assert.Equal(t, "test", buf.String()) 50 | } 51 | 52 | func TestSyncOutputSetOutputLocksAndSets(t *testing.T) { 53 | var buf1, buf2 bytes.Buffer 54 | syncOutput := &SyncOutput{output: &buf1} 55 | syncOutput.SetOutput(&buf2) 56 | _, err := syncOutput.Write([]byte("test")) 57 | 58 | require.NoError(t, err) 59 | assert.Empty(t, buf1.String()) 60 | assert.Equal(t, "test", buf2.String()) 61 | } 62 | -------------------------------------------------------------------------------- /internal/utils/map.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "cmp" 5 | "maps" 6 | "slices" 7 | ) 8 | 9 | func SortedKeys[T cmp.Ordered, U any](m map[T]U) []T { 10 | keys := Collect(maps.Keys(m)) 11 | slices.Sort(keys) 12 | return keys 13 | } 14 | -------------------------------------------------------------------------------- /internal/utils/map_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/Zxilly/go-size-analyzer/internal/utils" 9 | ) 10 | 11 | func TestSortedKeysReturnsSortedKeysForIntegerMap(t *testing.T) { 12 | m := map[int]string{3: "three", 1: "one", 2: "two"} 13 | expected := []int{1, 2, 3} 14 | result := utils.SortedKeys(m) 15 | assert.Equal(t, expected, result) 16 | } 17 | 18 | func TestSortedKeysReturnsSortedKeysForStringMap(t *testing.T) { 19 | m := map[string]int{"b": 2, "a": 1, "c": 3} 20 | expected := []string{"a", "b", "c"} 21 | result := utils.SortedKeys(m) 22 | assert.Equal(t, expected, result) 23 | } 24 | 25 | func TestSortedKeysReturnsEmptySliceForEmptyMap(t *testing.T) { 26 | m := map[int]string{} 27 | expected := make([]int, 0) 28 | result := utils.SortedKeys(m) 29 | assert.Equal(t, expected, result) 30 | } 31 | -------------------------------------------------------------------------------- /internal/utils/reader.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | type ReaderAtAdapter struct { 9 | readerAt io.ReaderAt 10 | offset int64 11 | } 12 | 13 | func NewReaderAtAdapter(readerAt io.ReaderAt) *ReaderAtAdapter { 14 | return &ReaderAtAdapter{readerAt: readerAt} 15 | } 16 | 17 | func (r *ReaderAtAdapter) Read(p []byte) (n int, err error) { 18 | n, err = r.readerAt.ReadAt(p, r.offset) 19 | r.offset += int64(n) 20 | if errors.Is(err, io.EOF) && n > 0 { 21 | return n, nil 22 | } 23 | return n, err 24 | } 25 | -------------------------------------------------------------------------------- /internal/utils/reader_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestReaderAtAdapter_Read(t *testing.T) { 13 | data := []byte("Hello, World!") 14 | buffer := bytes.NewReader(data) 15 | reader := NewReaderAtAdapter(buffer) 16 | 17 | // Test reading the entire data 18 | readData := make([]byte, len(data)) 19 | n, err := reader.Read(readData) 20 | require.NoError(t, err) 21 | assert.Len(t, data, n) 22 | assert.Equal(t, data, readData) 23 | 24 | // Test reading beyond the data 25 | readData = make([]byte, 10) 26 | n, err = reader.Read(readData) 27 | require.ErrorIs(t, err, io.EOF) 28 | assert.Zero(t, n) 29 | } 30 | -------------------------------------------------------------------------------- /internal/utils/set.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "maps" 5 | ) 6 | 7 | type Set[T comparable] map[T]struct{} 8 | 9 | func NewSet[T comparable]() Set[T] { 10 | return make(Set[T]) 11 | } 12 | 13 | func (s Set[T]) Add(item T) { 14 | s[item] = struct{}{} 15 | } 16 | 17 | func (s Set[T]) Remove(item T) { 18 | delete(s, item) 19 | } 20 | 21 | func (s Set[T]) Contains(item T) bool { 22 | _, exists := s[item] 23 | return exists 24 | } 25 | 26 | func (s Set[T]) Equals(other Set[T]) bool { 27 | if len(s) != len(other) { 28 | return false 29 | } 30 | 31 | for k := range s { 32 | if !other.Contains(k) { 33 | return false 34 | } 35 | } 36 | 37 | return true 38 | } 39 | 40 | func (s Set[T]) ToSlice() []T { 41 | return Collect(maps.Keys(s)) 42 | } 43 | -------------------------------------------------------------------------------- /internal/utils/set_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewSet(t *testing.T) { 10 | set := NewSet[int]() 11 | assert.NotNil(t, set) 12 | assert.Empty(t, set) 13 | } 14 | 15 | func TestAdd(t *testing.T) { 16 | set := NewSet[int]() 17 | set.Add(1) 18 | assert.True(t, set.Contains(1)) 19 | } 20 | 21 | func TestRemove(t *testing.T) { 22 | set := NewSet[int]() 23 | set.Add(1) 24 | set.Remove(1) 25 | assert.False(t, set.Contains(1)) 26 | } 27 | 28 | func TestContains_ExistingItem(t *testing.T) { 29 | set := NewSet[int]() 30 | set.Add(1) 31 | assert.True(t, set.Contains(1)) 32 | } 33 | 34 | func TestContains_NonExistingItem(t *testing.T) { 35 | set := NewSet[int]() 36 | assert.False(t, set.Contains(1)) 37 | } 38 | 39 | func TestEquals_EqualSets(t *testing.T) { 40 | set1 := NewSet[int]() 41 | set1.Add(1) 42 | set1.Add(2) 43 | 44 | set2 := NewSet[int]() 45 | set2.Add(1) 46 | set2.Add(2) 47 | 48 | assert.True(t, set1.Equals(set2)) 49 | } 50 | 51 | func TestEquals_NotEqualSets_DifferentLengths(t *testing.T) { 52 | set1 := NewSet[int]() 53 | set1.Add(1) 54 | 55 | set2 := NewSet[int]() 56 | set2.Add(1) 57 | set2.Add(2) 58 | 59 | assert.False(t, set1.Equals(set2)) 60 | } 61 | 62 | func TestEquals_NotEqualSets_DifferentItems(t *testing.T) { 63 | set1 := NewSet[int]() 64 | set1.Add(1) 65 | 66 | set2 := NewSet[int]() 67 | set2.Add(2) 68 | 69 | assert.False(t, set1.Equals(set2)) 70 | } 71 | 72 | func TestToSlice(t *testing.T) { 73 | set := NewSet[int]() 74 | set.Add(1) 75 | set.Add(2) 76 | 77 | slice := set.ToSlice() 78 | assert.Contains(t, slice, 1) 79 | assert.Contains(t, slice, 2) 80 | assert.Len(t, slice, 2) 81 | } 82 | -------------------------------------------------------------------------------- /internal/utils/signal.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | ) 9 | 10 | // WaitSignal waits for a Ctrl+C signal to exit the program. 11 | func WaitSignal() { 12 | done := make(chan os.Signal, 1) 13 | signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) 14 | 15 | slog.Info("Press Ctrl+C to exit") 16 | <-done 17 | } 18 | -------------------------------------------------------------------------------- /internal/utils/table.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/jedib0t/go-pretty/v6/table" 5 | "github.com/jedib0t/go-pretty/v6/text" 6 | ) 7 | 8 | func GetTableStyle() table.Style { 9 | ret := table.StyleLight 10 | ret.Format.Footer = text.FormatDefault 11 | 12 | return ret 13 | } 14 | -------------------------------------------------------------------------------- /internal/webui/.gitignore: -------------------------------------------------------------------------------- 1 | index.html 2 | -------------------------------------------------------------------------------- /internal/webui/embed.go: -------------------------------------------------------------------------------- 1 | //go:build embed 2 | 3 | package webui 4 | 5 | import ( 6 | _ "embed" 7 | ) 8 | 9 | //go:embed index.html 10 | var tmpl string 11 | 12 | func GetTemplate() string { 13 | return tmpl 14 | } 15 | -------------------------------------------------------------------------------- /internal/webui/embed_test.go: -------------------------------------------------------------------------------- 1 | //go:build embed 2 | 3 | package webui_test 4 | 5 | import ( 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "golang.org/x/net/html" 12 | 13 | "github.com/Zxilly/go-size-analyzer/internal/constant" 14 | "github.com/Zxilly/go-size-analyzer/internal/webui" 15 | ) 16 | 17 | func TestGetTemplate(t *testing.T) { 18 | got := webui.GetTemplate() 19 | 20 | // Should contain printer.ReplacedStr 21 | assert.Contains(t, got, constant.ReplacedStr) 22 | 23 | // Should html 24 | _, err := html.Parse(strings.NewReader(got)) 25 | require.NoError(t, err) 26 | 27 | // run again for test net mode cache 28 | got = webui.GetTemplate() 29 | assert.Contains(t, got, constant.ReplacedStr) 30 | _, err = html.Parse(strings.NewReader(got)) 31 | require.NoError(t, err) 32 | } 33 | -------------------------------------------------------------------------------- /internal/webui/flag.go: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | type UpdateCacheFlag bool 4 | -------------------------------------------------------------------------------- /internal/webui/net_test.go: -------------------------------------------------------------------------------- 1 | //go:build !embed && !js && !wasm 2 | 3 | package webui 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/alecthomas/kong" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/stretchr/testify/suite" 13 | 14 | "github.com/Zxilly/go-size-analyzer/internal/constant" 15 | ) 16 | 17 | func TestUpdateCacheFlag(t *testing.T) { 18 | var option struct { 19 | Flag UpdateCacheFlag `help:"Update cache"` 20 | } 21 | 22 | exited := false 23 | 24 | k, err := kong.New(&option, 25 | kong.Name("test"), 26 | kong.Description("test"), 27 | kong.Exit(func(_ int) { 28 | exited = true 29 | })) 30 | require.NoError(t, err) 31 | 32 | _, err = k.Parse([]string{"--flag"}) 33 | require.NoError(t, err) 34 | 35 | assert.True(t, exited) 36 | } 37 | 38 | type CacheSuite struct { 39 | suite.Suite 40 | } 41 | 42 | func (s *CacheSuite) TestGetTemplate() { 43 | s.Run("cache exist", func() { 44 | cacheFile, err := getCacheFilePath() 45 | s.Require().NoError(err) 46 | 47 | _, err = updateCache(cacheFile) 48 | s.Require().NoError(err) 49 | 50 | got := GetTemplate() 51 | s.Contains(got, constant.ReplacedStr) 52 | }) 53 | 54 | s.Run("cache not exist", func() { 55 | cacheFile, err := getCacheFilePath() 56 | s.Require().NoError(err) 57 | 58 | err = os.Remove(cacheFile) 59 | s.Require().NoError(err) 60 | 61 | got := GetTemplate() 62 | s.Contains(got, constant.ReplacedStr) 63 | }) 64 | } 65 | 66 | func TestCacheSuite(t *testing.T) { 67 | suite.Run(t, new(CacheSuite)) 68 | } 69 | -------------------------------------------------------------------------------- /internal/webui/server.go: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | func HostServer(content []byte, listen string) io.Closer { 13 | server := &http.Server{ 14 | Addr: listen, 15 | ReadHeaderTimeout: time.Second * 5, 16 | ReadTimeout: time.Second * 10, 17 | Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 18 | w.Header().Set("Content-Type", "text/html") 19 | w.Header().Set("Server", "go-size-analyzer") 20 | w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 21 | _, _ = w.Write(content) 22 | }), 23 | } 24 | server.SetKeepAlivesEnabled(false) 25 | go func() { 26 | err := server.ListenAndServe() 27 | if errors.Is(err, http.ErrServerClosed) { 28 | slog.Info("webui server closed") 29 | } else { 30 | slog.Error(fmt.Sprintf("webui server error: %v", err)) 31 | } 32 | }() 33 | return server 34 | } 35 | -------------------------------------------------------------------------------- /internal/webui/server_test.go: -------------------------------------------------------------------------------- 1 | //go:build !js && !wasm 2 | 3 | package webui_test 4 | 5 | import ( 6 | "io" 7 | "net/http" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/Zxilly/go-size-analyzer/internal/webui" 15 | ) 16 | 17 | func TestHostServer(t *testing.T) { 18 | content := []byte("test content") 19 | listen := "127.0.0.1:8080" 20 | 21 | l := webui.HostServer(content, listen) 22 | defer func(l io.Closer) { 23 | _ = l.Close() 24 | }(l) 25 | assert.NotNil(t, l) 26 | 27 | // wait for the server to start 28 | time.Sleep(1 * time.Second) 29 | // Send a test request to the server 30 | req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080", nil) 31 | require.NoError(t, err) 32 | 33 | resp, err := http.DefaultClient.Do(req) 34 | require.NoError(t, err) 35 | _ = resp.Body.Close() 36 | assert.Equal(t, http.StatusOK, resp.StatusCode) 37 | } 38 | -------------------------------------------------------------------------------- /internal/wrapper/elf_test.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "debug/elf" 5 | "encoding/binary" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGoArchReturnsExpectedArchitectures(t *testing.T) { 12 | tests := []struct { 13 | machine elf.Machine 14 | expectedArch string 15 | byteOrder binary.ByteOrder 16 | }{ 17 | {elf.EM_386, "386", binary.LittleEndian}, 18 | {elf.EM_X86_64, "amd64", binary.LittleEndian}, 19 | {elf.EM_ARM, "arm", binary.LittleEndian}, 20 | {elf.EM_AARCH64, "arm64", binary.LittleEndian}, 21 | {elf.EM_PPC64, "ppc64", binary.BigEndian}, // Adjusted for big endian 22 | {elf.EM_PPC64, "ppc64le", binary.LittleEndian}, // Explicitly little endian 23 | {elf.EM_S390, "s390x", binary.BigEndian}, 24 | {0, "", binary.LittleEndian}, // Test for an unsupported machine type 25 | } 26 | 27 | for _, test := range tests { 28 | mockFile := new(elf.File) 29 | mockFile.FileHeader = elf.FileHeader{ 30 | Machine: test.machine, 31 | ByteOrder: test.byteOrder, 32 | } 33 | wrapper := ElfWrapper{file: mockFile} 34 | 35 | arch := wrapper.GoArch() 36 | assert.Equal(t, test.expectedArch, arch) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/wrapper/macho_test.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/blacktop/go-macho" 7 | "github.com/blacktop/go-macho/types" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGoArchReturnsCorrectArchitectureString(t *testing.T) { 13 | tests := []struct { 14 | cpu types.CPU 15 | expected string 16 | }{ 17 | {types.CPUI386, "386"}, 18 | {types.CPUAmd64, "amd64"}, 19 | {types.CPUArm, "arm"}, 20 | {types.CPUArm64, "arm64"}, 21 | {types.CPUPpc64, "ppc64"}, 22 | {types.CPU(0), ""}, // Unsupported CPU type 23 | } 24 | 25 | for _, test := range tests { 26 | m := MachoWrapper{file: &macho.File{FileTOC: macho.FileTOC{FileHeader: types.FileHeader{CPU: test.cpu}}}} 27 | result := m.GoArch() 28 | assert.Equal(t, test.expected, result) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/wrapper/pe_test.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "debug/pe" 5 | "testing" 6 | ) 7 | 8 | func TestGoArchReturnsExpectedArchitectureString(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | machine uint16 12 | expected string 13 | }{ 14 | {"Returns386ForI386Machine", pe.IMAGE_FILE_MACHINE_I386, "386"}, 15 | {"ReturnsAmd64ForAmd64Machine", pe.IMAGE_FILE_MACHINE_AMD64, "amd64"}, 16 | {"ReturnsArmForArmMachine", pe.IMAGE_FILE_MACHINE_ARMNT, "arm"}, 17 | {"ReturnsArm64ForArm64Machine", pe.IMAGE_FILE_MACHINE_ARM64, "arm64"}, 18 | {"ReturnsEmptyStringForUnknownMachine", 0xFFFF, ""}, 19 | } 20 | 21 | for _, test := range tests { 22 | t.Run(test.name, func(t *testing.T) { 23 | mockFile := &pe.File{FileHeader: pe.FileHeader{Machine: test.machine}} 24 | wrapper := PeWrapper{file: mockFile} 25 | 26 | result := wrapper.GoArch() 27 | if result != test.expected { 28 | t.Errorf("Expected %s, got %s for machine type %v", test.expected, result, test.machine) 29 | } 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/wrapper/static.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/Zxilly/go-size-analyzer/internal/utils" 7 | ) 8 | 9 | // src\cmd\link\internal\ld\data.go 10 | var ignoreSymbols = utils.NewSet[string]() 11 | 12 | func init() { 13 | symbols := []string{ 14 | "runtime.text", 15 | "runtime.etext", 16 | "runtime.rodata", 17 | "runtime.erodata", 18 | "runtime.noptrdata", 19 | "runtime.enoptrdata", 20 | "runtime.bss", 21 | "runtime.ebss", 22 | "runtime.gcdata", 23 | "runtime.gcbss", 24 | "runtime.noptrbss", 25 | "runtime.enoptrbss", 26 | "runtime.end", 27 | "runtime.covctrs", 28 | "runtime.ecovctrs", 29 | 30 | "runtime.__start___sancov_cntrs", 31 | "runtime.__stop___sancov_cntrs", 32 | "internal/fuzz._counters", 33 | "internal/fuzz._ecounters", 34 | 35 | "runtime.rodata", 36 | "runtime.erodata", 37 | "runtime.types", 38 | "runtime.etypes", 39 | 40 | "runtime.itablink", 41 | "runtime.symtab", 42 | "runtime.esymtab", 43 | "runtime.pclntab", 44 | "runtime.pcheader", 45 | "runtime.funcnametab", 46 | "runtime.cutab", 47 | "runtime.filetab", 48 | "runtime.pctab", 49 | "runtime.functab", 50 | "runtime.epclntab", 51 | 52 | "runtime.zerobase", 53 | 54 | "go:buildinfo", 55 | "go:buildinfo.ref", 56 | } 57 | 58 | for _, sym := range symbols { 59 | ignoreSymbols.Add(sym) 60 | } 61 | } 62 | 63 | var ErrAddrNotFound = errors.New("address not found") 64 | -------------------------------------------------------------------------------- /internal/wrapper/wrapper.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "debug/dwarf" 5 | "debug/elf" 6 | "debug/pe" 7 | "errors" 8 | 9 | "github.com/ZxillyFork/gore" 10 | "github.com/blacktop/go-macho" 11 | 12 | "github.com/Zxilly/go-size-analyzer/internal/entity" 13 | "github.com/Zxilly/go-size-analyzer/internal/utils" 14 | ) 15 | 16 | const GoStringSymbol = "go:string.*" 17 | 18 | var ErrNoSymbolTable = errors.New("no symbol table found") 19 | 20 | type RawFileWrapper interface { 21 | Text() (textStart uint64, text []byte, err error) 22 | GoArch() string 23 | ReadAddr(addr, size uint64) ([]byte, error) 24 | LoadSymbols(marker func(name string, addr, size uint64, typ entity.AddrType), goSCb func(addr, size uint64)) error 25 | LoadSections() *entity.Store 26 | DWARF() (*dwarf.Data, error) 27 | } 28 | 29 | func NewWrapper(file any) RawFileWrapper { 30 | switch f := file.(type) { 31 | case *elf.File: 32 | return &ElfWrapper{f} 33 | case *pe.File: 34 | return &PeWrapper{f, utils.GetImageBase(f)} 35 | case *macho.File: 36 | return NewMachoWrapper(f) 37 | case gore.WasmInfo: 38 | return &WasmWrapper{f.Mod, f.Memory} 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/wrapper/wrapper_test.go: -------------------------------------------------------------------------------- 1 | package wrapper 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewWrapper(t *testing.T) { 10 | ret := NewWrapper(nil) 11 | assert.Nil(t, ret) 12 | } 13 | -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | .Python 2 | [Bb]in 3 | [Ii]nclude 4 | [Ll]ib 5 | [Ll]ib64 6 | [Ll]ocal 7 | [Ss]cripts 8 | pyvenv.cfg 9 | .venv 10 | pip-selfcheck.json 11 | __pycache__/ 12 | lib/__pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | *.so 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | *.manifest 32 | *.spec 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | htmlcov/ 36 | .tox/ 37 | .nox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | *.py,cover 45 | .hypothesis/ 46 | .pytest_cache/ 47 | cover/ 48 | *.mo 49 | *.pot 50 | *.log 51 | local_settings.py 52 | db.sqlite3 53 | db.sqlite3-journal 54 | instance/ 55 | .webassets-cache 56 | .scrapy 57 | docs/_build/ 58 | .pybuilder/ 59 | target/ 60 | .ipynb_checkpoints 61 | profile_default/ 62 | ipython_config.py 63 | .pdm.toml 64 | __pypackages__/ 65 | celerybeat-schedule 66 | celerybeat.pid 67 | *.sage.py 68 | .env 69 | env/ 70 | venv/ 71 | ENV/ 72 | env.bak/ 73 | venv.bak/ 74 | .spyderproject 75 | .spyproject 76 | .ropeproject 77 | /site 78 | .mypy_cache/ 79 | .dmypy.json 80 | dmypy.json 81 | .pyre/ 82 | .pytype/ 83 | cython_debug/ 84 | bins/ -------------------------------------------------------------------------------- /scripts/.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /scripts/ensure.py: -------------------------------------------------------------------------------- 1 | import os 2 | from argparse import ArgumentParser 3 | 4 | from tool.remote import load_remote_binaries_as_test 5 | 6 | if __name__ == "__main__": 7 | ap = ArgumentParser() 8 | ap.add_argument("--example", action="store_true", help="Download example binaries.") 9 | ap.add_argument("--real", action="store_true", help="Download real binaries.") 10 | ap.add_argument("--force", action="store_true", help="Force refresh.") 11 | 12 | args = ap.parse_args() 13 | 14 | if args.force: 15 | os.environ["FORCE_REFRESH"] = "true" 16 | 17 | def cond(name: str) -> bool: 18 | if args.example: 19 | return name.startswith("bin-") 20 | elif args.real: 21 | return not name.startswith("bin-") 22 | return True 23 | 24 | load_remote_binaries_as_test(cond) 25 | -------------------------------------------------------------------------------- /scripts/pgo.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | 5 | import tool.gsa 6 | from tests import run_integration_tests 7 | from tool.gsa import build_pgo_gsa 8 | from tool.utils import get_project_root, require_go 9 | 10 | 11 | def merge_profiles(): 12 | # walk result dirs 13 | # merge profiles 14 | profiles = [] 15 | for d in os.listdir(os.path.join(get_project_root(), "results")): 16 | d = os.path.join(get_project_root(), "results", d) 17 | if not os.path.isdir(d): 18 | continue 19 | 20 | pd = os.path.join(d, "json", "profiler") 21 | if not os.path.exists(pd): 22 | print(f"Skipping {pd}, not a profiler dir") 23 | continue 24 | 25 | p = os.path.join(pd, "cpu.pprof") 26 | if not os.path.exists(p): 27 | print(f"Skipping {p}", os.listdir(pd)) 28 | continue 29 | profiles.append(p) 30 | 31 | profile = subprocess.check_output( 32 | args=[ 33 | require_go(), 34 | "tool", 35 | "pprof", 36 | "-proto", 37 | *profiles, 38 | ], 39 | cwd=get_project_root(), 40 | ) 41 | 42 | with open(os.path.join(get_project_root(), "default.pgo"), "wb") as f: 43 | f.write(profile) 44 | 45 | 46 | if __name__ == '__main__': 47 | shutil.rmtree(os.path.join(get_project_root(), "results"), ignore_errors=True) 48 | 49 | with build_pgo_gsa() as gsa: 50 | run_integration_tests("real", gsa) 51 | merge_profiles() 52 | -------------------------------------------------------------------------------- /scripts/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gsa-scripts" 3 | version = "0.1.0" 4 | authors = ["Zxilly "] 5 | description = "Scripts for GSA" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "markdown-strings>=3.4.0", 9 | "matplotlib>=3.10.0", 10 | "psutil>=7.0.0", 11 | "requests>=2.32.3", 12 | "tqdm>=4.67.1", 13 | ] 14 | 15 | [dependency-groups] 16 | research = [ 17 | "watchdog>=6.0.0", 18 | ] 19 | -------------------------------------------------------------------------------- /scripts/report.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | from markdown_strings import header, code_block 5 | 6 | from tool.utils import get_project_root, write_github_summary, details 7 | 8 | # these are the tips that are not considered as errors 9 | tips = [ 10 | "DWARF parsing failed", 11 | "No symbol table found", 12 | "Disassembler not supported" 13 | ] 14 | 15 | 16 | def check_line(line: str) -> bool: 17 | if "level=WARN" not in line and "level=ERROR" not in line: 18 | return False 19 | 20 | for tip in tips: 21 | if tip in line: 22 | return False 23 | 24 | return True 25 | 26 | 27 | def need_report(f: str) -> bool: 28 | with open(f, "r", encoding="utf-8") as f: 29 | for line in f.readlines(): 30 | if check_line(line): 31 | return True 32 | return False 33 | 34 | 35 | def filter_output(f: str) -> str: 36 | ret = [] 37 | with open(f, "r", encoding="utf-8") as f: 38 | lines = f.readlines() 39 | for line in lines: 40 | if check_line(line): 41 | ret.append(line) 42 | 43 | # truncate the output if it's more than 50 lines 44 | if len(ret) > 50: 45 | ret = ret[:50] 46 | ret.append("truncated output...") 47 | 48 | return "".join(ret) 49 | 50 | 51 | def generate_image_url(p: str) -> str: 52 | with open(p, "r", encoding="utf-8") as f: 53 | data = f.read() 54 | 55 | resp = requests.post("https://bin2image.zxilly.dev", data=data, headers={"X-Optimize-Svg": "true"}) 56 | resp.raise_for_status() 57 | 58 | return resp.text 59 | 60 | 61 | is_ci = os.getenv("CI", False) 62 | 63 | if __name__ == '__main__': 64 | results = os.path.join(get_project_root(), "results") 65 | 66 | if not os.path.exists(results): 67 | raise FileNotFoundError(f"Directory {results} does not exist") 68 | 69 | graphs = "" 70 | 71 | for root, dirs, files in os.walk(results): 72 | for file in files: 73 | if file.endswith(".output.txt"): 74 | output_file_path = str(os.path.join(root, file)) 75 | if need_report(output_file_path): 76 | write_github_summary(header(f"Found bad case in {output_file_path}", header_level=4) + '\n') 77 | write_github_summary(details(code_block(filter_output(output_file_path))) + '\n') 78 | break 79 | 80 | if file.endswith(".graph.svg"): 81 | image_url = generate_image_url(str(os.path.join(root, file))) 82 | graphs += header(f"Graph for {file}", header_level=4) + '\n' 83 | graphs += f'{file}' + '\n\n' 84 | 85 | if graphs: 86 | write_github_summary(header("Graphs", header_level=3) + '\n') 87 | write_github_summary(details(graphs) + '\n') 88 | -------------------------------------------------------------------------------- /scripts/research/.gitignore: -------------------------------------------------------------------------------- 1 | main.* -------------------------------------------------------------------------------- /scripts/research/watch.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import time 4 | 5 | from watchdog.events import PatternMatchingEventHandler 6 | from watchdog.observers import Observer 7 | 8 | p = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | compiling = False 11 | 12 | 13 | def compile_go(): 14 | global compiling 15 | if compiling: 16 | return 17 | compiling = True 18 | print("Compiling...") 19 | # os.system("go tool compile -S -o main.o main.go") 20 | out = subprocess.check_output( 21 | ["go", "tool", "compile", "-S", "-o", "main.o", "-trimpath", p, "main.go"], 22 | text=True) 23 | with open("main.s.txt", "w") as f: 24 | f.write(out) 25 | 26 | out = subprocess.check_output(["go", "tool", "objdump", "-s", "main.", "-S", "-gnu", "main.o"], text=True) 27 | with open("main.r.txt", "w") as f: 28 | f.write(out) 29 | 30 | print("Compiled") 31 | compiling = False 32 | 33 | 34 | class ReCompileHandler(PatternMatchingEventHandler): 35 | def __init__(self, *args, **kwargs): 36 | super().__init__(*args, **kwargs) 37 | self._last_event_time = time.time() 38 | 39 | def on_modified(self, event): 40 | if time.time() - self._last_event_time < 1: 41 | return 42 | compile_go() 43 | self._last_event_time = time.time() 44 | 45 | 46 | if __name__ == '__main__': 47 | 48 | event_handler = ReCompileHandler( 49 | patterns=["main.go"], 50 | ignore_directories=True, 51 | ) 52 | observer = Observer() 53 | observer.schedule(event_handler, p, recursive=False) 54 | observer.start() 55 | try: 56 | while True: 57 | time.sleep(1) 58 | finally: 59 | observer.stop() 60 | observer.join() 61 | -------------------------------------------------------------------------------- /scripts/skip.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zxilly/go-size-analyzer/e0fca767d6f0b50e681b29e37bda0a75d9062041/scripts/skip.csv -------------------------------------------------------------------------------- /scripts/tool/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zxilly/go-size-analyzer/e0fca767d6f0b50e681b29e37bda0a75d9062041/scripts/tool/__init__.py -------------------------------------------------------------------------------- /scripts/tool/example.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | 5 | from .utils import log 6 | 7 | TARGET_TAG = "latest" 8 | BIN_REPO = "Zxilly/go-testdata" 9 | versions = ["1.16", "1.18", "1.21"] 10 | 11 | full_versions = [f"1.{i}" for i in range(11, 22)] 12 | 13 | release_info_cache = None 14 | 15 | 16 | def get_release_info(): 17 | global release_info_cache 18 | if release_info_cache is None: 19 | # read GitHub token if possible 20 | token = os.getenv('GITHUB_TOKEN') 21 | headers = {} 22 | if token: 23 | headers['Authorization'] = f'Bearer {token}' 24 | 25 | response = requests.get(f'https://api.github.com/repos/{BIN_REPO}/releases/tags/{TARGET_TAG}', headers=headers) 26 | response.raise_for_status() 27 | release_info_cache = response.json() 28 | return release_info_cache 29 | 30 | 31 | def get_example_download_url(filename: str) -> None | str: 32 | release_info = get_release_info() 33 | 34 | file_info = None 35 | for asset in release_info['assets']: 36 | if asset['name'] == filename: 37 | file_info = asset 38 | break 39 | 40 | if file_info is None: 41 | log(f'File {filename} not found.') 42 | return None 43 | 44 | return file_info['browser_download_url'] 45 | -------------------------------------------------------------------------------- /scripts/tool/html.py: -------------------------------------------------------------------------------- 1 | import json 2 | from html.parser import HTMLParser 3 | 4 | 5 | class DataParser(HTMLParser): 6 | def __init__(self): 7 | super().__init__() 8 | self.in_data = False 9 | self.data = None 10 | 11 | def handle_starttag(self, tag, attrs): 12 | if tag == "script": 13 | for attr in attrs: 14 | if attr[0] == "type" and attr[1] == "application/json": 15 | self.in_data = True 16 | 17 | def handle_data(self, data): 18 | if self.in_data: 19 | self.data = data 20 | 21 | def handle_endtag(self, tag): 22 | if self.in_data: 23 | self.in_data = False 24 | 25 | def get_data(self): 26 | return self.data 27 | 28 | 29 | def assert_html_valid(h: str): 30 | # parse html 31 | parser = DataParser() 32 | parser.feed(h) 33 | 34 | json_data = parser.get_data() 35 | if json_data is None: 36 | raise Exception("Failed to find data element in the html.") 37 | 38 | # try load value as json 39 | try: 40 | content = json.loads(json_data) 41 | except json.JSONDecodeError: 42 | raise Exception("Failed to parse data element as json.") 43 | 44 | # check if the data is correct 45 | keys = ["name", "size", "packages", "sections"] 46 | for key in keys: 47 | if key not in content: 48 | raise Exception(f"Missing key {key} in the data.") 49 | -------------------------------------------------------------------------------- /scripts/tool/junit.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | 4 | 5 | def require_go_junit_report() -> str: 6 | gjr = shutil.which("go-junit-report") 7 | if gjr is None: 8 | raise Exception("go-junit-report is required to generate JUnit reports.") 9 | return gjr 10 | 11 | 12 | def generate_junit(stdout: str, target: str): 13 | gjr = require_go_junit_report() 14 | with open(target, "w", encoding="utf-8") as f: 15 | subprocess.run( 16 | [gjr], 17 | input=stdout, 18 | stdout=f, 19 | text=True, 20 | check=True, 21 | ) 22 | 23 | 24 | -------------------------------------------------------------------------------- /scripts/tool/matplotlib.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | import matplotlib 4 | from matplotlib import pyplot as plt 5 | 6 | matplotlib.use('svg') 7 | matplotlib.rcParams['svg.fonttype'] = 'none' 8 | 9 | 10 | def draw_usage( 11 | title: str, 12 | cpu_percentages: list[float], 13 | memory_usage_mb: list[float], 14 | timestamps: list[float] 15 | ) -> str: 16 | buf = StringIO() 17 | 18 | fig, ax1 = plt.subplots(figsize=(14, 5)) 19 | 20 | color = 'tab:blue' 21 | ax1.set_xlabel('Time (seconds)') 22 | ax1.set_ylabel('CPU %', color=color) 23 | ax1.plot(timestamps, cpu_percentages, color=color) 24 | ax1.tick_params(axis='y', labelcolor=color) 25 | 26 | ax1.set_xticks(range(0, int(max(timestamps)) + 1, 1)) 27 | ax1.set_xlim(0, int(max(timestamps))) 28 | 29 | # Add grid to ax1 30 | ax1.grid(True, which='both', linestyle='--', linewidth=0.5) 31 | 32 | ax2 = ax1.twinx() 33 | 34 | color = 'tab:purple' 35 | ax2.set_ylabel('Memory (MB)', color=color) 36 | ax2.plot(timestamps, memory_usage_mb, color=color) 37 | ax2.tick_params(axis='y', labelcolor=color) 38 | 39 | ax2.set_xticks(ax1.get_xticks()) 40 | ax2.set_xlim(ax1.get_xlim()) 41 | 42 | plt.title(f'{title} usage') 43 | 44 | plt.savefig(buf, format='svg') 45 | buf.seek(0) 46 | 47 | plt.clf() 48 | plt.close() 49 | 50 | svg = buf.getvalue() 51 | return svg 52 | -------------------------------------------------------------------------------- /scripts/tool/merge.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from .utils import get_project_root, log, get_covdata_integration_dir, require_go 5 | 6 | 7 | def merge_covdata(): 8 | log("Merging coverage data...") 9 | 10 | def merge_covdata_dir(d: str, output: str): 11 | if os.path.exists(output): 12 | os.remove(output) 13 | 14 | subprocess.check_call( 15 | [ 16 | require_go(), 17 | "tool", 18 | "covdata", 19 | "textfmt", 20 | "-i=" + d, 21 | "-o=" + output, 22 | ], 23 | cwd=get_project_root(), 24 | ) 25 | log(f"Merged coverage data from {d}.") 26 | 27 | if not os.path.exists(output): 28 | raise Exception("Failed to merge coverage data.") 29 | else: 30 | log(f"Saved enhanced coverage data to {output}.") 31 | 32 | def abs_path(s: str): 33 | return os.path.abspath(os.path.join(get_project_root(), s)) 34 | 35 | merge_covdata_dir(get_covdata_integration_dir(), abs_path("integration.profile")) 36 | 37 | log("Merged coverage data.") 38 | -------------------------------------------------------------------------------- /scripts/tool/process.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import time 4 | from threading import Thread 5 | 6 | import psutil 7 | 8 | from .matplotlib import draw_usage 9 | from .utils import get_covdata_integration_dir, get_project_root 10 | 11 | 12 | def run_process( 13 | pargs: list[str], 14 | name: str, 15 | timeout=240, 16 | profiler_dir: str = None, 17 | draw: bool = False) -> [str, bytes | None]: 18 | 19 | env = os.environ.copy() 20 | env["GOCOVERDIR"] = get_covdata_integration_dir() 21 | if profiler_dir is not None: 22 | env["OUTPUT_DIR"] = profiler_dir 23 | 24 | process = subprocess.Popen( 25 | args=pargs, 26 | env=env, cwd=get_project_root(), 27 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 28 | text=True, 29 | encoding="utf-8", 30 | ) 31 | 32 | cpu_percentages = [] 33 | memory_usage_mb = [] 34 | timestamps = [] 35 | output = "" 36 | start_time = time.time() 37 | 38 | def collect_stdout(): 39 | nonlocal output 40 | for line in iter(process.stdout.readline, ""): 41 | output += line 42 | 43 | Thread(target=collect_stdout).start() 44 | 45 | try: 46 | ps_process = psutil.Process(process.pid) 47 | 48 | while process.poll() is None: 49 | percent = ps_process.cpu_percent(interval=0.1) 50 | mem = ps_process.memory_info().rss / (1024 * 1024) 51 | elapsed_time = time.time() - start_time 52 | if elapsed_time > timeout: 53 | raise TimeoutError(f"Process {name} timed out after {timeout} seconds.") 54 | 55 | if draw: 56 | cpu_percentages.append(percent) 57 | memory_usage_mb.append(mem) 58 | timestamps.append(elapsed_time) 59 | 60 | except TimeoutError as e: 61 | process.kill() 62 | raise e 63 | except psutil.NoSuchProcess: 64 | pass 65 | 66 | pic: None | str = None 67 | 68 | if draw and timestamps[-1] >= 2: 69 | pic = draw_usage(name, cpu_percentages, memory_usage_mb, timestamps) 70 | 71 | return [output, pic] 72 | -------------------------------------------------------------------------------- /scripts/wasm.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os.path 3 | import shutil 4 | import subprocess 5 | import tempfile 6 | 7 | from tool.utils import get_project_root, require_go, log 8 | 9 | 10 | def wasm_location() -> str: 11 | return os.path.join(get_project_root(), "ui", "gsa.wasm") 12 | 13 | 14 | def require_binaryen(): 15 | o = shutil.which("wasm-opt") 16 | if o is None: 17 | print("wasm-opt not found in PATH. Please install binaryen.") 18 | exit(1) 19 | return o 20 | 21 | 22 | if __name__ == '__main__': 23 | ap = argparse.ArgumentParser() 24 | ap.add_argument("--raw", action="store_true", help="Do not optimize the wasm binary") 25 | args = ap.parse_args() 26 | 27 | go = require_go() 28 | opt = require_binaryen() 29 | 30 | env = { 31 | "GOOS": "js", 32 | "GOARCH": "wasm", 33 | } 34 | env.update(os.environ) 35 | 36 | tmp_dir = tempfile.TemporaryDirectory(prefix="gsa-wasm") 37 | tmp_file = tempfile.NamedTemporaryFile(dir=tmp_dir.name, delete=False) 38 | tmp_file.close() 39 | 40 | try: 41 | log("Building wasm binary") 42 | result = subprocess.run( 43 | [ 44 | go, 45 | "build", 46 | "-trimpath", 47 | "-o", tmp_file.name, 48 | "./cmd/wasm/main_js_wasm.go" 49 | ], 50 | text=True, 51 | cwd=get_project_root(), 52 | stderr=subprocess.PIPE, 53 | stdout=subprocess.PIPE, 54 | timeout=120, 55 | env=env 56 | ) 57 | result.check_returncode() 58 | log("Wasm binary built successfully") 59 | except subprocess.CalledProcessError as e: 60 | log("Error building wasm:") 61 | print(f"stdout: {e.stdout}") 62 | print(f"stderr: {e.stderr}") 63 | exit(1) 64 | 65 | if args.raw: 66 | shutil.copy(tmp_file.name, wasm_location()) 67 | else: 68 | try: 69 | log("Optimizing wasm") 70 | result = subprocess.run( 71 | [ 72 | opt, 73 | tmp_file.name, 74 | "-O3", 75 | "--enable-bulk-memory", 76 | "-o", wasm_location() 77 | ], 78 | text=True, 79 | stderr=subprocess.PIPE, 80 | stdout=subprocess.PIPE, 81 | timeout=300 82 | ) 83 | result.check_returncode() 84 | log("Wasm optimized successfully") 85 | except subprocess.CalledProcessError as e: 86 | log("Error optimizing wasm:") 87 | print(f"stdout: {e.stdout}") 88 | print(f"stderr: {e.stderr}") 89 | exit(1) 90 | 91 | tmp_dir.cleanup() -------------------------------------------------------------------------------- /testdata/result.gob.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zxilly/go-size-analyzer/e0fca767d6f0b50e681b29e37bda0a75d9062041/testdata/result.gob.gz -------------------------------------------------------------------------------- /testdata/wasm/main.go: -------------------------------------------------------------------------------- 1 | // Should set GOOS and GOARCH to wasm 2 | // 3 | //go:generate go build -trimpath -o test.wasm main.go 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | //go:noinline 11 | func helloworld() string { 12 | return fmt.Sprintf("Hello, world!") 13 | } 14 | 15 | func main() { 16 | println(helloworld()) 17 | } 18 | -------------------------------------------------------------------------------- /testdata/wasm/test.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zxilly/go-size-analyzer/e0fca767d6f0b50e681b29e37bda0a75d9062041/testdata/wasm/test.wasm -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | "*.snap", 4 | "*.sum", 5 | "*.mod", 6 | "*.csv", 7 | "result.json" 8 | ] 9 | 10 | [default.extend-words] 11 | typ = "typ" -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .million/ 26 | 27 | data.json 28 | gsa.wasm 29 | 30 | coverage/ 31 | test-results.xml -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # UI for golang-size-analyzer 2 | 3 | Uses code from [rollup-plugin-visualizer](https://github.com/btd/rollup-plugin-visualizer) 4 | 5 | ## Build 6 | 7 | ```bash 8 | pnpm install 9 | pnpm run build:ui 10 | ``` 11 | -------------------------------------------------------------------------------- /ui/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from "@antfu/eslint-config"; 2 | 3 | export default antfu({ 4 | react: true, 5 | rules: { 6 | "no-console": "off", 7 | }, 8 | stylistic: { 9 | indent: 2, 10 | quotes: "double", 11 | semi: true, 12 | }, 13 | }, { 14 | ignores: [ 15 | "dist", 16 | "coverage", 17 | "src/generated/schema.ts", 18 | "src/runtime/*", 19 | ], 20 | }); 21 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GSA TreeMap 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "go-size-analyzer-ui", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "packageManager": "pnpm@10.8.1", 7 | "scripts": { 8 | "build:ui": "tsc && vite -c vite.config.ts build", 9 | "build:explorer": "tsc && vite -c vite.config-explorer.ts build", 10 | "dev:ui": "vite -c vite.config.ts", 11 | "dev:explorer": "vite -c vite.config-explorer.ts", 12 | "lint": "eslint .", 13 | "lint:fix": "eslint . --fix", 14 | "test": "vitest run" 15 | }, 16 | "dependencies": { 17 | "@emotion/react": "^11.14.0", 18 | "@emotion/styled": "^11.14.0", 19 | "@mui/icons-material": "^7.1.0", 20 | "@mui/material": "^7.1.0", 21 | "@types/lodash.memoize": "^4.1.9", 22 | "d3-array": "^3.2.4", 23 | "d3-color": "^3.1.0", 24 | "d3-hierarchy": "^3.1.2", 25 | "d3-scale": "^4.0.2", 26 | "lodash.memoize": "^4.1.2", 27 | "react": "^19.1.0", 28 | "react-dom": "^19.1.0", 29 | "react-dropzone": "^14.3.8", 30 | "react-use": "^17.6.0", 31 | "valibot": "^1.1.0" 32 | }, 33 | "devDependencies": { 34 | "@antfu/eslint-config": "^4.13.2", 35 | "@codecov/vite-plugin": "1.9.1", 36 | "@eslint-react/eslint-plugin": "^1.50.0", 37 | "@microsoft/eslint-formatter-sarif": "^3.1.0", 38 | "@testing-library/dom": "^10.4.0", 39 | "@testing-library/jest-dom": "^6.6.3", 40 | "@testing-library/react": "^16.3.0", 41 | "@testing-library/user-event": "^14.6.1", 42 | "@types/d3-array": "^3.2.1", 43 | "@types/d3-color": "^3.1.3", 44 | "@types/d3-hierarchy": "^3.1.7", 45 | "@types/d3-scale": "^4.0.9", 46 | "@types/golang-wasm-exec": "^1.15.2", 47 | "@types/node": "^22.15.29", 48 | "@types/react": "^19.1.6", 49 | "@types/react-dom": "^19.1.5", 50 | "@vitejs/plugin-react": "^4.5.0", 51 | "@vitest/coverage-istanbul": "^3.1.4", 52 | "eslint": "^9.28.0", 53 | "eslint-plugin-import-x": "^4.15.0", 54 | "eslint-plugin-react-hooks": "5.2.0", 55 | "eslint-plugin-react-refresh": "^0.4.20", 56 | "jsdom": "^26.1.0", 57 | "sass": "^1.89.1", 58 | "terser": "^5.40.0", 59 | "typescript": "^5.8.3", 60 | "vite": "^6.3.5", 61 | "vite-plugin-html": "^3.2.2", 62 | "vite-plugin-singlefile": "^2.2.0", 63 | "vitest": "^3.1.4" 64 | }, 65 | "pnpm": { 66 | "peerDependencyRules": { 67 | "allowedVersions": { 68 | "eslint": "9" 69 | } 70 | }, 71 | "overrides": { 72 | "micromatch": "4.0.8" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ui/src/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import type { RefObject } from "react"; 2 | import type { Entry } from "./tool/entry.ts"; 3 | import React, { useMemo, useRef } from "react"; 4 | import { useMouse } from "./tool/useMouse.ts"; 5 | 6 | const Tooltip_marginX = 10; 7 | const Tooltip_marginY = 30; 8 | 9 | export interface TooltipProps { 10 | moveRef: RefObject; 11 | getTargetNode: (e: EventTarget) => Entry | null; 12 | } 13 | 14 | export const Tooltip: React.FC 15 | = ({ 16 | moveRef, 17 | getTargetNode, 18 | }) => { 19 | const ref = useRef(null); 20 | 21 | const { 22 | clientX: x, 23 | clientY: y, 24 | isOver, 25 | eventTarget: mouseEventTarget, 26 | } = useMouse(moveRef); 27 | 28 | const node = useMemo(() => { 29 | if (!mouseEventTarget) { 30 | return null; 31 | } 32 | 33 | return getTargetNode(mouseEventTarget); 34 | }, [getTargetNode, mouseEventTarget]); 35 | 36 | const path = useMemo(() => { 37 | return node?.getName() ?? ""; 38 | }, [node]); 39 | 40 | const content = useMemo(() => { 41 | return node?.toString() ?? ""; 42 | }, [node]); 43 | 44 | let style: { left?: number; top?: number; visibility?: "hidden" } = { 45 | visibility: "hidden", 46 | }; 47 | if (!(!ref.current || !x || !y || !isOver)) { 48 | const pos = { 49 | left: x + Tooltip_marginX, 50 | top: y + Tooltip_marginY, 51 | }; 52 | 53 | const boundingRect = ref.current.getBoundingClientRect(); 54 | 55 | if (pos.left + boundingRect.width > window.innerWidth) { 56 | // Shifting horizontally 57 | pos.left = window.innerWidth - boundingRect.width; 58 | } 59 | 60 | if (pos.top + boundingRect.height > window.innerHeight) { 61 | // Flipping vertically 62 | pos.top = y - Tooltip_marginY - boundingRect.height; 63 | } 64 | 65 | style = pos; 66 | } 67 | 68 | return ( 69 | (isOver && node) && ( 70 |
71 |
{path}
72 |
73 |             {content}
74 |           
75 |
76 | ) 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /ui/src/Treemap.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { describe, expect, it } from "vitest"; 4 | import { getTestResult } from "./test/testhelper.ts"; 5 | import { createEntry } from "./tool/entry.ts"; 6 | import TreeMap from "./TreeMap.tsx"; 7 | 8 | function getTestEntry() { 9 | return createEntry(getTestResult()); 10 | } 11 | 12 | describe("treeMap", () => { 13 | it("render snapshot", () => { 14 | const rr = render( 15 | , 16 | ); 17 | 18 | expect(rr.container).toMatchSnapshot(); 19 | }); 20 | 21 | it("render with hash", () => { 22 | window.location.hash = "#bin-linux-1.21-amd64#std-packages#runtime#runtime1.go"; 23 | 24 | const { getByText } = render( 25 | , 26 | ); 27 | 28 | const rect = getByText("runtime1.go"); 29 | expect(rect).toBeInTheDocument(); 30 | 31 | const strokeEle = rect.parentElement?.children.item(0); 32 | expect(strokeEle).not.toBeNull(); 33 | 34 | // 35 | expect(strokeEle).toHaveAttribute("stroke", "#fff"); 36 | expect(strokeEle).toHaveAttribute("stroke-width", "2"); 37 | }); 38 | 39 | it("auto set hash", () => { 40 | const { getByText } = render( 41 | , 42 | ); 43 | 44 | const rect = getByText("symtab.go"); 45 | 46 | fireEvent.click(rect); 47 | 48 | expect(window.location.hash).toBe("#bin-linux-1.21-amd64#std-packages#runtime#symtab.go"); 49 | }); 50 | 51 | it("accept invalid hash", () => { 52 | window.location.hash = "#invalid-hash"; 53 | 54 | const { getByText } = render( 55 | , 56 | ); 57 | 58 | expect(getByText("Main Packages Size")).not.toBeNull(); 59 | expect(getByText("Std Packages Size")).not.toBeNull(); 60 | }); 61 | 62 | it("should handle move event", async () => { 63 | const { getByText, container } = render( 64 | , 65 | ); 66 | 67 | const svg = container.querySelector("svg"); 68 | expect(svg).not.toBeNull(); 69 | 70 | const rect = getByText("symtab.go"); 71 | 72 | const user = userEvent.setup(); 73 | 74 | await user.pointer({ 75 | coords: { 76 | x: 1, 77 | y: 1, 78 | }, 79 | }); 80 | await user.hover(rect); 81 | 82 | const tooltip = document.querySelector(".tooltip"); 83 | expect(tooltip).not.toBeNull(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /ui/src/explorer/Explorer.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render, screen, waitFor } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { afterEach, describe, expect, it, vi } from "vitest"; 4 | import { Explorer } from "./Explorer"; 5 | 6 | vi.mock("../worker/helper.ts"); 7 | 8 | describe("explorer", () => { 9 | afterEach(() => { 10 | vi.restoreAllMocks(); 11 | cleanup(); 12 | }); 13 | 14 | describe("wasm success", () => { 15 | it("should display loading state initially", () => { 16 | render(); 17 | expect(screen.getByText("Loading WebAssembly module...")).toBeInTheDocument(); 18 | }); 19 | 20 | it("should display file selector when no file is selected", async () => { 21 | render(); 22 | await waitFor(() => screen.getByText("Select a go binary")); 23 | }); 24 | 25 | it("should display error when analysis fails", async () => { 26 | render(); 27 | 28 | await waitFor(() => screen.getByText("Select a go binary")); 29 | 30 | await userEvent.upload(screen.getByTestId("file-selector"), new File(["test"], "fail")); 31 | await waitFor(() => screen.getByText("Failed to analyze fail")); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /ui/src/explorer/Explorer.wasm.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import { it } from "vitest"; 3 | import { Explorer } from "./Explorer.tsx"; 4 | 5 | it("explorer should display error when loading fails", async () => { 6 | render(); 7 | await waitFor(() => screen.getByText("Failed to load WebAssembly module")); 8 | }); 9 | -------------------------------------------------------------------------------- /ui/src/explorer/FileSelector.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { describe, expect, it, vi } from "vitest"; 4 | import { FileSelector } from "./FileSelector.tsx"; 5 | 6 | function createFileWithSize(sizeInBytes: number, fileName = "test.txt", fileType = "text/plain") { 7 | return { 8 | name: fileName, 9 | size: sizeInBytes, 10 | type: fileType, 11 | } as unknown as File; 12 | } 13 | 14 | describe("fileSelector", () => { 15 | it("should render correctly", () => { 16 | const mockHandler = vi.fn(); 17 | const { getByText } = render(); 18 | expect(getByText("Click or drag file to analyze")).toBeInTheDocument(); 19 | }); 20 | 21 | it("should call handler when file size is within limit", async () => { 22 | const mockHandler = vi.fn(); 23 | const { getByTestId } = render(); 24 | const file = createFileWithSize(1024 * 1024 * 29); 25 | await userEvent.upload(getByTestId("file-selector"), file); 26 | expect(mockHandler).toHaveBeenCalledWith(file); 27 | }); 28 | 29 | it("should not call handler when file size exceeds limit", async () => { 30 | const mockHandler = vi.fn(); 31 | const { getByTestId } = render(); 32 | const file = createFileWithSize(1024 * 1024 * 31); 33 | 34 | await userEvent.upload(getByTestId("file-selector"), file); 35 | expect(mockHandler).not.toHaveBeenCalled(); 36 | }); 37 | 38 | it("should call handler when file size exceeds limit and user chooses to continue", async () => { 39 | const mockHandler = vi.fn(); 40 | const { getByTestId, getByText } = render(); 41 | const file = createFileWithSize(1024 * 1024 * 31); 42 | 43 | await userEvent.upload(getByTestId("file-selector"), file); 44 | await userEvent.click(getByText("Continue")); 45 | 46 | expect(mockHandler).toHaveBeenCalledWith(file); 47 | }); 48 | 49 | it("should not call handler when file size exceeds limit and user chooses to cancel", async () => { 50 | const mockHandler = vi.fn(); 51 | const { getByTestId, getByText } = render(); 52 | const file = createFileWithSize(1024 * 1024 * 31); 53 | 54 | await userEvent.upload(getByTestId("file-selector"), file as File); 55 | await userEvent.click(getByText("Cancel")); 56 | 57 | expect(mockHandler).not.toHaveBeenCalled(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /ui/src/explorer_main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import { Explorer } from "./explorer/Explorer.tsx"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | import { createEntry } from "./tool/entry.ts"; 5 | import { loadDataFromEmbed } from "./tool/utils.ts"; 6 | import TreeMap from "./TreeMap.tsx"; 7 | 8 | ReactDOM.createRoot(document.getElementById("root")!).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /ui/src/runtime/fs.d.ts: -------------------------------------------------------------------------------- 1 | declare type FSCallback = (line: string) => void; 2 | 3 | export declare function setCallback(callback: FSCallback): void; 4 | 5 | export declare function resetCallback(): void; 6 | -------------------------------------------------------------------------------- /ui/src/runtime/fs.js: -------------------------------------------------------------------------------- 1 | let outputBuf = ""; 2 | const decoder = new TextDecoder("utf-8"); 3 | function enosys() { 4 | const err = new Error("not implemented"); 5 | err.code = "ENOSYS"; 6 | return err; 7 | } 8 | 9 | const defaultFSCallback = line => console.log(line); 10 | 11 | let fsCallback = defaultFSCallback; 12 | 13 | export function setCallback(callback) { 14 | fsCallback = callback; 15 | } 16 | 17 | export function resetCallback() { 18 | fsCallback = defaultFSCallback; 19 | } 20 | 21 | globalThis.fs = { 22 | constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused 23 | writeSync(fd, buf) { 24 | outputBuf += decoder.decode(buf); 25 | const nl = outputBuf.lastIndexOf("\n"); 26 | if (nl !== -1) { 27 | fsCallback(outputBuf.substring(0, nl)); 28 | outputBuf = outputBuf.substring(nl + 1); 29 | } 30 | return buf.length; 31 | }, 32 | write(fd, buf, offset, length, position, callback) { 33 | if (offset !== 0 || length !== buf.length || position !== null) { 34 | callback(enosys()); 35 | return; 36 | } 37 | const n = this.writeSync(fd, buf); 38 | callback(null, n); 39 | }, 40 | chmod(path, mode, callback) { callback(enosys()); }, 41 | chown(path, uid, gid, callback) { callback(enosys()); }, 42 | close(fd, callback) { callback(enosys()); }, 43 | fchmod(fd, mode, callback) { callback(enosys()); }, 44 | fchown(fd, uid, gid, callback) { callback(enosys()); }, 45 | fstat(fd, callback) { callback(enosys()); }, 46 | fsync(fd, callback) { callback(null); }, 47 | ftruncate(fd, length, callback) { callback(enosys()); }, 48 | lchown(path, uid, gid, callback) { callback(enosys()); }, 49 | link(path, link, callback) { callback(enosys()); }, 50 | lstat(path, callback) { callback(enosys()); }, 51 | mkdir(path, perm, callback) { callback(enosys()); }, 52 | open(path, flags, mode, callback) { callback(enosys()); }, 53 | read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, 54 | readdir(path, callback) { callback(enosys()); }, 55 | readlink(path, callback) { callback(enosys()); }, 56 | rename(from, to, callback) { callback(enosys()); }, 57 | rmdir(path, callback) { callback(enosys()); }, 58 | stat(path, callback) { callback(enosys()); }, 59 | symlink(path, link, callback) { callback(enosys()); }, 60 | truncate(path, length, callback) { callback(enosys()); }, 61 | unlink(path, callback) { callback(enosys()); }, 62 | utimes(path, atime, mtime, callback) { callback(enosys()); }, 63 | }; 64 | -------------------------------------------------------------------------------- /ui/src/runtime/wasm_exec.d.ts: -------------------------------------------------------------------------------- 1 | declare module "./wasm_exec.js" { 2 | const value: never; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/schema/schema.ts: -------------------------------------------------------------------------------- 1 | import type { GenericSchema, InferInput } from "valibot"; 2 | import { array, boolean, lazy, literal, number, object, optional, record, safeParse, string, union } from "valibot"; 3 | 4 | export const SectionSchema = object({ 5 | name: string(), 6 | size: number(), 7 | file_size: number(), 8 | known_size: number(), 9 | offset: number(), 10 | end: number(), 11 | addr: number(), 12 | addr_end: number(), 13 | only_in_memory: boolean(), 14 | debug: boolean(), 15 | }); 16 | 17 | export type Section = InferInput; 18 | 19 | export const FileSchema = object({ 20 | file_path: string(), 21 | size: number(), 22 | pcln_size: number(), 23 | }); 24 | 25 | export type File = InferInput; 26 | 27 | export const FileSymbolSchema = object({ 28 | name: string(), 29 | addr: number(), 30 | size: number(), 31 | type: union([literal("unknown"), literal("text"), literal("data")]), 32 | }); 33 | 34 | export type FileSymbol = InferInput; 35 | 36 | interface PackageRef { 37 | name: string; 38 | type: "main" | "std" | "vendor" | "generated" | "unknown" | "cgo"; 39 | subPackages: Record; 40 | files: File[]; 41 | symbols: FileSymbol[]; 42 | size: number; 43 | importedBy?: string[]; 44 | } 45 | 46 | export const PackageSchema: GenericSchema = object({ 47 | name: string(), 48 | type: union([ 49 | literal("main"), 50 | literal("std"), 51 | literal("vendor"), 52 | literal("generated"), 53 | literal("unknown"), 54 | literal("cgo"), 55 | ]), 56 | subPackages: record(string(), lazy(() => PackageSchema)), 57 | files: array(FileSchema), 58 | symbols: array(FileSymbolSchema), 59 | size: number(), 60 | importedBy: optional(array(string())), 61 | }); 62 | 63 | export type Package = InferInput; 64 | 65 | export const ResultSchema = object({ 66 | name: string(), 67 | size: number(), 68 | packages: record(string(), PackageSchema), 69 | sections: array(SectionSchema), 70 | analyzers: optional(array(union([literal("dwarf"), literal("disasm"), literal("symbol"), literal("pclntab")]))), 71 | }); 72 | 73 | export type Result = InferInput; 74 | 75 | export function parseResult(data: string): Result | null { 76 | const obj = JSON.parse(data); 77 | const result = safeParse(ResultSchema, obj); 78 | 79 | if (result.success) { 80 | return result.output; 81 | } 82 | console.error("Failed to parse result", result.issues); 83 | return null; 84 | } 85 | -------------------------------------------------------------------------------- /ui/src/style.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, 3 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 4 | --background-color: #2b2d42; 5 | --text-color: #edf2f4; 6 | } 7 | 8 | html { 9 | box-sizing: border-box; 10 | } 11 | 12 | *, 13 | *:before, 14 | *:after { 15 | box-sizing: inherit; 16 | } 17 | 18 | html { 19 | background-color: var(--background-color) !important; 20 | color: var(--text-color); 21 | font-family: var(--font-family), serif; 22 | } 23 | 24 | body { 25 | padding: 0; 26 | margin: 0; 27 | } 28 | 29 | html, 30 | body { 31 | height: 100vh; 32 | width: 100vw; 33 | overflow: hidden; 34 | } 35 | 36 | body { 37 | display: flex; 38 | flex-direction: column; 39 | } 40 | 41 | svg#treemap-svg { 42 | vertical-align: middle; 43 | width: 100%; 44 | height: 100%; 45 | max-height: 100vh; 46 | } 47 | 48 | #root { 49 | flex-grow: 1; 50 | height: 100vh; 51 | padding: 10px; 52 | } 53 | 54 | .tooltip { 55 | position: absolute; 56 | z-index: 1070; 57 | 58 | border: 2px solid; 59 | border-radius: 5px; 60 | 61 | padding: 5px; 62 | 63 | white-space: nowrap; 64 | 65 | font-size: 0.875rem; 66 | 67 | background-color: var(--background-color); 68 | color: var(--text-color); 69 | } 70 | 71 | .node { 72 | cursor: pointer; 73 | } 74 | -------------------------------------------------------------------------------- /ui/src/test/testhelper.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import path from "node:path"; 3 | import { assert } from "vitest"; 4 | import { parseResult } from "../schema/schema.ts"; 5 | 6 | export function getTestResult() { 7 | const data = readFileSync( 8 | path.join(__dirname, "..", "..", "..", "testdata", "result.json"), 9 | ).toString(); 10 | 11 | const r = parseResult(data); 12 | assert.isNotNull(r); 13 | 14 | return r!; 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/tool/aligner.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Aligner } from "./aligner.ts"; 3 | 4 | it("aligner should correctly add and align strings", () => { 5 | const al = new Aligner(); 6 | al.add("short", "post1"); 7 | al.add("a bit longer", "post2"); 8 | expect(al.toString()).toBe("short post1\n" 9 | + "a bit longer post2"); 10 | }); 11 | 12 | it("aligner should handle empty pre string", () => { 13 | const al = new Aligner(); 14 | al.add("", "post1"); 15 | al.add("a bit longer", "post2"); 16 | expect(al.toString()).toBe(" post1\n" 17 | + "a bit longer post2"); 18 | }); 19 | 20 | it("aligner should handle empty post string", () => { 21 | const al = new Aligner(); 22 | al.add("short", ""); 23 | al.add("a bit longer", "post2"); 24 | expect(al.toString()).toBe("short \na bit longer post2"); 25 | }); 26 | 27 | it("aligner should handle empty pre and post strings", () => { 28 | const al = new Aligner(); 29 | al.add("", ""); 30 | al.add("a bit longer", "post2"); 31 | expect(al.toString()).toBe(" \n" 32 | + "a bit longer post2"); 33 | }); 34 | 35 | it("aligner should handle no added strings", () => { 36 | const al = new Aligner(); 37 | expect(al.toString()).toBe(""); 38 | }); 39 | -------------------------------------------------------------------------------- /ui/src/tool/aligner.ts: -------------------------------------------------------------------------------- 1 | import { max } from "d3-array"; 2 | 3 | export class Aligner { 4 | private pre: string[] = []; 5 | private post: string[] = []; 6 | 7 | public add(pre: string, post: string): Aligner { 8 | this.pre.push(pre); 9 | this.post.push(post); 10 | return this; 11 | } 12 | 13 | public add_if(pre: string, post: string, cond: boolean): Aligner { 14 | if (cond) { 15 | this.pre.push(pre); 16 | this.post.push(post); 17 | } 18 | return this; 19 | } 20 | 21 | public toString(): string { 22 | // determine the maximum length of the pre-strings 23 | const maxPreLength = max(this.pre, d => d.length) ?? 0; 24 | let ret = ""; 25 | for (let i = 0; i < this.pre.length; i++) { 26 | ret += `${this.pre[i].padEnd(maxPreLength + 1) + this.post[i]}\n`; 27 | } 28 | ret = ret.trimEnd(); 29 | return ret; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ui/src/tool/color.ts: -------------------------------------------------------------------------------- 1 | import type { RGBColor } from "d3-color"; 2 | import type { HierarchyNode } from "d3-hierarchy"; 3 | import type { Entry } from "./entry.ts"; 4 | import { hsl } from "d3-color"; 5 | 6 | import { scaleLinear, scaleSequential } from "d3-scale"; 7 | 8 | type CssColor = string; 9 | 10 | export const COLOR_BASE: CssColor = "#cecece"; 11 | 12 | // https://www.w3.org/TR/WCAG20/#relativeluminancedef 13 | const rc = 0.2126; 14 | const gc = 0.7152; 15 | const bc = 0.0722; 16 | // low-gamma adjust coefficient 17 | const lowc = 1 / 12.92; 18 | 19 | function adjustGamma(p: number) { 20 | return ((p + 0.055) / 1.055) ** 2.4; 21 | } 22 | 23 | function relativeLuminance(o: RGBColor) { 24 | const rsrgb = o.r / 255; 25 | const gsrgb = o.g / 255; 26 | const bsrgb = o.b / 255; 27 | 28 | const r = rsrgb <= 0.03928 ? rsrgb * lowc : adjustGamma(rsrgb); 29 | const g = gsrgb <= 0.03928 ? gsrgb * lowc : adjustGamma(gsrgb); 30 | const b = bsrgb <= 0.03928 ? bsrgb * lowc : adjustGamma(bsrgb); 31 | 32 | return r * rc + g * gc + b * bc; 33 | } 34 | 35 | export interface NodeColor { 36 | backgroundColor: CssColor; 37 | fontColor: CssColor; 38 | } 39 | 40 | export type NodeColorGetter = (node: HierarchyNode) => NodeColor; 41 | 42 | function createRainbowColor(root: HierarchyNode): NodeColorGetter { 43 | const colorParentMap = new Map, CssColor>(); 44 | colorParentMap.set(root, COLOR_BASE); 45 | 46 | if (root.children != null) { 47 | const colorScale = scaleSequential([0, root.children.length], n => hsl(360 * n, 0.3, 0.85)); 48 | root.children.forEach((c, id) => { 49 | colorParentMap.set(c, colorScale(id).toString()); 50 | }); 51 | } 52 | 53 | const colorMap = new Map, NodeColor>(); 54 | 55 | const lightScale = scaleLinear().domain([0, root.height]).range([0.9, 0.3]); 56 | 57 | const getBackgroundColor = (node: HierarchyNode) => { 58 | const parents = node.ancestors(); 59 | const colorStr 60 | = parents.length === 1 61 | ? colorParentMap.get(parents[0]) 62 | : colorParentMap.get(parents[parents.length - 2]); 63 | 64 | const hslColor = hsl(colorStr as string); 65 | hslColor.l = lightScale(node.depth); 66 | 67 | return hslColor; 68 | }; 69 | 70 | return (node: HierarchyNode): NodeColor => { 71 | if (!colorMap.has(node)) { 72 | const backgroundColor = getBackgroundColor(node); 73 | const l = relativeLuminance(backgroundColor.rgb()); 74 | const fontColor = l > 0.19 ? "#000" : "#fff"; 75 | colorMap.set(node, { 76 | backgroundColor: backgroundColor.toString(), 77 | fontColor, 78 | }); 79 | } 80 | 81 | return colorMap.get(node)!; 82 | }; 83 | } 84 | 85 | export default createRainbowColor; 86 | -------------------------------------------------------------------------------- /ui/src/tool/const.ts: -------------------------------------------------------------------------------- 1 | export const TOP_PADDING = 20; 2 | export const PADDING = 1; 3 | -------------------------------------------------------------------------------- /ui/src/tool/copy.ts: -------------------------------------------------------------------------------- 1 | export function shallowCopy(obj: T): T { 2 | const copy = Object.create(Object.getPrototypeOf(obj)); 3 | return Object.assign(copy, obj); 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/tool/id.ts: -------------------------------------------------------------------------------- 1 | let count = 1; 2 | 3 | export function orderedID() { 4 | return count++; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/tool/useHash.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from "@testing-library/react"; 2 | import { beforeAll, describe, expect, it } from "vitest"; 3 | import { useHash } from "./useHash"; 4 | 5 | describe("useHash", () => { 6 | beforeAll(() => { 7 | window.location = { hash: "" } as any; 8 | }); 9 | 10 | it("initializes with the current window location hash", () => { 11 | window.location.hash = "#initial"; 12 | const { result } = renderHook(() => useHash()); 13 | expect(result.current[0]).toBe("#initial"); 14 | }); 15 | 16 | it("updates hash when setHash is called with a new value", () => { 17 | const { result } = renderHook(() => useHash()); 18 | act(() => { 19 | result.current[1]("#newHash"); 20 | }); 21 | expect(window.location.hash).toBe("#newHash"); 22 | }); 23 | 24 | it("does not update hash if setHash is called with the current hash value", () => { 25 | window.location.hash = "#sameHash"; 26 | const { result } = renderHook(() => useHash()); 27 | act(() => { 28 | result.current[1]("#sameHash"); 29 | }); 30 | expect(window.location.hash).toBe("#sameHash"); 31 | }); 32 | 33 | it("removes hash when setHash is called with an empty string", () => { 34 | window.location.hash = "#toBeRemoved"; 35 | const { result } = renderHook(() => useHash()); 36 | act(() => { 37 | result.current[1](""); 38 | }); 39 | expect(window.location.hash).toBe(""); 40 | }); 41 | 42 | it("responds to window hashchange event", () => { 43 | const { result } = renderHook(() => useHash()); 44 | act(() => { 45 | window.location.hash = "#changedViaEvent"; 46 | window.dispatchEvent(new HashChangeEvent("hashchange")); 47 | }); 48 | expect(result.current[0]).toBe("#changedViaEvent"); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /ui/src/tool/useHash.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { useLifecycles } from "react-use"; 3 | import { off, on } from "react-use/lib/misc/util"; 4 | 5 | /** 6 | * read and write url hash, response to url hash change 7 | */ 8 | export function useHash() { 9 | const [hash, setHash] = useState(() => window.location.hash); 10 | 11 | const onHashChange = useCallback(() => { 12 | setHash(window.location.hash); 13 | }, []); 14 | 15 | useLifecycles( 16 | () => { 17 | on(window, "hashchange", onHashChange); 18 | }, 19 | () => { 20 | off(window, "hashchange", onHashChange); 21 | }, 22 | ); 23 | 24 | const _setHash = useCallback( 25 | (newHash: string) => { 26 | if (newHash !== hash) { 27 | window.location.hash = newHash; 28 | if (newHash === "") { 29 | history.pushState(null, "", " "); 30 | } 31 | } 32 | }, 33 | [hash], 34 | ); 35 | 36 | return [hash, _setHash] as const; 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/tool/useMouse.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from "react"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export interface useMouseResult { 5 | clientX: number | null; 6 | clientY: number | null; 7 | isOver: boolean; 8 | eventTarget: EventTarget | null; 9 | } 10 | 11 | export function useMouse(ref: RefObject): useMouseResult { 12 | const [clientX, setClientX] = useState(null); 13 | const [clientY, setClientY] = useState(null); 14 | const [eventTarget, setEventTarget] = useState(null); 15 | 16 | const [isOver, setIsOver] = useState(false); 17 | 18 | useEffect(() => { 19 | const abort = new AbortController(); 20 | const signal = abort.signal; 21 | 22 | if (ref.current) { 23 | ref.current.addEventListener("mousemove", (e) => { 24 | setClientX(e.clientX); 25 | setClientY(e.clientY); 26 | 27 | if (e.target !== eventTarget) { 28 | setEventTarget(e.target); 29 | } 30 | }, { signal }); 31 | ref.current.addEventListener("mouseover", () => { 32 | setIsOver(true); 33 | }, { signal }); 34 | ref.current.addEventListener("mouseout", () => { 35 | setIsOver(false); 36 | }, { signal }); 37 | } 38 | 39 | return () => { 40 | abort.abort(); 41 | }; 42 | }, [eventTarget, ref]); 43 | 44 | return { 45 | clientX, 46 | clientY, 47 | isOver, 48 | eventTarget, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/tool/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { getTestResult } from "../test/testhelper.ts"; 3 | import { formatBytes, loadDataFromEmbed, title, trimPrefix } from "./utils.ts"; 4 | 5 | it("formatBytes should correctly format bytes into human readable format", () => { 6 | expect(formatBytes(0)).toBe("0 B"); 7 | expect(formatBytes(1024)).toBe("1 KB"); 8 | expect(formatBytes(1048576)).toBe("1 MB"); 9 | }); 10 | 11 | it("title should capitalize the first letter of the string", () => { 12 | expect(title("hello")).toBe("Hello"); 13 | expect(title("world")).toBe("World"); 14 | }); 15 | 16 | it("trimPrefix should remove the prefix from the string", () => { 17 | expect(trimPrefix("HelloWorld", "Hello")).toBe("World"); 18 | expect(trimPrefix("HelloWorld", "World")).toBe("HelloWorld"); 19 | }); 20 | 21 | describe("loadDataFromEmbed", () => { 22 | it("should return parsed data when data is correctly formatted", () => { 23 | const data = getTestResult(); 24 | 25 | document.body.innerHTML = `
${JSON.stringify(data)}
`; 26 | expect(() => loadDataFromEmbed()).not.toThrow(); 27 | }); 28 | 29 | it("should throw error when element with id data is not found", () => { 30 | document.body.innerHTML = ""; // No element with id="data" 31 | expect(() => loadDataFromEmbed()).toThrow("Failed to find data element"); 32 | }); 33 | 34 | it("should throw error when data is null", () => { 35 | document.body.innerHTML = `
{}
`; 36 | expect(() => loadDataFromEmbed()).toThrow("Failed to parse data"); 37 | }); 38 | 39 | it("should throw error when data is not parsable", () => { 40 | document.body.innerHTML = `
unparsable data
`; 41 | expect(() => loadDataFromEmbed()).toThrow(`Unexpected token 'u', "unparsable data" is not valid JSON`); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /ui/src/tool/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "../schema/schema.ts"; 2 | import { parseResult } from "../schema/schema.ts"; 3 | 4 | export function loadDataFromEmbed(): Result { 5 | const doc = document.querySelector("#data")!; 6 | if (doc === null) { 7 | throw new Error("Failed to find data element"); 8 | } 9 | 10 | const ret = parseResult(doc.textContent!); 11 | if (ret === null) { 12 | throw new Error("Failed to parse data"); 13 | } 14 | return ret; 15 | } 16 | 17 | export function formatBytes(bytes: number) { 18 | if (bytes === 0) 19 | return "0 B"; 20 | const k = 1024; 21 | const dm = 2; 22 | const sizes = ["B", "KB", "MB", "GB"]; 23 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 24 | return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; 25 | } 26 | 27 | export function title(s: string): string { 28 | return s[0].toUpperCase() + s.slice(1); 29 | } 30 | 31 | export function trimPrefix(str: string, prefix: string) { 32 | if (str.startsWith(prefix)) { 33 | return str.slice(prefix.length); 34 | } 35 | return str; 36 | } 37 | -------------------------------------------------------------------------------- /ui/src/worker/__mocks__/helper.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "../../schema/schema.ts"; 2 | import { getTestResult } from "../../test/testhelper.ts"; 3 | 4 | export class GsaInstance { 5 | log: any; 6 | 7 | private constructor(_worker: any, log: any) { 8 | this.log = log; 9 | } 10 | 11 | static async create(_log: (line: string) => void): Promise { 12 | return new GsaInstance({}, {}); 13 | } 14 | 15 | async analyze(filename: string, _data: Uint8Array): Promise { 16 | if (filename === "fail") { 17 | return null; 18 | } 19 | 20 | for (let i = 0; i < 10; i++) { 21 | this.log(`Processing ${i}`); 22 | } 23 | 24 | return getTestResult(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/worker/event.ts: -------------------------------------------------------------------------------- 1 | export interface LoadEvent { 2 | type: "load"; 3 | status: "success" | "error"; 4 | reason?: string; 5 | } 6 | 7 | export interface AnalyzeEvent { 8 | type: "analyze"; 9 | result: import("../schema/schema.ts").Result | null; 10 | } 11 | 12 | export interface LogEvent { 13 | type: "log"; 14 | line: string; 15 | } 16 | 17 | export type WasmEvent = LoadEvent | AnalyzeEvent | LogEvent; 18 | -------------------------------------------------------------------------------- /ui/src/worker/helper.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "../schema/schema.ts"; 2 | import type { LoadEvent, WasmEvent } from "./event.ts"; 3 | import worker from "./worker.ts?worker&url"; 4 | 5 | export class GsaInstance { 6 | logHandler: (line: string) => void; 7 | worker: Worker; 8 | 9 | private constructor(worker: Worker, log: (line: string) => void) { 10 | this.worker = worker; 11 | this.logHandler = log; 12 | } 13 | 14 | static async create(log: (line: string) => void): Promise { 15 | const ret = new GsaInstance( 16 | new Worker(worker, { 17 | type: "module", 18 | }), 19 | log, 20 | ); 21 | 22 | return new Promise((resolve, reject) => { 23 | const loadCb = (e: MessageEvent) => { 24 | const data = e.data; 25 | 26 | if (data.type !== "load") { 27 | return reject(new Error("Unexpected message type")); 28 | } 29 | 30 | ret.worker.removeEventListener("message", loadCb); 31 | if (data.status === "success") { 32 | resolve(ret); 33 | } 34 | else { 35 | reject(new Error(data.reason)); 36 | } 37 | }; 38 | 39 | ret.worker.addEventListener("message", loadCb); 40 | }); 41 | } 42 | 43 | async analyze(filename: string, data: Uint8Array): Promise { 44 | return new Promise((resolve) => { 45 | const analyzeCb = (e: MessageEvent) => { 46 | const data = e.data; 47 | 48 | switch (data.type) { 49 | case "log": 50 | this.logHandler(data.line); 51 | break; 52 | case "analyze": 53 | this.worker.removeEventListener("message", analyzeCb); 54 | resolve(data.result); 55 | } 56 | }; 57 | 58 | this.worker.addEventListener("message", analyzeCb); 59 | this.worker.postMessage([filename, data], [data.buffer]); 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /ui/src/worker/worker.ts: -------------------------------------------------------------------------------- 1 | import type { AnalyzeEvent, LoadEvent, LogEvent } from "./event.ts"; 2 | import gsa from "../../gsa.wasm?init"; 3 | import { setCallback } from "../runtime/fs"; 4 | import "../runtime/wasm_exec.js"; 5 | 6 | declare const self: DedicatedWorkerGlobalScope; 7 | declare function gsa_analyze(name: string, data: Uint8Array): import("../schema/schema.ts").Result; 8 | 9 | async function init() { 10 | const go = new Go(); 11 | 12 | const inst = await gsa(go.importObject); 13 | 14 | go.run(inst).then(() => { 15 | console.error("Go exited"); 16 | }); 17 | } 18 | 19 | init().then(() => { 20 | self.postMessage({ 21 | status: "success", 22 | type: "load", 23 | } satisfies LoadEvent); 24 | 25 | setCallback((line) => { 26 | self.postMessage({ 27 | type: "log", 28 | line, 29 | } satisfies LogEvent); 30 | }); 31 | }).catch((e: Error) => { 32 | self.postMessage({ 33 | status: "error", 34 | type: "load", 35 | reason: e.message, 36 | } satisfies LoadEvent); 37 | }); 38 | 39 | self.onmessage = (e: MessageEvent<[string, Uint8Array]>) => { 40 | const [filename, data] = e.data; 41 | 42 | const result = gsa_analyze(filename, data); 43 | 44 | self.postMessage({ 45 | result, 46 | type: "analyze", 47 | } satisfies AnalyzeEvent); 48 | }; 49 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "jsx": "react-jsx", 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable", 9 | "WebWorker" 10 | ], 11 | "useDefineForClassFields": true, 12 | "module": "ESNext", 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "types": [ 17 | "@testing-library/jest-dom", 18 | "@types/golang-wasm-exec", 19 | "vite/client" 20 | ], 21 | "allowImportingTsExtensions": true, 22 | "allowJs": true, 23 | /* Linting */ 24 | "strict": true, 25 | "strictNullChecks": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "noEmit": true, 30 | "esModuleInterop": true, 31 | "isolatedModules": true, 32 | "skipLibCheck": true 33 | }, 34 | "references": [ 35 | { 36 | "path": "./tsconfig.node.json" 37 | } 38 | ], 39 | "include": [ 40 | "src" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "types": [ 7 | "vitest/importMeta" 8 | ], 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": [ 14 | "vite.config.ts", 15 | "vite.config-explorer.ts", 16 | "vitest.config.ts", 17 | "vitest.setup.ts", 18 | "vite.common.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /ui/vite.common.ts: -------------------------------------------------------------------------------- 1 | import type { BuildOptions, HtmlTagDescriptor, Plugin, PluginOption } from "vite"; 2 | import { execSync } from "node:child_process"; 3 | import * as path from "node:path"; 4 | import process from "node:process"; 5 | import { codecovVitePlugin } from "@codecov/vite-plugin"; 6 | import react from "@vitejs/plugin-react"; 7 | 8 | export function getSha(): string | undefined { 9 | const envs = process.env; 10 | if (!(envs?.CI)) { 11 | console.info("Not a CI build"); 12 | return undefined; 13 | } 14 | 15 | if (envs.PULL_REQUEST_COMMIT_SHA) { 16 | console.info(`PR build detected, sha: ${envs.PULL_REQUEST_COMMIT_SHA}`); 17 | return envs.PULL_REQUEST_COMMIT_SHA; 18 | } 19 | 20 | console.info(`CI build detected, not a PR build`); 21 | return envs.GITHUB_SHA; 22 | } 23 | 24 | export function getVersionTag(): HtmlTagDescriptor | null { 25 | try { 26 | const commitDate = execSync("git log -1 --format=%cI").toString().trimEnd(); 27 | const branchName = execSync("git rev-parse --abbrev-ref HEAD").toString().trimEnd(); 28 | const commitHash = execSync("git rev-parse HEAD").toString().trimEnd(); 29 | const lastCommitMessage = execSync("git show -s --format=%s").toString().trimEnd(); 30 | 31 | return { 32 | tag: "script", 33 | children: 34 | `console.info("Branch: ${branchName}");` 35 | + `console.info("Commit: ${commitHash}");` 36 | + `console.info("Date: ${commitDate}");` 37 | + `console.info("Message: ${lastCommitMessage}");`, 38 | }; 39 | } 40 | catch (e) { 41 | console.warn("Failed to get git info", e); 42 | return null; 43 | } 44 | } 45 | 46 | export function codecov(name: string): Plugin[] | undefined { 47 | if (process.env.CODECOV_TOKEN === undefined) { 48 | console.info("CODECOV_TOKEN is not set, codecov plugin will be disabled"); 49 | return undefined; 50 | } 51 | 52 | return codecovVitePlugin({ 53 | enableBundleAnalysis: true, 54 | bundleName: name, 55 | uploadToken: process.env.CODECOV_TOKEN, 56 | uploadOverrides: { 57 | sha: getSha(), 58 | }, 59 | debug: true, 60 | }); 61 | } 62 | 63 | export function commonPlugin(): (PluginOption[] | Plugin | Plugin[])[] { 64 | return [ 65 | react(), 66 | ]; 67 | } 68 | 69 | export function build(dir: string): BuildOptions { 70 | return { 71 | outDir: path.join("dist", dir), 72 | minify: "terser", 73 | terserOptions: { 74 | compress: { 75 | passes: 2, 76 | dead_code: true, 77 | }, 78 | }, 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /ui/vite.config-explorer.ts: -------------------------------------------------------------------------------- 1 | import * as process from "node:process"; 2 | import { defineConfig } from "vite"; 3 | import { createHtmlPlugin } from "vite-plugin-html"; 4 | import { build, codecov, commonPlugin, getVersionTag } from "./vite.common"; 5 | 6 | const tags = []; 7 | const versionTag = getVersionTag(); 8 | if (versionTag) { 9 | tags.push(versionTag); 10 | } 11 | 12 | if (process.env.GSA_TELEMETRY) { 13 | tags.push({ 14 | tag: "script", 15 | attrs: { 16 | "defer": true, 17 | "src": "https://trail.learningman.top/script.js", 18 | "data-website-id": "1aab8912-b4b0-4561-a683-81a730bdb944", 19 | }, 20 | }); 21 | } 22 | 23 | export default defineConfig({ 24 | plugins: [ 25 | ...commonPlugin(), 26 | createHtmlPlugin({ 27 | minify: true, 28 | entry: "./src/explorer_main.tsx", 29 | inject: { tags }, 30 | }), 31 | codecov("gsa-explorer"), 32 | ], 33 | clearScreen: false, 34 | build: build("explorer"), 35 | server: { 36 | watch: { 37 | usePolling: true, 38 | }, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import type { HtmlTagDescriptor } from "vite"; 2 | import * as fs from "node:fs"; 3 | import * as process from "node:process"; 4 | import { defineConfig } from "vite"; 5 | import { createHtmlPlugin } from "vite-plugin-html"; 6 | import { viteSingleFile } from "vite-plugin-singlefile"; 7 | import { build, codecov, commonPlugin, getVersionTag } from "./vite.common"; 8 | 9 | const placeHolder = `"GSA_PACKAGE_DATA"`; 10 | 11 | function getPlaceHolder(): string { 12 | if (process.env.NODE_ENV === "production") { 13 | return placeHolder; 14 | } 15 | 16 | try { 17 | let target: URL; 18 | 19 | if (fs.existsSync("../data.json") && !import.meta.vitest) { 20 | target = new URL("../data.json", import.meta.url); 21 | } 22 | else { 23 | target = new URL("../testdata/result.json", import.meta.url); 24 | } 25 | 26 | return fs.readFileSync( 27 | target, 28 | "utf-8", 29 | ); 30 | } 31 | catch (e) { 32 | console.error("Failed to load data.json, for dev you should create one with gsa", e); 33 | return placeHolder; 34 | } 35 | } 36 | 37 | const tags: HtmlTagDescriptor[] = [ 38 | { 39 | injectTo: "head", 40 | tag: "script", 41 | attrs: { 42 | type: "application/json", 43 | id: "data", 44 | }, 45 | children: getPlaceHolder(), 46 | }, 47 | 48 | ]; 49 | const versionTag = getVersionTag(); 50 | if (versionTag) { 51 | tags.push(versionTag); 52 | } 53 | 54 | export default defineConfig({ 55 | plugins: [ 56 | ...commonPlugin(), 57 | createHtmlPlugin({ 58 | minify: true, 59 | entry: "./src/main.tsx", 60 | inject: { tags }, 61 | }), 62 | viteSingleFile( 63 | { 64 | removeViteModuleLoader: true, 65 | }, 66 | ), 67 | codecov("gsa-ui"), 68 | ], 69 | clearScreen: false, 70 | build: build("webui"), 71 | }); 72 | -------------------------------------------------------------------------------- /ui/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import react from "@vitejs/plugin-react"; 3 | import { coverageConfigDefaults, defineConfig } from "vitest/config"; 4 | 5 | const reporters = ["default", "junit"]; 6 | if (process.env.CI) { 7 | reporters.push("github-actions"); 8 | } 9 | 10 | export default defineConfig({ 11 | plugins: [ 12 | react() as any, 13 | ], 14 | test: { 15 | environment: "jsdom", 16 | setupFiles: ["./vitest.setup.ts"], 17 | coverage: { 18 | provider: "istanbul", 19 | enabled: true, 20 | exclude: [ 21 | "src/tool/wasm_exec.js", 22 | "src/schema/schema.ts", 23 | "src/generated/schema.ts", 24 | "src/test/testhelper.ts", 25 | "**/__mocks__/**", 26 | "**/*.js", 27 | "vite.*.ts", 28 | ...coverageConfigDefaults.exclude, 29 | ], 30 | }, 31 | reporters, 32 | outputFile: { 33 | junit: "test-results.xml", 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /ui/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { cleanup } from "@testing-library/react"; 2 | import { afterEach } from "vitest"; 3 | import "@testing-library/jest-dom/vitest"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | 8 | if (typeof window !== "undefined") { 9 | // cleanup jsdom 10 | window.location.hash = ""; 11 | document.body.innerHTML = ""; 12 | document.head.innerHTML = ""; 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package gsa 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "runtime/debug" 7 | "strings" 8 | "time" 9 | 10 | "github.com/dustin/go-humanize" 11 | ) 12 | 13 | const ( 14 | unknownVersion = "(devel)" 15 | unknownProperty = "N/A" 16 | ) 17 | 18 | const ( 19 | // StaticVersion when update this, also update the version string in workflow 20 | StaticVersion = "1" 21 | ) 22 | 23 | var ( 24 | version = unknownVersion 25 | commit = unknownProperty 26 | buildDate = unknownProperty 27 | commitDate = unknownProperty 28 | dirtyBuild = unknownProperty 29 | ) 30 | 31 | func SprintVersion() string { 32 | info, ok := debug.ReadBuildInfo() 33 | if ok { 34 | if version == unknownVersion && info.Main.Version != "" { 35 | version = info.Main.Version 36 | } 37 | 38 | for _, kv := range info.Settings { 39 | switch kv.Key { 40 | case "vcs.revision": 41 | if commit == unknownProperty && kv.Value != "" { 42 | commit = kv.Value 43 | } 44 | case "vcs.time": 45 | if commitDate == unknownProperty && kv.Value != "" { 46 | commitDate = kv.Value 47 | } 48 | case "vcs.modified": 49 | if dirtyBuild == unknownProperty && kv.Value != "" { 50 | dirtyBuild = kv.Value 51 | } 52 | } 53 | } 54 | } 55 | 56 | formattedBool := func(b string) string { 57 | switch b { 58 | case "true": 59 | return "yes" 60 | case "false": 61 | return "no" 62 | default: 63 | return b 64 | } 65 | } 66 | 67 | const layout = "2006-01-02 15:04:05" 68 | buildDateTime, err := time.Parse(time.RFC3339, buildDate) 69 | if err == nil { 70 | buildDate = buildDateTime.Format(layout) + " (" + humanize.Time(buildDateTime) + ")" 71 | } 72 | commitDateTime, err := time.Parse(time.RFC3339, commitDate) 73 | if err == nil { 74 | commitDate = commitDateTime.Format(layout) + " (" + humanize.Time(commitDateTime) + ")" 75 | } 76 | 77 | s := new(strings.Builder) 78 | 79 | s.WriteString("▓▓▓ gsa\n\n") 80 | values := map[string]string{ 81 | "Version": version, 82 | "Git Commit": commit, 83 | "Build Date": buildDate, 84 | "Commit Date": commitDate, 85 | "Dirty Build": formattedBool(dirtyBuild), 86 | "Go Version": runtime.Version(), 87 | "Platform": fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 88 | } 89 | keys := []string{"Version", "Git Commit", "Build Date", "Commit Date", "Dirty Build", "Go Version", "Platform"} 90 | 91 | for _, k := range keys { 92 | if values[k] == unknownProperty { 93 | continue 94 | } 95 | 96 | _, _ = fmt.Fprintf(s, " %-11s %s\n", k, values[k]) 97 | } 98 | 99 | return s.String() 100 | } 101 | -------------------------------------------------------------------------------- /version_test.go: -------------------------------------------------------------------------------- 1 | package gsa 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSprintVersion(t *testing.T) { 11 | t.Run("Normal", func(t *testing.T) { 12 | got := SprintVersion() 13 | 14 | keys := []string{"Version", "Go Version", "Platform"} 15 | for _, key := range keys { 16 | assert.Contains(t, got, key) 17 | } 18 | }) 19 | 20 | t.Run("Fake release", func(t *testing.T) { 21 | buildDate = time.Now().Format(time.RFC3339) 22 | commitDate = time.Now().Format(time.RFC3339) 23 | dirtyBuild = "true" 24 | 25 | got := SprintVersion() 26 | 27 | keys := []string{"Version", "Go Version", "Platform", "Build Date", "Commit Date", "Dirty Build"} 28 | for _, key := range keys { 29 | assert.Contains(t, got, key) 30 | } 31 | }) 32 | } 33 | --------------------------------------------------------------------------------